Skip to content

Commit d7dff3c

Browse files
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 <[email protected]>
1 parent 42d0a12 commit d7dff3c

2 files changed

Lines changed: 81 additions & 34 deletions

File tree

extensions/document-extract/document-extractor.test.ts

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -55,20 +55,35 @@ describe("PDF document extractor", () => {
5555
});
5656
});
5757

58-
it("extracts text first and renders fallback images through clawpdf", async () => {
59-
pdfDocument.extract.mockResolvedValueOnce({ text: "", images: [] }).mockResolvedValueOnce({
60-
text: "",
61-
images: [
62-
{
63-
type: "image",
64-
bytes: Uint8Array.from(Buffer.from("png")),
65-
mimeType: "image/png",
66-
page: 1,
67-
width: 10,
68-
height: 10,
69-
},
70-
],
71-
});
58+
it("extracts text first and renders each fallback page with its own pixel budget", async () => {
59+
pdfDocument.extract
60+
.mockResolvedValueOnce({ text: "", images: [] })
61+
.mockResolvedValueOnce({
62+
text: "",
63+
images: [
64+
{
65+
type: "image",
66+
bytes: Uint8Array.from(Buffer.from("png1")),
67+
mimeType: "image/png",
68+
page: 1,
69+
width: 5,
70+
height: 10,
71+
},
72+
],
73+
})
74+
.mockResolvedValueOnce({
75+
text: "",
76+
images: [
77+
{
78+
type: "image",
79+
bytes: Uint8Array.from(Buffer.from("png2")),
80+
mimeType: "image/png",
81+
page: 2,
82+
width: 5,
83+
height: 10,
84+
},
85+
],
86+
});
7287
const extractor = createPdfDocumentExtractor();
7388

7489
const result = await extractor.extract(request());
@@ -82,18 +97,24 @@ describe("PDF document extractor", () => {
8297
maxPages: 2,
8398
maxTextChars: 200_000,
8499
});
100+
// Each page renders in its own extract() call, with the aggregate pixel cap
101+
// allocated across selected pages so later pages are not starved.
85102
expect(pdfDocument.extract).toHaveBeenNthCalledWith(2, {
86103
mode: "images",
87-
maxPages: 2,
88-
image: {
89-
maxDimension: 10_000,
90-
maxPixels: 100,
91-
forms: true,
92-
},
104+
pages: [1],
105+
image: { maxDimension: 10_000, maxPixels: 50, forms: true },
106+
});
107+
expect(pdfDocument.extract).toHaveBeenNthCalledWith(3, {
108+
mode: "images",
109+
pages: [2],
110+
image: { maxDimension: 10_000, maxPixels: 50, forms: true },
93111
});
94112
expect(result).toEqual({
95113
text: "",
96-
images: [{ type: "image", data: "cG5n", mimeType: "image/png" }],
114+
images: [
115+
{ type: "image", data: "cG5nMQ==", mimeType: "image/png" },
116+
{ type: "image", data: "cG5nMg==", mimeType: "image/png" },
117+
],
97118
});
98119
expect(pdfDocument.destroy).toHaveBeenCalledTimes(1);
99120
});
@@ -131,8 +152,9 @@ describe("PDF document extractor", () => {
131152
expect(pdfDocument.destroy).not.toHaveBeenCalled();
132153
});
133154

134-
it("filters selected pages before passing them to clawpdf", async () => {
155+
it("filters selected pages and renders them one page per image call", async () => {
135156
pdfDocument.extract
157+
.mockResolvedValueOnce({ text: "", images: [] })
136158
.mockResolvedValueOnce({ text: "", images: [] })
137159
.mockResolvedValueOnce({ text: "", images: [] });
138160
const extractor = createPdfDocumentExtractor();
@@ -141,11 +163,15 @@ describe("PDF document extractor", () => {
141163

142164
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
143165
1,
144-
expect.objectContaining({ pages: [2, 1] }),
166+
expect.objectContaining({ mode: "text", pages: [2, 1] }),
145167
);
146168
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
147169
2,
148-
expect.objectContaining({ pages: [2, 1] }),
170+
expect.objectContaining({ mode: "images", pages: [2] }),
171+
);
172+
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
173+
3,
174+
expect.objectContaining({ mode: "images", pages: [1] }),
149175
);
150176
});
151177

extensions/document-extract/document-extractor.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,17 +83,38 @@ async function extractPdfContent(
8383
return { text, images: [] };
8484
}
8585

86+
// clawpdf's image render budget (maxPixels) is shared across every page in one
87+
// extract() call: the first page consumes it and later pages collapse to 1x1
88+
// PNGs that vision models reject. Render each page separately, allocating the
89+
// remaining aggregate budget across pages that still need rendering.
90+
const imagePages =
91+
pages ?? Array.from({ length: Math.min(pdf.pageCount, request.maxPages) }, (_, i) => i + 1);
92+
8693
try {
87-
const imageResult = await pdf.extract({
88-
mode: "images",
89-
...pageSelection,
90-
image: {
91-
maxDimension: MAX_RENDER_DIMENSION,
92-
maxPixels: request.maxPixels,
93-
forms: true,
94-
},
95-
});
96-
return { text, images: imageResult.images.map(toDocumentImage) };
94+
const images: DocumentExtractedImage[] = [];
95+
let remainingPixels = request.maxPixels;
96+
for (let index = 0; index < imagePages.length; index += 1) {
97+
if (remainingPixels <= 0) {
98+
break;
99+
}
100+
const pagesRemaining = imagePages.length - index;
101+
const maxPixelsPerPage = Math.max(1, Math.ceil(remainingPixels / pagesRemaining));
102+
const pageNumber = imagePages[index];
103+
const imageResult = await pdf.extract({
104+
mode: "images",
105+
pages: [pageNumber],
106+
image: {
107+
maxDimension: MAX_RENDER_DIMENSION,
108+
maxPixels: maxPixelsPerPage,
109+
forms: true,
110+
},
111+
});
112+
for (const image of imageResult.images) {
113+
images.push(toDocumentImage(image));
114+
remainingPixels -= image.width * image.height;
115+
}
116+
}
117+
return { text, images };
97118
} catch (err) {
98119
request.onImageExtractionError?.(err);
99120
return { text, images: [] };

0 commit comments

Comments
 (0)