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
JavaScript
;
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();
}
}