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.

156 lines (155 loc) 6.76 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.renderPagesInWorkerPool = renderPagesInWorkerPool; const node_fs_1 = require("node:fs"); const node_path_1 = require("node:path"); const node_worker_threads_1 = require("node:worker_threads"); /** * Locates the compiled worker entry. In the published package (and any `out/` build) it sits * next to this file. When this module runs from `src/` (vitest transforms TypeScript in-place, * so `__dirname` is `src/`), fall back to the repo's `out/` build — the integration tests * compile it first. Workers can only execute plain JavaScript, never `.ts` sources. */ function resolveWorkerEntryPath() { const compiled = (0, node_path_1.join)(__dirname, 'pageRenderWorker.js'); if ((0, node_fs_1.existsSync)(compiled)) { return compiled; } return (0, node_path_1.join)(__dirname, '..', 'out', 'pageRenderWorker.js'); } /** * Renders pages across a pool of worker threads. * * Each worker receives its own structured-clone copy of `pdfBuffer` (pdf.js documents cannot be * shared across threads) and loads the document once. Tasks are dispatched dynamically — a worker * gets its next page as soon as it finishes one — so heavy pages don't stall the queue behind * them. `onPageRendered` runs on the main thread per completed page (this is where file-mode * output is written through the existing sink, keeping every path-security guard main-side); * an error it throws is attributed to that page's index. The pool never settles while an * `onPageRendered` call is still pending — no write escapes the function's lifetime. * * Failure semantics: after the first error no new pages are dispatched and in-flight pages * settle. Per-page failures (a page that fails to render, or whose output write fails) are * collected by index and the LOWEST-index one is thrown — mirroring * `processPagesWithSlidingWindow`. Worker-level failures (document load failure, worker crash, * startup failure, unexpected exit) are FATAL: the first one is thrown with priority over any * per-page error, regardless of what the crashed worker was doing at the time. Workers are * always terminated before this function settles. */ async function renderPagesInWorkerPool(pdfBuffer, documentOptions, materializeContent, tasks, poolSize, onPageRendered) { if (tasks.length === 0) { return; } const workerEntryPath = resolveWorkerEntryPath(); const workerCount = Math.min(poolSize, tasks.length); const initData = { pdfBuffer, documentOptions, materializeContent }; let nextTaskIndex = 0; let fatalError; let hasFatalError = false; const errorsByIndex = new Map(); const recordFatal = (error) => { if (!hasFatalError) { hasFatalError = true; fatalError = error; } }; const shouldStop = () => hasFatalError || errorsByIndex.size > 0; const runWorker = () => new Promise((resolveWorker) => { let worker; try { worker = new node_worker_threads_1.Worker(workerEntryPath, { workerData: initData }); } catch (error) { // A synchronous spawn failure is fatal; recording it flips shouldStop() so the // sibling workers stop dispatching and wind down through their normal path. recordFatal(error); resolveWorker(); return; } let settled = false; // Serializes this worker's response handling; finish() awaits it so a pending // onPageRendered (e.g. a file write) can never outlive the pool. let pendingWork = Promise.resolve(); const finish = () => { if (!settled) { settled = true; void worker.terminate(); void pendingWork.then(() => resolveWorker()); } }; const dispatchNext = () => { if (shouldStop() || nextTaskIndex >= tasks.length) { finish(); return; } const task = tasks[nextTaskIndex]; nextTaskIndex += 1; const request = { type: 'render', index: task.index, pageNumber: task.pageNumber, pageName: task.pageName, }; worker.postMessage(request); }; const handleResponse = async (response) => { if (response.type === 'fatal') { recordFatal(response.error); finish(); return; } if (response.type === 'render-error') { errorsByIndex.set(response.index, response.error); dispatchNext(); return; } // Re-wrap the structured-cloned bytes as a Buffer without copying. const content = response.content !== undefined ? Buffer.from(response.content.buffer, response.content.byteOffset, response.content.byteLength) : undefined; try { await onPageRendered(response.index, { pageNumber: response.pageNumber, name: response.name, width: response.width, height: response.height, rotation: response.rotation, content, }); } catch (error) { errorsByIndex.set(response.index, error); } dispatchNext(); }; worker.on('message', (response) => { if (settled) { return; } pendingWork = pendingWork.then(() => handleResponse(response)).catch(recordFatal); }); worker.on('error', (error) => { // Any worker 'error' is a worker-level failure (crash, startup failure such as a // missing entry file) — always fatal, never attributed to the page it happened // to be rendering. recordFatal(error); finish(); }); worker.on('exit', () => { // terminate() sets `settled` before this fires; an exit seen while unsettled is // a crash. Record it as fatal so the pool neither hangs nor blames a page. if (!settled) { recordFatal(new Error('Render worker exited unexpectedly.')); } finish(); }); dispatchNext(); }); await Promise.all(Array.from({ length: workerCount }, () => runWorker())); if (hasFatalError) { throw fatalError; } if (errorsByIndex.size > 0) { throw errorsByIndex.get(Math.min(...errorsByIndex.keys())); } }