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.

176 lines (175 loc) 11 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.pdfToPngCore = pdfToPngCore; const node_path_1 = require("node:path"); const const_js_1 = require("./const.js"); const filesystemSink_js_1 = require("./filesystemSink.js"); const outputWriter_js_1 = require("./outputWriter.js"); const pageMode_js_1 = require("./pageMode.js"); const pageOrchestrator_js_1 = require("./pageOrchestrator.js"); const pdfInput_js_1 = require("./pdfInput.js"); const pdfjsLoader_js_1 = require("./pdfjsLoader.js"); const workerPool_js_1 = require("./workerPool.js"); async function processPagesWithSlidingWindow(pageNumbers, concurrencyLimit, processPage) { const results = new Array(pageNumbers.length); let nextIndex = 0; // Errors keyed by page index. Several in-flight pages can fail before the window drains; // the error thrown afterwards is always the failing page with the LOWEST index, so the // surfaced error is deterministic and matches what a strict page-order loop would report, // regardless of which rejection happened to settle first. const errorsByIndex = new Map(); async function runWorker() { while (errorsByIndex.size === 0 && nextIndex < pageNumbers.length) { const currentIndex = nextIndex; nextIndex += 1; try { results[currentIndex] = await processPage(pageNumbers[currentIndex], currentIndex); } catch (error) { errorsByIndex.set(currentIndex, error); } } } const workerCount = Math.min(concurrencyLimit, pageNumbers.length); await Promise.allSettled(Array.from({ length: workerCount }, () => runWorker())); if (errorsByIndex.size > 0) { throw errorsByIndex.get(Math.min(...errorsByIndex.keys())); } return results; } /** * Finds the first output filename that more than one processed page resolves to. * * Page names are resolved up front (before any output I/O) so a non-injective `outputFileMaskFunc` * or a duplicated `pagesToProcess` entry surfaces as a clear, deterministic error instead of a raw * `EEXIST` from the exclusive-create (`'wx'`) write — which previously also left the first colliding * file on disk and leaked the absolute output path. See VAL-001. * * Collision is detected case-insensitively (keys are lower-cased): on case-insensitive, * case-preserving filesystems (macOS APFS, Windows NTFS — the default developer environments) * names such as `Page.png` and `page.png` are the SAME file, so writing both would otherwise slip * past an exact-string check and fail with the very raw `EEXIST` (partial output + leaked absolute * path) this pre-flight exists to prevent. Keying case-insensitively makes the "unique filename" * guarantee hold portably across every platform. The reported `name` is the first-seen original * (case preserved) for an actionable message. * * Iterates in first-seen order, so the reported duplicate is deterministic regardless of whether * pages are later rendered sequentially or in parallel. */ function findDuplicateOutputName(names, pageNumbers) { const pagesByKey = new Map(); for (let index = 0; index < names.length; index += 1) { const key = names[index].toLowerCase(); const existing = pagesByKey.get(key); pagesByKey.set(key, { name: existing?.name ?? names[index], pages: [...(existing?.pages ?? []), pageNumbers[index]], }); } for (const { name, pages } of pagesByKey.values()) { if (pages.length > 1) { return { name, pages }; } } return undefined; } /** * Internal conversion entry point that bypasses the public-API normalization step. * * Callers — currently the public `pdfToPng()` wrapper and the CLI — are responsible for * producing a fully-validated `NormalizedPdfToPngOptions` (via `normalizePdfToPngOptions`) * before invoking this function. The single-normalize contract is what makes * `NormalizedPdfToPngOptions` the sole validation boundary of the library. * * This module is NOT re-exported from `src/index.ts`; it is an internal seam. */ async function pdfToPngCore(pdfFile, normalizedProps) { const pageViewportScale = normalizedProps.viewportScale; const pdfFileBuffer = await (0, pdfInput_js_1.getPdfFileBuffer)(pdfFile, normalizedProps.maxInputBytes); // Worker mode needs the raw bytes AFTER the main-thread document load, but getPdfDocument // transfers (detaches) the buffer it is given — so copy first. Worker-mode-only cost: one // extra copy of the input; each worker then receives its own structured-clone of this copy. const useWorkerThreads = normalizedProps.renderInWorkerThreads === true && !normalizedProps.returnMetadataOnly; const workerPdfBytes = useWorkerThreads ? Uint8Array.from(pdfFileBuffer) : undefined; const pdfDocument = await (0, pdfjsLoader_js_1.getPdfDocument)(pdfFileBuffer, normalizedProps); // Wrap ALL post-load work in this try so the worker is destroyed even if setup steps // (path resolution, mkdir, realpath, sink construction) throw — not just render-time errors. try { const pagesToProcess = normalizedProps.pagesToProcess ?? Array.from({ length: pdfDocument.numPages }, (_, index) => index + 1); const validPagesToProcess = pagesToProcess.filter((pageNumber) => pageNumber <= pdfDocument.numPages && pageNumber >= 1); const returnMetadataOnly = normalizedProps.returnMetadataOnly; // Metadata-only conversions render nothing and write nothing, so they never prepare a folder. // The path is resolved HERE, before any user-supplied outputFileMaskFunc runs below, so a // mask callback calling process.chdir() cannot redirect a relative outputFolder. Creation // and the realpath baseline happen later, after validation. const resolvedOutputFolder = returnMetadataOnly || normalizedProps.outputFolder === undefined ? undefined : (0, outputWriter_js_1.resolveOutputFolder)(normalizedProps.outputFolder); const defaultMask = typeof pdfFile === 'string' ? (0, node_path_1.parse)(pdfFile).name : const_js_1.PDF_TO_PNG_OPTIONS_DEFAULTS.outputFileMask; // Resolve every page name up front. resolvePageName also enforces the non-empty and // flat-filename rules, so that validation continues to fire for in-memory conversions too. const resolvedNames = validPagesToProcess.map((pageNumber) => (0, pageOrchestrator_js_1.resolvePageName)(pageNumber, defaultMask, normalizedProps.outputFileMaskFunc)); // Collisions only corrupt output when pages are written to disk; in-memory / metadata-only // conversions may legitimately repeat a name. Reject duplicates before any output I/O // (before mkdir/realpath/write) so nothing is created and no partial output is left behind. if (resolvedOutputFolder !== undefined) { const duplicate = findDuplicateOutputName(resolvedNames, validPagesToProcess); if (duplicate !== undefined) { throw new Error(`Duplicate output filename "${duplicate.name}" for pages ${duplicate.pages.join(', ')}. ` + `Each processed page must resolve to a unique filename.`); } } // Folder creation and the realpath baseline live in outputWriter.ts — this is the first // output I/O of the conversion, so it must follow the duplicate check. const outputSink = resolvedOutputFolder !== undefined ? new filesystemSink_js_1.FilesystemSink(await (0, outputWriter_js_1.prepareOutputFolder)(resolvedOutputFolder)) : undefined; const pageMode = (0, pageMode_js_1.optionsToPageMode)(normalizedProps, outputSink); // Worker-thread mode: pages rasterize + encode inside a pool of worker threads (true // multi-core parallelism — the main-thread modes below share one JS thread for all // rendering). The main thread keeps everything else: page filtering, name resolution, // duplicate detection (all above), and per-page output finalization — file writes go // through the same sink and path-security guards as every other mode. if (useWorkerThreads && pageMode.kind !== 'metadata' && workerPdfBytes !== undefined) { const workerResults = new Array(validPagesToProcess.length); const tasks = validPagesToProcess.map((pageNumber, index) => ({ index, pageNumber, pageName: resolvedNames[index], })); const documentOptions = { viewportScale: normalizedProps.viewportScale, disableFontFace: normalizedProps.disableFontFace, useSystemFonts: normalizedProps.useSystemFonts, enableXfa: normalizedProps.enableXfa, pdfFilePassword: normalizedProps.pdfFilePassword, verbosityLevel: normalizedProps.verbosityLevel, }; await (0, workerPool_js_1.renderPagesInWorkerPool)(workerPdfBytes, documentOptions, (0, pageOrchestrator_js_1.shouldMaterializeContent)(pageMode), tasks, normalizedProps.concurrencyLimit, async (index, page) => { const rendered = { kind: 'content', pageNumber: page.pageNumber, name: page.name, content: page.content, path: '', width: page.width, height: page.height, rotation: page.rotation, }; workerResults[index] = await (0, pageOrchestrator_js_1.finalizePageOutput)(rendered, pageMode); }); return workerResults; } const processPage = async (pageNumber, index) => await (0, pageOrchestrator_js_1.processAndSavePage)(pdfDocument, resolvedNames[index], pageNumber, pageViewportScale, pageMode); // Sequential mode also runs through the sliding window, with a fixed window of // SEQUENTIAL_PIPELINE_WINDOW (3): the PNG encodes (libuv threadpool) and disk writes of // finished pages overlap the next page's render on the JS thread. Result order and // rendered pixels are identical to a strict one-at-a-time loop; side effects (disk // writes) may complete out of page order, and up to three canvases are alive at a time. const windowSize = normalizedProps.processPagesInParallel === true ? normalizedProps.concurrencyLimit : const_js_1.SEQUENTIAL_PIPELINE_WINDOW; // Returned directly (not spread into push(...)) — spreading a huge result array into one // call exceeds V8's argument-count cap and crashes on very large page counts. return await processPagesWithSlidingWindow(validPagesToProcess, windowSize, processPage); } finally { await pdfDocument.loadingTask.destroy(); } }