Skip to content

Commit d9949de

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

3 files changed

Lines changed: 104 additions & 7 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 when artifacts do not define slide sections.

packages/exporters/src/pptx.test.ts

Lines changed: 45 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(
@@ -30,6 +30,10 @@ let tempDir = '';
3030

3131
beforeAll(() => {
3232
tempDir = mkdtempSync(join(tmpdir(), 'codesign-pptx-test-'));
33+
});
34+
35+
beforeEach(() => {
36+
vi.clearAllMocks();
3337
launchMock.mockResolvedValue({
3438
newPage: newPageMock,
3539
close: closeMock,
@@ -131,6 +135,46 @@ describe('exportPptx', () => {
131135
);
132136
});
133137

138+
it('uses slide-like containers when section elements are absent', async () => {
139+
querySectionsMock.mockImplementation(async (selector: string) =>
140+
selector === 'section' ? [] : [{ boundingBox: sectionBoundingBoxMock }],
141+
);
142+
const dest = join(tempDir, 'slide-class-visual.pptx');
143+
144+
await exportPptx('<div class="slide"><h1>Visual</h1></div>', dest);
145+
146+
expect(querySectionsMock).toHaveBeenNthCalledWith(1, 'section');
147+
expect(querySectionsMock).toHaveBeenNthCalledWith(2, '[data-slide], [data-pptx-slide], .slide');
148+
expect(screenshotMock).toHaveBeenCalledWith(
149+
expect.objectContaining({ type: 'png', clip: expect.any(Object) }),
150+
);
151+
});
152+
153+
it('paginates sectionless documents into viewport-sized screenshots', async () => {
154+
querySectionsMock.mockResolvedValue([]);
155+
evaluateMock.mockImplementation(async (source: unknown) =>
156+
typeof source === 'function' ? { pageCount: 3 } : undefined,
157+
);
158+
const dest = join(tempDir, 'sectionless-visual.pptx');
159+
160+
await exportPptx('<main><h1>Long artifact</h1></main>', dest);
161+
162+
expect(screenshotMock).toHaveBeenCalledTimes(3);
163+
expect(screenshotMock).toHaveBeenNthCalledWith(1, {
164+
type: 'png',
165+
clip: { x: 0, y: 0, width: 1280, height: 720 },
166+
});
167+
expect(screenshotMock).toHaveBeenNthCalledWith(2, {
168+
type: 'png',
169+
clip: { x: 0, y: 720, width: 1280, height: 720 },
170+
});
171+
expect(screenshotMock).toHaveBeenNthCalledWith(3, {
172+
type: 'png',
173+
clip: { x: 0, y: 1440, width: 1280, height: 720 },
174+
});
175+
expect(screenshotMock).not.toHaveBeenCalledWith(expect.objectContaining({ fullPage: true }));
176+
});
177+
134178
it('wraps JSX source before screenshotting PPTX slides', async () => {
135179
setContentMock.mockClear();
136180
const dest = join(tempDir, 'jsx-visual.pptx');

packages/exporters/src/pptx.ts

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@ interface SlideContent {
3030
bullets: string[];
3131
}
3232

33+
interface BrowserLayoutElement {
34+
scrollHeight: number;
35+
offsetHeight: number;
36+
clientHeight: number;
37+
style: { minHeight: string };
38+
}
39+
40+
interface BrowserDocumentShape {
41+
documentElement: BrowserLayoutElement;
42+
body: BrowserLayoutElement | null;
43+
}
44+
45+
const PRIMARY_SLIDE_SELECTOR: string = 'section';
46+
const FALLBACK_SLIDE_SELECTOR: string = '[data-slide], [data-pptx-slide], .slide';
47+
3348
/**
3449
* Render a design source artifact to PPTX using pptxgenjs.
3550
*
@@ -154,11 +169,15 @@ async function renderSlideScreenshots(
154169
});
155170
await page.evaluate('document.fonts?.ready ?? Promise.resolve()');
156171

157-
const sectionHandles = await page.$$('section');
158172
const screenshots: Buffer[] = [];
159-
if (sectionHandles.length > 0) {
160-
for (const section of sectionHandles) {
161-
const box = await section.boundingBox();
173+
let slideHandles = await page.$$(PRIMARY_SLIDE_SELECTOR);
174+
if (slideHandles.length === 0) {
175+
slideHandles = await page.$$(FALLBACK_SLIDE_SELECTOR);
176+
}
177+
178+
if (slideHandles.length > 0) {
179+
for (const slideElement of slideHandles) {
180+
const box = await slideElement.boundingBox();
162181
if (!box || box.width <= 0 || box.height <= 0) continue;
163182
const image = await page.screenshot({
164183
type: 'png',
@@ -173,8 +192,37 @@ async function renderSlideScreenshots(
173192
}
174193
}
175194
if (screenshots.length === 0) {
176-
const image = await page.screenshot({ type: 'png', fullPage: true });
177-
screenshots.push(Buffer.from(image));
195+
const { pageCount } = await page.evaluate((slideHeight) => {
196+
const doc = (globalThis as unknown as { document: BrowserDocumentShape }).document;
197+
const root = doc.documentElement;
198+
const body = doc.body;
199+
const scrollHeight = Math.max(
200+
root.scrollHeight,
201+
root.offsetHeight,
202+
root.clientHeight,
203+
body?.scrollHeight ?? 0,
204+
body?.offsetHeight ?? 0,
205+
body?.clientHeight ?? 0,
206+
);
207+
const pageCount = Math.max(1, Math.ceil(scrollHeight / slideHeight));
208+
const captureHeight = pageCount * slideHeight;
209+
root.style.minHeight = `${captureHeight}px`;
210+
if (body) body.style.minHeight = `${captureHeight}px`;
211+
return { pageCount };
212+
}, viewport.height);
213+
214+
for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
215+
const image = await page.screenshot({
216+
type: 'png',
217+
clip: {
218+
x: 0,
219+
y: pageIndex * viewport.height,
220+
width: viewport.width,
221+
height: viewport.height,
222+
},
223+
});
224+
screenshots.push(Buffer.from(image));
225+
}
178226
}
179227
return screenshots;
180228
} finally {

0 commit comments

Comments
 (0)