Skip to content

Commit 99477c3

Browse files
committed
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.
1 parent 1069c60 commit 99477c3

2 files changed

Lines changed: 73 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: 10,
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: 10,
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 so clawpdf's pixel budget is not
101+
// shared across pages (which would starve later pages down to 1x1 images).
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: 100, forms: true },
106+
});
107+
expect(pdfDocument.extract).toHaveBeenNthCalledWith(3, {
108+
mode: "images",
109+
pages: [2],
110+
image: { maxDimension: 10_000, maxPixels: 100, 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: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,17 +83,30 @@ 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 in its own call so the
89+
// budget applies per page and every selected page yields a usable image.
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+
for (const pageNumber of imagePages) {
96+
const imageResult = await pdf.extract({
97+
mode: "images",
98+
pages: [pageNumber],
99+
image: {
100+
maxDimension: MAX_RENDER_DIMENSION,
101+
maxPixels: request.maxPixels,
102+
forms: true,
103+
},
104+
});
105+
for (const image of imageResult.images) {
106+
images.push(toDocumentImage(image));
107+
}
108+
}
109+
return { text, images };
97110
} catch (err) {
98111
request.onImageExtractionError?.(err);
99112
return { text, images: [] };

0 commit comments

Comments
 (0)