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 = `
${'
Task
'.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 = `
+
Intro
+
Market
+
`; + + 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) => + `
${i + 1}
`, + ).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