Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
175 changes: 175 additions & 0 deletions packages/exporters/src/png.chrome.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `<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);

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) =>
`<section id="${id}" class="slide${i === 0 ? ' active' : ' hidden'}"${i === 0 ? '' : ' aria-hidden="true"'}>${id}</section>`,
)
.join('');
const html = `<!doctype html><html><head><style>
body { margin: 0; }
.deck { width: 1280px; height: 720px; position: relative; }
.slide { position: absolute; inset: 0; display: none; opacity: 0; transition: opacity 0.3s; }
.slide.active { display: grid; opacity: 1; }
.slide.hidden, .slide[aria-hidden="true"] { visibility: hidden; }
</style></head><body><div class="deck">${slides}</div></body></html>`;

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 = `<!doctype html><html><head><style>
body { margin: 0; }
.deck { width: 1280px; height: 720px; position: relative; }
.slide { position: absolute; inset: 0; display: none; opacity: 0; }
.slide[data-active="true"] { display: grid; opacity: 1; }
.intro { color: rgb(200, 0, 0); }
.market { color: rgb(0, 0, 200); }
</style></head><body><div class="deck">
<section id="intro" class="slide intro" data-title="Intro" data-active="true">Intro</section>
<section id="market" class="slide market" data-title="Market" data-active="false">Market</section>
</div></body></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) =>
`<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);
},
);
Loading
Loading