diff --git a/.changeset/png-export.md b/.changeset/png-export.md
new file mode 100644
index 00000000..e8ed350c
--- /dev/null
+++ b/.changeset/png-export.md
@@ -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.
diff --git a/apps/desktop/src/main/exporter-ipc.test.ts b/apps/desktop/src/main/exporter-ipc.test.ts
index e158182e..cf30eead 100644
--- a/apps/desktop/src/main/exporter-ipc.test.ts
+++ b/apps/desktop/src/main/exporter-ipc.test.ts
@@ -52,6 +52,10 @@ describe('parseRequest', () => {
expect(result.defaultFilename).toBe('report.pdf');
});
+ it('accepts a png request', () => {
+ expect(parseRequest({ format: 'png', artifactSource: '
' }).format).toBe('png');
+ });
+
it('accepts workspace source context for local asset exports', () => {
const result = parseRequest({
format: 'zip',
@@ -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',
);
diff --git a/apps/desktop/src/main/exporter-ipc.ts b/apps/desktop/src/main/exporter-ipc.ts
index a1e523a2..e4a98b3d 100644
--- a/apps/desktop/src/main/exporter-ipc.ts
+++ b/apps/desktop/src/main/exporter-ipc.ts
@@ -27,6 +27,7 @@ import { readWorkspaceFileAt } from './workspace-reader';
const FORMAT_FILTERS: Record = {
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'] }],
@@ -95,6 +96,7 @@ export function parseRequest(raw: unknown): ExportRequest {
if (
format !== 'html' &&
format !== 'pdf' &&
+ format !== 'png' &&
format !== 'pptx' &&
format !== 'zip' &&
format !== 'markdown'
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
index 6ecfbf78..b5f6a25f 100644
--- a/apps/desktop/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -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'
diff --git a/apps/desktop/src/renderer/src/components/PreviewToolbar.tsx b/apps/desktop/src/renderer/src/components/PreviewToolbar.tsx
index a1527a9d..95c35600 100644
--- a/apps/desktop/src/renderer/src/components/PreviewToolbar.tsx
+++ b/apps/desktop/src/renderer/src/components/PreviewToolbar.tsx
@@ -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,
diff --git a/packages/exporters/package.json b/packages/exporters/package.json
index f17c6608..4147ca98 100644
--- a/packages/exporters/package.json
+++ b/packages/exporters/package.json
@@ -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"
diff --git a/packages/exporters/src/index.ts b/packages/exporters/src/index.ts
index 957c90c5..7c048ac1 100644
--- a/packages/exporters/src/index.ts
+++ b/packages/exporters/src/index.ts
@@ -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[] };
@@ -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';
@@ -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);
diff --git a/packages/exporters/src/png.chrome.test.ts b/packages/exporters/src/png.chrome.test.ts
new file mode 100644
index 00000000..1e3a2b17
--- /dev/null
+++ b/packages/exporters/src/png.chrome.test.ts
@@ -0,0 +1,175 @@
+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 shown = await page.$$eval('section', (sections) =>
+ sections
+ .filter((section) => section.getBoundingClientRect().height > 0)
+ .map((section) => ({
+ top: section.getBoundingClientRect().top + window.scrollY,
+ className: section.className,
+ })),
+ );
+ return {
+ laidOut,
+ tops: shown.map((section) => section.top),
+ classes: shown.map((section) => section.className),
+ };
+ } 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, classes } = await layoutInChrome(scaffold);
+
+ expect(laidOut).toBe(2);
+ expect(tops).toHaveLength(2);
+ expect(tops[0]).toBeGreaterThanOrEqual(0);
+ expect(tops[1]).toBeGreaterThan(tops[0] ?? 0);
+ expect(classes).toEqual(['slide title-slide', 'slide content-slide']);
+ }, 60_000);
+
+ it('leaves pages whose sections are all visible unchanged', async () => {
+ const page = `${''.repeat(3)}`;
+
+ const { laidOut, tops } = await layoutInChrome(page);
+
+ expect(laidOut).toBe(0);
+ expect(tops).toHaveLength(3);
+ }, 60_000);
+
+ async function stackedSlides(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 shown = await page.$$eval('section', (sections) =>
+ sections
+ .filter((section) => getComputedStyle(section).display !== 'none')
+ .map((section) => {
+ const style = getComputedStyle(section);
+ return [
+ section.id,
+ section.className,
+ section.dataset['title'] ?? '',
+ style.opacity,
+ style.visibility,
+ style.color,
+ ].join(' | ');
+ }),
+ );
+ return { laidOut, shown };
+ } finally {
+ await browser.close();
+ }
+ }
+
+ it('applies the active-slide styles to every stacked slide', async () => {
+ const slides = ['one', 'two', 'three']
+ .map(
+ (id, i) =>
+ ``,
+ )
+ .join('');
+ const html = `${slides}
`;
+
+ const { laidOut, shown } = await stackedSlides(html);
+
+ expect(laidOut).toBe(3);
+ expect(shown).toEqual([
+ 'one | slide active | | 1 | visible | rgb(0, 0, 0)',
+ 'two | slide active | | 1 | visible | rgb(0, 0, 0)',
+ 'three | slide active | | 1 | visible | rgb(0, 0, 0)',
+ ]);
+ }, 60_000);
+
+ it('keeps the id, own classes and content attributes of each slide', async () => {
+ const html = `
+
+
+
`;
+
+ const { laidOut, shown } = await stackedSlides(html);
+
+ expect(laidOut).toBe(2);
+ expect(shown).toEqual([
+ 'intro | slide intro | Intro | 1 | visible | rgb(200, 0, 0)',
+ 'market | slide market | Market | 1 | visible | rgb(0, 0, 200)',
+ ]);
+ }, 60_000);
+
+ it('captures the full height of a long stacked deck', async () => {
+ const slides = Array.from(
+ { length: 20 },
+ (_, i) =>
+ ``,
+ ).join('');
+ const html = `${slides}`;
+ 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);
+ },
+);
diff --git a/packages/exporters/src/png.test.ts b/packages/exporters/src/png.test.ts
new file mode 100644
index 00000000..8e0f3df9
--- /dev/null
+++ b/packages/exporters/src/png.test.ts
@@ -0,0 +1,153 @@
+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();
+ 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('hi
', 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('hi
'), {
+ 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('x
', 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('x
', 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('x
', join(tempDir, 'fail.png'))).rejects.toMatchObject({
+ code: 'EXPORTER_PNG_FAILED',
+ });
+ expect(closeMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps a saved PNG and removes the profile when Chrome fails to close', async () => {
+ closeMock.mockRejectedValueOnce(new Error('close failed'));
+ const { exportPng } = await import('./png');
+ const dest = join(tempDir, 'close-fails.png');
+
+ await expect(exportPng('x
', dest)).resolves.toEqual({
+ bytes: fakePngBytes.length,
+ path: dest,
+ });
+ expect(readFileSync(dest)).toEqual(fakePngBytes);
+ expect(rmMock).toHaveBeenCalledWith(expect.stringContaining('codesign-png-'), {
+ recursive: true,
+ force: true,
+ });
+ });
+
+ it('reports the capture error and removes the profile when Chrome also fails to close', async () => {
+ screenshotMock.mockRejectedValueOnce(new Error('boom'));
+ closeMock.mockRejectedValueOnce(new Error('close failed'));
+ const { exportPng } = await import('./png');
+
+ await expect(exportPng('x
', join(tempDir, 'both-fail.png'))).rejects.toMatchObject({
+ code: 'EXPORTER_PNG_FAILED',
+ message: 'PNG export failed: boom',
+ });
+ expect(rmMock).toHaveBeenCalledWith(expect.stringContaining('codesign-png-'), {
+ recursive: true,
+ force: true,
+ });
+ });
+});
diff --git a/packages/exporters/src/png.ts b/packages/exporters/src/png.ts
new file mode 100644
index 00000000..34de7003
--- /dev/null
+++ b/packages/exporters/src/png.ts
@@ -0,0 +1,164 @@
+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 {
+ 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> | 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));
+ }
+ await page.evaluate(EXPAND_DECK_SCRIPT);
+ 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 {
+ // Cleanup must not replace the export result or the original error. When
+ // close() rejects, puppeteer has already fallen back to killing Chrome.
+ try {
+ await browser?.close();
+ } catch {
+ /* noop */
+ }
+ try {
+ await rm(userDataDir, { recursive: true, force: true });
+ } catch {
+ /* noop */
+ }
+ }
+}
+
+// Decks that show one slide at a time (the built-in 16:9 scaffold
+// hides inactive slides with display: none) would otherwise export only the
+// visible slide. Each slide gets its own copy of the deck container, showing
+// that slide alone, and the copies replace the page body in slide order, as
+// PDF export does for decks. Uses the same slide-size checks as PDF export.
+// Returns the number of slides laid out, or 0 when the page is left as is.
+export const EXPAND_DECK_SCRIPT = `(() => {
+ const parents = new Set(Array.from(document.querySelectorAll('section'), (el) => el.parentElement));
+ for (const parent of parents) {
+ if (!parent || parent === document.body) continue;
+ const slides = Array.from(parent.children).filter((el) => el.tagName === 'SECTION');
+ const visible = slides.filter((el) => getComputedStyle(el).display !== 'none');
+ if (slides.length < 2 || visible.length !== 1) continue;
+ const active = visible[0];
+ const rect = active.getBoundingClientRect();
+ const ratio = rect.width / Math.max(1, rect.height);
+ if (rect.width < 480 || rect.height < 270 || ratio < 1.5 || ratio > 1.95) continue;
+ const display = getComputedStyle(active).display;
+ // The shown slide carries a mark the others lack, such as an "active" class
+ // or data-active="true". Each copy moves that mark to its own slide, so
+ // styles tied to it, not just display, apply there. A class or attribute
+ // counts as the mark only if giving the shown slide the other slides' value
+ // hides it; ids, per-slide classes and content attributes stay in place.
+ const others = slides.filter((el) => el !== active);
+ const hidesActive = (change) => {
+ const saved = active.getAttributeNames().map((name) => [name, active.getAttribute(name)]);
+ active.style.transition = 'none';
+ change();
+ const style = getComputedStyle(active);
+ const hidden = style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0';
+ for (const name of active.getAttributeNames()) active.removeAttribute(name);
+ for (const [name, value] of saved) active.setAttribute(name, value);
+ return hidden;
+ };
+ const activeClasses = Array.from(active.classList).filter(
+ (name) =>
+ others.every((el) => !el.classList.contains(name)) &&
+ hidesActive(() => active.classList.remove(name)),
+ );
+ const otherClasses = Array.from(others[0].classList).filter(
+ (name) =>
+ !active.classList.contains(name) &&
+ others.every((el) => el.classList.contains(name)) &&
+ hidesActive(() => active.classList.add(name)),
+ );
+ const setMark = (el, name, value) => {
+ if (value === null) el.removeAttribute(name);
+ else el.setAttribute(name, value);
+ };
+ const marks = Array.from(new Set(slides.flatMap((el) => el.getAttributeNames())))
+ .filter((name) => name !== 'class' && name !== 'style')
+ .map((name) => ({ name, shown: active.getAttribute(name), other: others[0].getAttribute(name) }))
+ .filter(
+ (mark) =>
+ mark.shown !== mark.other &&
+ others.every((el) => el.getAttribute(mark.name) === mark.other) &&
+ hidesActive(() => setMark(active, mark.name, mark.other)),
+ );
+ const frames = slides.map((_, index) => {
+ const frame = parent.cloneNode(true);
+ const copies = Array.from(frame.children).filter((el) => el.tagName === 'SECTION');
+ copies.forEach((el, i) => {
+ const shown = i === index;
+ el.classList.remove(...(shown ? otherClasses : activeClasses));
+ el.classList.add(...(shown ? activeClasses : otherClasses));
+ for (const mark of marks) setMark(el, mark.name, shown ? mark.shown : mark.other);
+ el.style.display = shown ? display : 'none';
+ });
+ frame.style.margin = '0 auto 24px';
+ return frame;
+ });
+ const body = document.body;
+ for (const node of Array.from(body.querySelectorAll('style, link[rel="stylesheet"]'))) {
+ document.head.appendChild(node.cloneNode(true));
+ }
+ body.replaceChildren(...frames);
+ document.documentElement.style.height = 'auto';
+ body.style.height = 'auto';
+ body.style.display = 'block';
+ body.style.padding = '24px 0 0';
+ // Force layout now: without it the full-page capture can measure the page
+ // before the stacked slides are laid out and clip the image to the viewport.
+ void document.documentElement.scrollHeight;
+ return frames.length;
+ }
+ return 0;
+})()`;
diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json
index 3c7c7bdf..242a3d0e 100644
--- a/packages/i18n/src/locales/en.json
+++ b/packages/i18n/src/locales/en.json
@@ -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; slide decks (one slide per ) are stacked in order into one tall image"
+ },
"pptx": {
"label": "PPTX",
"hint": "High-fidelity slides; one per "
@@ -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.",
diff --git a/packages/i18n/src/locales/es.json b/packages/i18n/src/locales/es.json
index c612f5a4..16f3d798 100644
--- a/packages/i18n/src/locales/es.json
+++ b/packages/i18n/src/locales/es.json
@@ -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; las presentaciones (una diapositiva por ) se apilan en orden en una sola imagen alta"
+ },
"pptx": {
"label": "PPTX",
"hint": "Diapositivas de alta fidelidad; una por "
@@ -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ó.",
diff --git a/packages/i18n/src/locales/pt-BR.json b/packages/i18n/src/locales/pt-BR.json
index b6747f88..2a975c00 100644
--- a/packages/i18n/src/locales/pt-BR.json
+++ b/packages/i18n/src/locales/pt-BR.json
@@ -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; apresentações (um slide por ) são empilhadas em ordem numa única imagem alta"
+ },
"pptx": {
"label": "PPTX",
"hint": "Slides de alta fidelidade; um por "
@@ -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.",
diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json
index 7973e01e..1162a71f 100644
--- a/packages/i18n/src/locales/zh-CN.json
+++ b/packages/i18n/src/locales/zh-CN.json
@@ -956,6 +956,10 @@
"label": "PDF",
"hint": "通过你本机已安装的 Chrome 渲染"
},
+ "png": {
+ "label": "PNG",
+ "hint": "整页 2 倍图,通过本机 Chrome 渲染;多页幻灯片(每个 一页)按顺序纵向拼成一张长图"
+ },
"pptx": {
"label": "PPTX",
"hint": "高保真幻灯片;每个 一页"
@@ -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 导出失败。",
diff --git a/packages/shared/src/error-codes.ts b/packages/shared/src/error-codes.ts
index b0e8d258..de8b172c 100644
--- a/packages/shared/src/error-codes.ts
+++ b/packages/shared/src/error-codes.ts
@@ -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',
@@ -376,7 +377,7 @@ export const ERROR_CODE_DESCRIPTIONS: Record