Skip to content

Commit 5c33d9f

Browse files
fix(exporters): paginate sectionless pptx screenshots
1 parent 7431267 commit 5c33d9f

3 files changed

Lines changed: 152 additions & 8 deletions

File tree

.changeset/polite-pptx-pages.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@open-codesign/exporters": patch
3+
---
4+
5+
Paginate PPTX image exports with fallback slide selectors when artifacts do not define section slides.

packages/exporters/src/pptx.test.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
22
import { tmpdir } from 'node:os';
33
import { join } from 'node:path';
4-
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
4+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
55
import { exportPptx, extractSlides } from './pptx';
66

77
const pngBytes = Buffer.from(
@@ -17,6 +17,8 @@ const screenshotMock = vi.fn();
1717
const closeMock = vi.fn();
1818
const sectionBoundingBoxMock = vi.fn();
1919
const querySectionsMock = vi.fn();
20+
const defaultFallbackSlideSelector =
21+
'[data-slide], [data-pptx-slide], [data-slide-container], .slide';
2022

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

3133
beforeAll(() => {
3234
tempDir = mkdtempSync(join(tmpdir(), 'codesign-pptx-test-'));
35+
});
36+
37+
beforeEach(() => {
38+
vi.clearAllMocks();
3339
launchMock.mockResolvedValue({
3440
newPage: newPageMock,
3541
close: closeMock,
@@ -51,6 +57,12 @@ afterAll(() => {
5157
rmSync(tempDir, { recursive: true, force: true });
5258
});
5359

60+
function mockPaginationPageCount(pageCount: number): void {
61+
evaluateMock.mockImplementation(async (source: unknown) =>
62+
typeof source === 'string' && source.includes('pageCount') ? { pageCount } : undefined,
63+
);
64+
}
65+
5466
describe('extractSlides', () => {
5567
it('treats each <section> as a slide and pulls the heading + bullets', () => {
5668
const html = `
@@ -131,6 +143,87 @@ describe('exportPptx', () => {
131143
);
132144
});
133145

146+
it('uses slide-like containers when section elements are absent', async () => {
147+
querySectionsMock.mockImplementation(async (selector: string) =>
148+
selector === 'section' ? [] : [{ boundingBox: sectionBoundingBoxMock }],
149+
);
150+
const dest = join(tempDir, 'slide-class-visual.pptx');
151+
152+
await exportPptx('<div class="slide"><h1>Visual</h1></div>', dest);
153+
154+
expect(querySectionsMock).toHaveBeenNthCalledWith(1, 'section');
155+
expect(querySectionsMock).toHaveBeenNthCalledWith(2, defaultFallbackSlideSelector);
156+
expect(screenshotMock).toHaveBeenCalledWith(
157+
expect.objectContaining({ type: 'png', clip: expect.any(Object) }),
158+
);
159+
});
160+
161+
it('uses a caller-provided fallback slide selector', async () => {
162+
querySectionsMock.mockImplementation(async (selector: string) =>
163+
selector === '[data-slide-container]' ? [{ boundingBox: sectionBoundingBoxMock }] : [],
164+
);
165+
const dest = join(tempDir, 'custom-slide-selector.pptx');
166+
167+
await exportPptx('<article data-slide-container><h1>Visual</h1></article>', dest, {
168+
slideSelector: '[data-slide-container]',
169+
});
170+
171+
expect(querySectionsMock).toHaveBeenNthCalledWith(1, 'section');
172+
expect(querySectionsMock).toHaveBeenNthCalledWith(2, '[data-slide-container]');
173+
expect(screenshotMock).toHaveBeenCalledTimes(1);
174+
});
175+
176+
it('paginates sectionless documents into viewport-sized screenshots', async () => {
177+
querySectionsMock.mockResolvedValue([]);
178+
mockPaginationPageCount(3);
179+
const dest = join(tempDir, 'sectionless-visual.pptx');
180+
181+
await exportPptx('<main><h1>Long artifact</h1></main>', dest);
182+
183+
expect(screenshotMock).toHaveBeenCalledTimes(3);
184+
expect(screenshotMock).toHaveBeenNthCalledWith(1, {
185+
type: 'png',
186+
clip: { x: 0, y: 0, width: 1280, height: 720 },
187+
});
188+
expect(screenshotMock).toHaveBeenNthCalledWith(2, {
189+
type: 'png',
190+
clip: { x: 0, y: 720, width: 1280, height: 720 },
191+
});
192+
expect(screenshotMock).toHaveBeenNthCalledWith(3, {
193+
type: 'png',
194+
clip: { x: 0, y: 1440, width: 1280, height: 720 },
195+
});
196+
expect(screenshotMock).not.toHaveBeenCalledWith(expect.objectContaining({ fullPage: true }));
197+
});
198+
199+
it('keeps short sectionless documents to one viewport-sized screenshot', async () => {
200+
querySectionsMock.mockResolvedValue([]);
201+
mockPaginationPageCount(1);
202+
const dest = join(tempDir, 'short-sectionless-visual.pptx');
203+
204+
await exportPptx('<main><h1>Short artifact</h1></main>', dest);
205+
206+
expect(screenshotMock).toHaveBeenCalledTimes(1);
207+
expect(screenshotMock).toHaveBeenCalledWith({
208+
type: 'png',
209+
clip: { x: 0, y: 0, width: 1280, height: 720 },
210+
});
211+
});
212+
213+
it('exports an empty sectionless document as one screenshot slide', async () => {
214+
querySectionsMock.mockResolvedValue([]);
215+
mockPaginationPageCount(1);
216+
const dest = join(tempDir, 'empty-sectionless-visual.pptx');
217+
218+
await exportPptx('', dest);
219+
220+
expect(screenshotMock).toHaveBeenCalledTimes(1);
221+
expect(screenshotMock).toHaveBeenCalledWith({
222+
type: 'png',
223+
clip: { x: 0, y: 0, width: 1280, height: 720 },
224+
});
225+
});
226+
134227
it('wraps JSX source before screenshotting PPTX slides', async () => {
135228
setContentMock.mockClear();
136229
const dest = join(tempDir, 'jsx-visual.pptx');

packages/exporters/src/pptx.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,19 @@ export interface ExportPptxOptions extends LocalAssetOptions {
2323
renderTimeoutMs?: number;
2424
/** Viewport used when rasterizing slides. */
2525
viewport?: { width: number; height: number };
26+
/** CSS selector used to find slide-like containers when no <section> elements exist. */
27+
slideSelector?: string;
2628
}
2729

2830
interface SlideContent {
2931
title: string;
3032
bullets: string[];
3133
}
3234

35+
const PRIMARY_SLIDE_SELECTOR: string = 'section';
36+
const DEFAULT_FALLBACK_SLIDE_SELECTOR: string =
37+
'[data-slide], [data-pptx-slide], [data-slide-container], .slide';
38+
3339
/**
3440
* Render a design source artifact to PPTX using pptxgenjs.
3541
*
@@ -129,7 +135,11 @@ async function renderSlideScreenshots(
129135
const { findSystemChrome } = await import('./chrome-discovery');
130136
const puppeteer = (await import('puppeteer-core')).default;
131137

132-
const viewport = opts.viewport ?? { width: 1280, height: 720 };
138+
const requestedViewport = opts.viewport ?? { width: 1280, height: 720 };
139+
const viewport = {
140+
width: Math.max(1, requestedViewport.width),
141+
height: Math.max(1, requestedViewport.height),
142+
};
133143
const executablePath = opts.chromePath ?? (await findSystemChrome());
134144
let html = buildHtmlDocument(artifactSource, { prettify: false });
135145
html = await inlineLocalAssetsInHtml(html, opts);
@@ -154,11 +164,15 @@ async function renderSlideScreenshots(
154164
});
155165
await page.evaluate('document.fonts?.ready ?? Promise.resolve()');
156166

157-
const sectionHandles = await page.$$('section');
158167
const screenshots: Buffer[] = [];
159-
if (sectionHandles.length > 0) {
160-
for (const section of sectionHandles) {
161-
const box = await section.boundingBox();
168+
let slideHandles = await page.$$(PRIMARY_SLIDE_SELECTOR);
169+
if (slideHandles.length === 0) {
170+
slideHandles = await page.$$(opts.slideSelector ?? DEFAULT_FALLBACK_SLIDE_SELECTOR);
171+
}
172+
173+
if (slideHandles.length > 0) {
174+
for (const slideElement of slideHandles) {
175+
const box = await slideElement.boundingBox();
162176
if (!box || box.width <= 0 || box.height <= 0) continue;
163177
const image = await page.screenshot({
164178
type: 'png',
@@ -173,8 +187,40 @@ async function renderSlideScreenshots(
173187
}
174188
}
175189
if (screenshots.length === 0) {
176-
const image = await page.screenshot({ type: 'png', fullPage: true });
177-
screenshots.push(Buffer.from(image));
190+
const pagination = (await page.evaluate(`
191+
(() => {
192+
const slideHeight = ${JSON.stringify(viewport.height)};
193+
const root = document.documentElement;
194+
const body = document.body;
195+
const scrollHeight = Math.max(
196+
root.scrollHeight,
197+
root.offsetHeight,
198+
root.clientHeight,
199+
body ? body.scrollHeight : 0,
200+
body ? body.offsetHeight : 0,
201+
body ? body.clientHeight : 0
202+
);
203+
const pageCount = Math.max(1, Math.ceil(scrollHeight / slideHeight));
204+
const captureHeight = pageCount * slideHeight;
205+
root.style.minHeight = captureHeight + 'px';
206+
if (body) body.style.minHeight = captureHeight + 'px';
207+
return { pageCount };
208+
})()
209+
`)) as { pageCount: number };
210+
const pageCount = Number.isFinite(pagination.pageCount) ? pagination.pageCount : 1;
211+
212+
for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
213+
const image = await page.screenshot({
214+
type: 'png',
215+
clip: {
216+
x: 0,
217+
y: pageIndex * viewport.height,
218+
width: viewport.width,
219+
height: viewport.height,
220+
},
221+
});
222+
screenshots.push(Buffer.from(image));
223+
}
178224
}
179225
return screenshots;
180226
} finally {

0 commit comments

Comments
 (0)