Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions config/knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const bundledPluginIgnoredRuntimeDependencies = [
"lit",
"linkedom",
"openclaw",
"pdfjs-dist",
"clawpdf",
] as const;

const rootBundledPluginRuntimeDependencies = [
Expand All @@ -70,7 +70,7 @@ const rootBundledPluginRuntimeDependencies = [
"minimatch",
"node-edge-tts",
"openshell",
"pdfjs-dist",
"clawpdf",
"tokenjuice",
] as const;

Expand Down
6 changes: 3 additions & 3 deletions docs/gateway/openresponses-http-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,9 @@ Current behavior:
rasterized into images and passed to the model, and the injected file block uses
the placeholder `[PDF content rendered to images]`.

PDF parsing is provided by the bundled `document-extract` plugin, which uses the
Node-friendly `pdfjs-dist` legacy build (no worker). The modern PDF.js build
expects browser workers/DOM globals, so it is not used in the Gateway.
PDF parsing is provided by the bundled `document-extract` plugin, which uses
`clawpdf` and its packaged PDFium WebAssembly runtime for text extraction and
page rendering.

URL fetch defaults:

Expand Down
4 changes: 2 additions & 2 deletions docs/tools/pdf.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ Fallback details:
text-only model, OpenClaw drops the rendered images and continues with the
extracted text.
- Extraction fallback uses the bundled `document-extract` plugin. The plugin owns
`pdfjs-dist`; `@napi-rs/canvas` is used only when image rendering fallback is
available.
`clawpdf`, which provides text extraction and image rendering through PDFium
WebAssembly.

## Config

Expand Down
177 changes: 98 additions & 79 deletions extensions/document-extract/document-extractor.test.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,45 @@
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";

const { canvasSizes, getDocumentMock, pdfDocument } = vi.hoisted(() => ({
canvasSizes: [] as Array<{ width: number; height: number }>,
getDocumentMock: vi.fn(),
const { createEngineMock, openPdfMock, pdfDocument } = vi.hoisted(() => ({
createEngineMock: vi.fn(),
openPdfMock: vi.fn(),
pdfDocument: {
numPages: 2,
getPage: vi.fn(async () => ({
getTextContent: vi.fn(async () => ({ items: [] })),
getViewport: vi.fn(({ scale }: { scale: number }) => ({
width: 1000 * scale,
height: 1000 * scale,
})),
render: vi.fn(() => ({ promise: Promise.resolve() })),
})),
pageCount: 2,
extract: vi.fn(),
destroy: vi.fn(),
},
}));

vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({
getDocument: getDocumentMock,
}));

vi.mock("@napi-rs/canvas", () => ({
createCanvas: vi.fn((width: number, height: number) => {
canvasSizes.push({ width, height });
return {
toBuffer: vi.fn(() => Buffer.from("png")),
};
}),
vi.mock("clawpdf", () => ({
createEngine: createEngineMock,
}));

import { createPdfDocumentExtractor } from "./document-extractor.js";

const require = createRequire(import.meta.url);

function requireFirstMockArg(mock: ReturnType<typeof vi.fn>, label: string) {
const [call] = mock.mock.calls;
if (!call) {
throw new Error(`Expected ${label}`);
}
return call[0];
function request(overrides = {}) {
return {
buffer: Buffer.from("%PDF-1.4"),
mimeType: "application/pdf",
maxPages: 2,
maxPixels: 100,
minTextChars: 10,
...overrides,
};
}

describe("PDF document extractor", () => {
afterAll(() => {
vi.doUnmock("pdfjs-dist/legacy/build/pdf.mjs");
vi.doUnmock("@napi-rs/canvas");
vi.doUnmock("clawpdf");
vi.resetModules();
});

beforeEach(() => {
canvasSizes.length = 0;
getDocumentMock.mockReset();
getDocumentMock.mockReturnValue({ promise: Promise.resolve(pdfDocument) });
pdfDocument.getPage.mockClear();
createEngineMock.mockResolvedValue({ open: openPdfMock });
openPdfMock.mockReset();
openPdfMock.mockResolvedValue(pdfDocument);
pdfDocument.pageCount = 2;
pdfDocument.extract.mockReset();
pdfDocument.destroy.mockReset();
});

it("declares PDF support", () => {
Expand All @@ -70,55 +54,90 @@ describe("PDF document extractor", () => {
});
});

it("treats maxPixels as a hard total image rendering budget", async () => {
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,
},
],
});
const extractor = createPdfDocumentExtractor();

const result = await extractor.extract({
buffer: Buffer.from("%PDF-1.4"),
mimeType: "application/pdf",
maxPages: 2,
maxPixels: 100,
minTextChars: 10,
});
const result = await extractor.extract(request());

if (!result) {
throw new Error("Expected PDF extraction result");
}
expect(result.images).toHaveLength(1);
expect(canvasSizes).toEqual([{ width: 10, height: 10 }]);
expect(openPdfMock).toHaveBeenCalledWith(expect.any(Uint8Array));
expect(pdfDocument.extract).toHaveBeenNthCalledWith(1, {
mode: "text",
maxPages: 2,
maxTextChars: 200_000,
});
expect(pdfDocument.extract).toHaveBeenNthCalledWith(2, {
mode: "images",
maxPages: 2,
image: {
maxDimension: 10_000,
maxPixels: 100,
forms: true,
},
});
expect(result).toEqual({
text: "",
images: [{ type: "image", data: "cG5n", mimeType: "image/png" }],
});
expect(pdfDocument.destroy).toHaveBeenCalledTimes(1);
});

it("passes standardFontDataUrl to pdfjs getDocument as a package-root filesystem path", async () => {
it("skips image fallback when enough text is extracted", async () => {
pdfDocument.extract.mockResolvedValueOnce({ text: "enough text", images: [] });
const extractor = createPdfDocumentExtractor();

await extractor.extract({
buffer: Buffer.from("%PDF-1.4"),
mimeType: "application/pdf",
maxPages: 1,
maxPixels: 4_000_000,
minTextChars: 200,
});
const result = await extractor.extract(request({ minTextChars: 5 }));

expect(getDocumentMock).toHaveBeenCalledTimes(1);
const params = requireFirstMockArg(getDocumentMock, "pdfjs getDocument call");
const { data, standardFontDataUrl, ...stableParams } = params as {
data: Uint8Array;
disableWorker: boolean;
standardFontDataUrl: string;
};
expect(stableParams).toEqual({
disableWorker: true,
});
expect(data).toBeInstanceOf(Uint8Array);
expect(typeof standardFontDataUrl).toBe("string");

const expectedStandardFontDataUrl =
path.join(path.dirname(require.resolve("pdfjs-dist/package.json")), "standard_fonts") + "/";
expect(standardFontDataUrl).toBe(expectedStandardFontDataUrl);
expect(path.isAbsolute(standardFontDataUrl)).toBe(true);
expect(standardFontDataUrl.endsWith("/")).toBe(true);
expect(standardFontDataUrl.startsWith("file://")).toBe(false);
expect(existsSync(standardFontDataUrl)).toBe(true);
expect(existsSync(path.join(standardFontDataUrl, "LiberationSans-Regular.ttf"))).toBe(true);
expect(result).toEqual({ text: "enough text", images: [] });
expect(pdfDocument.extract).toHaveBeenCalledTimes(1);
expect(pdfDocument.destroy).toHaveBeenCalledTimes(1);
});

it("filters selected pages before passing them to clawpdf", async () => {
pdfDocument.extract
.mockResolvedValueOnce({ text: "", images: [] })
.mockResolvedValueOnce({ text: "", images: [] });
const extractor = createPdfDocumentExtractor();

await extractor.extract(request({ pageNumbers: [3, 2, 0, 1], maxPages: 2 }));

expect(pdfDocument.extract).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ pages: [2, 1] }),
);
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ pages: [2, 1] }),
);
});

it("reports image fallback failures and returns extracted text", async () => {
const onImageExtractionError = vi.fn();
const failure = new Error("render failed");
pdfDocument.extract
.mockResolvedValueOnce({ text: "short", images: [] })
.mockRejectedValueOnce(failure);
const extractor = createPdfDocumentExtractor();

const result = await extractor.extract(request({ onImageExtractionError }));

expect(result).toEqual({ text: "short", images: [] });
expect(onImageExtractionError).toHaveBeenCalledWith(failure);
expect(pdfDocument.destroy).toHaveBeenCalledTimes(1);
});
});
Loading
Loading