Skip to content

Commit e331e69

Browse files
authored
feat(exporters): export the active design as PNG (#471)
## Summary Sharing a design in chat, docs, or slides usually needs an image, and the Export menu only offered HTML, PDF, PPTX, ZIP, and Markdown. This adds PNG. It renders the same export document as PDF with the installed Chrome (`puppeteer-core`, already a dependency) and saves a full-page screenshot at 2x. The existing exporters are unchanged; the missing-Chrome message now names both PDF and PNG export in all four locales. Decks that display one `<section>` slide at a time, such as the built-in 16:9 scaffold, would otherwise export only the visible slide. Before the screenshot, each slide gets its own copy of the deck container and the copies are stacked in the page, as PDF export does for decks, using the same slide-size checks; pages whose sections are all visible are left unchanged. In each copy, the mark that shows a slide (for example an `active` class or `data-active="true"`) moves to that copy's slide, so styles tied to it, such as opacity, apply to every slide. A class or attribute counts as that mark only if giving the shown slide the other slides' value hides it; ids, per-slide layout classes, and content attributes stay on their own slide. The Export menu hint for PNG says that slide decks are stacked in order into one tall image. The page is therefore not captured strictly as it renders on load for such decks. Layout is forced after stacking so the full-page capture measures the new height. Closing Chrome and removing its temporary profile cannot change the export result: a failed close neither replaces a saved PNG nor hides the original error, and the profile is still removed afterwards (puppeteer has already fallen back to killing Chrome by the time `close()` rejects). ## Type of change - [ ] Bug fix - [x] New feature - [ ] Refactor (no behavior change) - [ ] Documentation - [ ] Build / CI / tooling - [ ] Breaking change ## Linked issue No linked issue. ## Checklist - [x] I checked the linked issue / relevant context before starting - [x] `pnpm lint && pnpm typecheck && pnpm test` passes locally - [x] Added/updated tests for the change - [x] Added a changeset (`pnpm changeset`) if user-visible - [ ] Updated docs if behavior changed ## Dependency additions (if any) None. ## Screenshots / recordings (UI changes) Not attached. The Export menu gains a PNG item between PDF and PPTX. ## Checks - `pnpm lint` (680 files), `pnpm typecheck` (10 tasks) - Pre-push `pnpm -r typecheck && pnpm lint && pnpm test` with `CI=1` (the repo's two-worker limit): 10 tasks passed; desktop 178 files, 2,610 tests. Without the worker limit, a different real-Chrome test timed out on each of three macOS runs (`PromptInput.browser`, `preview-runtime`, `source-edit-engine.text-coverage`); each passed when rerun alone. - `packages/exporters/src/png.test.ts`: 2x viewport and full-page capture, caller viewport, a saved PNG kept when profile removal fails, failure wrapped as `EXPORTER_PNG_FAILED` with Chrome closed, a saved PNG kept and the profile removed when Chrome fails to close, and the capture error reported (not the close error) when both fail. The two close cases fail without the fix. - `CODESIGN_EXPORT_BROWSER_TESTS=1` browser tests in `png.chrome.test.ts` (same gate as the existing HTML browser test): the built-in 16:9 scaffold lays out both slides top to bottom; a page with three visible sections is left unchanged; a 20-slide deck exports at its full height (28,008 px at 2x); a deck whose shown slide is marked by an `active` class, with `opacity` and `visibility` rules, ends up with all three slides visible and fully opaque (with display-only stacking, slides 2 and 3 had opacity 0); a two-slide deck keeps each slide's id, own class, `data-title`, and colour; the 16:9 scaffold keeps `title-slide` and `content-slide` on their own pages. The last two fail when every differing class or attribute is moved. All pass locally on macOS. Without the forced layout, the 20-slide export came back clipped to the viewport (1,600 px) on every run, and an 8-slide export did so intermittently. - With the system Chrome on macOS, the 16:9 scaffold exported as one 2560 × 2794 PNG containing both slides, and a JSX page with twelve visible sections exported at 2560 × 4592, the same as before this change. - With the system Chrome on macOS, a five-slide deck in a temporary workspace, using a local `@font-face` file and a local `<img>`, exported as one 2560 × 6912 PNG (the expected full height). Sampling the slide backgrounds from top to bottom gives slides 1–5 in order, and every slide shows the local font and image with the same layout as the first. - Not yet checked on Windows.
1 parent c7b0f23 commit e331e69

15 files changed

Lines changed: 547 additions & 8 deletions

File tree

‎.changeset/png-export.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@open-codesign/exporters": patch
3+
"@open-codesign/desktop": patch
4+
"@open-codesign/shared": patch
5+
"@open-codesign/i18n": patch
6+
---
7+
8+
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.

‎apps/desktop/src/main/exporter-ipc.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ describe('parseRequest', () => {
5252
expect(result.defaultFilename).toBe('report.pdf');
5353
});
5454

55+
it('accepts a png request', () => {
56+
expect(parseRequest({ format: 'png', artifactSource: '<html/>' }).format).toBe('png');
57+
});
58+
5559
it('accepts workspace source context for local asset exports', () => {
5660
const result = parseRequest({
5761
format: 'zip',
@@ -172,6 +176,7 @@ describe('export path helpers', () => {
172176
expect(ensureExportExtension('/tmp/report', 'pdf')).toBe('/tmp/report.pdf');
173177
expect(ensureExportExtension('/tmp/report.PDF', 'pdf')).toBe('/tmp/report.PDF');
174178
expect(ensureExportExtension('/tmp/report.txt', 'pdf')).toBe('/tmp/report.txt.pdf');
179+
expect(ensureExportExtension('/tmp/home', 'png')).toBe('/tmp/home.png');
175180
expect(ensureExportExtension('/tmp/report.markdown', 'markdown')).toBe(
176181
'/tmp/report.markdown.md',
177182
);

‎apps/desktop/src/main/exporter-ipc.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { readWorkspaceFileAt } from './workspace-reader';
2727
const FORMAT_FILTERS: Record<ExporterFormat, Electron.FileFilter[]> = {
2828
html: [{ name: 'HTML', extensions: ['html'] }],
2929
pdf: [{ name: 'PDF', extensions: ['pdf'] }],
30+
png: [{ name: 'PNG image', extensions: ['png'] }],
3031
pptx: [{ name: 'PowerPoint', extensions: ['pptx'] }],
3132
zip: [{ name: 'ZIP archive', extensions: ['zip'] }],
3233
markdown: [{ name: 'Markdown', extensions: ['md'] }],
@@ -95,6 +96,7 @@ export function parseRequest(raw: unknown): ExportRequest {
9596
if (
9697
format !== 'html' &&
9798
format !== 'pdf' &&
99+
format !== 'png' &&
98100
format !== 'pptx' &&
99101
format !== 'zip' &&
100102
format !== 'markdown'

‎apps/desktop/src/preload/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export interface ValidateKeyError {
7070
message: string;
7171
}
7272

73-
export type ExportFormat = 'html' | 'pdf' | 'pptx' | 'zip' | 'markdown';
73+
export type ExportFormat = 'html' | 'pdf' | 'png' | 'pptx' | 'zip' | 'markdown';
7474
export type WorkspaceFileKind =
7575
| 'html'
7676
| 'jsx'

‎apps/desktop/src/renderer/src/components/PreviewToolbar.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { PreviewToolbarMenu } from './PreviewToolbarMenu';
77
import './PreviewToolbar.css';
88

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

1212
export function PreviewToolbar({
1313
canFullscreen = false,

‎packages/exporters/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
".": "./src/index.ts",
1010
"./html": "./src/html.ts",
1111
"./pdf": "./src/pdf.ts",
12+
"./png": "./src/png.ts",
1213
"./pptx": "./src/pptx.ts",
1314
"./zip": "./src/zip.ts",
1415
"./markdown": "./src/markdown.ts"

‎packages/exporters/src/index.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import { CodesignError, ERROR_CODES } from '@open-codesign/shared';
1111
import type { LocalAssetOptions } from './assets';
1212

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

1616
export type ExportOptions = LocalAssetOptions & { assets?: import('./zip').ZipAsset[] };
@@ -38,6 +38,7 @@ export type { ExportHtmlOptions } from './html';
3838
export type { ExportMarkdownOptions, MarkdownMeta } from './markdown';
3939
export { htmlToMarkdown } from './markdown';
4040
export type { ExportPdfOptions } from './pdf';
41+
export type { ExportPngOptions } from './png';
4142
export type { ExportPptxOptions } from './pptx';
4243
export type { ExportZipOptions, ZipAsset } from './zip';
4344

@@ -63,6 +64,10 @@ export async function exportArtifact(
6364
const mod = await import('./pdf');
6465
return mod.exportPdf(artifactSource, destinationPath, opts);
6566
}
67+
if (format === 'png') {
68+
const mod = await import('./png');
69+
return mod.exportPng(artifactSource, destinationPath, opts);
70+
}
6671
if (format === 'pptx') {
6772
const mod = await import('./pptx');
6873
return mod.exportPptx(artifactSource, destinationPath, opts);
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
import puppeteer from 'puppeteer-core';
5+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
6+
import { findSystemChrome } from './chrome-discovery';
7+
import { EXPAND_DECK_SCRIPT, exportPng } from './png';
8+
9+
let tempDir = '';
10+
11+
beforeAll(() => {
12+
tempDir = mkdtempSync(join(tmpdir(), 'codesign-png-chrome-test-'));
13+
});
14+
15+
afterAll(() => {
16+
rmSync(tempDir, { recursive: true, force: true });
17+
});
18+
19+
async function layoutInChrome(html: string) {
20+
const browser = await puppeteer.launch({
21+
executablePath: await findSystemChrome(),
22+
headless: true,
23+
});
24+
try {
25+
const page = await browser.newPage();
26+
await page.setViewport({ width: 1280, height: 800 });
27+
await page.setContent(html, { waitUntil: 'load' });
28+
const laidOut = await page.evaluate(EXPAND_DECK_SCRIPT);
29+
const shown = await page.$$eval('section', (sections) =>
30+
sections
31+
.filter((section) => section.getBoundingClientRect().height > 0)
32+
.map((section) => ({
33+
top: section.getBoundingClientRect().top + window.scrollY,
34+
className: section.className,
35+
})),
36+
);
37+
return {
38+
laidOut,
39+
tops: shown.map((section) => section.top),
40+
classes: shown.map((section) => section.className),
41+
};
42+
} finally {
43+
await browser.close();
44+
}
45+
}
46+
47+
describe.runIf(process.env['CODESIGN_EXPORT_BROWSER_TESTS'] === '1')(
48+
'PNG deck layout in system Chrome',
49+
() => {
50+
it('stacks every slide of the built-in 16:9 deck scaffold', async () => {
51+
const scaffold = readFileSync(
52+
new URL(
53+
'../../../apps/desktop/resources/templates/scaffolds/decks/slide-16-9.html',
54+
import.meta.url,
55+
),
56+
'utf8',
57+
);
58+
59+
const { laidOut, tops, classes } = await layoutInChrome(scaffold);
60+
61+
expect(laidOut).toBe(2);
62+
expect(tops).toHaveLength(2);
63+
expect(tops[0]).toBeGreaterThanOrEqual(0);
64+
expect(tops[1]).toBeGreaterThan(tops[0] ?? 0);
65+
expect(classes).toEqual(['slide title-slide', 'slide content-slide']);
66+
}, 60_000);
67+
68+
it('leaves pages whose sections are all visible unchanged', async () => {
69+
const page = `<main>${'<section style="height:160px">Task</section>'.repeat(3)}</main>`;
70+
71+
const { laidOut, tops } = await layoutInChrome(page);
72+
73+
expect(laidOut).toBe(0);
74+
expect(tops).toHaveLength(3);
75+
}, 60_000);
76+
77+
async function stackedSlides(html: string) {
78+
const browser = await puppeteer.launch({
79+
executablePath: await findSystemChrome(),
80+
headless: true,
81+
});
82+
try {
83+
const page = await browser.newPage();
84+
await page.setViewport({ width: 1280, height: 800 });
85+
await page.setContent(html, { waitUntil: 'load' });
86+
const laidOut = await page.evaluate(EXPAND_DECK_SCRIPT);
87+
const shown = await page.$$eval('section', (sections) =>
88+
sections
89+
.filter((section) => getComputedStyle(section).display !== 'none')
90+
.map((section) => {
91+
const style = getComputedStyle(section);
92+
return [
93+
section.id,
94+
section.className,
95+
section.dataset['title'] ?? '',
96+
style.opacity,
97+
style.visibility,
98+
style.color,
99+
].join(' | ');
100+
}),
101+
);
102+
return { laidOut, shown };
103+
} finally {
104+
await browser.close();
105+
}
106+
}
107+
108+
it('applies the active-slide styles to every stacked slide', async () => {
109+
const slides = ['one', 'two', 'three']
110+
.map(
111+
(id, i) =>
112+
`<section id="${id}" class="slide${i === 0 ? ' active' : ' hidden'}"${i === 0 ? '' : ' aria-hidden="true"'}>${id}</section>`,
113+
)
114+
.join('');
115+
const html = `<!doctype html><html><head><style>
116+
body { margin: 0; }
117+
.deck { width: 1280px; height: 720px; position: relative; }
118+
.slide { position: absolute; inset: 0; display: none; opacity: 0; transition: opacity 0.3s; }
119+
.slide.active { display: grid; opacity: 1; }
120+
.slide.hidden, .slide[aria-hidden="true"] { visibility: hidden; }
121+
</style></head><body><div class="deck">${slides}</div></body></html>`;
122+
123+
const { laidOut, shown } = await stackedSlides(html);
124+
125+
expect(laidOut).toBe(3);
126+
expect(shown).toEqual([
127+
'one | slide active | | 1 | visible | rgb(0, 0, 0)',
128+
'two | slide active | | 1 | visible | rgb(0, 0, 0)',
129+
'three | slide active | | 1 | visible | rgb(0, 0, 0)',
130+
]);
131+
}, 60_000);
132+
133+
it('keeps the id, own classes and content attributes of each slide', async () => {
134+
const html = `<!doctype html><html><head><style>
135+
body { margin: 0; }
136+
.deck { width: 1280px; height: 720px; position: relative; }
137+
.slide { position: absolute; inset: 0; display: none; opacity: 0; }
138+
.slide[data-active="true"] { display: grid; opacity: 1; }
139+
.intro { color: rgb(200, 0, 0); }
140+
.market { color: rgb(0, 0, 200); }
141+
</style></head><body><div class="deck">
142+
<section id="intro" class="slide intro" data-title="Intro" data-active="true">Intro</section>
143+
<section id="market" class="slide market" data-title="Market" data-active="false">Market</section>
144+
</div></body></html>`;
145+
146+
const { laidOut, shown } = await stackedSlides(html);
147+
148+
expect(laidOut).toBe(2);
149+
expect(shown).toEqual([
150+
'intro | slide intro | Intro | 1 | visible | rgb(200, 0, 0)',
151+
'market | slide market | Market | 1 | visible | rgb(0, 0, 200)',
152+
]);
153+
}, 60_000);
154+
155+
it('captures the full height of a long stacked deck', async () => {
156+
const slides = Array.from(
157+
{ length: 20 },
158+
(_, i) =>
159+
`<section class="slide"${i === 0 ? ' data-active="true"' : ''}>${i + 1}</section>`,
160+
).join('');
161+
const html = `<!doctype html><html><head><style>
162+
body { margin: 0; display: grid; place-items: center; height: 100%; }
163+
.deck { width: 1200px; aspect-ratio: 16 / 9; position: relative; }
164+
.slide { position: absolute; inset: 0; display: none; }
165+
.slide[data-active="true"] { display: block; }
166+
</style></head><body><main class="deck">${slides}</main></body></html>`;
167+
const dest = join(tempDir, 'long-deck.png');
168+
169+
await exportPng(html, dest, { sourcePath: 'deck.html' });
170+
171+
// 24px top padding, then 20 slides of 675px each followed by a 24px gap, at 2x.
172+
expect(readFileSync(dest).readUInt32BE(20)).toBe((24 + 20 * (675 + 24)) * 2);
173+
}, 120_000);
174+
},
175+
);

0 commit comments

Comments
 (0)