Removed
- Removed the bespoke
NodeCanvasFactory(src/node.canvas.factory.ts) and its tests. Rendering now uses pdf.js's built-in Node canvas factory (PDFDocumentProxy.canvasFactory, backed by@napi-rs/canvas) directly. The previous code selected this factory at runtime anyway — theisNodeCanvasFactory()duck-type guard always matched pdf.js's own factory, so the project's class and itsnew NodeCanvasFactory()fallback were never exercised on the render path. The@napi-rs/canvasdependency is unchanged (kept as a direct dependency so pdf.js's renderer is always able to load it). Rendered PNG output is unchanged — the visual-comparison suites pass. Resolves backlog item ARCH-015. pdf.js'scanvasFactoryis validated at runtime (it must expose callablecreate/destroy) rather than force-cast, the render path now asserts both the returnedcanvasandcontextare non-null before use, anddestroy()receives the exactCanvasAndContextobject pdf.js returned (preserving any internal fields it needs for cleanup).
Fixed
outputFileMaskFuncnow rejects non-string return values before page processing. Previously truthy non-string values passed the separator check through implicit coercion and could escape into metadata results as a non-stringname, violating thePngPageOutputcontract.- Parallel page processing now propagates a worker rejection whose reason is
undefined. PreviouslyprocessPagesWithSlidingWindow()usedundefinedas both the "no error" sentinel and a possible rejection payload, so that failure was swallowed and the conversion resolved with anundefinedpage result. PngPageOutput.width/heightare now always integer pixel dimensions that match the rendered PNG. Previously they were reported straight from pdf.js'sPageViewport, whose lengths are unrounded floats, while@napi-rs/canvastruncates fractional dimensions when it allocates the bitmap. Any PDF whoseviewportScale × pageDimensionwas fractional therefore reported a non-integer size that disagreed with the actual image — e.g. a 595×842 pt (A4) page atviewportScale: 1.5reportedwidth: 892.5for an 892 px-wide PNG. Both the render path (renderPdfPage) and thereturnMetadataOnlypath (getPageMetadata) now floor viewport lengths to pixels via the sharedtoPixelDimensionhelper, so the two paths agree and both match the bitmap. US-Letter assets (612×792) at integer scales are unaffected.- A
viewportScalesmall enough to floor a page to0px in either dimension now throws an actionable"…cannot produce a valid image. Increase viewportScale."error from bothrenderPdfPageandgetPageMetadata, instead of returning a phantom0×0metadata result or surfacing an opaque canvas-factoryAssertionError. The page is released before the render path throws. returnMetadataOnly(getPageMetadata) now enforces theMAX_CANVAS_PIXELSlimit, matchingrenderPdfPage. Previously the oversized-page guard lived only on the render path, so aviewportScalewhose viewport area exceeded the limit threw"Canvas …×… px exceeds the … pixel limit. Reduce viewportScale."on a real render but silently returned those (unrenderable) dimensions in metadata-only mode — a phantom result for a page that cannot be rendered, the same failure mode the floor-to-zero guard already prevents on both paths. The two paths now reject oversized pages with the identical message via the sharedcanvasPixelLimitErrorbuilder (mirroringnonRenderableDimensionsError).- The
MAX_CANVAS_PIXELScanvas-area guard now bounds the rendered (floored) canvas —floor(viewportWidth) × floor(viewportHeight)— instead of the unrounded fractional viewport area. Because the canvas is allocated with floored dimensions (via the sharedtoPixelDimensionhelper), a page whose un-floored viewport area slightly exceeded the limit while its actually-allocated bitmap fit within it was wrongly rejected with"Canvas …×… px exceeds the … pixel limit. Reduce viewportScale.". This affects a narrowviewportScaleband — e.g. a 612×792 pt US-Letter page atviewportScale ≈ 14.3636produces an un-floored area of100,000,739px (over the100,000,000cap) but a real8790×11375 = 99,986,250px bitmap (under it), so the page is renderable yet was refused. BothrenderPdfPageand thereturnMetadataOnlypath (getPageMetadata) now floor viewport lengths before the area check, so the guard matches the bitmap actually allocated and the two paths stay symmetric. Pages that genuinely exceed the limit still throw the identical message on both paths, and peak canvas memory remains bounded atMAX_CANVAS_PIXELS × 4 bytes ≈ 400 MB.
Security
- SEC-001:
outputFileMaskFuncfilenames are now rejected synchronously when they contain a/or\path separator, closing a residual TOCTOU window where a co-tenant with write access tooutputFoldercould swap an intermediate directory for a symlink between therealpath(dirname(...))check and theopen(..., 'wx')call insavePNGfile(). The guard fires both inresolvePageName(early) and insavePNGfile(defense in depth). The existing flat-filename contract is unchanged. - SEC-002: Added
PdfToPngOptions.maxInputBytes(default256 MiBviaMAX_INPUT_BYTES) bounding input PDF size. The path branch ofgetPdfFileBuffer()now runsfs.stat()beforefs.readFile()and rejects (a) non-regular files (/dev/zero, FIFOs, sockets, character devices) and (b) inputs whose size exceedsmaxInputBytes. The buffer /Uint8Arraybranch validatesbyteLengthagainst the same cap. Together these block unbounded memory consumption from untrusted input paths and oversized buffers. - SEC-003:
concurrencyLimitnow enforces an upper bound ofMAX_CONCURRENCY_LIMIT(16) whenprocessPagesInParallelistrue. At the cap, peak in-flight canvas memory ≈16 × MAX_CANVAS_PIXELS × 4 bytes ≈ 6.4 GiB— a defensible ceiling for typical service containers. Values above16(e.g.Number.MAX_SAFE_INTEGER) throw synchronously before any rendering starts. The default4and lower values are unaffected.
Changed
- Migrated
pdfjs-distfrom~5.7.284to~6.0.227. pdf.js v6 removedPDFDocumentProxy.destroy(), so document/worker teardown now usespdfDocument.loadingTask.destroy()(theloadingTaskgetter exists in both v5 and v6, and the removeddestroy()previously delegated to it). The public API, default options, asset paths (cmaps/standard_fonts), thelegacy/build/pdf.mjsimport path, and rendered PNG output are all unchanged — the visual-comparison suites pass against the existing v5-generated reference images. - CI now blocks on
npm run build:strict; the strict type-check is no longer advisory.continue-on-error: trueis removed from.github/workflows/test.ymland the dedicated CI "Strict type check" step is replaced bypretestgating (avoiding a double run on CI).pretestnow runsbuild:strictalongsidebuild:test— the two type-checks enforce different contracts:build:test(usingtsconfig.json, no DOM lib) gatessrc/against accidental DOM globals (document,window) that production builds would reject;build:strict(usingtsconfig.strict.json,skipLibCheck: false+ DOM lib for@napi-rs/canvastype resolution) gates against upstream type regressions inpdfjs-dist/@napi-rs/canvas. Localnpm testandprepublishOnlynow gate on both. - Improved README accuracy and usability for npm consumers, and simplified the package funding metadata so
npm fundexposes the Buy Me a Coffee URL.
Refactored
- Updated the stale version pin in the existing
@ts-ignoresuppression insrc/pageRenderer.tsfrompdfjs-dist@~5.6.205topdfjs-dist@~6.0.xand clarified why@ts-ignore(not@ts-expect-error) is required for this site — the underlying type error is hidden bybuild:test'sskipLibCheck:true, which would cause@ts-expect-errorto report as unused. Added a comment intsconfig.strict.jsonexplaining the intentional DOM-lib divergence fromtsconfig.json. Added a "Strict type-check" section toCONTRIBUTING.mddocumenting the failure-handling playbook (default@ts-expect-errorfor self-cleaning;@ts-ignoreexception forskipLibCheck-hidden errors).