From 4ee5eb7d0a24bec9cd54f13b24d047ad52958751 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 22 Aug 2026 00:17:43 +0100 Subject: [PATCH] Add pdf-lib dependency and implement PDF merging functionality This commit introduces the `pdf-lib` library to facilitate PDF document manipulation. A new function, `mergePdfBuffers`, is added to handle the merging of multiple PDF buffers into a single document. Additionally, the PDF generation process is updated to support pagination, allowing for the export of each paginated page before merging. The changes enhance the PDF generation capabilities, ensuring that documents can be created with multiple pages while maintaining proper formatting and structure. Corresponding tests have been added to verify the new functionality. --- package.json | 1 + pnpm-lock.yaml | 53 ++- src/templates/__tests__/pdffactory.test.js | 65 +++- src/templates/assets/basetemplate.ejs | 12 +- src/templates/assets/previewpagination.js | 372 +++++++++++++++++++++ src/templates/assets/previewtemplate.ejs | 6 +- src/templates/assets/rendertemplate.ejs | 4 +- src/templates/assets/styles.css | 34 ++ src/templates/pdffactory.js | 112 ++++++- src/templates/templatemanager.js | 14 +- 10 files changed, 636 insertions(+), 37 deletions(-) diff --git a/package.json b/package.json index 73e9288..6717fce 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "node-cron": "^4.2.1", "nodemailer": "*", "nodemon": "^3.1.11", + "pdf-lib": "^1.17.1", "pdf-to-img": "^6.2.0", "pg": "^8.16.3", "posthtml": "^0.16.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81bb7cb..68ecaeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: nodemon: specifier: ^3.1.11 version: 3.1.11 + pdf-lib: + specifier: ^1.17.1 + version: 1.17.1 pdf-to-img: specifier: ^6.2.0 version: 6.2.0 @@ -1434,6 +1437,12 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@pdf-lib/standard-fonts@1.0.0': + resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==} + + '@pdf-lib/upng@1.0.1': + resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2272,8 +2281,8 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} - chromedriver@152.0.0: - resolution: {integrity: sha512-JBMT88W2bMGSytKcIy6A3F/0DrGJeKTO1ZSThSNICeexSY+xBAEuAvjzlsGkIMaebqNF0KkDUI203CJ+GTA12g==} + chromedriver@152.0.1: + resolution: {integrity: sha512-8zet2yPAFOSlYxUIcQ/1yOOC1xP4ooPZiKFRdlJhFawBKvlZIHDU6qv4LDOfPEw1yXbyu+tQTzbL0Vfb+vHdQQ==} engines: {node: '>=22'} hasBin: true @@ -4295,6 +4304,9 @@ packages: path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + pdf-lib@1.17.1: + resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + pdf-to-img@6.2.0: resolution: {integrity: sha512-54CaUwpTXw0SkG6U3Twx7AJNM2ZuFNpDCJ7oHmmW113gT8RwLZSbzv4/GOr+H0PFpFisuHcUirZBKgXkJXA1zg==} engines: {node: '>=20.19.0 || >=22.13.0 || >=24'} @@ -5020,6 +5032,9 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -7121,6 +7136,14 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@pdf-lib/standard-fonts@1.0.0': + dependencies: + pako: 1.0.11 + + '@pdf-lib/upng@1.0.1': + dependencies: + pako: 1.0.11 + '@pkgjs/parseargs@0.11.0': optional: true @@ -8146,7 +8169,7 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - chromedriver@152.0.0: + chromedriver@152.0.1: dependencies: '@testim/chrome-version': 1.1.4 adm-zip: 0.6.0 @@ -8617,12 +8640,12 @@ snapshots: dependencies: eslint: 9.39.2 - eslint-config-standard-jsx@11.0.0(eslint-plugin-react@7.37.5(eslint@8.57.1))(eslint@8.57.1): + eslint-config-standard-jsx@11.0.0(eslint-plugin-react@7.37.5(eslint@9.39.2))(eslint@8.57.1): dependencies: eslint: 8.57.1 eslint-plugin-react: 7.37.5(eslint@8.57.1) - eslint-config-standard@17.1.0(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@9.39.2))(eslint-plugin-promise@6.6.0(eslint@9.39.2))(eslint@8.57.1): + eslint-config-standard@17.1.0(eslint-plugin-import@2.32.0(eslint@9.39.2))(eslint-plugin-n@15.7.0(eslint@9.39.2))(eslint-plugin-promise@6.6.0(eslint@9.39.2))(eslint@8.57.1): dependencies: eslint: 8.57.1 eslint-plugin-import: 2.32.0(eslint@8.57.1) @@ -9133,7 +9156,7 @@ snapshots: call-bound: 1.0.4 define-properties: 1.2.1 functions-have-names: 1.2.3 - hasown: 2.0.2 + hasown: 2.0.4 is-callable: 1.2.7 functions-have-names@1.2.3: {} @@ -9276,7 +9299,6 @@ snapshots: hasown@2.0.4: dependencies: function-bind: 1.1.2 - optional: true hmac-drbg@1.0.1: dependencies: @@ -9516,7 +9538,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 is-set@2.0.3: {} @@ -10049,7 +10071,7 @@ snapshots: dependencies: jwk-to-pem: 2.0.7 optionalDependencies: - chromedriver: 152.0.0 + chromedriver: 152.0.1 transitivePeerDependencies: - debug - kerberos @@ -10574,6 +10596,13 @@ snapshots: path-to-regexp@8.3.0: {} + pdf-lib@1.17.1: + dependencies: + '@pdf-lib/standard-fonts': 1.0.0 + '@pdf-lib/upng': 1.0.1 + pako: 1.0.11 + tslib: 1.14.1 + pdf-to-img@6.2.0: dependencies: pdfjs-dist: 5.6.205 @@ -11220,8 +11249,8 @@ snapshots: standard@17.1.2: dependencies: eslint: 8.57.1 - eslint-config-standard: 17.1.0(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@9.39.2))(eslint-plugin-promise@6.6.0(eslint@9.39.2))(eslint@8.57.1) - eslint-config-standard-jsx: 11.0.0(eslint-plugin-react@7.37.5(eslint@8.57.1))(eslint@8.57.1) + eslint-config-standard: 17.1.0(eslint-plugin-import@2.32.0(eslint@9.39.2))(eslint-plugin-n@15.7.0(eslint@9.39.2))(eslint-plugin-promise@6.6.0(eslint@9.39.2))(eslint@8.57.1) + eslint-config-standard-jsx: 11.0.0(eslint-plugin-react@7.37.5(eslint@9.39.2))(eslint@8.57.1) eslint-plugin-import: 2.32.0(eslint@8.57.1) eslint-plugin-n: 15.7.0(eslint@8.57.1) eslint-plugin-promise: 6.6.0(eslint@8.57.1) @@ -11476,6 +11505,8 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tslib@1.14.1: {} + tslib@2.8.1: {} tweetnacl@1.0.3: {} diff --git a/src/templates/__tests__/pdffactory.test.js b/src/templates/__tests__/pdffactory.test.js index b3cf0d1..86ff120 100644 --- a/src/templates/__tests__/pdffactory.test.js +++ b/src/templates/__tests__/pdffactory.test.js @@ -2,7 +2,8 @@ import { jest } from '@jest/globals'; const page = { setContent: jest.fn().mockResolvedValue(undefined), - evaluate: jest.fn().mockResolvedValue(120), + waitForFunction: jest.fn().mockResolvedValue(undefined), + evaluate: jest.fn(), pdf: jest.fn().mockResolvedValue(Buffer.from('pdf-bytes')), close: jest.fn().mockResolvedValue(undefined), }; @@ -19,10 +20,28 @@ const puppeteer = { executablePath: jest.fn().mockReturnValue('/fake/puppeteer/chrome'), }; +const copiedPage = {}; +const donorPdf = { + getPageIndices: jest.fn(() => [0]), +}; +const mergedPdf = { + copyPages: jest.fn(async () => [copiedPage]), + addPage: jest.fn(), + save: jest.fn(async () => new Uint8Array([9, 8, 7])), +}; +const PDFDocument = { + create: jest.fn(async () => mergedPdf), + load: jest.fn(async () => donorPdf), +}; + jest.unstable_mockModule('puppeteer', () => ({ default: puppeteer, })); +jest.unstable_mockModule('pdf-lib', () => ({ + PDFDocument, +})); + jest.unstable_mockModule('../../config.js', () => ({ default: { server: { @@ -45,12 +64,30 @@ jest.unstable_mockModule('log4js', () => ({ const { generatePDF, startPDFBrowser, closePDFBrowser } = await import('../pdffactory.js'); +function mockEvaluate({ pageCount = 1, height = 120 } = {}) { + page.evaluate.mockImplementation(async (fn) => { + const source = String(fn); + if (source.includes('getPageCount')) { + return pageCount; + } + if (source.includes('showPage')) { + return undefined; + } + if (source.includes('measureVisiblePageHeight') || source.includes('scrollHeight')) { + return height; + } + return pageCount; + }); +} + describe('PDF Factory', () => { beforeEach(() => { jest.clearAllMocks(); browserInstance.connected = true; browserInstance.newPage.mockResolvedValue(page); puppeteer.launch.mockResolvedValue(browserInstance); + page.pdf.mockResolvedValue(Buffer.from('pdf-bytes')); + mockEvaluate({ pageCount: 1, height: 120 }); }); afterEach(async () => { @@ -87,9 +124,16 @@ describe('PDF Factory', () => { expect(puppeteer.launch).toHaveBeenCalledTimes(1); }); - it('measures auto height from the content element', async () => { + it('waits for page overflow pagination before measuring', async () => { + await generatePDF('', { width: 100, height: 50 }); + + expect(page.waitForFunction).toHaveBeenCalled(); + }); + + it('measures auto height from the visible page', async () => { await generatePDF('', { width: 80, height: 'auto' }); + expect(page.waitForFunction).toHaveBeenCalled(); expect(page.evaluate).toHaveBeenCalled(); expect(page.pdf).toHaveBeenCalledWith( expect.objectContaining({ @@ -99,6 +143,23 @@ describe('PDF Factory', () => { ); }); + it('exports each paginated page then merges with pdf-lib', async () => { + mockEvaluate({ pageCount: 2, height: 120 }); + page.pdf + .mockResolvedValueOnce(Buffer.from('page-one')) + .mockResolvedValueOnce(Buffer.from('page-two')); + + const result = await generatePDF('', { width: 100, height: 50 }); + + expect(page.pdf).toHaveBeenCalledTimes(2); + expect(page.evaluate).toHaveBeenCalledWith(expect.any(Function), 0); + expect(page.evaluate).toHaveBeenCalledWith(expect.any(Function), 1); + expect(PDFDocument.create).toHaveBeenCalledTimes(1); + expect(PDFDocument.load).toHaveBeenCalledTimes(2); + expect(mergedPdf.addPage).toHaveBeenCalledTimes(2); + expect(result).toEqual(Buffer.from([9, 8, 7])); + }); + it('can start the browser before the first render', async () => { await startPDFBrowser(); expect(puppeteer.launch).toHaveBeenCalledTimes(1); diff --git a/src/templates/assets/basetemplate.ejs b/src/templates/assets/basetemplate.ejs index cec82b4..565ba91 100644 --- a/src/templates/assets/basetemplate.ejs +++ b/src/templates/assets/basetemplate.ejs @@ -1,5 +1,8 @@ - + @@ -17,6 +20,13 @@ body { } + <% if (typeof infiniteHeight === 'undefined' || !infiniteHeight) { %> + @page { + size: <%= (pageWidth) || (width) || '50mm' %> <%= (pageHeight) || (height) || '50mm' %>; + margin: 0; + } + <% } %> + .previewWrapper { width: <%= (scaledPageWidth) || (scaledWidth) || '50mm' %>; height: <%= (scaledPageHeight) || (scaledHeight) || '50mm' %>; diff --git a/src/templates/assets/previewpagination.js b/src/templates/assets/previewpagination.js index e69de29..670f927 100644 --- a/src/templates/assets/previewpagination.js +++ b/src/templates/assets/previewpagination.js @@ -0,0 +1,372 @@ +(function () { + var EPSILON_PX = 1; + var MAX_PAGES = 100; + var UNSPLITTABLE = 'tr, thead, tfoot, img, svg, hr, canvas, video, picture, col, colgroup'; + var IGNORED = 'script, style, link, meta, .documentPageBreak'; + var PAGE_BREAK_SELECTOR = '.documentPageBreak, pagebreak'; + + function markPaginated() { + document.documentElement.setAttribute('data-paginated', 'true'); + } + + function isInfiniteHeight() { + return document.documentElement.getAttribute('data-infinite-height') === 'true'; + } + + function getPageSelector() { + if (document.querySelector('.previewDocument')) { + return '.previewDocument'; + } + return '.renderDocument'; + } + + function getClipBottom(page) { + var rect = page.getBoundingClientRect(); + var style = window.getComputedStyle(page); + return ( + rect.bottom - + (parseFloat(style.borderBottomWidth) || 0) - + (parseFloat(style.paddingBottom) || 0) + ); + } + + function hasOverflow(page) { + if (page.scrollHeight > page.clientHeight + EPSILON_PX) { + return true; + } + var clipBottom = getClipBottom(page); + var nodes = page.querySelectorAll('*'); + for (var i = 0; i < nodes.length; i += 1) { + if (isIgnored(nodes[i])) { + continue; + } + if (isOverflowing(nodes[i], clipBottom)) { + return true; + } + } + return false; + } + + function isIgnored(el) { + return el.matches(IGNORED); + } + + function isOverflowing(el, clipBottom) { + var rect = el.getBoundingClientRect(); + if (rect.width <= 0 && rect.height <= 0) { + return false; + } + return rect.bottom > clipBottom + EPSILON_PX; + } + + function hasPreviousContent(page, node) { + var current = node; + while (current && current !== page) { + if (current.previousElementSibling) { + return true; + } + current = current.parentElement; + } + return false; + } + + function findFirstOverflowingLeaf(page, clipBottom, locked) { + function walk(el) { + if (locked.has(el) || isIgnored(el)) { + return null; + } + if (!isOverflowing(el, clipBottom)) { + return null; + } + if (!el.matches(UNSPLITTABLE) && el.children.length > 0) { + for (var i = 0; i < el.children.length; i += 1) { + var found = walk(el.children[i]); + if (found) { + return found; + } + } + return null; + } + return el; + } + + for (var i = 0; i < page.children.length; i += 1) { + var found = walk(page.children[i]); + if (found) { + return found; + } + } + return null; + } + + function findSplitNode(page) { + var locked = new Set(); + var clipBottom = getClipBottom(page); + var safety = 0; + var maxLocks = page.querySelectorAll('*').length + 10; + + while (safety < maxLocks) { + safety += 1; + var splitNode = findFirstOverflowingLeaf(page, clipBottom, locked); + if (!splitNode || splitNode === page) { + return null; + } + if (!hasPreviousContent(page, splitNode)) { + locked.add(splitNode); + continue; + } + return splitNode; + } + return null; + } + + function cloneEmpty(el) { + var clone = el.cloneNode(false); + clone.removeAttribute('id'); + return clone; + } + + function moveFrom(startNode, destination) { + var node = startNode; + while (node) { + var next = node.nextElementSibling; + destination.appendChild(node); + node = next; + } + } + + function isEffectivelyEmpty(el) { + var clone = el.cloneNode(true); + var breaks = clone.querySelectorAll(PAGE_BREAK_SELECTOR); + for (var i = 0; i < breaks.length; i += 1) { + breaks[i].remove(); + } + if (clone.querySelector('img, svg, canvas, video, picture, hr, table, .documentBarcode')) { + return false; + } + return String(clone.textContent || '').trim() === ''; + } + + function moveOverflowToNewPage(page, splitNode, options) { + options = options || {}; + var discardSplitNode = options.discardSplitNode === true; + var newPage = cloneEmpty(page); + var ancestors = []; + var parent = splitNode.parentElement; + while (parent && parent !== page) { + ancestors.push(parent); + parent = parent.parentElement; + } + + var clonedAncestors = []; + for (var i = 0; i < ancestors.length; i += 1) { + clonedAncestors.push(cloneEmpty(ancestors[i])); + } + + var attachPoint = newPage; + for (var i = clonedAncestors.length - 1; i >= 0; i -= 1) { + attachPoint.appendChild(clonedAncestors[i]); + attachPoint = clonedAncestors[i]; + } + + var inner = clonedAncestors.length > 0 ? clonedAncestors[0] : newPage; + if (discardSplitNode) { + moveFrom(splitNode.nextElementSibling, inner); + splitNode.remove(); + } else { + moveFrom(splitNode, inner); + } + + for (var i = 0; i < ancestors.length; i += 1) { + var destination = i + 1 < clonedAncestors.length ? clonedAncestors[i + 1] : newPage; + moveFrom(ancestors[i].nextElementSibling, destination); + } + + if (isEffectivelyEmpty(newPage)) { + return null; + } + + return newPage; + } + + function isBeforeInDocument(a, b) { + if (!a || !b || a === b) { + return false; + } + return Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); + } + + function splitPage(page, skipOverflow) { + var pageBreak = page.querySelector(PAGE_BREAK_SELECTOR); + var overflowNode = skipOverflow ? null : findSplitNode(page); + + var splitNode = null; + var discardSplitNode = false; + + if (pageBreak && overflowNode) { + if (isBeforeInDocument(overflowNode, pageBreak)) { + splitNode = overflowNode; + } else { + splitNode = pageBreak; + discardSplitNode = true; + } + } else if (pageBreak) { + splitNode = pageBreak; + discardSplitNode = true; + } else if (overflowNode) { + splitNode = overflowNode; + } + + if (!splitNode) { + if (pageBreak && pageBreak.isConnected) { + pageBreak.remove(); + } + return null; + } + + var nextPage = moveOverflowToNewPage(page, splitNode, { + discardSplitNode: discardSplitNode + }); + if (!nextPage && pageBreak && pageBreak.isConnected) { + pageBreak.remove(); + } + return nextPage; + } + + function insertPageAfter(sourcePage, newPage) { + var wrapper = sourcePage.closest('.previewWrapper'); + if (wrapper && wrapper.parentElement) { + var newWrapper = document.createElement('div'); + newWrapper.className = wrapper.className; + newWrapper.appendChild(newPage); + wrapper.after(newWrapper); + return; + } + sourcePage.after(newPage); + } + + function getPageElements() { + var renderPages = document.querySelectorAll('.renderDocument'); + if (renderPages.length > 0) { + return renderPages; + } + return document.querySelectorAll('.previewDocument'); + } + + function getPageShell(pageEl) { + return pageEl.closest('.previewWrapper') || pageEl; + } + + var paginationApi = { + getPageCount: function () { + return getPageElements().length || 1; + }, + showPage: function (index) { + var pages = getPageElements(); + for (var i = 0; i < pages.length; i += 1) { + var pageEl = pages[i]; + var shell = getPageShell(pageEl); + if (i === index) { + shell.classList.remove('fcPaginationHidden'); + pageEl.classList.add('fcPaginationVisible'); + } else { + shell.classList.add('fcPaginationHidden'); + pageEl.classList.remove('fcPaginationVisible'); + } + } + document.documentElement.setAttribute('data-active-page', String(index)); + document.documentElement.setAttribute('data-page-count', String(pages.length || 1)); + }, + showAllPages: function () { + var pages = getPageElements(); + for (var i = 0; i < pages.length; i += 1) { + var pageEl = pages[i]; + var shell = getPageShell(pageEl); + shell.classList.remove('fcPaginationHidden'); + pageEl.classList.remove('fcPaginationVisible'); + } + document.documentElement.removeAttribute('data-active-page'); + }, + measureVisiblePageHeight: function () { + var pages = getPageElements(); + for (var i = 0; i < pages.length; i += 1) { + var pageEl = pages[i]; + var shell = getPageShell(pageEl); + if (shell.classList.contains('fcPaginationHidden')) { + continue; + } + return pageEl.scrollHeight; + } + var fallback = document.getElementById('content'); + return fallback ? fallback.scrollHeight : 0; + }, + }; + + window.__fcDocumentPagination = paginationApi; + + function paginate() { + var selector = getPageSelector(); + var page = document.querySelector(selector); + if (!page) { + return; + } + + var skipOverflow = isInfiniteHeight(); + var guard = 0; + while (page && guard < MAX_PAGES) { + guard += 1; + var nextPage = splitPage(page, skipOverflow); + if (!nextPage) { + break; + } + insertPageAfter(page, nextPage); + page = nextPage; + } + + document.documentElement.setAttribute( + 'data-page-count', + String(paginationApi.getPageCount()) + ); + } + + function waitForImages() { + var images = Array.prototype.slice.call(document.images || []); + return Promise.all( + images.map(function (img) { + if (img.complete) { + return Promise.resolve(); + } + return new Promise(function (resolve) { + img.addEventListener('load', resolve, { once: true }); + img.addEventListener('error', resolve, { once: true }); + }); + }) + ); + } + + function start() { + try { + paginate(); + } finally { + markPaginated(); + } + } + + function waitAndStart() { + var fontsReady = + document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve(); + Promise.all([Promise.resolve(fontsReady).catch(function () {}), waitForImages()]).then( + function () { + window.requestAnimationFrame(function () { + window.requestAnimationFrame(start); + }); + } + ); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', waitAndStart); + } else { + waitAndStart(); + } +})(); diff --git a/src/templates/assets/previewtemplate.ejs b/src/templates/assets/previewtemplate.ejs index 25e4d7b..1037b0f 100644 --- a/src/templates/assets/previewtemplate.ejs +++ b/src/templates/assets/previewtemplate.ejs @@ -1,5 +1,7 @@
-
-
<%- content %>
+
+
+
<%- content %>
+
diff --git a/src/templates/assets/rendertemplate.ejs b/src/templates/assets/rendertemplate.ejs index 9415dae..f2c2775 100644 --- a/src/templates/assets/rendertemplate.ejs +++ b/src/templates/assets/rendertemplate.ejs @@ -1 +1,3 @@ -
<%- content %>
+
+
<%- content %>
+
diff --git a/src/templates/assets/styles.css b/src/templates/assets/styles.css index 1565a7c..4eff267 100644 --- a/src/templates/assets/styles.css +++ b/src/templates/assets/styles.css @@ -15,8 +15,16 @@ body { box-sizing: border-box; width: fit-content; } +.previewPages { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 32px; + width: fit-content; +} .previewWrapper { position: relative; + flex-shrink: 0; } .previewDocument { background: #ffffff; @@ -28,8 +36,30 @@ body { box-sizing: content-box; } +.renderPages { + display: flex; + flex-direction: column; +} + .renderDocument { box-sizing: content-box; + overflow: hidden; + break-after: page; + page-break-after: always; +} + +.renderDocument:last-child { + break-after: auto; + page-break-after: auto; +} + +.fcPaginationHidden { + display: none !important; +} + +.fcPaginationVisible { + break-after: auto !important; + page-break-after: auto !important; } .documentText { @@ -66,6 +96,10 @@ h4.documentTitle { border: none; } +.documentPageBreak { + display: none; +} + .contentPlaceholder { border: 1px solid black; max-height: 250px; diff --git a/src/templates/pdffactory.js b/src/templates/pdffactory.js index d3a41cd..cc27ba1 100644 --- a/src/templates/pdffactory.js +++ b/src/templates/pdffactory.js @@ -1,5 +1,6 @@ import log4js from 'log4js'; import puppeteer from 'puppeteer'; +import { PDFDocument } from 'pdf-lib'; import config from '../config.js'; import { getPuppeteerLaunchOptions } from '../puppeteerLaunchOptions.js'; @@ -100,6 +101,75 @@ async function withPage(run) { } } +async function getPaginationPageCount(page) { + const pageCount = await page.evaluate(() => { + const api = window.__fcDocumentPagination; + if (!api || typeof api.getPageCount !== 'function') { + return 1; + } + return api.getPageCount() || 1; + }); + return Math.max(1, Number(pageCount) || 1); +} + +async function showPaginationPage(page, index) { + await page.evaluate((pageIndex) => { + const api = window.__fcDocumentPagination; + if (api && typeof api.showPage === 'function') { + api.showPage(pageIndex); + } + }, index); +} + +async function measureVisiblePageHeight(page) { + return page.evaluate(() => { + const api = window.__fcDocumentPagination; + if (api && typeof api.measureVisiblePageHeight === 'function') { + return api.measureVisiblePageHeight(); + } + const content = document.getElementById('content'); + return content ? content.scrollHeight : 0; + }); +} + +function buildPdfOptions(options, height, isAutoHeight, isolatePage) { + return { + printBackground: true, + preferCSSPageSize: isolatePage !== true && isAutoHeight !== true, + width: options.width ? `${options.width}mm` : undefined, + height: height + ? isAutoHeight + ? `${height + 2}px` + : `${height}mm` + : undefined, + margin: { + top: '0mm', + right: '0mm', + bottom: '0mm', + left: '0mm', + }, + }; +} + +export async function mergePdfBuffers(buffers = []) { + if (!Array.isArray(buffers) || buffers.length === 0) { + return Buffer.alloc(0); + } + if (buffers.length === 1) { + return Buffer.isBuffer(buffers[0]) ? buffers[0] : Buffer.from(buffers[0]); + } + + const merged = await PDFDocument.create(); + for (const bytes of buffers) { + const donor = await PDFDocument.load(bytes); + const copiedPages = await merged.copyPages(donor, donor.getPageIndices()); + for (const copiedPage of copiedPages) { + merged.addPage(copiedPage); + } + } + return Buffer.from(await merged.save()); +} + /** * Generates a PDF from HTML content using a persistent Puppeteer browser * @param {string} html - The HTML content to convert to PDF @@ -115,28 +185,34 @@ export async function generatePDF(html, options = {}) { waitUntil: 'domcontentloaded', }); - var height = options?.height || '50'; + await page + .waitForFunction( + () => document.documentElement.getAttribute('data-paginated') === 'true', + { timeout: 15000 } + ) + .catch(() => {}); - if (options.height == 'auto') { - const calculatedHeight = await page.evaluate(() => { - return document.getElementById('content').scrollHeight; - }); + const pageCount = await getPaginationPageCount(page); + const isAutoHeight = options.height == 'auto' || options.infiniteHeight == true; + const isolatePages = pageCount > 1; + const pdfBuffers = []; - height = calculatedHeight; + for (let index = 0; index < pageCount; index += 1) { + if (isolatePages) { + await showPaginationPage(page, index); + } + + var height = options?.height || '50'; + if (isAutoHeight) { + height = await measureVisiblePageHeight(page); + } + + pdfBuffers.push( + await page.pdf(buildPdfOptions(options, height, isAutoHeight, isolatePages)) + ); } - return page.pdf({ - printBackground: true, - preferCSSPageSize: true, - width: options.width ? `${options.width}mm` : undefined, - height: height ? (options.height == 'auto' ? `${height + 2}px` : `${height}mm`) : undefined, - margin: { - top: '0mm', - right: '0mm', - bottom: '0mm', - left: '0mm', - }, - }); + return mergePdfBuffers(pdfBuffers); }); } catch (error) { logger.error('Error generating PDF:', error.message); diff --git a/src/templates/templatemanager.js b/src/templates/templatemanager.js index 596e316..e397951 100644 --- a/src/templates/templatemanager.js +++ b/src/templates/templatemanager.js @@ -218,6 +218,13 @@ async function transformCustomElements(content) { }, }; }), + (tree) => + tree.match([{ tag: 'PageBreak' }, { tag: 'pagebreak' }], () => ({ + tag: 'hr', + attrs: { + class: 'documentPageBreak', + }, + })), (tree) => tree.match({ tag: 'ProgressBar' }, (node) => { return { @@ -332,7 +339,7 @@ async function transformCustomElements(content) { }, }; }), - ]).process(content); + ]).process(content, { recognizeSelfClosing: true }); return result.html; } @@ -526,7 +533,10 @@ export class TemplateManager { paddingLeft: `${padding.paddingLeft}mm`, scale: `${scale}`, baseCSS: baseCSS, - previewPaginationScript: preview ? previewPaginationScript : '', + infiniteHeight: infiniteHeight, + pageWidth: `${pageWidth}mm`, + pageHeight: infiniteHeight ? 'auto' : `${pageHeight}mm`, + previewPaginationScript: previewPaginationScript, }, defaultOptions );