Files
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

214 lines
7.8 KiB
TypeScript

import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import JSZip from 'jszip';
import { PDFDocument } from 'pdf-lib';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
buildScreenshotPdf,
buildScreenshotPptx,
buildDeckRenderInput,
decodeSlideDataUrls,
readSlideFiles,
} from '../src/deck-export.js';
// 1x1 transparent PNG.
const PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
const PNG_DATA_URL = `data:image/png;base64,${PNG_BASE64}`;
// 1x1 JPEG.
const JPEG_DATA_URL =
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAAAv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AfwD/2Q==';
describe('decodeSlideDataUrls', () => {
it('decodes base64 PNG data URLs into tagged buffers', () => {
const img = decodeSlideDataUrls([PNG_DATA_URL])[0]!;
expect(Buffer.isBuffer(img.buffer)).toBe(true);
expect(img.jpeg).toBe(false);
// PNG magic number.
expect(img.buffer.subarray(0, 4).toString('hex')).toBe('89504e47');
});
it('decodes base64 JPEG data URLs and tags them as jpeg', () => {
const img = decodeSlideDataUrls([JPEG_DATA_URL])[0]!;
expect(img.jpeg).toBe(true);
// JPEG SOI marker.
expect(img.buffer.subarray(0, 2).toString('hex')).toBe('ffd8');
});
it('rejects a non-image data URL', () => {
expect(() => decodeSlideDataUrls(['data:text/plain;base64,aGk='])).toThrow();
});
it('rejects a value that is not a data URL', () => {
expect(() => decodeSlideDataUrls(['not-a-data-url'])).toThrow();
});
});
describe('readSlideFiles', () => {
let dir = '';
beforeAll(async () => {
dir = mkdtempSync(path.join(tmpdir(), 'od-slide-files-'));
await writeFile(path.join(dir, 'slide-0.png'), Buffer.from(PNG_BASE64, 'base64'));
await writeFile(
path.join(dir, 'slide-1.jpeg'),
Buffer.from(JPEG_DATA_URL.split(',')[1]!, 'base64'),
);
});
afterAll(() => {
if (dir) rmSync(dir, { recursive: true, force: true });
});
it('reads files into tagged buffers (jpeg flag from extension)', async () => {
const [png, jpeg] = await readSlideFiles([
path.join(dir, 'slide-0.png'),
path.join(dir, 'slide-1.jpeg'),
]);
expect(png!.jpeg).toBe(false);
expect(png!.buffer.subarray(0, 4).toString('hex')).toBe('89504e47');
expect(jpeg!.jpeg).toBe(true);
expect(jpeg!.buffer.subarray(0, 2).toString('hex')).toBe('ffd8');
});
it('throws when a path is empty', async () => {
await expect(readSlideFiles([''])).rejects.toThrow();
});
it('throws when a file does not exist', async () => {
await expect(readSlideFiles([path.join(dir, 'missing.png')])).rejects.toMatchObject({
code: 'ENOENT',
});
});
});
describe('buildDeckRenderInput', () => {
it('injects a deck-stage fallback runtime before desktop rendering', async () => {
const projectsRoot = mkdtempSync(path.join(tmpdir(), 'od-deck-render-input-'));
const projectId = 'project-1';
const projectDir = path.join(projectsRoot, projectId);
mkdirSync(projectDir, { recursive: true });
await writeFile(
path.join(projectDir, 'broken-deck.html'),
`<!doctype html>
<html>
<body>
<deck-stage width="1920" height="1080">
<section class="slide">One</section>
<section class="slide">Two</section>
</deck-stage>
<script>
/**
* Original runtime was truncated while documenting a speaker notes tag.
* Example marker: <script type="application/json" id="speaker-notes">
[]
</script>
</body>
</html>`,
);
try {
const request = await buildDeckRenderInput({
daemonUrl: 'http://127.0.0.1:60636',
deck: true,
fileName: 'broken-deck.html',
projectId,
projectsRoot,
});
expect(request.input.html).toContain('data-od-deck-stage-fallback');
expect(request.input.html).toContain("window.customElements.define('deck-stage'");
expect(request.input.html).toContain("type: 'od:slide-state'");
} finally {
rmSync(projectsRoot, { recursive: true, force: true });
}
});
it('uses supplied sourceHtml for version exports instead of reading the current file', async () => {
const projectsRoot = mkdtempSync(path.join(tmpdir(), 'od-deck-render-input-'));
const projectId = 'project-1';
const projectDir = path.join(projectsRoot, projectId);
mkdirSync(projectDir, { recursive: true });
await writeFile(path.join(projectDir, 'deck.html'), '<html><body>Current deck</body></html>');
try {
const request = await buildDeckRenderInput({
daemonUrl: 'http://127.0.0.1:60636',
deck: true,
fileName: 'deck.html',
projectId,
projectsRoot,
sourceHtml: '<html><body>Historical deck version</body></html>',
});
expect(request.input.html).toContain('Historical deck version');
expect(request.input.html).not.toContain('Current deck');
expect(request.input.baseHref).toBe('http://127.0.0.1:60636/api/projects/project-1/raw/');
} finally {
rmSync(projectsRoot, { recursive: true, force: true });
}
});
});
describe('buildScreenshotPptx', () => {
it('produces a non-empty OOXML (zip) document with one slide per image', async () => {
const pngs = decodeSlideDataUrls([PNG_DATA_URL, PNG_DATA_URL]);
const out = await buildScreenshotPptx(pngs, { title: 'Test Deck' });
expect(out.length).toBeGreaterThan(0);
// .pptx is a ZIP container — starts with the local file header "PK".
expect(out.subarray(0, 2).toString('latin1')).toBe('PK');
});
// Reads the slide-size aspect (cx/cy) from a .pptx buffer's presentation.xml.
async function pptxSlideAspect(buf: Buffer): Promise<number> {
const zip = await JSZip.loadAsync(buf);
const xml = await zip.file('ppt/presentation.xml')!.async('string');
const m = /<p:sldSz[^>]*\bcx="(\d+)"[^>]*\bcy="(\d+)"/.exec(xml);
if (!m) throw new Error('no sldSz in presentation.xml');
return Number(m[1]) / Number(m[2]);
}
it('defaults to a 16:9 slide layout', async () => {
const out = await buildScreenshotPptx(decodeSlideDataUrls([PNG_DATA_URL]));
expect(await pptxSlideAspect(out)).toBeCloseTo(16 / 9, 2);
});
it('follows a non-16:9 deck aspect (4:3) instead of forcing 16:9', async () => {
const out = await buildScreenshotPptx(decodeSlideDataUrls([PNG_DATA_URL]), { aspect: 4 / 3 });
expect(await pptxSlideAspect(out)).toBeCloseTo(4 / 3, 2);
});
it('follows a portrait deck aspect (9:16)', async () => {
const out = await buildScreenshotPptx(decodeSlideDataUrls([PNG_DATA_URL]), { aspect: 9 / 16 });
expect(await pptxSlideAspect(out)).toBeCloseTo(9 / 16, 2);
});
it('throws when there are no slides', async () => {
await expect(buildScreenshotPptx([])).rejects.toThrow(/no slides/i);
});
});
describe('buildScreenshotPdf', () => {
it('produces a %PDF document', async () => {
const pngs = decodeSlideDataUrls([PNG_DATA_URL]);
const out = await buildScreenshotPdf(pngs);
expect(out.subarray(0, 5).toString('latin1')).toBe('%PDF-');
});
it('normalizes page size to points (not raw capture pixels, so DPR cannot bloat it)', async () => {
const pngs = decodeSlideDataUrls([PNG_DATA_URL]); // a 1x1 px image
const out = await buildScreenshotPdf(pngs);
const doc = await PDFDocument.load(out);
const { width, height } = doc.getPage(0).getSize();
// aspect 1 => normalized 960x960 pt (PowerPoint-ish), NOT the 1x1 pixel size.
expect(Math.round(width)).toBe(960);
expect(Math.round(height)).toBe(960);
});
it('throws when there are no slides', async () => {
await expect(buildScreenshotPdf([])).rejects.toThrow(/no slides/i);
});
});