Skip to content

Commit b97b6e4

Browse files
PPLT-6073: native PDF visual testing via POST /percy/pdf/snapshot (#2418)
* feat(cli-pdf): native PDF visual testing via POST /percy/pdf/snapshot Adds first-class PDF support to the CLI so an SDK can hand over PDF bytes and get one Percy snapshot per page, with synchronous comparison results. This is the replacement for the external percy-pdf solution, which wrapped the CLI from outside by serving a pdf.js viewer and driving Percy's renderer through the viewer's DOM with per-page `execute` scripts. New package @percy/cli-pdf is a leaf library: PDF bytes in, page rasters and their root DOM out. It holds no reference to @percy/core, which is what lets core list it as an optionalDependency without a cycle -- users who never snapshot a PDF do not install pdfjs-dist or the @napi-rs/canvas prebuilds. @percy/core gains the POST /percy/pdf/snapshot route and pdf-snapshot.js, which validates the request, decodes the base64 document, lazily imports @percy/cli-pdf, and pushes one snapshot per selected page through percy.upload() with `resources` as a function so rasterizing happens inside the queue task and inherits its concurrency. Each page carries resources and no `tag`, so createSnapshotsQueue routes it via client.sendSnapshot -- these are real web snapshots, not comparisons. This mirrors cli-upload's web-token path. The document travels as base64 in an ordinary JSON body and the sync response is always a JSON object (never a bare array) carrying a per-page array. Both are deliberate: every SDK, including the .NET wrapper's Dictionary-to-JSON helper and its JObject.Parse of the response, can call this with the HTTP client it already has, with no multipart or streaming code. Page snapshots are named `<name> | Page N`, matching percy-pdf exactly so teams migrating keep their approved baselines instead of orphaning them. Oversized pages are fitted rather than rejected: Legal (1224x2016 at scale 2) and A3 exceed Percy's 2000px cap and are exactly the documents this targets, so fitScale reduces the scale deterministically from the page's own dimensions and warns. pdfjs-dist is pinned to 4.x rather than 6.x, which requires Node >=22.13. @percy/sdk-utils exports postPdfSnapshot as the shared seam every SDK wraps. Note that sync mode is only reachable through this endpoint under `percy exec`: percy.syncMode() force-disables sync under skipUploads/deferUploads/delayUploads, which the `snapshot` and `upload` commands set. A `percy pdf <dir>` command could therefore never return comparison results, so none is added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(cli-pdf): pin pdfjs-dist to 2.x so yarn install works on Node 14 CI installs on Node 14.21.3 and yarn enforces `engines` across the whole tree, so `yarn install` aborted with: error @percy/cli-pdf@1.32.8: The engine "node" is incompatible with this module. Expected version ">=18". Got "14.21.3" Dropping cli-pdf's own `engines` field is not sufficient: pdfjs-dist declares `node: ">=18"` from 3.x onward, so yarn would fail on the dependency instead. pdfjs-dist 2.16.105 is the last line that declares no engines constraint, and @napi-rs/canvas is already `>= 10`, so 2.x is what keeps the repo installable on its current Node floor. Rasterization output is equivalent -- verified end to end: unchanged document gives zero diffs on every page, and a document changed on page 2 only reports a diff on page 2 while pages 1 and 3 stay at zero. The 2.x legacy build is CommonJS rather than ESM, so the import moves to pdfjs-dist/legacy/build/pdf.js with `mod.default ?? mod` interop. cli-pdf's engines now matches its sibling packages at >=14. Also removes source comments across the PDF changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(cli-pdf): don't bind createRequire to the name `require` packages/cli-command/test/noRequireBinding.test.js guards every packages/*/src file against `const require = createRequire(...)`: the name collides with Babel's transforms and crashes the packaged pkg binary with "_require is not a function". rasterize.js needed it to resolve pdfjs-dist's on-disk standard_fonts and cmaps directories, so the binding is renamed to cjsRequire as the guard suggests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(cli-pdf): add the package to CI and close two coverage gaps The Test workflow's package list is hardcoded, so @percy/cli-pdf's suite was never running in CI at all -- the same gap #2402 closed for cli-app. Added to the matrix. CI runs test:coverage, which enforces the repo's 100% threshold, so two dead spots had to go first: - loadDocument used `mod.default ?? mod` for CJS interop, but the pdfjs legacy build always exposes `.default` (verified: `mod.default` is an object while `mod.getDocument` is undefined), leaving `?? mod` unreachable. It now reads `mod.default` directly. - pages.js skips empty segments in a string selection and nothing exercised that path. Added coverage for '1,,3' and '2,', plus the case where a selection resolves to no pages at all. Verified the rasterizer really does work on Node 14, the matrix version: pdfjs 2.16.105 plus the @napi-rs/canvas native binding render all three fixture pages with identical non-white pixel counts to Node 22. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(cli-pdf): rasterize in the discovery browser, drop @napi-rs/canvas The discovery Chromium is already launched eagerly by percy.start() and sits completely idle for the whole of a PDF run (verified: every discovery queueInfo line reports total 0). Shipping @napi-rs/canvas alongside it meant paying 25MB of platform-specific native prebuilds, plus a native-binary dependency in a widely distributed CLI, to duplicate a renderer already present and running. pdf.js now runs inside a browser page instead of in the Node process: - @percy/cli-pdf drops @napi-rs/canvas entirely and becomes pure helpers plus pdf.js assets: page selection, the page DOM, pdfjs-dist asset paths, and the functions that execute in the page context. It remains an optionalDependency so nobody pays for pdfjs-dist's 34MB unless they snapshot a PDF. - @percy/core gains pdf-rasterize.js, which owns the browser work: a throwaway loopback origin (Server.serve) exposing pdf.js, its worker, standard_fonts, cmaps and the document itself, then a page that injects pdf.js and renders each selected page to a canvas, returning a PNG data URL per page. Serving the assets over a real origin is what makes standard fonts work: pdf.js fetches standardFontDataUrl/cMapUrl over HTTP, and base-14 fonts such as Helvetica are not embedded in most documents. isEvalSupported stays false -- PDFs are untrusted input. The rasterizer now calls percy.browser.launch() explicitly. It is idempotent, and this makes PDF snapshots work under skipDiscovery, where the eager launch does not happen. Two side effects worth noting. Rendering is now the CLI's pinned Chromium rather than a separately versioned Skia, so page rasters are as reproducible as the rest of Percy's pipeline instead of tracking a native dependency's version. And existing PDF baselines must be regenerated, since the rasterizer changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(cli-pdf): cover the page-context scripts to meet the 100% threshold CI reported browser-scripts.js at 23.81% statements: openDocument, measurePages, renderPage and destroyDocument are serialized and executed in the browser page, so nothing in the Node suite ever ran them. pages.js:59 also had one uncovered branch, the singular form of the out-of-range message. Rather than mark the page scripts ignored, the suite now stands up fake `window` and `document` globals and invokes them directly. That covers the code and asserts behaviour that was genuinely untested: - the exact URLs pdf.js is handed (worker, document, standard_fonts, cmaps) and that isEvalSupported stays false - the window.pdfjsLib fallback, and the error when pdf.js never initialised - fractional viewports rounding up - the white canvas pre-fill, without which transparent PDF regions rasterize to alpha-0 black and diff against anything - page handles being released even when rendering rejects Adds a 1-page-document case so both arms of the pluralisation in the out-of-range message are exercised. 44 specs, all passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(core): cover the PDF error paths to meet the 100% threshold All 1278 core specs passed, but the job failed on coverage: pdf-rasterize.js | 95.24 | 70.00 | 100 | 95.24 | 36,64 pdf-snapshot.js | 95.89 | 95.12 | 100 | 95.71 | 34,52,118 Every gap was an error or warning path. Now covered: the invalid-scale guard (each of its three arms), the fitScale warning via a Legal-size page, the too-short-base64 and non-object-pdf branches of decodePdf, a blank name, a non-object request body, an empty body, a browser failure surfacing as a rasterization error, and a page returning a failure status. Two small production changes fell out of writing them: - loadPdfModule takes an injectable loader, defaulting to the real dynamic import, so the 501 "package is not installed" path is reachable from a test instead of only when the optional dependency is genuinely absent. - The body guard now also rejects Buffers. api.js leaves req.body as raw bytes when JSON.parse fails, and `typeof Buffer === 'object'`, so a malformed body slipped past the check and produced a confusing "Missing required `name`" rather than "Expected a JSON object body". Found by the test. rasterizePdf's `options = {}` default is dropped: its single caller always passes options, so the default arm was unreachable branch weight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(core): share the image-snapshot wrapper and take the extraction path Two problems, found by comparing against example-non-rendering-project. 1. buildPageHtml duplicated cli-upload's wrapper DOM. That shape is a contract with percy-api, not cosmetics: extract_and_process_upload_snapshot recovers the image by matching /<img\s+src="([^"]+)"\s+width="(\d+)px"\s+height="(\d+)px"/ against the root resource. A mismatch is not an error -- extraction raises, percy-api rescues, and the snapshot silently falls back to being rendered. Two divergent copies of that was a latent bug, and mine had already drifted (a trailing alt="" plus extra CSS; harmless only because the regex is unanchored). The wrapper now lives once, in core's utils as buildImageSnapshotHtml / createImageSnapshotResources. cli-upload's getImageResources delegates to it, and the PDF path uses it, so buildPageHtml is gone. core cannot import cli-upload (cli-upload -> cli-command -> core would cycle), but cli-upload already reaches core's utils via @percy/cli-command/utils, so this needs no new dependency either way. image-snapshot-resources.test.js pins percy-api's regex verbatim, so drift is caught in CI rather than degrading silently in production. 2. PDF pages were not taking the extraction path at all. Comparison#upload_snapshot? gates on `user_agent&.include?('@percy/cli-upload')` plus a root resource URL under http://local/. The PDF endpoint only ever forwarded the SDK's own clientInfo, so every page was fully re-rendered by the renderer fleet despite the CLI already having produced the exact PNG. Measured per page, same document: 19s/11s/9s rendered versus 1s/1s/1s extracted. The endpoint now also tags the build with @percy/cli-pdf and @percy/cli-upload. The percy-api check is a substring match, so naming cli-pdf alongside keeps the User-Agent honest about which code ran instead of impersonating the upload command. Note for operators: upload_extraction_allowed? only short-circuits on a project's default base branch. Elsewhere it mirrors the base comparison, so existing baselines need regenerating before comparison builds will extract. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(regression): compare rendered PDF pages byte-for-byte Adds Track P to the regression suite: rasterizes the PDFs in test/regression/assets/pdfs/ through @percy/core's rasterizePdf — the same path POST /percy/pdf/snapshot takes, pdf.js rendering each page in the discovery browser — and asserts every produced PNG is byte-identical to a committed golden. Byte equality catches anything that changes what reaches Percy: scale selection, canvas size, pixel output, PNG encoding. Token-free and build-free, so it runs on every PR. Goldens are platform-scoped under expected/<platform>-<arch>/ because PNG bytes are only reproducible for one platform and Chromium build — Percy pins a different Chromium snapshot per platform, and glyph rasterization goes through CoreText on macOS versus FreeType on Linux. Each set carries a manifest recording the browser build it came from, and a failing run reports a browser mismatch so a Chromium bump is not mistaken for a regression. Pages with raster images are not byte-reproducible even on one machine: Chromium picks between two anti-aliasing paths for a clipped image edge from run to run. Measured on jack sparrow resume.pdf as 6 of 15 runs differing, always the same 270 pixels in the same 193x193 box around the circular photo crop, never more than 52 per channel, out of 2,005,644 (0.013%). Such pages declare a pixel budget in TOLERANCES set at ~2x the measured worst case; the byte comparison still runs first and only falls back to the budget when it fails, a dimension change is never tolerated, and everything not listed must match byte-for-byte. The CI step is a temporary bootstrap: linux-x64 goldens have to be produced by the Linux Chromium build, so it generates and uploads them as an artifact. Once those are committed the step collapses to a plain compare run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(regression): assert PDF page bytes against linux-x64 goldens in CI Adds the goldens the previous commit's bootstrap step generated on the CI runner and collapses that step into a plain compare run, so the regression job now asserts on PDF page bytes instead of recording them. The macOS and Linux renders confirm why the goldens have to be platform scoped: page dimensions match exactly, but 9.7% of pixels differ on single-page-sample and 12.6% on multipage-sample-pdf page 2, at a max channel delta of 255 across the whole text area. Glyph rasterization is delegated to the platform font backend — CoreText on macOS, FreeType on Linux — so the glyph bitmaps themselves differ rather than just the PNG encoding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): address PR review on the PDF snapshot path Resolves the CodeRabbit review on #2418. config: `pages` and `excludePages` spelled the selection grammar out as a regex that disagreed with parseSelection() in @percy/cli-pdf — the pattern rejected "1,,3" and "2,", which the parser accepts by skipping empty parts. The shape is now defined once as `pageSelection` and its pattern constrains only the character set, leaving the grammar and every semantic rule to the parser that actually reads the value. Note this drift was latent rather than user-visible: shouldHideError() in @percy/config suppresses every `oneOf` error unless the schema carries a custom `error`, so neither pattern has ever produced a warning. pdf-snapshot: a buffer shorter than the %PDF- magic reported "not valid base64-encoded data", which misdescribes input that decoded fine and was merely too short. It now says so, matching what the test was already named. pdf-snapshot tests: save and restore jasmine.DEFAULT_TIMEOUT_INTERVAL around the suite so the 240s bump does not leak into suites that run after it, and assert the /scale schema warning separately from the rasterizer's 400 — the existing assertion would still pass if schema validation were dropped. regression: the PDF byte track wrote each golden inside the render loop, so a run that failed its own sanity checks still overwrote the baseline and exited 0 in update mode. Writes are buffered and flushed only after the failure check, so a bad render leaves the committed set untouched. semgrep: the track's path.join() calls are flagged as path traversal. They join process.platform/arch, a slug already reduced to [a-z0-9-], and filenames read from the committed fixture directory — no external input reaches them — so both files are suppressed at the file level with that rationale, as the existing entries are. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): address PDF review findings on #2418 Four fixes from review, plus one change the third one forces. escapeAttr: the wrapper's `img src` went through escapeHtml, which rewrites `'` to &#39;. encodeURIComponent leaves `'` literal, so for a document named "Jack's Resume" the src diverged from the registered resource URL, percy-api's extractor found no match, and the page silently fell back to being rendered at ~10x the cost with nothing surfaced. This also regressed @percy/cli-upload, which interpolated the URL raw before the wrapper was shared. Attribute context is double-quoted, so only & and " need escaping; `'`, < and > must be left alone. escapeHtml still guards the <title> text. Page#eval threw exceptionDetails.exception.description -- a bare string -- so every caller's error.message was undefined and an in-page failure surfaced as "Could not rasterize PDF: undefined". It now throws a real Error whose message is the description's first line, with the remote stack preserved verbatim as `stack` so nothing is lost. Limits: MAX_PAGES (250) enforced after exclusions so it counts what is actually selected; a 30s timeout on every in-page call, since Page.TIMEOUT only covers navigation and Runtime.callFunctionOn with awaitPromise waits forever; and the 50MB cap checked against the encoded length so Buffer.from never allocates for an oversized body. Rasterization failures answer 400 only when the caller can fix them -- the rasterizer tags those. A browser launch failure, an OOM or a CDP disconnect is a 500, not a report that the SDK sent a malformed request. The render timeout makes a leak reachable that was theoretical before: a timed-out page is exactly when close() rejects, which would strand the asset server still holding the customer's PDF. Both closes are now settled together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): close the high findings from the second review Page#eval's Error broke the logging contract. @percy/logger renders a thrown Error as Error.prototype.toString and only falls back to `stack` at debug level, so a default `name` both doubled the prefix ("Error: Error: test error") and dropped the remote frames the user needs to debug their own execute script. snapshot.test.js "logs execute errors and does not snapshot" pinned exactly that text and went red in CI. Blanking `name` makes toString return the description verbatim, so the logged output is byte-identical to the string this replaced. Parsing the name off the first line does NOT work -- it strips the frames. withTimeout had no test at all, on a package with a 100% coverage gate, which means the fix for "no render timeout" shipped with no evidence it worked. It is now exported and covered: timeout, pass-through, early rejection, timer cleanup, and that a late rejection never surfaces as unhandled. `timer.unref?.()` became `timer.unref()` -- the optional call's false branch is unreachable in Node and would have failed the branch gate on its own. The asset server binds 127.0.0.1 instead of inheriting Server's "::" default. It serves the customer's PDF unauthenticated and its only client is the local discovery browser. An explicit host now beats PERCY_SERVER_HOST, so widening the API server cannot widen this one. Two evals were still un-raced despite the comment claiming every in-page call was: the pdf.js injection and destroyDocument. Either one reproduced the original hang exactly. Both now go through withTimeout. Cleanup failures are logged rather than silently discarded -- a socket that will not drain is still holding that PDF. And the `promise.catch(() => {})` guard is gone: Promise.race attaches handlers to every input, so the loser's late rejection was already handled and the comment described a hazard that cannot occur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 16038d5 commit b97b6e4

48 files changed

Lines changed: 2790 additions & 37 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ jobs:
7272
- '@percy/cli-exec'
7373
- '@percy/cli-snapshot'
7474
- '@percy/cli-upload'
75+
- '@percy/cli-pdf'
7576
- '@percy/cli-build'
7677
- '@percy/cli-config'
7778
- '@percy/sdk-utils'
@@ -302,6 +303,15 @@ jobs:
302303
run: yarn test:regression:config
303304
- name: Run functional discovery tests (token-free)
304305
run: yarn test:regression:functional
306+
# Compares rendered PDF pages byte-for-byte against the linux-x64
307+
# goldens. PNG bytes are only reproducible for one platform + Chromium
308+
# build (Percy pins a different Chromium snapshot per platform, and glyph
309+
# rasterization goes through CoreText on macOS vs FreeType on Linux), so
310+
# these goldens were generated on this runner and only this job asserts on
311+
# them. Regenerate after a Chromium bump by re-running this step with
312+
# UPDATE_PDF_GOLDENS=1 and committing the result.
313+
- name: Run PDF rasterization byte tests (token-free)
314+
run: yarn test:regression:pdf
305315
# Visual track runs last and is the ONLY step that creates a Percy build,
306316
# so the PR's single build carries all visual snapshots (no stray build
307317
# superseding it on the same commit).

.semgrepignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,16 @@ packages/cli-command/src/intelliStory.js
6161
# to assert no `require()` bindings leak in; the traversal roots are static
6262
# literals, no user input flows here. semgrep flags the path.join() anyway.
6363
packages/cli-command/test/noRequireBinding.test.js
64+
65+
# The PDF byte-comparison regression track (test/regression/pdf-render.test.js
66+
# and its helper) joins paths from three local sources only: `platformKey()`,
67+
# which is `${process.platform}-${process.arch}`; a slug that slugify() has
68+
# already reduced to [a-z0-9-] via basename(); and PDF filenames read straight
69+
# out of the committed fixture directory with fs.readdirSync. No request or
70+
# user input reaches these joins — the track runs offline against checked-in
71+
# fixtures and creates no Percy build. semgrep's
72+
# javascript.lang.security.audit.path-traversal.path-join-resolve-traversal
73+
# rule flags the joins regardless, and inline `// nosemgrep` is not honored by
74+
# the CI semgrep version — suppress at the file level with this rationale.
75+
test/regression/lib/pdf-render.js
76+
test/regression/pdf-render.test.js

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
"global:unlink": "lerna exec -- yarn unlink",
2727
"test:regression": "node test/regression/regression.test.js",
2828
"test:regression:config": "node test/regression/config-validation.test.js",
29-
"test:regression:functional": "node test/regression/functional.test.js"
29+
"test:regression:functional": "node test/regression/functional.test.js",
30+
"test:regression:pdf": "node test/regression/pdf-render.test.js"
3031
},
3132
"devDependencies": {
3233
"@babel/cli": "^7.11.6",

packages/cli-pdf/package.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"name": "@percy/cli-pdf",
3+
"version": "1.32.10-beta.0",
4+
"license": "MIT",
5+
"description": "Renders PDF documents into per-page images for Percy snapshots",
6+
"repository": {
7+
"type": "git",
8+
"url": "https://github.com/percy/cli",
9+
"directory": "packages/cli-pdf"
10+
},
11+
"publishConfig": {
12+
"access": "public",
13+
"tag": "beta"
14+
},
15+
"engines": {
16+
"node": ">=14"
17+
},
18+
"files": [
19+
"dist"
20+
],
21+
"main": "./dist/index.js",
22+
"type": "module",
23+
"exports": "./dist/index.js",
24+
"scripts": {
25+
"build": "node ../../scripts/build",
26+
"lint": "eslint --ignore-path ../../.gitignore .",
27+
"test": "node ../../scripts/test",
28+
"test:coverage": "yarn test --coverage"
29+
},
30+
"dependencies": {
31+
"@percy/logger": "1.32.10-beta.0",
32+
"pdfjs-dist": "^2.16.105"
33+
}
34+
}

packages/cli-pdf/src/assets.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import path from 'path';
2+
import { createRequire } from 'module';
3+
4+
const cjsRequire = createRequire(import.meta.url);
5+
6+
export function pdfjsAssets() {
7+
let root = path.dirname(cjsRequire.resolve('pdfjs-dist/package.json'));
8+
9+
return {
10+
root,
11+
buildDir: path.join(root, 'legacy/build'),
12+
standardFontsDir: path.join(root, 'standard_fonts'),
13+
cmapsDir: path.join(root, 'cmaps'),
14+
libPath: path.join(root, 'legacy/build/pdf.js'),
15+
workerFile: 'pdf.worker.js'
16+
};
17+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
export const MIN_DIMENSION = 10;
2+
export const MAX_DIMENSION = 2000;
3+
4+
export const DEFAULT_SCALE = 2;
5+
export const MAX_SCALE = 5;
6+
7+
// Wall-clock ceiling for a single page's in-page work (open, measure, render).
8+
// Page#eval resolves off `Runtime.callFunctionOn` with `awaitPromise: true`,
9+
// which has no timeout of its own -- Page.TIMEOUT only covers navigation. A PDF
10+
// that wedges pdf.js would otherwise hang the HTTP request forever while
11+
// holding a browser page and a listening asset server.
12+
export const PAGE_RENDER_TIMEOUT = 30000;
13+
14+
export function fitScale(requestedScale, { width, height }) {
15+
return Math.min(requestedScale, MAX_DIMENSION / width, MAX_DIMENSION / height);
16+
}
17+
18+
export function assertRasterDimensions(pageNumber, width, height) {
19+
if (width < MIN_DIMENSION || height < MIN_DIMENSION) {
20+
throw new Error(
21+
`Page ${pageNumber} rasterized to ${width}x${height}px, below Percy's ` +
22+
`${MIN_DIMENSION}px minimum. Increase \`scale\`.`
23+
);
24+
}
25+
}
26+
27+
export async function openDocument(_, { origin }) {
28+
let lib = window['pdfjs-dist/build/pdf'] || window.pdfjsLib;
29+
30+
if (!lib) {
31+
throw new Error('pdf.js did not initialise in the page');
32+
}
33+
34+
lib.GlobalWorkerOptions.workerSrc = `${origin}/pdfjs/pdf.worker.js`;
35+
36+
let doc = await lib.getDocument({
37+
url: `${origin}/doc.pdf`,
38+
isEvalSupported: false,
39+
standardFontDataUrl: `${origin}/standard_fonts/`,
40+
cMapUrl: `${origin}/cmaps/`,
41+
cMapPacked: true
42+
}).promise;
43+
44+
window.__percyPdf = { lib, doc };
45+
46+
return { pageCount: doc.numPages };
47+
}
48+
49+
export async function measurePages(_, { pageNumbers }) {
50+
let { doc } = window.__percyPdf;
51+
let sizes = [];
52+
53+
for (let pageNumber of pageNumbers) {
54+
let page = await doc.getPage(pageNumber);
55+
let { width, height } = page.getViewport({ scale: 1 });
56+
sizes.push({ pageNumber, width, height });
57+
page.cleanup();
58+
}
59+
60+
return sizes;
61+
}
62+
63+
export async function renderPage(_, { pageNumber, scale }) {
64+
let { doc } = window.__percyPdf;
65+
let page = await doc.getPage(pageNumber);
66+
67+
try {
68+
let viewport = page.getViewport({ scale });
69+
let width = Math.ceil(viewport.width);
70+
let height = Math.ceil(viewport.height);
71+
72+
let canvas = document.createElement('canvas');
73+
canvas.width = width;
74+
canvas.height = height;
75+
76+
let context = canvas.getContext('2d');
77+
context.fillStyle = '#ffffff';
78+
context.fillRect(0, 0, width, height);
79+
80+
await page.render({ canvasContext: context, viewport }).promise;
81+
82+
return {
83+
width,
84+
height,
85+
dataUrl: canvas.toDataURL('image/png')
86+
};
87+
} finally {
88+
page.cleanup();
89+
}
90+
}
91+
92+
export async function destroyDocument() {
93+
let state = window.__percyPdf;
94+
95+
if (state?.doc) {
96+
await state.doc.destroy();
97+
delete window.__percyPdf;
98+
}
99+
100+
return true;
101+
}

packages/cli-pdf/src/index.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export { pdfjsAssets } from './assets.js';
2+
export { resolvePages, MAX_PAGES } from './pages.js';
3+
export {
4+
openDocument,
5+
measurePages,
6+
renderPage,
7+
destroyDocument,
8+
fitScale,
9+
assertRasterDimensions,
10+
DEFAULT_SCALE,
11+
MAX_SCALE,
12+
PAGE_RENDER_TIMEOUT,
13+
MIN_DIMENSION,
14+
MAX_DIMENSION
15+
} from './browser-scripts.js';

packages/cli-pdf/src/pages.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Ceiling on how many pages one request may rasterize. Every page is held in
2+
// memory as a PNG twice over -- once in the rasterizer's result array, once in
3+
// the resource closure the snapshot queue keeps -- and each additionally crosses
4+
// CDP as a base64 data URL. A 50MB PDF can carry thousands of pages, so without
5+
// a cap a single request can exhaust the heap. Callers who genuinely want more
6+
// can narrow with `pages` and issue several requests.
7+
export const MAX_PAGES = 250;
8+
9+
function parseSelection(value, pageCount) {
10+
if (value == null) return range(1, pageCount);
11+
if (typeof value === 'number') return [toPageNumber(value)];
12+
if (Array.isArray(value)) return value.map(toPageNumber);
13+
14+
if (typeof value !== 'string') {
15+
throw new Error(`Invalid page selection: expected a number, array or string, got ${typeof value}`);
16+
}
17+
18+
let selected = [];
19+
20+
for (let part of value.split(',')) {
21+
part = part.trim();
22+
if (!part) continue;
23+
24+
let match = /^(\d+)\s*-\s*(\d+)?$/.exec(part);
25+
26+
if (match) {
27+
let from = toPageNumber(match[1]);
28+
let to = match[2] == null ? pageCount : toPageNumber(match[2]);
29+
if (to < from) throw new Error(`Invalid page range "${part}": end page is before start page`);
30+
selected.push(...range(from, to));
31+
} else if (/^\d+$/.test(part)) {
32+
selected.push(toPageNumber(part));
33+
} else {
34+
throw new Error(`Invalid page selection "${part}": expected a page number or a range like "2-5"`);
35+
}
36+
}
37+
38+
return selected;
39+
}
40+
41+
function toPageNumber(value) {
42+
let n = Number(value);
43+
if (!Number.isInteger(n) || n < 1) {
44+
throw new Error(`Invalid page number "${value}": page numbers are 1-based integers`);
45+
}
46+
return n;
47+
}
48+
49+
function range(from, to) {
50+
let out = [];
51+
for (let i = from; i <= to; i++) out.push(i);
52+
return out;
53+
}
54+
55+
export function resolvePages({ pages, excludePages } = {}, pageCount) {
56+
if (!Number.isInteger(pageCount) || pageCount < 1) {
57+
throw new Error(`Invalid page count: ${pageCount}`);
58+
}
59+
60+
let selected = parseSelection(pages, pageCount);
61+
let excluded = new Set(excludePages == null ? [] : parseSelection(excludePages, pageCount));
62+
63+
let outOfRange = [...new Set(selected.filter(p => p > pageCount))];
64+
if (outOfRange.length) {
65+
throw new Error(
66+
`Requested page${outOfRange.length > 1 ? 's' : ''} ${outOfRange.join(', ')} ` +
67+
`but the document has only ${pageCount} page${pageCount > 1 ? 's' : ''}`
68+
);
69+
}
70+
71+
let resolved = [...new Set(selected)]
72+
.filter(p => !excluded.has(p))
73+
.sort((a, b) => a - b);
74+
75+
if (!resolved.length) {
76+
throw new Error('No pages left to snapshot after applying `pages` and `excludePages`');
77+
}
78+
79+
if (resolved.length > MAX_PAGES) {
80+
throw new Error(
81+
`Requested ${resolved.length} pages but the maximum per request is ${MAX_PAGES}. ` +
82+
'Narrow the selection with `pages` (for example "1-100") and issue several requests.'
83+
);
84+
}
85+
86+
return resolved;
87+
}
88+
89+
export { parseSelection as _parseSelection };

packages/cli-pdf/test/.eslintrc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
env:
2+
jasmine: true
3+
rules:
4+
import/no-extraneous-dependencies: off
5+
no-return-assign: off
6+
no-sequences: off
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import { pdfjsAssets } from '../src/assets.js';
4+
5+
describe('@percy/cli-pdf assets', () => {
6+
let assets = pdfjsAssets();
7+
8+
it('resolves the installed pdfjs-dist root', () => {
9+
expect(fs.existsSync(path.join(assets.root, 'package.json'))).toBe(true);
10+
});
11+
12+
it('points at the legacy build directory', () => {
13+
expect(fs.existsSync(assets.buildDir)).toBe(true);
14+
expect(fs.existsSync(path.join(assets.buildDir, 'pdf.js'))).toBe(true);
15+
expect(fs.existsSync(path.join(assets.buildDir, assets.workerFile))).toBe(true);
16+
});
17+
18+
it('points at the font and cmap data pdf.js fetches at runtime', () => {
19+
expect(fs.existsSync(assets.standardFontsDir)).toBe(true);
20+
expect(fs.existsSync(assets.cmapsDir)).toBe(true);
21+
expect(fs.readdirSync(assets.standardFontsDir).length).toBeGreaterThan(0);
22+
expect(fs.readdirSync(assets.cmapsDir).length).toBeGreaterThan(0);
23+
});
24+
25+
it('exposes the injectable pdf.js library file', () => {
26+
expect(fs.existsSync(assets.libPath)).toBe(true);
27+
expect(fs.readFileSync(assets.libPath, 'utf-8')).toContain('getDocument');
28+
});
29+
});

0 commit comments

Comments
 (0)