github dichovsky/pdf-to-png-converter v4.2.0

5 hours ago

Added

  • renderInWorkerThreads (default false, CLI flag --render-in-worker-threads): pages are rasterized and PNG-encoded in a pool of Node.js worker threads, giving true multi-core parallelism. processPagesInParallel only interleaves pages on a single JS thread — rasterization itself never runs concurrently there — so on CPU-bound documents (large embedded images, complex vector art) it cannot use more than one core; worker mode can. Measured ~3× end-to-end on a 12-page image-heavy document with the default pool of 4. The pool size is concurrencyLimit (same 1..16 bound, same validation, which now also applies when only renderInWorkerThreads is set). Each worker loads its own copy of the document, so the cost is roughly one PDF copy plus one pdf.js instance of memory per worker, one additional copy of the input retained on the main thread for the duration of the conversion, and a few hundred milliseconds of pool startup per conversion — it pays off on multi-page, render-heavy work and can be slower than the default for small documents. Rendered pixels are identical to single-threaded mode and results are still returned in page order. Page filtering, output-name resolution, duplicate-name detection, and all file writes stay on the main thread, so disk output goes through the same OutputSink and the same SEC-001/002/003 path-security guards as every other mode. outputFileMaskFunc is fully supported (names are resolved before dispatch). Takes precedence over processPagesInParallel; ignored when returnMetadataOnly is true, since metadata extraction does not render.
  • A "Performance Notes" section in the README covering pipelined processing, libuv threadpool sizing (UV_THREADPOOL_SIZE), strict serial processing, and when multi-core rendering pays off.
  • npm run bench, an on-demand benchmark harness (scripts/benchmark.ts) used to size the sequential pipeline window and to measure worker-thread mode. Dev-only — the published tarball still ships out/ alone.

Changed

  • Sequential (default) processing is now pipelined. Pages previously ran in a strict one-at-a-time loop; they now run through the same sliding-window scheduler as parallel mode with a fixed window of SEQUENTIAL_PIPELINE_WINDOW (3), so the off-thread PNG encode (canvas.encode('png'), on the libuv threadpool) and the disk write of a finished page overlap the next page's render on the JS thread. The returned PngPageOutput[] is still strictly page-ordered and rendered pixels are unchanged, but three side effects are observable: files may finish writing out of page order (consume the resolved, ordered array rather than directory-watch order); when a page fails, pages already in flight run to completion before the returned promise rejects; and peak canvas memory is up to three live canvases instead of one. For strictly one page in flight — minimal memory, strict on-disk ordering — use processPagesInParallel: true with concurrencyLimit: 1, a sliding window of exactly one page. The window size of 3 was chosen by paired A/B measurement: PNG encode costs roughly 2× render on text-heavy documents, and a second in-flight encode recovered a consistent ~2–5% median end-to-end margin over a window of 2, while a window of 4 measured within noise of 3.
  • The file-path input branch now hands its buffer to pdf.js zero-copy. fsPromises.readFile returns a freshly allocated Buffer that is never exposed to the caller, so pdf.js may safely transfer (detach) its underlying ArrayBuffer — one full copy of the PDF is no longer made on every conversion. The handoff is guarded on the view spanning its entire ArrayBuffer (byteOffset === 0 and byteLength === buffer.buffer.byteLength), so a future pooled allocation sharing backing memory with unrelated data would fall back to the copy path; empty and detached buffers also take the copy path, so pdf.js still raises its clear "empty PDF" error instead of an opaque constructor TypeError. Caller-owned Buffer / Uint8Array / ArrayBufferLike inputs are unaffected and still copied defensively.
  • savePNGfile() now performs a single realpath syscall per write instead of two. Because output filenames are guaranteed flat, the file's directory is the output folder, so one fresh realpath of the output folder immediately before open(), compared for exact equality against the value captured at conversion start, detects any symlink swap or rename of the folder or its ancestors — equality is strictly stronger than the previous containment check. The SEC-001/002/003 threat model and all rejection messages are unchanged.
  • Migrated pdfjs-dist from ~6.0.227 to ~6.2.108 and @napi-rs/canvas from ~1.0.0 to ~1.0.3. No public API, default, asset-path, or import-path change; rendered PNG output is unchanged — the visual-comparison suites pass against the existing reference images.
  • The development toolchain now type-checks and builds with the TypeScript 7 native compiler (@typescript/native, alias for typescript@~7.0.2), while the typescript package name is the official TypeScript 6 compatibility alias (@typescript/typescript6) that keeps the JS compiler API available to the codemap generator, ts-node scripts, and typescript-eslint. Dev-only change — no runtime, type, or API change to the published package.
  • Replaced the unmaintained license-checker devDependency with license-checker-rseidelsohn and added a brace-expansion@^5.0.8 override, clearing the high-severity advisories that made npm audit --audit-level=high — a gate in both the CI and publish workflows — fail. Dev-only; the override does not affect consumers, whose own root overrides govern their tree.
  • An input value that is not a byte container at all — no numeric length, not an ArrayBuffer.isView, not an ArrayBuffer/SharedArrayBuffer (e.g. {} or a number) — is now rejected at the input seam with Unsupported buffer type: <shape> instead of being passed through to pdf.js, which reported it as The PDF file is empty, i.e. its size is zero bytes. — misleading, since the value is the wrong type rather than an empty document. The set of inputs that successfully convert is unchanged: cross-realm typed arrays and ArrayBuffers (from node:vm / isolated-vm, where instanceof is realm-bound), Uint8ClampedArray, and byte array-likes such as the data field of a Buffer that round-tripped through JSON as { type: 'Buffer', data: number[] } are all still accepted and normalized to Uint8Array. DataView input, which previously reached pdf.js and failed as an "empty" PDF, now converts. maxInputBytes is applied to array-like inputs, which carry no byteLength and previously escaped the cap.
  • release:postcheck now runs a real conversion against the freshly published package, in worker-thread mode, and compares the result with single-threaded output (Q5). renderInWorkerThreads spawns out/pageRenderWorker.js by a path resolved inside the installed out/ directory, and every in-repo test runs against the source tree where that file always exists — so a packaging change that dropped or relocated it would have broken worker mode for consumers with the whole suite still green. Q5 also asserts that worker entry exists in the installed package directly, so the check cannot silently degrade into comparing single-threaded output against itself. Release tooling only; no runtime or API change.

Fixed

  • The sliding-window scheduler now reports a deterministic error when several pages fail. It previously kept whichever rejection settled first, so with more than one page in flight the surfaced error depended on scheduling; it now collects failures by page index and throws the one with the lowest index, matching what a strict page-order loop would report. This applies to processPagesInParallel and, now that the default path is pipelined, to sequential conversions too.
  • Conversions of very large page counts no longer risk a crash while assembling the result. Page outputs were appended with push(...results), whose spread exceeds V8's maximum argument count once the array is large enough; the window result is now returned directly.

Security

  • savePNGfile() now rejects the output filename "." with the explicit Output file name must be a plain filename, received: . error. Under join(), "." collapses to the output folder itself and produced an empty relative path, so it slipped past the escaping-path checks and surfaced as a raw EEXIST/EISDIR from open() that leaked the absolute output folder path in the error message — the same path-disclosure failure mode SEC-001 and VAL-001 were introduced to prevent. Reachable through an outputFileMaskFunc returning ".". ".." needs no twin guard: it resolves to the parent folder, which the existing escaping-relative-path check already rejects cleanly.

Refactored

  • Output-folder preparation moved from pdfToPngCore into outputWriter, which now exposes resolveOutputFolder() and prepareOutputFolder() returning an OutputFolderHandle (the resolved path plus the realpath baseline every write is checked against). savePNGfile() and FilesystemSink take that handle instead of two positional strings that callers had to keep in sync, and the whole SEC-001/002/003 threat model — folder creation, the baseline, and the per-write re-check that consumes it — now lives in one module. Call order is unchanged in both directions that matter: the path is still resolved against the CWD at conversion start, before any user-supplied outputFileMaskFunc can call process.chdir() and redirect a relative outputFolder; and the duplicate-output-filename check still runs before any output I/O, so a conversion that fails validation still creates no directory. Both invariants now have explicit regression tests. Internal only; savePNGfile is not part of the public API (resolves ARCH-012).
  • The flat-filename predicate is now owned by a single module, src/flatFilename.ts (containsPathSeparator + SEPARATOR_DESCRIPTION), instead of being duplicated verbatim in pageOrchestrator and outputWriter. Both call sites keep their existing, distinct error messages. This predicate is load-bearing for SEC-001 — rejecting path separators is what closes the TOCTOU window on intermediate directory components — so a future fix landing in only one copy was a real risk (resolves ARCH-016).
  • getPdfFileBuffer() now returns Uint8Array rather than Uint8Array | ArrayBufferLike, making src/pdfInput.ts the single owner of the shape handed to pdf.js. The pdfFileBuffer instanceof Uint8Array ? … : new Uint8Array(…) re-derivation is gone from both getPdfDocument() (whose parameter is now Uint8Array) and the worker-mode buffer copy in pdfToPngCore. Rendered output and the defensive-copy guarantees for caller-owned buffers are unchanged (resolves ARCH-014).

Full changelog: https://github.com/dichovsky/pdf-to-png-converter/blob/v4.2.0/CHANGELOG.md

Don't miss a new pdf-to-png-converter release

NewReleases is sending notifications on new releases.