@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
787 lines (786 loc) • 35.2 kB
JavaScript
/**
* PDF Processor with Image Fallback Support
*
* Handles PDF processing for all providers:
* - Native PDF support for providers that accept PDF directly (Google AI, Vertex, OpenAI, Anthropic, Bedrock)
* - PDF → Image conversion for providers that don't support native PDF (Azure, Mistral, Ollama)
*
* Page counting, text extraction and page rendering all go through pdf-parse,
* so the process only ever loads pdf-parse's pdfjs-dist. pdfjs keeps its Node
* worker in a process-wide global, and a second pdfjs version fails its
* API-vs-Worker version check against whichever copy loaded first.
*/
import { createRequire } from "node:module";
import path from "node:path";
import { PDF_LIMITS } from "../core/constants.js";
import { ErrorFactory } from "./errorHandling.js";
import { logger } from "./logger.js";
/**
* Provider configurations for PDF handling
*
* supportsNative: true = Send PDF as FilePart (mimeType: application/pdf)
* supportsNative: false = Convert PDF pages to PNG images and send as ImageParts
*/
const PDF_PROVIDER_CONFIGS = {
anthropic: {
maxSizeMB: 5,
maxPages: 100,
supportsNative: true,
requiresCitations: false,
apiType: "document",
},
bedrock: {
maxSizeMB: 5,
maxPages: 100,
supportsNative: true,
requiresCitations: "auto",
apiType: "document",
},
"google-vertex": {
maxSizeMB: 5,
maxPages: 100,
supportsNative: true,
requiresCitations: false,
apiType: "document",
},
vertex: {
maxSizeMB: 5,
maxPages: 100,
supportsNative: true,
requiresCitations: false,
apiType: "document",
},
"google-ai-studio": {
maxSizeMB: 2000,
maxPages: 100,
supportsNative: true,
requiresCitations: false,
apiType: "files-api",
},
gemini: {
maxSizeMB: 2000,
maxPages: 100,
supportsNative: true,
requiresCitations: false,
apiType: "files-api",
},
"google-ai": {
maxSizeMB: 2000,
maxPages: 100,
supportsNative: true,
requiresCitations: false,
apiType: "files-api",
},
openai: {
maxSizeMB: 10,
maxPages: 100,
supportsNative: true,
requiresCitations: false,
apiType: "files-api",
},
// Azure does NOT support native PDF - must convert to images
azure: {
maxSizeMB: 10,
maxPages: 100,
supportsNative: false,
requiresCitations: false,
apiType: "files-api",
},
"azure-openai": {
maxSizeMB: 10,
maxPages: 100,
supportsNative: false,
requiresCitations: false,
apiType: "files-api",
},
litellm: {
maxSizeMB: 10,
maxPages: 100,
supportsNative: false, // LiteLLM is a proxy — underlying model may not support native PDF; default to safe text extraction
requiresCitations: false,
apiType: "files-api",
},
"openai-compatible": {
maxSizeMB: 10,
maxPages: 100,
supportsNative: false,
requiresCitations: false,
apiType: "files-api",
},
mistral: {
maxSizeMB: 10,
maxPages: 100,
supportsNative: false,
requiresCitations: false,
apiType: "files-api",
},
"hugging-face": {
maxSizeMB: 10,
maxPages: 100,
supportsNative: true,
requiresCitations: false,
apiType: "files-api",
},
huggingface: {
maxSizeMB: 10,
maxPages: 100,
supportsNative: true,
requiresCitations: false,
apiType: "files-api",
},
};
export class PDFProcessor {
// PDF magic bytes: %PDF-
static PDF_SIGNATURE = Buffer.from("%PDF-", "ascii");
// ============================================================================
// PDF Validation & Processing
// ============================================================================
static async process(content, options) {
const provider = (options?.provider || "unknown").toLowerCase();
const config = PDF_PROVIDER_CONFIGS[provider];
if (!PDFProcessor.isValidPDF(content)) {
throw new Error("Invalid PDF file format. File must start with %PDF- header.");
}
if (!config) {
const supportedProviders = Object.keys(PDF_PROVIDER_CONFIGS).join(", ");
throw new Error(`PDF files are not configured for ${provider} provider.\n` +
`Configured providers: ${supportedProviders}\n` +
`Current provider: ${provider}\n\n` +
`Options:\n` +
`1. Switch to a configured provider (--provider openai or --provider vertex)\n` +
`2. Contact support to add ${provider} PDF configuration`);
}
const sizeMB = content.length / (1024 * 1024);
if (sizeMB > config.maxSizeMB) {
throw new Error(`PDF size ${sizeMB.toFixed(2)}MB exceeds ${config.maxSizeMB}MB limit for ${provider}`);
}
const metadata = PDFProcessor.extractBasicMetadata(content);
// #287: prefer an accurate page count (pdf-parse/pdfjs) over the unreliable
// header regex for both limit enforcement and returned metadata; fall back
// to the regex estimate when the accurate probe times out or fails.
const accuratePages = await PDFProcessor.getAccuratePageCount(content);
if (accuratePages !== null) {
metadata.estimatedPages = accuratePages;
}
if (metadata.estimatedPages && metadata.estimatedPages > config.maxPages) {
const enforceLimits = options?.enforceLimits !== false;
if (enforceLimits) {
throw ErrorFactory.pdfPageLimitExceeded(metadata.estimatedPages, config.maxPages, provider);
}
else {
logger.warn(`[PDF] ⚠️ LIMIT BYPASS: Processing ${metadata.estimatedPages}-page PDF despite ${config.maxPages}-page limit for ${provider}. ` +
`This may cause API rejection, token errors, or unexpected costs. ` +
`Consider splitting the PDF or using a different provider.`);
}
}
if (provider === "bedrock" && options?.bedrockApiMode === "converse") {
logger.info("[PDF] Using Bedrock Converse API. " +
"Visual PDF analysis requires citations enabled. " +
"Text-only mode: ~1,000 tokens/3 pages. " +
"Visual mode: ~7,000 tokens/3 pages.");
}
logger.info("[PDF] ✅ Validated PDF file", {
provider,
size: `${sizeMB.toFixed(2)}MB`,
version: metadata.version,
estimatedPages: metadata.estimatedPages,
apiType: config.apiType,
supportsNative: config.supportsNative,
});
return {
type: "pdf",
content,
mimeType: "application/pdf",
metadata: {
confidence: 100,
size: content.length,
...metadata,
provider,
apiType: config.apiType,
// #349: surface the provider's citations requirement so downstream
// provider adapters (e.g. Bedrock Converse document blocks) can act on
// it, instead of the config field being read nowhere.
requiresCitations: config.requiresCitations,
},
};
}
/**
* Check if a provider supports native PDF input
* @param provider - Provider name
* @returns true if provider can accept PDF directly, false if requires image conversion
*/
static supportsNativePDF(provider) {
const normalizedProvider = provider.toLowerCase();
const config = PDF_PROVIDER_CONFIGS[normalizedProvider];
return config?.supportsNative ?? false;
}
static getProviderConfig(provider) {
return PDF_PROVIDER_CONFIGS[provider] || null;
}
/**
* Best-effort page count for a PDF whose count the caller did not supply
* (#309). Mirrors what `process()` derives for the `input.pdfFiles` path:
* the accurate pdfjs count when the document parses, otherwise the header
* regex estimate. Returns null when neither can determine a count.
*
* Exists so the aggregate page-limit guard can enforce against PDFs handed
* in via `input.content`, where `metadata.pages` is optional and routinely
* omitted — without it, an absent count silently sums as zero.
*/
static async resolvePageCount(buffer) {
const accurate = await PDFProcessor.getAccuratePageCount(buffer);
if (accurate !== null) {
return accurate;
}
return PDFProcessor.extractBasicMetadata(buffer).estimatedPages;
}
static isValidPDF(buffer) {
if (buffer.length < 5) {
return false;
}
return buffer.subarray(0, 5).equals(PDFProcessor.PDF_SIGNATURE);
}
static extractBasicMetadata(buffer) {
const headerSize = Math.min(10000, buffer.length);
const header = buffer.toString("utf-8", 0, headerSize);
const versionMatch = header.match(/%PDF-(\d\.\d)/);
const version = versionMatch ? versionMatch[1] : "unknown";
const pageMatches = header.match(/\/Type\s*\/Page[^s]/g);
const estimatedPages = pageMatches ? pageMatches.length : null;
return {
version,
estimatedPages,
filename: undefined,
};
}
/**
* Accurate page count via pdf-parse (pdfjs) (#287). The header regex in
* `extractBasicMetadata` only sees plaintext `/Type /Page` markers and misses
* compressed/object-stream PDFs (and miscounts when a page dict spans a chunk
* boundary). This parses the document properly, bounded by a timeout so a
* pathological PDF can't block; returns null on timeout/failure so the caller
* falls back to the regex estimate.
*/
static async getAccuratePageCount(buffer) {
try {
const { PDFParse } = await import("pdf-parse");
const pdf = new PDFParse({ data: new Uint8Array(buffer) });
// Captured outside the try so the `finally` below can always clear it
// — otherwise a `getInfo()` that wins the race leaves this timer
// running until PAGE_COUNT_TIMEOUT_MS fires for nothing. `.unref()`
// so it can never itself hold the process open in the meantime.
let timeoutHandle;
try {
const info = await Promise.race([
pdf.getInfo(),
new Promise((_, reject) => {
timeoutHandle = setTimeout(() => reject(new Error("page-count timeout")), PDF_LIMITS.PAGE_COUNT_TIMEOUT_MS);
timeoutHandle.unref?.();
}),
]);
const total = info.total;
return typeof total === "number" && total > 0 ? total : null;
}
finally {
if (timeoutHandle !== undefined) {
clearTimeout(timeoutHandle);
}
try {
// Optional call: guards against a runtime pdf-parse version whose
// instances lack destroy(), while the typed v2 class declares it.
await pdf.destroy?.();
}
catch {
// A throwing destroy() must not discard an otherwise-valid page
// count returned above (matches fileReferenceRegistry.ts's
// pdf.destroy() cleanup pattern) — swallow it.
}
}
}
catch (error) {
logger.debug(`[PDF] Accurate page count unavailable; using regex estimate: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
/**
* Font and CMap directories of the pdfjs-dist copy pdf-parse itself loads,
* so non-embedded standard fonts and CJK text render instead of dropping
* out. Resolved from pdf-parse's location, never our own, to stay on that
* single copy. Undefined when resolution fails; rendering still works, with
* pdfjs falling back to its built-in font substitution.
*/
static pdfjsAssetDirs;
static resolvePdfjsAssetDirs() {
if (PDFProcessor.pdfjsAssetDirs === undefined) {
try {
const pdfParseEntry = createRequire(import.meta.url).resolve("pdf-parse");
const pdfjsRoot = path.dirname(createRequire(pdfParseEntry).resolve("pdfjs-dist/package.json"));
PDFProcessor.pdfjsAssetDirs = {
standardFontDataUrl: path.join(pdfjsRoot, "standard_fonts") + path.sep,
cMapUrl: path.join(pdfjsRoot, "cmaps") + path.sep,
};
}
catch (error) {
logger.debug(`[PDF→Image] pdfjs font/CMap directories unavailable: ${error instanceof Error ? error.message : String(error)}`);
PDFProcessor.pdfjsAssetDirs = null;
}
}
return PDFProcessor.pdfjsAssetDirs ?? undefined;
}
/**
* Open a PDF for page-by-page PNG rendering through pdf-parse. Rejects with
* pdf-parse's PasswordException for encrypted PDFs (#258), which the callers
* map to typed errors.
*/
static async openForRendering(pdfBuffer, scale, password) {
const { PDFParse } = await import("pdf-parse");
const parser = new PDFParse({
data: new Uint8Array(pdfBuffer),
isEvalSupported: false,
cMapPacked: true,
...PDFProcessor.resolvePdfjsAssetDirs(),
...(password ? { password } : {}),
});
try {
const { total } = await parser.getInfo();
return {
length: total,
getPage: async (pageNumber) => {
const shot = await parser.getScreenshot({
partial: [pageNumber],
scale,
imageBuffer: true,
imageDataUrl: false,
});
const page = shot.pages[0];
if (!page || page.data.length === 0) {
throw new Error(`page ${pageNumber} rendered no image data`);
}
return Buffer.from(page.data);
},
destroy: async () => {
await parser.destroy().catch(() => {
/* cleanup - ignore destroy errors */
});
},
};
}
catch (error) {
await parser.destroy().catch(() => {
/* cleanup - ignore destroy errors */
});
throw error;
}
}
static estimateTokens(pageCount, mode = "visual") {
if (mode === "text-only") {
return Math.ceil((pageCount / 3) * 1000);
}
else {
return Math.ceil((pageCount / 3) * 7000);
}
}
// ============================================================================
// PDF → Image Conversion (for providers without native PDF support)
// ============================================================================
/**
* Estimate the largest page's rendered pixel count from the PDF's MediaBox
* entries, WITHOUT rendering (#260). Parses `/MediaBox [llx lly urx ury]`
* (dimensions in points); rendered pixels ≈ (width·scale)·(height·scale).
* Returns 0 when no plaintext MediaBox is found (e.g. compressed object
* streams) — the caller then skips downscaling, matching prior behavior.
*
* A crafted/malformed MediaBox can have finite but astronomically large
* width/height; `width * height * scale * scale` can then overflow past
* `Number.MAX_VALUE` to `Infinity`. Clamp the product to
* `Number.MAX_SAFE_INTEGER` so it stays finite — a large-but-finite pixel
* estimate still triggers downscaling, whereas `Infinity` would collapse
* the computed downscale factor to 0 (see `convertToImages`).
*/
static largestPagePixels(pdfBuffer, scale) {
const text = pdfBuffer.toString("latin1");
const re = /\/MediaBox\s*\[\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s*\]/g;
let max = 0;
let match;
while ((match = re.exec(text)) !== null) {
const width = Math.abs(parseFloat(match[3]) - parseFloat(match[1]));
const height = Math.abs(parseFloat(match[4]) - parseFloat(match[2]));
if (Number.isFinite(width) && Number.isFinite(height)) {
const pixels = width * height * scale * scale;
const boundedPixels = Number.isFinite(pixels)
? Math.min(pixels, Number.MAX_SAFE_INTEGER)
: Number.MAX_SAFE_INTEGER;
if (boundedPixels > max) {
max = boundedPixels;
}
}
}
return max;
}
/**
* Convert a PDF buffer to an array of base64 PNG images
*
* This is used automatically when a provider (like Azure, Mistral, Ollama) doesn't
* support native PDF input but does support image input. The PDF pages are converted
* to PNG images and sent as vision content.
*
* @param pdfBuffer - PDF file content as Buffer
* @param options - Conversion options
* @returns Promise with conversion result including base64 images
*
* @example
* ```typescript
* // Check if conversion is needed
* if (!PDFProcessor.supportsNativePDF('azure')) {
* const result = await PDFProcessor.convertToImages(pdfBuffer, {
* scale: 2,
* maxPages: 10
* });
* // Use images in LLM input instead of PDF
* options.input.images = result.images;
* }
* ```
*/
static async convertToImages(pdfBuffer, options) {
const startTime = Date.now();
const { scale = PDF_LIMITS.DEFAULT_SCALE, maxPages = PDF_LIMITS.DEFAULT_MAX_PAGES, format = "png", maxCanvasPixels = PDF_LIMITS.DEFAULT_MAX_CANVAS_PIXELS, password, onProgress, } = options || {};
const images = [];
const warnings = [];
const pageErrors = [];
// Validation shared with convertToImagesStream (#302).
const sizeMB = PDFProcessor.validateImageConversionInput(pdfBuffer, {
format,
scale,
maxCanvasPixels,
maxPages,
});
logger.debug("[PDF→Image] ✅ PDF validation passed", {
bufferSize: pdfBuffer.length,
sizeMB: sizeMB.toFixed(2),
maxPages,
});
// #297: surface an up-front memory estimate so an operator can spot a
// conversion that will allocate a lot before it runs. Uses the cheap regex
// page estimate (the accurate pdf-parse count runs in process(), not here).
const estimatedPages = Math.max(1, PDFProcessor.extractBasicMetadata(pdfBuffer).estimatedPages ?? 1);
const estimatedMemoryMB = PDFProcessor.estimateConversionMemoryUsage(pdfBuffer.length, estimatedPages, scale);
logger.info("[PDF→Image] Estimated memory usage before conversion", {
scale,
estimatedPages,
estimatedMemoryMB,
sizeMB: Number(sizeMB.toFixed(2)),
});
let document;
try {
logger.debug("[PDF→Image] Starting PDF to image conversion", {
bufferSize: pdfBuffer.length,
scale,
maxPages: maxPages || "all",
});
// #260: pre-flight page-size check WITHOUT rendering. The renderer applies
// `scale` uniformly with no per-page hook and no pixel guard, so a very
// large page (e.g. an architectural drawing with a huge MediaBox) can
// allocate gigabytes of canvas. Read the largest MediaBox from the PDF
// bytes and, if that page at the requested scale would exceed
// maxCanvasPixels, downscale the whole render uniformly to stay under it.
let effectiveScale = scale;
const largestPixels = PDFProcessor.largestPagePixels(pdfBuffer, scale);
if (largestPixels > maxCanvasPixels) {
const downscale = Math.sqrt(maxCanvasPixels / largestPixels);
// Floor the result: an astronomically large (but now finite, see
// `largestPagePixels`) pixel estimate would otherwise push `downscale`
// — and therefore `effectiveScale` — toward 0, handing the renderer a
// degenerate viewport instead of a small-but-renderable page.
effectiveScale = Math.max(PDF_LIMITS.MIN_EFFECTIVE_SCALE, scale * downscale);
// Recompute the ratio actually applied (may differ from `downscale`
// when the floor above kicks in) so the logged estimate stays honest.
const actualDownscale = effectiveScale / scale;
const beforeMB = (largestPixels * 4) / (1024 * 1024);
const afterMB = (largestPixels * actualDownscale * actualDownscale * 4) /
(1024 * 1024);
const msg = `Downscaled render (scale ${scale} → ${effectiveScale.toFixed(3)}): ` +
`the largest page would allocate ~${beforeMB.toFixed(0)}MB, above the ` +
`maxCanvasPixels ceiling; reduced to ~${afterMB.toFixed(0)}MB per page.`;
logger.warn(`[PDF→Image] ⚠️ ${msg}`);
warnings.push(msg);
}
// Password forwarded for encrypted PDFs (#258). An indexed getPage()
// loop lets one bad page be isolated instead of aborting all.
document = await PDFProcessor.openForRendering(pdfBuffer, effectiveScale, password);
const totalPages = document.length;
// #294: convert page-by-page with per-page isolation. A single page whose
// canvas render throws (e.g. a degenerate MediaBox) no longer discards
// every already-converted page — it is recorded in `errors` and the rest
// continue.
for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
if (maxPages !== undefined && pageNum - 1 >= maxPages) {
warnings.push(`Stopped at page ${pageNum - 1} (maxPages limit: ${maxPages})`);
break;
}
try {
const page = await document.getPage(pageNum);
const base64Image = page.toString("base64");
images.push(base64Image);
logger.debug(`[PDF→Image] Converted page ${pageNum}`, {
imageSizeBytes: page.length,
base64Length: base64Image.length,
});
// #302: report progress after each successfully converted page.
if (onProgress) {
await onProgress({
pagesConverted: images.length,
totalPages,
elapsedMs: Date.now() - startTime,
});
}
}
catch (pageError) {
const msg = pageError instanceof Error ? pageError.message : String(pageError);
logger.warn(`[PDF→Image] ⚠️ page ${pageNum} failed to render: ${msg}`);
pageErrors.push({ page: pageNum, error: msg });
}
}
// Empty PDF (0 pages) or every page failed → treat as a conversion
// failure (kept inside the try so the password/format mapping below still
// applies), otherwise return the pages that did convert.
if (images.length === 0) {
if (pageErrors.length > 0) {
throw new Error(`All ${pageErrors.length} page(s) failed to render. First error: ${pageErrors[0].error}`);
}
throw new Error("PDF has 0 pages. Cannot convert empty PDF to images.");
}
const conversionTimeMs = Date.now() - startTime;
logger.info("[PDF→Image] ✅ PDF conversion completed", {
pageCount: images.length,
failedPages: pageErrors.length,
conversionTimeMs,
totalImageBytes: images.reduce((sum, img) => sum + img.length, 0),
});
return {
images,
pageCount: images.length,
conversionTimeMs,
warnings: warnings.length > 0 ? warnings : undefined,
errors: pageErrors.length > 0 ? pageErrors : undefined,
};
}
catch (error) {
const conversionTimeMs = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error("[PDF→Image] ❌ PDF conversion failed", {
error: errorMessage,
conversionTimeMs,
});
// #258: map pdfjs's PasswordException to an actionable typed error so a
// caller learns to supply (or correct) the password instead of seeing a
// generic "conversion failed". pdfjs code 1 = NEED_PASSWORD, 2 = INCORRECT.
const pdfErr = error;
if (pdfErr?.name === "PasswordException" ||
/password/i.test(errorMessage)) {
const incorrect = pdfErr.code === 2 || /incorrect|invalid/i.test(errorMessage);
throw incorrect
? ErrorFactory.pdfIncorrectPassword()
: ErrorFactory.pdfPasswordRequired();
}
throw new Error(`PDF to image conversion failed: ${errorMessage}`, {
cause: error,
});
}
finally {
await document?.destroy();
}
}
/**
* Shared input validation for the image-conversion paths. Returns the PDF
* size in MB (needed by the memory-estimate log). Throws on any invalid input
* with the same messages the batch path has always used.
*/
static validateImageConversionInput(pdfBuffer, opts) {
if (opts.format !== "png") {
throw new Error(`Invalid format: "${opts.format}". Only "png" format is currently supported.`);
}
if (!Number.isFinite(opts.scale) ||
opts.scale < PDF_LIMITS.MIN_SCALE ||
opts.scale > PDF_LIMITS.MAX_SCALE) {
throw new Error(`Invalid scale: ${opts.scale}. Scale must be a finite number between ${PDF_LIMITS.MIN_SCALE} and ${PDF_LIMITS.MAX_SCALE}.`);
}
if (!Number.isFinite(opts.maxCanvasPixels) || opts.maxCanvasPixels <= 0) {
throw new Error(`Invalid maxCanvasPixels: ${opts.maxCanvasPixels}. Must be a finite number greater than 0.`);
}
// #297: maxPages became caller-controlled, and an unvalidated 0/-1/NaN
// silently converts nothing, surfacing later as a misleading
// "PDF has 0 pages" from deep inside the renderer. Reject it here where
// the message can still name the offending option.
if (opts.maxPages !== undefined &&
(!Number.isInteger(opts.maxPages) || opts.maxPages < 1)) {
throw new Error(`Invalid maxPages: ${opts.maxPages}. Must be a whole number of at least 1.`);
}
if (!pdfBuffer || pdfBuffer.length < 5) {
throw new Error("Invalid PDF: Buffer is too small or empty. " +
"A valid PDF must be at least 5 bytes (PDF header).");
}
if (!PDFProcessor.isValidPDF(pdfBuffer)) {
throw new Error("Invalid PDF: File must start with %PDF- header. " +
"The provided buffer does not appear to be a valid PDF file.");
}
const sizeMB = pdfBuffer.length / (1024 * 1024);
if (sizeMB > PDF_LIMITS.MAX_SIZE_MB) {
throw new Error(`PDF too large for image conversion: ${sizeMB.toFixed(2)}MB exceeds ${PDF_LIMITS.MAX_SIZE_MB}MB limit. ` +
"Consider splitting the PDF or using a provider with native PDF support.");
}
return sizeMB;
}
/**
* Streaming variant of {@link convertToImages} (#302): yields each page's
* base64 PNG as soon as it renders instead of buffering the whole document,
* and reports progress via `options.onProgress`. A page that fails to render
* is yielded with `error` set (per-page isolation, #294) rather than aborting
* the stream.
*
* NOT a wrapper of/over {@link convertToImages}, despite the similar
* contract — the two are independent, parallel implementations (each does
* its own document open, downscale calculation, and page loop)
* rather than one delegating to the other. Keep behavior changes (page
* isolation, downscale, password handling) in sync across both by hand.
*/
static async *convertToImagesStream(pdfBuffer, options) {
const startTime = Date.now();
const { scale = PDF_LIMITS.DEFAULT_SCALE, maxPages = PDF_LIMITS.DEFAULT_MAX_PAGES, format = "png", maxCanvasPixels = PDF_LIMITS.DEFAULT_MAX_CANVAS_PIXELS, password, onProgress, } = options || {};
PDFProcessor.validateImageConversionInput(pdfBuffer, {
format,
scale,
maxCanvasPixels,
maxPages,
});
// #260: uniform downscale so the largest page stays under maxCanvasPixels.
let effectiveScale = scale;
const largestPixels = PDFProcessor.largestPagePixels(pdfBuffer, scale);
if (largestPixels > maxCanvasPixels) {
effectiveScale = scale * Math.sqrt(maxCanvasPixels / largestPixels);
}
// #258: map pdfjs's PasswordException to the same actionable typed error
// convertToImages raises — kept in sync per the class docstring above.
// Unlike the per-page loop below, a failure here happens before any page
// can be isolated, so it must reject the generator outright rather than
// being yielded as a page error.
let document;
try {
document = await PDFProcessor.openForRendering(pdfBuffer, effectiveScale, password);
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const pdfErr = error;
if (pdfErr?.name === "PasswordException" ||
/password/i.test(errorMessage)) {
const incorrect = pdfErr.code === 2 || /incorrect|invalid/i.test(errorMessage);
throw incorrect
? ErrorFactory.pdfIncorrectPassword()
: ErrorFactory.pdfPasswordRequired();
}
throw new Error(`PDF to image conversion failed: ${errorMessage}`, {
cause: error,
});
}
const totalPages = document.length;
let converted = 0;
try {
for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
if (maxPages !== undefined && pageNum - 1 >= maxPages) {
break;
}
try {
const page = await document.getPage(pageNum);
const base64Image = page.toString("base64");
converted++;
if (onProgress) {
await onProgress({
pagesConverted: converted,
totalPages,
elapsedMs: Date.now() - startTime,
});
}
yield {
pageIndex: pageNum,
image: base64Image,
imageSizeBytes: page.length,
};
}
catch (pageError) {
const msg = pageError instanceof Error ? pageError.message : String(pageError);
logger.warn(`[PDF→Image] ⚠️ page ${pageNum} failed to render (stream): ${msg}`);
yield {
pageIndex: pageNum,
image: "",
imageSizeBytes: 0,
error: msg,
};
}
}
}
finally {
await document.destroy();
}
}
/**
* Convert a PDF file path to an array of base64 PNG images
*
* @param pdfPath - Path to the PDF file
* @param options - Conversion options
* @returns Promise with conversion result
*/
static async convertFromPath(pdfPath, options) {
const fs = await import("fs/promises");
const pdfBuffer = await fs.readFile(pdfPath);
return PDFProcessor.convertToImages(pdfBuffer, options);
}
/**
* Check if PDF to image conversion is available
* Useful for feature detection
*
* @returns true if the pdf-parse renderer is available
*/
static async isImageConversionAvailable() {
try {
await import("pdf-parse");
return true;
}
catch {
return false;
}
}
/**
* Get estimated memory usage for converting a PDF
*
* @param pdfSizeBytes - Size of PDF file in bytes
* @param pageCount - Estimated number of pages
* @param scale - Scale factor
* @returns Estimated memory usage in MB
*/
static estimateConversionMemoryUsage(pdfSizeBytes, pageCount, scale = PDF_LIMITS.DEFAULT_SCALE) {
// Rough estimation:
// - Each page at scale 2 produces ~1-3MB PNG
// - pdfjs needs ~2x PDF size for processing
// - Output images need ~2MB per page on average
const pdfProcessingMB = (pdfSizeBytes / (1024 * 1024)) * 2;
const outputImagesMB = pageCount * 2 * scale;
return Math.ceil(pdfProcessingMB + outputImagesMB);
}
/**
* Get list of providers that require PDF → Image conversion
*/
static getImageFallbackProviders() {
return Object.entries(PDF_PROVIDER_CONFIGS)
.filter(([_, config]) => !config.supportsNative)
.map(([name]) => name);
}
/**
* Get list of providers that support native PDF
*/
static getNativePDFProviders() {
return Object.entries(PDF_PROVIDER_CONFIGS)
.filter(([_, config]) => config.supportsNative)
.map(([name]) => name);
}
}
// Export PDFImageConverter as an alias for backward compatibility
export const PDFImageConverter = PDFProcessor;