Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/png-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@open-codesign/exporters": patch
"@open-codesign/desktop": patch
"@open-codesign/shared": patch
"@open-codesign/i18n": patch
---

Add PNG to the Export menu. It renders the active design with the installed Chrome, like PDF export, and saves a full-page screenshot at 2x.
5 changes: 5 additions & 0 deletions apps/desktop/src/main/exporter-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ describe('parseRequest', () => {
expect(result.defaultFilename).toBe('report.pdf');
});

it('accepts a png request', () => {
expect(parseRequest({ format: 'png', artifactSource: '<html/>' }).format).toBe('png');
});

it('accepts workspace source context for local asset exports', () => {
const result = parseRequest({
format: 'zip',
Expand Down Expand Up @@ -172,6 +176,7 @@ describe('export path helpers', () => {
expect(ensureExportExtension('/tmp/report', 'pdf')).toBe('/tmp/report.pdf');
expect(ensureExportExtension('/tmp/report.PDF', 'pdf')).toBe('/tmp/report.PDF');
expect(ensureExportExtension('/tmp/report.txt', 'pdf')).toBe('/tmp/report.txt.pdf');
expect(ensureExportExtension('/tmp/home', 'png')).toBe('/tmp/home.png');
expect(ensureExportExtension('/tmp/report.markdown', 'markdown')).toBe(
'/tmp/report.markdown.md',
);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/exporter-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { readWorkspaceFileAt } from './workspace-reader';
const FORMAT_FILTERS: Record<ExporterFormat, Electron.FileFilter[]> = {
html: [{ name: 'HTML', extensions: ['html'] }],
pdf: [{ name: 'PDF', extensions: ['pdf'] }],
png: [{ name: 'PNG image', extensions: ['png'] }],
pptx: [{ name: 'PowerPoint', extensions: ['pptx'] }],
zip: [{ name: 'ZIP archive', extensions: ['zip'] }],
markdown: [{ name: 'Markdown', extensions: ['md'] }],
Expand Down Expand Up @@ -95,6 +96,7 @@ export function parseRequest(raw: unknown): ExportRequest {
if (
format !== 'html' &&
format !== 'pdf' &&
format !== 'png' &&
format !== 'pptx' &&
format !== 'zip' &&
format !== 'markdown'
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export interface ValidateKeyError {
message: string;
}

export type ExportFormat = 'html' | 'pdf' | 'pptx' | 'zip' | 'markdown';
export type ExportFormat = 'html' | 'pdf' | 'png' | 'pptx' | 'zip' | 'markdown';
export type WorkspaceFileKind =
| 'html'
| 'jsx'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { PreviewToolbarMenu } from './PreviewToolbarMenu';
import './PreviewToolbar.css';

const ZOOM_OPTIONS = [50, 75, 90, 100, 110, 125, 150, 175, 200] as const;
const EXPORT_FORMATS: ExportFormat[] = ['html', 'pdf', 'pptx', 'zip', 'markdown'];
const EXPORT_FORMATS: ExportFormat[] = ['html', 'pdf', 'png', 'pptx', 'zip', 'markdown'];

export function PreviewToolbar({
canFullscreen = false,
Expand Down
1 change: 1 addition & 0 deletions packages/exporters/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
".": "./src/index.ts",
"./html": "./src/html.ts",
"./pdf": "./src/pdf.ts",
"./png": "./src/png.ts",
"./pptx": "./src/pptx.ts",
"./zip": "./src/zip.ts",
"./markdown": "./src/markdown.ts"
Expand Down
7 changes: 6 additions & 1 deletion packages/exporters/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import { CodesignError, ERROR_CODES } from '@open-codesign/shared';
import type { LocalAssetOptions } from './assets';

export const EXPORTER_FORMATS = ['html', 'pdf', 'pptx', 'zip', 'markdown'] as const;
export const EXPORTER_FORMATS = ['html', 'pdf', 'png', 'pptx', 'zip', 'markdown'] as const;
export type ExporterFormat = (typeof EXPORTER_FORMATS)[number];

export type ExportOptions = LocalAssetOptions & { assets?: import('./zip').ZipAsset[] };
Expand Down Expand Up @@ -38,6 +38,7 @@ export type { ExportHtmlOptions } from './html';
export type { ExportMarkdownOptions, MarkdownMeta } from './markdown';
export { htmlToMarkdown } from './markdown';
export type { ExportPdfOptions } from './pdf';
export type { ExportPngOptions } from './png';
export type { ExportPptxOptions } from './pptx';
export type { ExportZipOptions, ZipAsset } from './zip';

Expand All @@ -63,6 +64,10 @@ export async function exportArtifact(
const mod = await import('./pdf');
return mod.exportPdf(artifactSource, destinationPath, opts);
}
if (format === 'png') {
const mod = await import('./png');
return mod.exportPng(artifactSource, destinationPath, opts);
}
if (format === 'pptx') {
const mod = await import('./pptx');
return mod.exportPptx(artifactSource, destinationPath, opts);
Expand Down
100 changes: 100 additions & 0 deletions packages/exporters/src/png.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';

const fakePngBytes = Buffer.from('\x89PNG fake');

const launchMock = vi.fn();
const setViewportMock = vi.fn();
const setContentMock = vi.fn();
const evaluateMock = vi.fn();
const screenshotMock = vi.fn();
const closeMock = vi.fn();

vi.mock('puppeteer-core', () => ({
default: { launch: launchMock },
}));

vi.mock('./chrome-discovery', () => ({
findSystemChrome: vi.fn(
async () => '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
),
}));

let tempDir = '';

beforeAll(() => {
tempDir = mkdtempSync(join(tmpdir(), 'codesign-png-test-'));
});

beforeEach(() => {
vi.clearAllMocks();
launchMock.mockResolvedValue({
newPage: async () => ({
setViewport: setViewportMock,
setContent: setContentMock,
evaluate: evaluateMock,
screenshot: screenshotMock,
}),
close: closeMock,
});
screenshotMock.mockResolvedValue(fakePngBytes);
});

afterAll(() => {
rmSync(tempDir, { recursive: true, force: true });
});

describe('exportPng', () => {
it('writes a full-page 2x screenshot of the rendered document', async () => {
const { exportPng } = await import('./png');
const dest = join(tempDir, 'out.png');

const result = await exportPng('<h1>hi</h1>', dest);

expect(launchMock).toHaveBeenCalledWith(
expect.objectContaining({
executablePath: expect.stringContaining('Chrome'),
headless: true,
}),
);
expect(setViewportMock).toHaveBeenCalledWith({
width: 1280,
height: 800,
deviceScaleFactor: 2,
});
expect(setContentMock).toHaveBeenCalledWith(expect.stringContaining('<h1>hi</h1>'), {
waitUntil: 'load',
timeout: 45_000,
});
expect(screenshotMock).toHaveBeenCalledWith({ type: 'png', fullPage: true });
expect(readFileSync(dest)).toEqual(fakePngBytes);
expect(result).toEqual({ bytes: fakePngBytes.length, path: dest });
expect(closeMock).toHaveBeenCalledTimes(1);
});

it('uses a caller-provided viewport', async () => {
const { exportPng } = await import('./png');

await exportPng('<p>x</p>', join(tempDir, 'narrow.png'), {
chromePath: '/tmp/fake-chrome',
viewport: { width: 390, height: 844 },
});

expect(launchMock).toHaveBeenCalledWith(
expect.objectContaining({ executablePath: '/tmp/fake-chrome' }),
);
expect(setViewportMock).toHaveBeenCalledWith({ width: 390, height: 844, deviceScaleFactor: 2 });
});

it('wraps browser failures in EXPORTER_PNG_FAILED and still closes Chrome', async () => {
screenshotMock.mockRejectedValueOnce(new Error('boom'));
const { exportPng } = await import('./png');

await expect(exportPng('<p>x</p>', join(tempDir, 'fail.png'))).rejects.toMatchObject({
code: 'EXPORTER_PNG_FAILED',
});
expect(closeMock).toHaveBeenCalledTimes(1);
});
});
65 changes: 65 additions & 0 deletions packages/exporters/src/png.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { CodesignError, ERROR_CODES } from '@open-codesign/shared';
import type { ExportResult } from './index';
import { type BrowserRenderOptions, buildExportHtmlDocument } from './rendered-html';

export type ExportPngOptions = BrowserRenderOptions;

const DEFAULT_VIEWPORT = { width: 1280, height: 800 } as const;
// 2x keeps text sharp when the image is pasted into chat, docs, or slides.
const DEVICE_SCALE_FACTOR = 2;

export async function exportPng(
artifactSource: string,
destinationPath: string,
opts: ExportPngOptions = {},
): Promise<ExportResult> {
const { findSystemChrome } = await import('./chrome-discovery');
const puppeteer = (await import('puppeteer-core')).default;
const executablePath = opts.chromePath ?? (await findSystemChrome());
const userDataDir = await mkdtemp(join(tmpdir(), 'codesign-png-'));
let browser: Awaited<ReturnType<typeof puppeteer.launch>> | null = null;

try {
browser = await puppeteer.launch({
executablePath,
headless: true,
userDataDir,
args: [
'--headless=new',
'--disable-dev-shm-usage',
'--disable-gpu',
'--no-first-run',
'--no-default-browser-check',
],
});
const page = await browser.newPage();
await page.setViewport({
...(opts.viewport ?? DEFAULT_VIEWPORT),
deviceScaleFactor: DEVICE_SCALE_FACTOR,
});
await page.setContent(await buildExportHtmlDocument(artifactSource, opts), {
waitUntil: opts.waitUntil ?? 'load',
timeout: opts.renderTimeoutMs ?? 45_000,
});
await page.evaluate('document.fonts?.ready ?? Promise.resolve()');
if (opts.settleMs && opts.settleMs > 0) {
await new Promise((resolve) => setTimeout(resolve, opts.settleMs));
}
const png = await page.screenshot({ type: 'png', fullPage: true });
await writeFile(destinationPath, png);
return { bytes: (await stat(destinationPath)).size, path: destinationPath };
} catch (err) {
if (err instanceof CodesignError) throw err;
throw new CodesignError(
`PNG export failed: ${err instanceof Error ? err.message : String(err)}`,
ERROR_CODES.EXPORTER_PNG_FAILED,
{ cause: err },
);
} finally {
if (browser) await browser.close();
await rm(userDataDir, { recursive: true, force: true });
}
}
7 changes: 6 additions & 1 deletion packages/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,10 @@
"label": "PDF",
"hint": "Rendered via your installed Chrome"
},
"png": {
"label": "PNG",
"hint": "Full-page image at 2x, via your installed Chrome"
},
"pptx": {
"label": "PPTX",
"hint": "High-fidelity slides; one per <section>"
Expand Down Expand Up @@ -1525,8 +1529,9 @@
"PREFERENCES_INVALID_TIMEOUT": "The generation timeout value is invalid.",
"SKILL_LOAD_FAILED": "One or more skills failed to load. Check your skill files for errors.",
"EXPORTER_UNKNOWN": "Unknown export format requested.",
"EXPORTER_NO_CHROME": "Chrome or Chromium was not found. Install it to enable PDF export.",
"EXPORTER_NO_CHROME": "Chrome or Chromium was not found. Install it to enable PDF and PNG export.",
"EXPORTER_PDF_FAILED": "PDF export failed. Ensure Chrome is installed and try again.",
"EXPORTER_PNG_FAILED": "PNG export failed. Ensure Chrome is installed and try again.",
"EXPORTER_PPTX_FAILED": "PowerPoint export failed.",
"EXPORTER_ZIP_UNSAFE_PATH": "Export was blocked: an asset path would escape the ZIP archive.",
"EXPORTER_ZIP_FAILED": "ZIP export failed.",
Expand Down
7 changes: 6 additions & 1 deletion packages/i18n/src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,10 @@
"label": "PDF",
"hint": "Renderizado a través de tu Chrome instalado"
},
"png": {
"label": "PNG",
"hint": "Imagen de página completa a 2x, mediante tu Chrome instalado"
},
"pptx": {
"label": "PPTX",
"hint": "Diapositivas de alta fidelidad; una por <section>"
Expand Down Expand Up @@ -1348,8 +1352,9 @@
"PREFERENCES_INVALID_TIMEOUT": "El valor del tiempo de espera de generación es inválido.",
"SKILL_LOAD_FAILED": "Uno o más habilidades fallaron al cargar. Revisa tus archivos de habilidades para errores.",
"EXPORTER_UNKNOWN": "Formato de exportación desconocido solicitado.",
"EXPORTER_NO_CHROME": "No se encontró Chrome o Chromium. Instálalo para habilitar la exportación a PDF.",
"EXPORTER_NO_CHROME": "No se encontró Chrome o Chromium. Instálalo para habilitar la exportación a PDF y PNG.",
"EXPORTER_PDF_FAILED": "La exportación a PDF falló. Asegúrate de que Chrome esté instalado e inténtalo de nuevo.",
"EXPORTER_PNG_FAILED": "La exportación a PNG falló. Asegúrate de que Chrome esté instalado e inténtalo de nuevo.",
"EXPORTER_PPTX_FAILED": "La exportación a PowerPoint falló.",
"EXPORTER_ZIP_UNSAFE_PATH": "La exportación fue bloqueada: una ruta de activo escaparía del archivo ZIP.",
"EXPORTER_ZIP_FAILED": "La exportación a ZIP falló.",
Expand Down
7 changes: 6 additions & 1 deletion packages/i18n/src/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,10 @@
"label": "PDF",
"hint": "Renderizado via seu Chrome instalado"
},
"png": {
"label": "PNG",
"hint": "Imagem da página inteira em 2x, pelo Chrome instalado"
},
"pptx": {
"label": "PPTX",
"hint": "Slides de alta fidelidade; um por <section>"
Expand Down Expand Up @@ -1338,8 +1342,9 @@
"PREFERENCES_INVALID_TIMEOUT": "O valor do tempo limite de geração é inválido.",
"SKILL_LOAD_FAILED": "Uma ou mais skills falharam ao carregar. Procure erros nos seus arquivos de skill.",
"EXPORTER_UNKNOWN": "Formato de exportação desconhecido.",
"EXPORTER_NO_CHROME": "Chrome ou Chromium não encontrado. Instale um deles para habilitar a exportação em PDF.",
"EXPORTER_NO_CHROME": "Chrome ou Chromium não encontrado. Instale um deles para habilitar a exportação em PDF e PNG.",
"EXPORTER_PDF_FAILED": "A exportação em PDF falhou. Garanta que o Chrome esteja instalado e tente novamente.",
"EXPORTER_PNG_FAILED": "A exportação em PNG falhou. Garanta que o Chrome esteja instalado e tente novamente.",
"EXPORTER_PPTX_FAILED": "A exportação em PowerPoint falhou.",
"EXPORTER_ZIP_UNSAFE_PATH": "A exportação foi bloqueada: um caminho de asset escaparia dos limites do arquivo ZIP.",
"EXPORTER_ZIP_FAILED": "A exportação em ZIP falhou.",
Expand Down
7 changes: 6 additions & 1 deletion packages/i18n/src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,10 @@
"label": "PDF",
"hint": "通过你本机已安装的 Chrome 渲染"
},
"png": {
"label": "PNG",
"hint": "整页 2 倍图,通过本机 Chrome 渲染"
},
"pptx": {
"label": "PPTX",
"hint": "高保真幻灯片;每个 <section> 一页"
Expand Down Expand Up @@ -1521,8 +1525,9 @@
"PREFERENCES_INVALID_TIMEOUT": "生成超时时长的设置无效。",
"SKILL_LOAD_FAILED": "有 skill 加载失败,请检查 skill 文件是否有误。",
"EXPORTER_UNKNOWN": "未知的导出格式。",
"EXPORTER_NO_CHROME": "未检测到 Chrome 或 Chromium,请安装后再进行 PDF 导出。",
"EXPORTER_NO_CHROME": "未检测到 Chrome 或 Chromium,请安装后再进行 PDF 或 PNG 导出。",
"EXPORTER_PDF_FAILED": "PDF 导出失败,请确认已安装 Chrome 后重试。",
"EXPORTER_PNG_FAILED": "PNG 导出失败,请确认已安装 Chrome 后重试。",
"EXPORTER_PPTX_FAILED": "PowerPoint 导出失败。",
"EXPORTER_ZIP_UNSAFE_PATH": "导出被阻止:有资源路径会越出 ZIP 包边界。",
"EXPORTER_ZIP_FAILED": "ZIP 导出失败。",
Expand Down
8 changes: 7 additions & 1 deletion packages/shared/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export const ERROR_CODES = {
EXPORTER_UNKNOWN: 'EXPORTER_UNKNOWN',
EXPORTER_NO_CHROME: 'EXPORTER_NO_CHROME',
EXPORTER_PDF_FAILED: 'EXPORTER_PDF_FAILED',
EXPORTER_PNG_FAILED: 'EXPORTER_PNG_FAILED',
EXPORTER_PPTX_FAILED: 'EXPORTER_PPTX_FAILED',
EXPORTER_ZIP_UNSAFE_PATH: 'EXPORTER_ZIP_UNSAFE_PATH',
EXPORTER_ZIP_FAILED: 'EXPORTER_ZIP_FAILED',
Expand Down Expand Up @@ -376,7 +377,7 @@ export const ERROR_CODE_DESCRIPTIONS: Record<CodesignErrorCode, ErrorCodeDescrip
category: 'other',
},
EXPORTER_NO_CHROME: {
userFacing: 'Chrome or Chromium was not found. Install it to enable PDF export.',
userFacing: 'Chrome or Chromium was not found. Install it to enable PDF and PNG export.',
userFacingKey: 'err.EXPORTER_NO_CHROME',
category: 'other',
},
Expand All @@ -385,6 +386,11 @@ export const ERROR_CODE_DESCRIPTIONS: Record<CodesignErrorCode, ErrorCodeDescrip
userFacingKey: 'err.EXPORTER_PDF_FAILED',
category: 'other',
},
EXPORTER_PNG_FAILED: {
userFacing: 'PNG export failed. Ensure Chrome is installed and try again.',
userFacingKey: 'err.EXPORTER_PNG_FAILED',
category: 'other',
},
EXPORTER_PPTX_FAILED: {
userFacing: 'PowerPoint export failed.',
userFacingKey: 'err.EXPORTER_PPTX_FAILED',
Expand Down
Loading