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