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
5 changes: 5 additions & 0 deletions .changeset/polite-pptx-pages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@open-codesign/exporters": patch
---

Paginate PPTX image exports with fallback slide selectors when artifacts do not define section slides.
95 changes: 94 additions & 1 deletion packages/exporters/src/pptx.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { exportPptx, extractSlides } from './pptx';

const pngBytes = Buffer.from(
Expand All @@ -17,6 +17,8 @@ const screenshotMock = vi.fn();
const closeMock = vi.fn();
const sectionBoundingBoxMock = vi.fn();
const querySectionsMock = vi.fn();
const defaultFallbackSlideSelector =
'[data-slide], [data-pptx-slide], [data-slide-container], .slide';

vi.mock('puppeteer-core', () => ({
default: { launch: launchMock },
Expand All @@ -30,6 +32,10 @@ let tempDir = '';

beforeAll(() => {
tempDir = mkdtempSync(join(tmpdir(), 'codesign-pptx-test-'));
});

beforeEach(() => {
vi.clearAllMocks();
launchMock.mockResolvedValue({
newPage: newPageMock,
close: closeMock,
Expand All @@ -51,6 +57,12 @@ afterAll(() => {
rmSync(tempDir, { recursive: true, force: true });
});

function mockPaginationPageCount(pageCount: number): void {
evaluateMock.mockImplementation(async (source: unknown) =>
typeof source === 'function' ? { pageCount } : undefined,
);
}

describe('extractSlides', () => {
it('treats each <section> as a slide and pulls the heading + bullets', () => {
const html = `
Expand Down Expand Up @@ -131,6 +143,87 @@ describe('exportPptx', () => {
);
});

it('uses slide-like containers when section elements are absent', async () => {
querySectionsMock.mockImplementation(async (selector: string) =>
selector === 'section' ? [] : [{ boundingBox: sectionBoundingBoxMock }],
);
const dest = join(tempDir, 'slide-class-visual.pptx');

await exportPptx('<div class="slide"><h1>Visual</h1></div>', dest);

expect(querySectionsMock).toHaveBeenNthCalledWith(1, 'section');
expect(querySectionsMock).toHaveBeenNthCalledWith(2, defaultFallbackSlideSelector);
expect(screenshotMock).toHaveBeenCalledWith(
expect.objectContaining({ type: 'png', clip: expect.any(Object) }),
);
});

it('uses a caller-provided fallback slide selector', async () => {
querySectionsMock.mockImplementation(async (selector: string) =>
selector === '[data-slide-container]' ? [{ boundingBox: sectionBoundingBoxMock }] : [],
);
const dest = join(tempDir, 'custom-slide-selector.pptx');

await exportPptx('<article data-slide-container><h1>Visual</h1></article>', dest, {
slideSelector: '[data-slide-container]',
});

expect(querySectionsMock).toHaveBeenNthCalledWith(1, 'section');
expect(querySectionsMock).toHaveBeenNthCalledWith(2, '[data-slide-container]');
expect(screenshotMock).toHaveBeenCalledTimes(1);
});

it('paginates sectionless documents into viewport-sized screenshots', async () => {
querySectionsMock.mockResolvedValue([]);
mockPaginationPageCount(3);
const dest = join(tempDir, 'sectionless-visual.pptx');

await exportPptx('<main><h1>Long artifact</h1></main>', dest);

expect(screenshotMock).toHaveBeenCalledTimes(3);
expect(screenshotMock).toHaveBeenNthCalledWith(1, {
type: 'png',
clip: { x: 0, y: 0, width: 1280, height: 720 },
});
expect(screenshotMock).toHaveBeenNthCalledWith(2, {
type: 'png',
clip: { x: 0, y: 720, width: 1280, height: 720 },
});
expect(screenshotMock).toHaveBeenNthCalledWith(3, {
type: 'png',
clip: { x: 0, y: 1440, width: 1280, height: 720 },
});
expect(screenshotMock).not.toHaveBeenCalledWith(expect.objectContaining({ fullPage: true }));
});

it('keeps short sectionless documents to one viewport-sized screenshot', async () => {
querySectionsMock.mockResolvedValue([]);
mockPaginationPageCount(1);
const dest = join(tempDir, 'short-sectionless-visual.pptx');

await exportPptx('<main><h1>Short artifact</h1></main>', dest);

expect(screenshotMock).toHaveBeenCalledTimes(1);
expect(screenshotMock).toHaveBeenCalledWith({
type: 'png',
clip: { x: 0, y: 0, width: 1280, height: 720 },
});
});

it('exports an empty sectionless document as one screenshot slide', async () => {
querySectionsMock.mockResolvedValue([]);
mockPaginationPageCount(1);
const dest = join(tempDir, 'empty-sectionless-visual.pptx');

await exportPptx('', dest);

expect(screenshotMock).toHaveBeenCalledTimes(1);
expect(screenshotMock).toHaveBeenCalledWith({
type: 'png',
clip: { x: 0, y: 0, width: 1280, height: 720 },
});
});

it('wraps JSX source before screenshotting PPTX slides', async () => {
setContentMock.mockClear();
const dest = join(tempDir, 'jsx-visual.pptx');
Expand Down
80 changes: 73 additions & 7 deletions packages/exporters/src/pptx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,19 @@ export interface ExportPptxOptions extends LocalAssetOptions {
renderTimeoutMs?: number;
/** Viewport used when rasterizing slides. */
viewport?: { width: number; height: number };
/** CSS selector used to find slide-like containers when no <section> elements exist. */
slideSelector?: string;
}

interface SlideContent {
title: string;
bullets: string[];
}

const PRIMARY_SLIDE_SELECTOR: string = 'section';
const DEFAULT_FALLBACK_SLIDE_SELECTOR: string =
'[data-slide], [data-pptx-slide], [data-slide-container], .slide';

/**
* Render a design source artifact to PPTX using pptxgenjs.
*
Expand Down Expand Up @@ -129,7 +135,11 @@ async function renderSlideScreenshots(
const { findSystemChrome } = await import('./chrome-discovery');
const puppeteer = (await import('puppeteer-core')).default;

const viewport = opts.viewport ?? { width: 1280, height: 720 };
const requestedViewport = opts.viewport ?? { width: 1280, height: 720 };
const viewport = {
width: Math.max(1, requestedViewport.width),
height: Math.max(1, requestedViewport.height),
};
const executablePath = opts.chromePath ?? (await findSystemChrome());
let html = buildHtmlDocument(artifactSource, { prettify: false });
html = await inlineLocalAssetsInHtml(html, opts);
Expand All @@ -154,11 +164,15 @@ async function renderSlideScreenshots(
});
await page.evaluate('document.fonts?.ready ?? Promise.resolve()');

const sectionHandles = await page.$$('section');
const screenshots: Buffer[] = [];
if (sectionHandles.length > 0) {
for (const section of sectionHandles) {
const box = await section.boundingBox();
let slideHandles = await page.$$(PRIMARY_SLIDE_SELECTOR);
if (slideHandles.length === 0) {
slideHandles = await page.$$(opts.slideSelector ?? DEFAULT_FALLBACK_SLIDE_SELECTOR);
}

if (slideHandles.length > 0) {
for (const slideElement of slideHandles) {
const box = await slideElement.boundingBox();
if (!box || box.width <= 0 || box.height <= 0) continue;
const image = await page.screenshot({
type: 'png',
Expand All @@ -173,15 +187,67 @@ async function renderSlideScreenshots(
}
}
if (screenshots.length === 0) {
const image = await page.screenshot({ type: 'png', fullPage: true });
screenshots.push(Buffer.from(image));
const pagination = await page.evaluate((slideHeight: number) => {
const readNumber = (target: object | null, key: string): number => {
if (!target) return 0;
const value = Reflect.get(target, key);
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
};
const setMinHeight = (target: object | null, height: number): void => {
if (!target) return;
const style = Reflect.get(target, 'style');
if (style && typeof style === 'object') {
Reflect.set(style, 'minHeight', `${height}px`);
}
};
const documentValue = Reflect.get(globalThis, 'document');
if (!documentValue || typeof documentValue !== 'object') return { pageCount: 1 };
const rootValue = Reflect.get(documentValue, 'documentElement');
if (!rootValue || typeof rootValue !== 'object') return { pageCount: 1 };
const bodyValue = Reflect.get(documentValue, 'body');
const root = rootValue;
const body = bodyValue && typeof bodyValue === 'object' ? bodyValue : null;
const scrollHeight = Math.max(
readNumber(root, 'scrollHeight'),
readNumber(root, 'offsetHeight'),
readNumber(root, 'clientHeight'),
readNumber(body, 'scrollHeight'),
readNumber(body, 'offsetHeight'),
readNumber(body, 'clientHeight'),
);
const pageCount = Math.max(1, Math.ceil(scrollHeight / slideHeight));
const captureHeight = pageCount * slideHeight;
setMinHeight(root, captureHeight);
setMinHeight(body, captureHeight);
return { pageCount };
}, viewport.height);
const pageCount = normalizePageCount(pagination);

for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
const image = await page.screenshot({
type: 'png',
clip: {
x: 0,
y: pageIndex * viewport.height,
width: viewport.width,
height: viewport.height,
},
});
screenshots.push(Buffer.from(image));
}
}
return screenshots;
} finally {
await browser.close();
}
}

function normalizePageCount(pagination: unknown): number {
if (!pagination || typeof pagination !== 'object' || !('pageCount' in pagination)) return 1;
const pageCount = pagination.pageCount;
return typeof pageCount === 'number' && Number.isFinite(pageCount) ? Math.max(1, pageCount) : 1;
}

const SECTION_RE = /<section\b[^>]*>([\s\S]*?)<\/section>/gi;
const HEADING_RE = /<h[1-3]\b[^>]*>([\s\S]*?)<\/h[1-3]>/i;
const LIST_ITEM_RE = /<li\b[^>]*>([\s\S]*?)<\/li>/gi;
Expand Down
Loading