mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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 <vincentkoc@ieee.org>
This commit is contained in:
@@ -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] }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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: [] };
|
||||
|
||||
Reference in New Issue
Block a user