UNPKG

pdf-to-png-converter

Version:

Node.js utility to convert PDF file/buffer pages to PNG files/buffers. No build-time compilation required — pre-built native binaries included for all major platforms.

168 lines (167 loc) 8.21 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.toPixelDimension = toPixelDimension; exports.nonRenderableDimensionsError = nonRenderableDimensionsError; exports.canvasPixelLimitError = canvasPixelLimitError; exports.normalizeRotation = normalizeRotation; exports.getPageMetadata = getPageMetadata; exports.renderPdfPage = renderPdfPage; const const_js_1 = require("./const.js"); /** * Runtime structural check that pdf.js's `canvasFactory` exposes the `create` / `destroy` slice * we rely on. pdf.js types the property as `Object`, so the value is validated at runtime rather * than force-cast — a missing or malformed factory then fails fast with a clear message instead of * surfacing a `TypeError` deep inside the render path. */ function isCanvasFactory(factory) { return (typeof factory === 'object' && factory !== null && 'create' in factory && typeof factory.create === 'function' && 'destroy' in factory && typeof factory.destroy === 'function'); } /** * Converts a (possibly fractional) viewport length into the integer pixel count the canvas * will actually allocate. `@napi-rs/canvas` truncates fractional dimensions toward zero when a * canvas is constructed (e.g. a 892.5-wide viewport yields an 892 px bitmap), so the reported * `width`/`height` must be floored to match the rendered PNG. Viewport lengths are always * non-negative, so `Math.floor` is equivalent to that truncation. Flooring here keeps the * render path and the `returnMetadataOnly` path reporting identical, image-accurate pixel sizes. * * @internal Exported for unit testing only; not part of the public API (`src/index.ts`). */ function toPixelDimension(viewportLength) { return Math.floor(viewportLength); } /** * Builds the error thrown when floored page dimensions collapse to a non-renderable size. * * A very small `viewportScale` (e.g. `0.001` on a 612 pt page) floors to `0` px. A 0-wide or * 0-tall bitmap cannot be rendered, so both the render and `returnMetadataOnly` paths reject it * with this identical, actionable message instead of either returning a phantom `0×0` metadata * result or surfacing an opaque canvas-factory `AssertionError`. * * @internal */ function nonRenderableDimensionsError(width, height) { return new Error(`Page dimensions floor to ${width}×${height} px at this viewportScale, which cannot produce a valid image. Increase viewportScale.`); } /** * Builds the error thrown when a page's rendered (floored) canvas area exceeds `MAX_CANVAS_PIXELS`. * * The guard compares the floored bitmap that is actually allocated — `floor(width) × floor(height)` * — against the limit, so it bounds real canvas memory rather than the slightly larger fractional * viewport area. A page this large cannot be rendered: `renderPdfPage` rejects it before allocating * a canvas to avoid OOM. The `returnMetadataOnly` path shares this guard so it reports the same * outcome a render would — rejecting an unrenderable page rather than returning phantom dimensions * for it. Mirrors {@link nonRenderableDimensionsError}, which keeps the two paths symmetric on the * lower bound. * * @internal */ function canvasPixelLimitError(canvasWidth, canvasHeight) { // Callers pass the already-floored bitmap dimensions, so the reported "Canvas W×H" matches the // canvas the guard actually enforces against. Pin the locale so the thousands separator is // stable across runtimes (an unqualified toLocaleString() varies — comma, space, or narrow // no-break space — yielding inconsistent user-facing output and brittle assertions). return new Error(`Canvas ${canvasWidth}×${canvasHeight} px exceeds the ${const_js_1.MAX_CANVAS_PIXELS.toLocaleString('en-US')} pixel limit. Reduce viewportScale.`); } function normalizeRotation(raw) { const normalized = ((raw % 360) + 360) % 360; switch (normalized) { case 0: return 0; case 90: return 90; case 180: return 180; case 270: return 270; default: throw new Error(`Unsupported PDF page rotation: ${raw}`); } } async function getPageMetadata(pdf, pageName, pageNumber, pageViewportScale) { const page = await pdf.getPage(pageNumber); const viewport = page.getViewport({ scale: pageViewportScale }); try { // Bound the canvas that is actually allocated — the floored bitmap — not the fractional // viewport area, so a page whose floored dimensions fit the limit is not wrongly rejected. const width = toPixelDimension(viewport.width); const height = toPixelDimension(viewport.height); if (width * height > const_js_1.MAX_CANVAS_PIXELS) { throw canvasPixelLimitError(width, height); } if (width <= 0 || height <= 0) { throw nonRenderableDimensionsError(width, height); } return { kind: 'metadata', pageNumber, name: pageName, content: undefined, path: '', width, height, rotation: normalizeRotation(page.rotate), }; } finally { page.cleanup(); } } async function renderPdfPage(pdf, pageName, pageNumber, pageViewportScale, returnPageContent) { const page = await pdf.getPage(pageNumber); const viewport = page.getViewport({ scale: pageViewportScale }); // Bound the canvas that is actually allocated — the floored bitmap — not the fractional // viewport area, so a page whose floored dimensions fit the limit is not wrongly rejected. const canvasWidth = toPixelDimension(viewport.width); const canvasHeight = toPixelDimension(viewport.height); if (canvasWidth * canvasHeight > const_js_1.MAX_CANVAS_PIXELS) { page.cleanup(); throw canvasPixelLimitError(canvasWidth, canvasHeight); } if (canvasWidth <= 0 || canvasHeight <= 0) { page.cleanup(); throw nonRenderableDimensionsError(canvasWidth, canvasHeight); } const canvasFactory = pdf.canvasFactory; if (!isCanvasFactory(canvasFactory)) { page.cleanup(); throw new Error('pdf.js did not provide a usable canvas factory (missing create/destroy).'); } const canvasAndContext = canvasFactory.create(canvasWidth, canvasHeight); const { canvas, context } = canvasAndContext; try { if (!canvas || !context) { throw new Error('pdf.js canvas factory returned a null canvas or context.'); } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore — upstream pdfjs-dist@~6.1.x expects DOM CanvasRenderingContext2D, but @napi-rs/canvas exposes SKRSContext2D here. @ts-ignore (not @ts-expect-error) is required because build:test runs with skipLibCheck:true, which hides this error and would make @ts-expect-error report as unused. await page.render({ canvasContext: context, viewport, canvas }).promise; return { kind: 'content', pageNumber, name: pageName, // Async encode() runs on the libuv threadpool (byte-identical to the synchronous // toBuffer — same native Skia encoder) so the JS thread is free to render another // page while this one compresses. The await sits inside the try block, so the // finally's canvasFactory.destroy() cannot run until encoding has finished. content: returnPageContent ? await canvas.encode('png') : undefined, path: '', width: canvasWidth, height: canvasHeight, rotation: normalizeRotation(page.rotate), }; } finally { page.cleanup(); // Pass the original object pdf.js handed back so any internal fields it needs for cleanup // survive. Guard on canvas: pdf.js's destroy() asserts a non-null canvas, so skip it when // create() yielded none (the try block has already thrown for that case). if (canvasAndContext.canvas) { canvasFactory.destroy(canvasAndContext); } } }