From d7dff3cbf405688e533139d5eb43d9f85072b7e7 Mon Sep 17 00:00:00 2001 From: Wynne668 Date: Thu, 25 Jun 2026 16:37:47 +0800 Subject: [PATCH] fix(document-extract): render PDF image fallback per page so multi-page scans don't starve later pages (#96390) * fix(document-extract): render PDF image fallback per page so multi-page scans don't starve later pages clawpdf's mode:"images" extract applies a single maxPixels budget across every page, so the first page consumes it and later pages collapse to ~1x1 PNGs that vision OCR models reject. Render each selected page in its own extract() call so the pixel budget resets per page and every page yields a usable image. * fix(document-extract): preserve aggregate PDF render budget --------- Co-authored-by: Vincent Koc --- .../document-extractor.test.ts | 74 +++++++++++++------ .../document-extract/document-extractor.ts | 41 +++++++--- 2 files changed, 81 insertions(+), 34 deletions(-) diff --git a/extensions/document-extract/document-extractor.test.ts b/extensions/document-extract/document-extractor.test.ts index f80c6e6a4dc3..9155474a1bb6 100644 --- a/extensions/document-extract/document-extractor.test.ts +++ b/extensions/document-extract/document-extractor.test.ts @@ -55,20 +55,35 @@ describe("PDF document extractor", () => { }); }); - it("extracts text first and renders fallback images through clawpdf", async () => { - pdfDocument.extract.mockResolvedValueOnce({ text: "", images: [] }).mockResolvedValueOnce({ - text: "", - images: [ - { - type: "image", - bytes: Uint8Array.from(Buffer.from("png")), - mimeType: "image/png", - page: 1, - width: 10, - height: 10, - }, - ], - }); + it("extracts text first and renders each fallback page with its own pixel budget", async () => { + pdfDocument.extract + .mockResolvedValueOnce({ text: "", images: [] }) + .mockResolvedValueOnce({ + text: "", + images: [ + { + type: "image", + bytes: Uint8Array.from(Buffer.from("png1")), + mimeType: "image/png", + page: 1, + width: 5, + height: 10, + }, + ], + }) + .mockResolvedValueOnce({ + text: "", + images: [ + { + type: "image", + bytes: Uint8Array.from(Buffer.from("png2")), + mimeType: "image/png", + page: 2, + width: 5, + height: 10, + }, + ], + }); const extractor = createPdfDocumentExtractor(); const result = await extractor.extract(request()); @@ -82,18 +97,24 @@ describe("PDF document extractor", () => { maxPages: 2, maxTextChars: 200_000, }); + // Each page renders in its own extract() call, with the aggregate pixel cap + // allocated across selected pages so later pages are not starved. expect(pdfDocument.extract).toHaveBeenNthCalledWith(2, { mode: "images", - maxPages: 2, - image: { - maxDimension: 10_000, - maxPixels: 100, - forms: true, - }, + pages: [1], + image: { maxDimension: 10_000, maxPixels: 50, forms: true }, + }); + expect(pdfDocument.extract).toHaveBeenNthCalledWith(3, { + mode: "images", + pages: [2], + image: { maxDimension: 10_000, maxPixels: 50, forms: true }, }); expect(result).toEqual({ text: "", - images: [{ type: "image", data: "cG5n", mimeType: "image/png" }], + images: [ + { type: "image", data: "cG5nMQ==", mimeType: "image/png" }, + { type: "image", data: "cG5nMg==", mimeType: "image/png" }, + ], }); expect(pdfDocument.destroy).toHaveBeenCalledTimes(1); }); @@ -131,8 +152,9 @@ describe("PDF document extractor", () => { expect(pdfDocument.destroy).not.toHaveBeenCalled(); }); - it("filters selected pages before passing them to clawpdf", async () => { + it("filters selected pages and renders them one page per image call", async () => { pdfDocument.extract + .mockResolvedValueOnce({ text: "", images: [] }) .mockResolvedValueOnce({ text: "", images: [] }) .mockResolvedValueOnce({ text: "", images: [] }); const extractor = createPdfDocumentExtractor(); @@ -141,11 +163,15 @@ describe("PDF document extractor", () => { expect(pdfDocument.extract).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ pages: [2, 1] }), + expect.objectContaining({ mode: "text", pages: [2, 1] }), ); expect(pdfDocument.extract).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ pages: [2, 1] }), + expect.objectContaining({ mode: "images", pages: [2] }), + ); + expect(pdfDocument.extract).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ mode: "images", pages: [1] }), ); }); diff --git a/extensions/document-extract/document-extractor.ts b/extensions/document-extract/document-extractor.ts index 22059442ad5b..e99ba5bc7a06 100644 --- a/extensions/document-extract/document-extractor.ts +++ b/extensions/document-extract/document-extractor.ts @@ -83,17 +83,38 @@ async function extractPdfContent( return { text, images: [] }; } + // clawpdf's image render budget (maxPixels) is shared across every page in one + // extract() call: the first page consumes it and later pages collapse to 1x1 + // PNGs that vision models reject. Render each page separately, allocating the + // remaining aggregate budget across pages that still need rendering. + const imagePages = + pages ?? Array.from({ length: Math.min(pdf.pageCount, request.maxPages) }, (_, i) => i + 1); + try { - const imageResult = await pdf.extract({ - mode: "images", - ...pageSelection, - image: { - maxDimension: MAX_RENDER_DIMENSION, - maxPixels: request.maxPixels, - forms: true, - }, - }); - return { text, images: imageResult.images.map(toDocumentImage) }; + const images: DocumentExtractedImage[] = []; + let remainingPixels = request.maxPixels; + for (let index = 0; index < imagePages.length; index += 1) { + if (remainingPixels <= 0) { + break; + } + const pagesRemaining = imagePages.length - index; + const maxPixelsPerPage = Math.max(1, Math.ceil(remainingPixels / pagesRemaining)); + const pageNumber = imagePages[index]; + const imageResult = await pdf.extract({ + mode: "images", + pages: [pageNumber], + image: { + maxDimension: MAX_RENDER_DIMENSION, + maxPixels: maxPixelsPerPage, + forms: true, + }, + }); + for (const image of imageResult.images) { + images.push(toDocumentImage(image)); + remainingPixels -= image.width * image.height; + } + } + return { text, images }; } catch (err) { request.onImageExtractionError?.(err); return { text, images: [] };