From a8c486697b7020f87d42966b8c985d96a47741a2 Mon Sep 17 00:00:00 2001 From: Sun-sunshine06 Date: Mon, 4 May 2026 23:53:25 +0800 Subject: [PATCH] fix(exporters): paginate sectionless pptx screenshots --- .changeset/polite-pptx-pages.md | 5 ++ packages/exporters/src/pptx.test.ts | 95 ++++++++++++++++++++++++++++- packages/exporters/src/pptx.ts | 80 +++++++++++++++++++++--- 3 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 .changeset/polite-pptx-pages.md diff --git a/.changeset/polite-pptx-pages.md b/.changeset/polite-pptx-pages.md new file mode 100644 index 00000000..ba1b1cdb --- /dev/null +++ b/.changeset/polite-pptx-pages.md @@ -0,0 +1,5 @@ +--- +"@open-codesign/exporters": patch +--- + +Paginate PPTX image exports with fallback slide selectors when artifacts do not define section slides. diff --git a/packages/exporters/src/pptx.test.ts b/packages/exporters/src/pptx.test.ts index 633b57d9..1d552880 100644 --- a/packages/exporters/src/pptx.test.ts +++ b/packages/exporters/src/pptx.test.ts @@ -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( @@ -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 }, @@ -30,6 +32,10 @@ let tempDir = ''; beforeAll(() => { tempDir = mkdtempSync(join(tmpdir(), 'codesign-pptx-test-')); +}); + +beforeEach(() => { + vi.clearAllMocks(); launchMock.mockResolvedValue({ newPage: newPageMock, close: closeMock, @@ -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
as a slide and pulls the heading + bullets', () => { const html = ` @@ -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('

Visual

', 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('

Visual

', 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('

Long artifact

', 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('

Short artifact

', 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'); diff --git a/packages/exporters/src/pptx.ts b/packages/exporters/src/pptx.ts index 08c3281b..ede29534 100644 --- a/packages/exporters/src/pptx.ts +++ b/packages/exporters/src/pptx.ts @@ -23,6 +23,8 @@ 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
elements exist. */ + slideSelector?: string; } interface SlideContent { @@ -30,6 +32,10 @@ interface SlideContent { 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. * @@ -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); @@ -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', @@ -173,8 +187,54 @@ 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 { @@ -182,6 +242,12 @@ async function renderSlideScreenshots( } } +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 = /]*>([\s\S]*?)<\/section>/gi; const HEADING_RE = /]*>([\s\S]*?)<\/h[1-3]>/i; const LIST_ITEM_RE = /]*>([\s\S]*?)<\/li>/gi;