diff --git a/lib/pdf.ts b/lib/pdf.ts index 6f1e90e..ab59c1e 100644 --- a/lib/pdf.ts +++ b/lib/pdf.ts @@ -2,40 +2,28 @@ * Server-side PDF utilities. * * Page counting is used to price orders, so it must be accurate and must never - * trust client-supplied counts. Previously this scanned the raw PDF bytes for - * `/Type /Page` markers, but that badly overcounts real-world PDFs: the marker - * also appears in object streams, annotations/form fields, orphaned objects - * left by incremental updates, and even inside content streams. A 200-page PDF - * could be counted as 467. + * trust client-supplied counts. * - * We now parse the document with pdfjs-dist (already a dependency, used - * client-side by the PdfOrder component) and read the authoritative page count - * from the parsed document. + * History: + * - Originally this scanned the raw PDF bytes for `/Type /Page` markers, which + * badly overcounts real-world PDFs — the marker also appears in object + * streams, annotations/form fields, orphaned objects left by incremental + * updates, and inside content streams. A 200-page PDF was counted as 467. + * - We then tried pdfjs-dist, but its default build references browser-only + * globals (`DOMMatrix`) and crashes under Node, and its legacy build was + * unreliable to trace into the Next.js standalone/container bundle — causing + * valid PDFs to be rejected as invalid in production. + * + * We now use `pdf-lib`: a dependency-free, pure-TypeScript PDF library that runs + * anywhere Node runs (no native addons, no DOM globals, no web worker, no + * bundler/file-tracing special-casing). It parses the document object graph and + * exposes an authoritative page count. */ -import type * as pdfjsTypes from "pdfjs-dist"; - -let pdfjsPromise: Promise | null = null; - -/** - * Lazily load pdfjs-dist. Importing at module scope can break the Cloudflare - * Workers runtime during bundling/SSR, so we defer to first use. The `canvas` - * native dependency is stubbed via pnpm.overrides (see shims/canvas), which is - * fine because we only parse the document structure here — we never render. - */ -function getPdfjs(): Promise { - if (!pdfjsPromise) { - // Use the legacy build: it targets Node/non-DOM environments and does - // not rely on browser-only globals, which suits server-side parsing. - pdfjsPromise = import( - "pdfjs-dist/legacy/build/pdf.mjs" - ) as unknown as Promise; - } - return pdfjsPromise; -} +import { PDFDocument } from "pdf-lib"; /** - * Count the number of pages in a PDF by parsing it with pdfjs. + * Count the number of pages in a PDF by parsing its structure. * * @param buffer Raw PDF bytes. * @returns The authoritative page count, or 0 if the file cannot be parsed as a @@ -43,28 +31,21 @@ function getPdfjs(): Promise { */ export async function countPdfPages(buffer: Buffer): Promise { try { - const pdfjs = await getPdfjs(); - // pdfjs mutates the underlying buffer, so hand it a fresh copy. It also - // expects a Uint8Array, not a Node Buffer view with a shared pool. - const data = new Uint8Array( - buffer.buffer.slice( - buffer.byteOffset, - buffer.byteOffset + buffer.byteLength, - ), - ); - const doc = await pdfjs.getDocument({ - data, - // Server-side hardening: don't fetch external resources, don't rely - // on a worker thread, and avoid eval-based font handling. - isEvalSupported: false, - useWorkerFetch: false, - disableFontFace: true, - }).promise; - const { numPages } = doc; - await doc.destroy(); + const doc = await PDFDocument.load(buffer, { + // We only need the page count, so skip the extra work of parsing all + // form fields, and don't refuse encrypted PDFs — a customer may well + // upload a password/permission-protected PDF and we can still read + // its page count without decrypting the content streams. + ignoreEncryption: true, + updateMetadata: false, + }); + const numPages = doc.getPageCount(); return typeof numPages === "number" && numPages > 0 ? numPages : 0; - } catch { - // Corrupt/encrypted/non-PDF input — signal invalid to the caller. + } catch (err) { + // Corrupt / non-PDF input — signal invalid to the caller. Log it so a + // genuine bad file can be told apart from an unexpected parser + // regression. + console.error("countPdfPages: failed to parse PDF", err); return 0; } } diff --git a/package.json b/package.json index 8f6f331..ab09a09 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "nextjs-google-analytics": "^2.3.7", "nodemailer": "^9.0.1", "payload": "^3.83.0", + "pdf-lib": "^1.17.1", "pdfjs-dist": "^5.6.205", "pug": "^3.0.4", "react": "19.2.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9df285..9d16daf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: payload: specifier: ^3.83.0 version: 3.83.0(graphql@16.13.2)(typescript@5.8.3) + pdf-lib: + specifier: ^1.17.1 + version: 1.17.1 pdfjs-dist: specifier: ^5.6.205 version: 5.6.205 @@ -1528,6 +1531,12 @@ packages: react: ^19.0.1 || ^19.1.2 || ^19.2.1 react-dom: ^19.0.1 || ^19.1.2 || ^19.2.1 + '@pdf-lib/standard-fonts@1.0.0': + resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==, tarball: https://packages.atlassian.com/api/npm/npm-remote/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz} + + '@pdf-lib/upng@1.0.1': + resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==, tarball: https://packages.atlassian.com/api/npm/npm-remote/@pdf-lib/upng/-/upng-1.0.1.tgz} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==, tarball: https://packages.atlassian.com/api/npm/npm-remote/@pinojs/redact/-/redact-0.4.0.tgz} @@ -3033,6 +3042,9 @@ packages: openid-client@5.7.1: resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==, tarball: https://packages.atlassian.com/api/npm/npm-remote/openid-client/-/openid-client-5.7.1.tgz} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==, tarball: https://packages.atlassian.com/api/npm/npm-remote/pako/-/pako-1.0.11.tgz} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==, tarball: https://packages.atlassian.com/api/npm/npm-remote/parent-module/-/parent-module-1.0.1.tgz} engines: {node: '>=6'} @@ -3072,6 +3084,9 @@ packages: peerDependencies: graphql: ^16.8.1 + pdf-lib@1.17.1: + resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==, tarball: https://packages.atlassian.com/api/npm/npm-remote/pdf-lib/-/pdf-lib-1.17.1.tgz} + pdfjs-dist@5.6.205: resolution: {integrity: sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==, tarball: https://packages.atlassian.com/api/npm/npm-remote/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz} engines: {node: '>=20.19.0 || >=22.13.0 || >=24'} @@ -3533,6 +3548,9 @@ packages: typescript: optional: true + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==, tarball: https://packages.atlassian.com/api/npm/npm-remote/tslib/-/tslib-1.14.1.tgz} + tslib@2.4.0: resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==, tarball: https://packages.atlassian.com/api/npm/npm-remote/tslib/-/tslib-2.4.0.tgz} @@ -5624,6 +5642,14 @@ snapshots: - supports-color - typescript + '@pdf-lib/standard-fonts@1.0.0': + dependencies: + pako: 1.0.11 + + '@pdf-lib/upng@1.0.1': + dependencies: + pako: 1.0.11 + '@pinojs/redact@0.4.0': {} '@popperjs/core@2.11.8': {} @@ -7357,6 +7383,8 @@ snapshots: object-hash: 2.2.0 oidc-token-hash: 5.2.0 + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -7430,6 +7458,13 @@ snapshots: - typescript - utf-8-validate + 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 + pdfjs-dist@5.6.205: optionalDependencies: '@napi-rs/canvas': 0.1.99 @@ -7955,6 +7990,8 @@ snapshots: optionalDependencies: typescript: 5.8.3 + tslib@1.14.1: {} + tslib@2.4.0: {} tslib@2.8.1: {}