UNPKG

@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
/** * 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;