Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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. Decks that show one slide at a time are expanded so every slide appears in order.
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
89 changes: 89 additions & 0 deletions packages/exporters/src/png.chrome.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import puppeteer from 'puppeteer-core';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { findSystemChrome } from './chrome-discovery';
import { EXPAND_DECK_SCRIPT, exportPng } from './png';

let tempDir = '';

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

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

async function layoutInChrome(html: string) {
const browser = await puppeteer.launch({
executablePath: await findSystemChrome(),
headless: true,
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.setContent(html, { waitUntil: 'load' });
const laidOut = await page.evaluate(EXPAND_DECK_SCRIPT);
const tops = await page.$$eval('section', (sections) =>
sections
.filter((section) => section.getBoundingClientRect().height > 0)
.map((section) => section.getBoundingClientRect().top + window.scrollY),
);
return { laidOut, tops };
} finally {
await browser.close();
}
}

describe.runIf(process.env['CODESIGN_EXPORT_BROWSER_TESTS'] === '1')(
'PNG deck layout in system Chrome',
() => {
it('stacks every slide of the built-in 16:9 deck scaffold', async () => {
const scaffold = readFileSync(
new URL(
'../../../apps/desktop/resources/templates/scaffolds/decks/slide-16-9.html',
import.meta.url,
),
'utf8',
);

const { laidOut, tops } = await layoutInChrome(scaffold);

expect(laidOut).toBe(2);
expect(tops).toHaveLength(2);
expect(tops[0]).toBeGreaterThanOrEqual(0);
expect(tops[1]).toBeGreaterThan(tops[0] ?? 0);
}, 60_000);

it('leaves pages whose sections are all visible unchanged', async () => {
const page = `<main>${'<section style="height:160px">Task</section>'.repeat(3)}</main>`;

const { laidOut, tops } = await layoutInChrome(page);

expect(laidOut).toBe(0);
expect(tops).toHaveLength(3);
}, 60_000);

it('captures the full height of a long stacked deck', async () => {
const slides = Array.from(
{ length: 20 },
(_, i) =>
`<section class="slide"${i === 0 ? ' data-active="true"' : ''}>${i + 1}</section>`,
).join('');
const html = `<!doctype html><html><head><style>
body { margin: 0; display: grid; place-items: center; height: 100%; }
.deck { width: 1200px; aspect-ratio: 16 / 9; position: relative; }
.slide { position: absolute; inset: 0; display: none; }
.slide[data-active="true"] { display: block; }
</style></head><body><main class="deck">${slides}</main></body></html>`;
const dest = join(tempDir, 'long-deck.png');

await exportPng(html, dest, { sourcePath: 'deck.html' });

// 24px top padding, then 20 slides of 675px each followed by a 24px gap, at 2x.
expect(readFileSync(dest).readUInt32BE(20)).toBe((24 + 20 * (675 + 24)) * 2);
}, 120_000);
},
);
122 changes: 122 additions & 0 deletions packages/exporters/src/png.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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();
const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() }));

vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
rmMock.mockImplementation(actual.rm);
return { ...actual, rm: rmMock };
});

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 { EXPAND_DECK_SCRIPT, 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(evaluateMock).toHaveBeenLastCalledWith(EXPAND_DECK_SCRIPT);
expect(evaluateMock.mock.invocationCallOrder.at(-1)).toBeLessThan(
screenshotMock.mock.invocationCallOrder[0] ?? 0,
);
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('keeps a saved PNG when the temporary Chrome profile cannot be removed', async () => {
rmMock.mockRejectedValueOnce(Object.assign(new Error('busy'), { code: 'EBUSY' }));
const { exportPng } = await import('./png');
const dest = join(tempDir, 'locked-profile.png');

await expect(exportPng('<p>x</p>', dest)).resolves.toEqual({
bytes: fakePngBytes.length,
path: dest,
});
});

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);
});
});
Loading
Loading