@graciousstar/node-red-contrib-pdf-to-image 1.6.1
Convert a PDF into PNG/JPEG images (page selection, DPI, rotation). Uses PDFium (WebAssembly) and a Rust encoder - fully MIT-compatible, no AGPL.
@graciousstar/node-red-contrib-pdf-to-image
A Node-RED node that converts a PDF into images using
PDFium compiled to WebAssembly
(via @hyzyla/pdfium) for
rasterising, and @napi-rs/image
for encoding.
Outputs PNG, JPEG, or RAW — raw skips encoding altogether and hands the bitmap to the next node, which is 1.9x – 3.1x faster in a processing chain.
Supports page selection, DPI scaling, JPEG quality, transparent backgrounds,
clockwise rotation, and the <stem>_page_<n>.<ext> file naming scheme with
collision counters.
Install
npm install @graciousstar/node-red-contrib-pdf-to-image
Or install it from the Node-RED editor via Manage palette → Install, searching for the full scoped name. Restart Node-RED afterwards so the node is registered.
The node appears in the palette as pdf-to-image under the PDF category; the npm scope does not change the node type, so existing flows keep working.
Running Node-RED in Docker? Add it to the image's package.json and rebuild
(docker compose up -d --build) rather than installing into a container that
gets replaced.
Input
msg.payload can be:
| Type | Meaning |
|---|---|
Buffer / Uint8Array / ArrayBuffer |
PDF bytes |
string starting with %PDF |
base64-encoded PDF |
| string path | PDF file read from disk, on the host running Node-RED |
| string (base64) | base64-encoded PDF, auto-detected via the %PDF magic in the decoded bytes |
object { data } / { buffer } / { path } |
any of the above |
Optional per-message overrides: msg.filename (output file stem),
msg.dpi, msg.format, msg.rotation, msg.jpegQuality,
msg.pageMode, msg.pageRange, msg.transparent.
RAW output (fastest)
Setting the format to RAW skips encoding entirely and hands the rendered bitmap to the next node. Encoding is most of the cost of a render, so this is the fastest option when the image feeds another processing node rather than being written out or displayed.
msg.payload becomes an object rather than a Buffer, carrying the bitmap
alongside the geometry needed to interpret it:
{
data: Buffer, // RGBA bytes, 4 per pixel
width: number,
height: number,
channels: 4,
colorSpace: "RGBA",
dtype: "uint8"
}
PDFium renders RGBA, so nothing is converted. At rotation 0 the render buffer is passed through with no copy at all.
Measured against PNG, 3 pages, median of 3:
| DPI | PNG | RAW | Speed-up |
|---|---|---|---|
| 150 | 224 ms | 118 ms | 1.9x |
| 200 | 272 ms | 142 ms | 1.9x |
| 300 | 502 ms | 200 ms | 2.5x |
On a text-heavy document the gain reaches 3.1x at 300 DPI.
Watch the memory. Raw bitmaps are large: three pages at 300 DPI is roughly
84 MB of payload against 2.5 MB as PNG, and a text-heavy A4 document
reaches ~99 MB. Node-RED holds that in the message, so raise the heap
(NODE_OPTIONS=--max-old-space-size=...) or convert a page at a time before
using RAW on large documents at high DPI.
Two further caveats: image viewer and preview nodes generally cannot display
raw bytes, so use PNG for those; and raw bytes have no container, so file
output mode writes .bin files with the geometry travelling on
msg.images[] instead.
Transparent background
Pages render onto white by default. Enable Transparent background (or set
msg.transparent) to keep the page background transparent in the PNG alpha
channel. JPEG has no alpha channel, so the option is ignored with a warning
when the output format is JPEG — otherwise a transparent background would
silently come out black.
Output
msg.payload— image bytes (Buffer) or an array of Buffers; for RAW an image object (or array of them) as described above; in file mode it is the written path / array of paths insteadmsg.images— array of{ page, width, height, filename, path }; RAW entries also carrychannels,colorSpaceanddtypemsg.pageCount,msg.pages,msg.dpi,msg.format,msg.rotation,msg.filename,msg.transparent,msg.durationMsmsg.timings— phase breakdown in ms:{ openMs, renderMs, renderPerPageMs, saveMs, totalMs }; eachmsg.images[i]entry also carries its ownrenderMs- the node logs the same breakdown (
open / render (per-page) / save / total) at info level - with one message per page enabled: one message per page, each with
msg.pageset
Example flow
[{"id":"pdf2img-demo","type":"tab","label":"PDF -> image"},{"id":"pdf2img-inj","type":"inject","z":"pdf2img-demo","name":"read /data/input.pdf","props":[{"p":"payload"},{"p":"filename","v":"input.pdf","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"str","x":140,"y":80,"wires":[["pdf2img-node"]]},{"id":"pdf2img-node","type":"pdf-to-image","z":"pdf2img-demo","name":"","pageMode":"all","pageRange":"1","dpi":200,"format":"PNG","jpegQuality":85,"rotation":0,"outputMode":"file","folder":"/data/pdf-output","stem":"","splitPages":false,"x":340,"y":80,"wires":[["pdf2img-debug"]]},{"id":"pdf2img-debug","type":"debug","z":"pdf2img-demo","name":"written files","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":540,"y":80,"wires":[]}]
The inject node in the example only sends the filename string; wire a file-read node (e.g.
fs-ops) or an HTTP upload into this node to feed it actual PDF bytes.
Notes
- Requires Node.js >= 18. Both engine packages expose CommonJS entry points.
- JPEG output uses
@napi-rs/image'sjpegSync(quality). At the same quality setting it produces larger files than releases before 1.5.0 did — see Known gaps below. - The destination folder is a path on whatever machine runs Node-RED, not
on the machine with the browser open. Node-RED must have write permission to
it; the folder is created if missing. In Docker that path is inside the
container, so point it at a mounted volume such as
/dataif the images need to outlive the container.
Licensing
Everything in this package, and every dependency it pulls in, is permissively licensed:
| Component | Role | License |
|---|---|---|
| this package | node | MIT |
@hyzyla/pdfium |
rasterising | MIT (over BSD-3-Clause PDFium) |
@napi-rs/image |
PNG/JPEG encoding | MIT |
There is no AGPL obligation. Releases before 1.5.0 rasterised with mupdf
(AGPL-3.0-or-later), which covered the running combination and required
offering source to users who reached it over a network. Upgrading to 1.5.0 or
later removes that constraint.
@napi-rs/image is a Node-API addon rather than pure WebAssembly. It ships
13 prebuilt targets — including linux-x64-musl and linux-arm64-musl
(verified in an Alpine container), linux-arm-gnueabihf for 32-bit Raspberry
Pi, and a wasm32-wasi fallback — so nothing is compiled at install time and
no toolchain is required.
Version history
| Version | Change |
|---|---|
| 1.6.1 | Fix red and blue being swapped in PNG and JPEG output |
| 1.6.0 | RAW output format, which skips encoding entirely |
| 1.5.0 | Rasterising moved from MuPDF to PDFium and encoding to a Rust encoder, making the package MIT throughout; transparent background option added |
| 1.0.x | Initial releases, rasterising with MuPDF |
Verification
When rasterising moved to PDFium in 1.5.0, output was compared against the previous implementation by downsampling both to an 8x8 luminance grid, which tolerates antialiasing and the 1px size difference but not a wrong orientation:
| Rotation | Mean difference (0-255) | Result |
|---|---|---|
| 0 | 3.09 | match |
| 90 | 3.07 | match |
| 180 | 3.09 | match |
| 270 | 3.11 | match |
A control comparing rot0 against rot90 from the same engine scores 43.0, confirming the check can actually detect a mismatch. The residual ~3.1 is rasteriser variance, present at rotation 0 where no rotation is applied.
The check earned its keep: an early revision had the 90/270 direction inverted. Output dimensions were correct and every smoke test passed, so nothing else would have caught it.
RAW payloads are checked separately for geometry consistency — data.length
must equal width * height * channels — at every rotation and with
transparency on and off.
Known gaps
JPEG files are larger at equal quality. The quality dial is not mis-scaled — both encoders land at the same PSNR for the same setting (q85 gives 40.17 dB against the pre-1.5.0 build's 40.34 dB).
@napi-rs/imageis simply less space-efficient: 17% larger on graphics-heavy pages and 31% – 34% larger on text-heavy ones, measured at matched PSNR.The encoder itself is 2.3x faster (31 ms against 74 ms for a 1629x1122 page); where end-to-end JPEG timings look slower it is PDFium's rasterising of that page, not the encoding.
Alternatives were measured and rejected:
Approach Result compressJpegSync(MozJPEG recompress)No gain — 350 KB vs 351 KB at the same PSNR, 2.4x slower. Re-encoding an already-lossy JPEG cancels the benefit. @jsquash/jpeg(MozJPEG from raw pixels)~6% smaller at matched PSNR but 7x – 11x slower (232 – 354 ms). No permissive JPEG encoder tested matches the old encoder's efficiency at an acceptable speed. If JPEG output size matters more to you than throughput, 1.5.0 onwards is a regression on that one axis; PNG and RAW are clear wins.
Output is 1px smaller in each dimension since 1.5.0 (e.g. 2172x1497 against 2173x1498), because PDFium rounds page sizes differently. Anything holding a golden image produced by an earlier release would need rebaselining.
RAW payloads are large — three pages at 300 DPI is roughly 84 MB, and a text-heavy A4 document reaches ~99 MB. See RAW output above.
The installed native binary is around 16.5 MB.
Installing with
--omit=optionalleaves@napi-rs/imageunable to find its native binding, failing at require time rather than install time.