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 @@