diff --git a/packages/converter/src/index.browser.ts b/packages/converter/src/index.browser.ts index ea820751..199afbce 100644 --- a/packages/converter/src/index.browser.ts +++ b/packages/converter/src/index.browser.ts @@ -1,32 +1,34 @@ import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'; import { pdf2img as _pdf2img, Pdf2ImgOptions } from './pdf2img.js'; import { pdf2size as _pdf2size, Pdf2SizeOptions } from './pdf2size.js'; - -let workerPort: Worker | null = null; +import workerSrc from './pdfjs-worker.js?worker&url'; const clonePdfData = (pdf: ArrayBuffer | Uint8Array) => pdf instanceof Uint8Array ? new Uint8Array(pdf) : new Uint8Array(pdf); -const getWorkerPort = () => { - if (typeof Worker === 'undefined') { - return null; +const loadingTaskMap = new WeakMap Promise }>(); + +const getDocument = async (pdf: ArrayBuffer | Uint8Array) => { + if ( + typeof Worker !== 'undefined' && + pdfjsLib.GlobalWorkerOptions.workerSrc !== workerSrc + ) { + pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc; } - workerPort ??= new Worker(new URL('./pdfjs-worker.ts', import.meta.url), { type: 'module' }); - return workerPort; -}; - -const getDocument = (pdf: ArrayBuffer | Uint8Array) => { - const port = getWorkerPort(); - - if (port && pdfjsLib.GlobalWorkerOptions.workerPort !== port) { - pdfjsLib.GlobalWorkerOptions.workerPort = port; - } - - return pdfjsLib.getDocument({ + const loadingTask = pdfjsLib.getDocument({ data: clonePdfData(pdf), isEvalSupported: false, - }).promise; + }); + const document = await loadingTask.promise; + loadingTaskMap.set(document, { destroy: () => loadingTask.destroy() }); + return document; +}; + +const destroyDocument = async (document: object) => { + const loadingTask = loadingTaskMap.get(document); + loadingTaskMap.delete(document); + await loadingTask?.destroy(); }; function dataURLToArrayBuffer(dataURL: string): ArrayBuffer { @@ -53,6 +55,7 @@ export const pdf2img = async ( ): Promise => _pdf2img(pdf, options, { getDocument, + destroyDocument, createCanvas: (width, height) => { const canvas = document.createElement('canvas'); canvas.width = width; @@ -69,6 +72,7 @@ export const pdf2img = async ( export const pdf2size = async (pdf: ArrayBuffer | Uint8Array, options: Pdf2SizeOptions = {}) => _pdf2size(pdf, options, { getDocument, + destroyDocument, }); export { img2pdf } from './img2pdf.js'; diff --git a/packages/converter/src/pdf2img.ts b/packages/converter/src/pdf2img.ts index 9744fe1b..e2156fdd 100644 --- a/packages/converter/src/pdf2img.ts +++ b/packages/converter/src/pdf2img.ts @@ -3,6 +3,7 @@ import type { ImageType } from './types.js'; interface Environment { getDocument: (pdf: ArrayBuffer | Uint8Array) => Promise; + destroyDocument?: (pdfDoc: PDFDocumentProxy) => Promise; createCanvas: (width: number, height: number) => HTMLCanvasElement | OffscreenCanvas; canvasToArrayBuffer: ( canvas: HTMLCanvasElement | OffscreenCanvas, @@ -28,40 +29,44 @@ export async function pdf2img( const { scale = 1, imageType = 'jpeg', range = {} } = options; const { start = 0, end = Infinity } = range; - const { getDocument, createCanvas, canvasToArrayBuffer } = env; + const { getDocument, destroyDocument, createCanvas, canvasToArrayBuffer } = env; const pdfDoc = await getDocument(pdf); - const numPages = pdfDoc.numPages; + try { + const numPages = pdfDoc.numPages; - const startPage = Math.max(start + 1, 1); - const endPage = Math.min(end + 1, numPages); + const startPage = Math.max(start + 1, 1); + const endPage = Math.min(end + 1, numPages); - const results: ArrayBuffer[] = []; + const results: ArrayBuffer[] = []; - for (let pageNum = startPage; pageNum <= endPage; pageNum++) { - const page = await pdfDoc.getPage(pageNum); - const viewport = page.getViewport({ scale }); + for (let pageNum = startPage; pageNum <= endPage; pageNum++) { + const page = await pdfDoc.getPage(pageNum); + const viewport = page.getViewport({ scale }); - const canvas = createCanvas(viewport.width, viewport.height); - if (!canvas) { - throw new Error('Failed to create canvas'); + const canvas = createCanvas(viewport.width, viewport.height); + if (!canvas) { + throw new Error('Failed to create canvas'); + } + + const context = canvas.getContext('2d') as CanvasRenderingContext2D; + if (!context) { + throw new Error('Failed to get canvas context'); + } + + await page.render({ + canvas: canvas as unknown as HTMLCanvasElement, + canvasContext: context, + viewport, + }).promise; + const arrayBuffer = canvasToArrayBuffer(canvas, imageType); + results.push(arrayBuffer); } - const context = canvas.getContext('2d') as CanvasRenderingContext2D; - if (!context) { - throw new Error('Failed to get canvas context'); - } - - await page.render({ - canvas: canvas as unknown as HTMLCanvasElement, - canvasContext: context, - viewport, - }).promise; - const arrayBuffer = canvasToArrayBuffer(canvas, imageType); - results.push(arrayBuffer); + return results; + } finally { + await destroyDocument?.(pdfDoc); } - - return results; } catch (error) { throw new Error(`[@pdfme/converter] pdf2img failed: ${(error as Error).message}`); } diff --git a/packages/converter/src/pdf2size.ts b/packages/converter/src/pdf2size.ts index 95bcc3d1..46de884f 100644 --- a/packages/converter/src/pdf2size.ts +++ b/packages/converter/src/pdf2size.ts @@ -3,6 +3,7 @@ import { Size, pt2mm } from '@pdfme/common'; interface Environment { getDocument: (pdf: ArrayBuffer | Uint8Array) => Promise; + destroyDocument?: (pdfDoc: PDFDocumentProxy) => Promise; } export interface Pdf2SizeOptions { @@ -15,19 +16,20 @@ export async function pdf2size( env: Environment, ): Promise { const { scale = 1 } = options; - const { getDocument } = env; - + const { getDocument, destroyDocument } = env; const pdfDoc = await getDocument(pdf); - const promises = Promise.all( - Array.from({ length: pdfDoc.numPages }, async (_, i) => { - return await pdfDoc.getPage(i + 1).then((page) => { - const { height, width } = page.getViewport({ scale, rotation: 0 }); + try { + return await Promise.all( + Array.from({ length: pdfDoc.numPages }, async (_, i) => { + return await pdfDoc.getPage(i + 1).then((page) => { + const { height, width } = page.getViewport({ scale, rotation: 0 }); - return { height: pt2mm(height), width: pt2mm(width) }; - }); - }), - ); - - return promises; + return { height: pt2mm(height), width: pt2mm(width) }; + }); + }), + ); + } finally { + await destroyDocument?.(pdfDoc); + } } diff --git a/packages/converter/src/pdfjs-dist-webpack.d.ts b/packages/converter/src/pdfjs-dist-webpack.d.ts index 74133a9c..4776a9c1 100644 --- a/packages/converter/src/pdfjs-dist-webpack.d.ts +++ b/packages/converter/src/pdfjs-dist-webpack.d.ts @@ -13,3 +13,8 @@ declare module 'pdfjs-dist/legacy/build/pdf.mjs' { } declare module 'pdfjs-dist/legacy/build/pdf.worker.mjs'; + +declare module '*?worker&url' { + const workerUrl: string; + export default workerUrl; +} diff --git a/packages/converter/src/pdfjs-worker.ts b/packages/converter/src/pdfjs-worker.js similarity index 100% rename from packages/converter/src/pdfjs-worker.ts rename to packages/converter/src/pdfjs-worker.js diff --git a/packages/ui/src/hooks.ts b/packages/ui/src/hooks.ts index 1f1148b1..e8109f16 100644 --- a/packages/ui/src/hooks.ts +++ b/packages/ui/src/hooks.ts @@ -68,16 +68,16 @@ export const useUIPreProcessor = ({ template, size, zoomLevel, maxZoom }: UIPreP _pageSizes = schemas.map(() => ({ width, height })); } else { const _basePdf = await getB64BasePdf(basePdf); - const uint8Array = b64toUint8Array(_basePdf); - // Create a new ArrayBuffer copy to avoid detachment issues - const pdfArrayBuffer = new ArrayBuffer(uint8Array.byteLength); - new Uint8Array(pdfArrayBuffer).set(uint8Array); + const createPdfArrayBuffer = () => { + const buffer = new ArrayBuffer(uint8Array.byteLength); + new Uint8Array(buffer).set(uint8Array); + return buffer; + }; - const [_pages, imgBuffers] = await Promise.all([ - pdf2size(pdfArrayBuffer), - pdf2img(pdfArrayBuffer.slice(), { scale: maxZoom }), - ]); + // Run sequentially with isolated buffers to avoid pdf.js worker races and buffer detachment. + const _pages = await pdf2size(createPdfArrayBuffer()); + const imgBuffers = await pdf2img(createPdfArrayBuffer(), { scale: maxZoom }); _pageSizes = _pages; paperWidth = _pageSizes[0].width * ZOOM; paperHeight = _pageSizes[0].height * ZOOM; diff --git a/playground/e2e/__image_snapshots__/final-form-pdf-page-0.png b/playground/e2e/__image_snapshots__/final-form-pdf-page-0.png index b783ff27..f780e4d7 100644 Binary files a/playground/e2e/__image_snapshots__/final-form-pdf-page-0.png and b/playground/e2e/__image_snapshots__/final-form-pdf-page-0.png differ diff --git a/playground/e2e/__image_snapshots__/final-form-pdf-page-1.png b/playground/e2e/__image_snapshots__/final-form-pdf-page-1.png index 832b0452..051e712c 100644 Binary files a/playground/e2e/__image_snapshots__/final-form-pdf-page-1.png and b/playground/e2e/__image_snapshots__/final-form-pdf-page-1.png differ diff --git a/playground/e2e/__image_snapshots__/final-form-pdf-page-2.png b/playground/e2e/__image_snapshots__/final-form-pdf-page-2.png new file mode 100644 index 00000000..7bbdd086 Binary files /dev/null and b/playground/e2e/__image_snapshots__/final-form-pdf-page-2.png differ diff --git a/playground/e2e/__image_snapshots__/modified-template-pdf-page-0.png b/playground/e2e/__image_snapshots__/modified-template-pdf-page-0.png index 6b1d1593..39c2f51c 100644 Binary files a/playground/e2e/__image_snapshots__/modified-template-pdf-page-0.png and b/playground/e2e/__image_snapshots__/modified-template-pdf-page-0.png differ diff --git a/playground/e2e/index.test.ts b/playground/e2e/index.test.ts index 0543f020..2ba3b1af 100644 --- a/playground/e2e/index.test.ts +++ b/playground/e2e/index.test.ts @@ -1,11 +1,10 @@ import puppeteer, { Browser, Page } from 'puppeteer'; import { pdf2img } from '@pdfme/converter'; -import { createRunner, parse, PuppeteerRunnerExtension } from '@puppeteer/replay'; +import { Template, Schema, cloneDeep } from '@pdfme/common'; +import { text, table, image, barcodes, select, checkbox, radioGroup } from '@pdfme/schemas'; import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; import { stripVTControlCharacters } from 'node:util'; import type { MatchImageOptions } from 'vitest-image-snapshot'; -import templateCreationRecord from './templateCreationRecord.json'; -import formInputRecord from './formInputRecord.json'; const previewUrlPattern = /https?:\/\/(?:localhost|127\.0\.0\.1):\d+\/?/; @@ -142,6 +141,121 @@ function getPdfSnapshotOptions(labelPrefix: string): MatchImageOptions { : snapshotOptions; } +const modifiedTemplateFieldNames = { + text: 'headline', + table: 'lineItems', + image: 'brandImage', + qrcode: 'supportQr', + select: 'status', + checkbox: 'approved', + radioGroup: 'selected', +} as const; + +type PlaygroundStorageState = { + template?: Template; + inputs?: Record[]; + mode?: 'form' | 'viewer'; +}; + +const cloneSchema = (schema: T, overrides: Partial): T => + ({ + ...cloneDeep(schema), + ...overrides, + position: { + ...schema.position, + ...overrides.position, + }, + }) as T; + +function buildModifiedTemplate(): Template { + const basePdf: Template['basePdf'] = { + width: 210, + height: 297, + padding: [20, 10, 20, 10], + }; + + return { + basePdf, + schemas: [ + [ + cloneSchema(text.propPanel.defaultSchema, { + name: modifiedTemplateFieldNames.text, + position: { x: 20, y: 20 }, + }), + cloneSchema(table.propPanel.defaultSchema, { + name: modifiedTemplateFieldNames.table, + position: { x: 20, y: 40 }, + width: 150, + }), + cloneSchema(image.propPanel.defaultSchema, { + name: modifiedTemplateFieldNames.image, + position: { x: 20, y: 90 }, + readOnly: true, + }), + cloneSchema(barcodes.qrcode.propPanel.defaultSchema, { + name: modifiedTemplateFieldNames.qrcode, + position: { x: 70, y: 95 }, + readOnly: true, + }), + cloneSchema(select.propPanel.defaultSchema, { + name: modifiedTemplateFieldNames.select, + position: { x: 115, y: 98 }, + }), + cloneSchema(checkbox.propPanel.defaultSchema, { + name: modifiedTemplateFieldNames.checkbox, + position: { x: 115, y: 120 }, + }), + cloneSchema(radioGroup.propPanel.defaultSchema, { + name: modifiedTemplateFieldNames.radioGroup, + position: { x: 130, y: 120 }, + }), + ], + ], + }; +} + +function buildFinalFormInputs(): Record[] { + const tableRows = Array.from({ length: 40 }, (_, index) => [ + `Person ${String(index + 1).padStart(2, '0')}`, + `City ${((index % 5) + 1).toString()}`, + `Summary ${index + 1}`, + ]); + + return [ + { + [modifiedTemplateFieldNames.text]: 'Filled by CI', + [modifiedTemplateFieldNames.table]: JSON.stringify(tableRows), + [modifiedTemplateFieldNames.select]: 'option2', + [modifiedTemplateFieldNames.checkbox]: 'true', + [modifiedTemplateFieldNames.radioGroup]: 'true', + }, + ]; +} + +async function loadRouteWithStorage( + page: Page, + path: '/designer' | '/form-viewer', + storageState: PlaygroundStorageState, +) { + await page.goto(`${baseUrl}${path}`, { waitUntil: 'networkidle2', timeout }); + await page.evaluate((state) => { + localStorage.removeItem('template'); + localStorage.removeItem('inputs'); + localStorage.removeItem('mode'); + + if (state.template) { + localStorage.setItem('template', JSON.stringify(state.template)); + } + if (state.inputs) { + localStorage.setItem('inputs', JSON.stringify(state.inputs)); + } + if (state.mode) { + localStorage.setItem('mode', state.mode); + } + }, storageState); + await page.reload({ waitUntil: 'networkidle2', timeout }); +} + async function waitForDesignerReady(page: Page, expectedText?: string) { await page.waitForFunction( (text) => { @@ -153,17 +267,13 @@ async function waitForDesignerReady(page: Page, expectedText?: string) { const canvas = document.querySelector('.pdfme-designer-canvas'); const spinner = document.querySelector('.pdfme-designer-root svg.lucide-loader-circle'); const paper = document.querySelector('.pdfme-designer-canvas [style*="background-image"]'); - const renderRoots = Array.from( - document.querySelectorAll('.pdfme-designer-canvas .selectable[title] > div'), + const titledSelectables = Array.from( + document.querySelectorAll('.pdfme-designer-canvas .selectable[title]'), ); - const renderersReady = - renderRoots.length > 0 && - renderRoots.every((element) => { - const content = element as HTMLElement; - return ( - content.childElementCount > 0 || (content.textContent?.trim().length ?? 0) > 0 - ); - }); + const renderersReady = titledSelectables.every((element) => { + const content = element.firstElementChild; + return !(content instanceof HTMLElement) || content.dataset.pdfmeRenderReady === 'true'; + }); const fontsLoaded = !document.fonts || document.fonts.status === 'loaded'; return hasExpectedText && !!canvas && !spinner && !!paper && fontsLoaded && renderersReady; }, @@ -187,6 +297,41 @@ async function waitForDesignerReady(page: Page, expectedText?: string) { }); } +async function waitForFormReady(page: Page, expectedText?: string) { + await page.waitForFunction( + (text) => { + const container = document.querySelector('div.flex-1.w-full'); + const hasExpectedText = + typeof text === 'string' && text.length > 0 + ? (container?.textContent?.includes(text) ?? false) + : true; + const titledSelectables = Array.from(document.querySelectorAll('.selectable[title]')); + const renderersReady = + titledSelectables.length > 0 && + titledSelectables.every((element) => { + const content = element.firstElementChild; + return !(content instanceof HTMLElement) || content.dataset.pdfmeRenderReady === 'true'; + }); + const fontsLoaded = !document.fonts || document.fonts.status === 'loaded'; + return hasExpectedText && renderersReady && fontsLoaded; + }, + { timeout }, + expectedText, + ); + + await page.evaluate(async () => { + if (document.fonts) { + await document.fonts.ready; + } + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + await new Promise((resolve) => { + setTimeout(() => resolve(), 150); + }); + }); +} + async function generatePdf(page: Page, browser: Browser): Promise { await page.waitForSelector('#generate-pdf', { timeout }); await page.click('#generate-pdf'); @@ -326,7 +471,7 @@ describe('Playground E2E Tests', () => { // 5. Load the Pedigree designer directly to avoid flaky list-page navigation in CI await page.goto(`${baseUrl}/?template=pedigree`, { waitUntil: 'networkidle2', timeout }); - await waitForDesignerReady(page); + await waitForDesignerReady(page, 'Pet Name'); // 7. Screenshot & compare await captureAndCompareScreenshot(page, 'pedigree-designer'); @@ -335,45 +480,25 @@ describe('Playground E2E Tests', () => { await generateAndComparePDF(page, browser, 'pedigree'); }); - // Skip the problematic test in CI environment - it('should modify template, generate PDF and compare, then input form data', async () => { + it('should load a deterministic template, generate PDF and compare, then render form inputs', async () => { if (!browser || !page) throw new Error('Browser/Page not initialized'); - const extension = new PuppeteerRunnerExtension(browser, page, { timeout }); - // 9. Press Reset button - await page.$eval('#reset-template', (el: Element) => (el as HTMLElement).click()); + const template = buildModifiedTemplate(); - // 10. Replay templateCreationRecord operations to add elements - const templateCreationUserFlow = parse(templateCreationRecord); - const templateCreationRunner = await createRunner(templateCreationUserFlow, extension); - await templateCreationRunner.run(); - await waitForDesignerReady(page); + await loadRouteWithStorage(page, '/designer', { template }); + await waitForDesignerReady(page, 'Type Something...'); - // 11. Screenshot & compare await captureAndCompareScreenshot(page, 'modified-template-designer'); - // 12. Generate PDF & compare await generateAndComparePDF(page, browser, 'modified-template'); - // 13. Save locally - await page.click('#save-local'); + await loadRouteWithStorage(page, '/form-viewer', { + template, + inputs: buildFinalFormInputs(), + mode: 'form', + }); + await waitForFormReady(page, 'Filled by CI'); - // 14. Move to form viewer - await page.click('#form-viewer-nav'); - await page.waitForFunction( - () => { - const container = document.querySelector('div.flex-1.w-full'); - return container ? container.textContent?.includes('Type Something...') : false; - }, - { timeout }, - ); - - // 15. Input form data - const formInputUserFlow = parse(formInputRecord); - const formInputRunner = await createRunner(formInputUserFlow, extension); - await formInputRunner.run(); - - // 16. Generate PDF & compare await generateAndComparePDF(page, browser, 'final-form'); }); });