Skip to content

Commit e3cba98

Browse files
authored
refactor(pdf): move document extraction to plugin
* refactor(pdf): move document extraction to plugin * fix(deps): sync document extract lockfile * fix(pdf): harden document extraction plugin
1 parent 915931a commit e3cba98

34 files changed

Lines changed: 1023 additions & 321 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9+
- Plugins/PDF: move local PDF extraction into a bundled `document-extract` plugin so core no longer owns `pdfjs-dist` or PDF image-rendering dependencies. Thanks @vincentkoc.
910
- Matrix: require full cross-signing identity trust for self-device verification and add `openclaw matrix verify self` so operators can establish that trust from the CLI. (#70401) Thanks @gumadeiras.
1011

1112
### Fixes

docs/gateway/openresponses-http-api.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,9 @@ Current behavior:
172172
rasterized into images and passed to the model, and the injected file block uses
173173
the placeholder `[PDF content rendered to images]`.
174174

175-
PDF parsing uses the Node-friendly `pdfjs-dist` legacy build (no worker). The modern
176-
PDF.js build expects browser workers/DOM globals, so it is not used in the Gateway.
175+
PDF parsing is provided by the bundled `document-extract` plugin, which uses the
176+
Node-friendly `pdfjs-dist` legacy build (no worker). The modern PDF.js build
177+
expects browser workers/DOM globals, so it is not used in the Gateway.
177178

178179
URL fetch defaults:
179180

docs/tools/pdf.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,9 @@ Fallback details:
112112
- If text extraction succeeds but image extraction would require vision on a
113113
text-only model, OpenClaw drops the rendered images and continues with the
114114
extracted text.
115-
- Extraction fallback requires `pdfjs-dist` (and `@napi-rs/canvas` for image rendering).
115+
- Extraction fallback uses the bundled `document-extract` plugin. The plugin owns
116+
`pdfjs-dist`; `@napi-rs/canvas` is used only when image rendering fallback is
117+
available.
116118

117119
## Config
118120

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const { canvasSizes, pdfDocument } = vi.hoisted(() => ({
4+
canvasSizes: [] as Array<{ width: number; height: number }>,
5+
pdfDocument: {
6+
numPages: 2,
7+
getPage: vi.fn(async () => ({
8+
getTextContent: vi.fn(async () => ({ items: [] })),
9+
getViewport: vi.fn(({ scale }: { scale: number }) => ({
10+
width: 1000 * scale,
11+
height: 1000 * scale,
12+
})),
13+
render: vi.fn(() => ({ promise: Promise.resolve() })),
14+
})),
15+
},
16+
}));
17+
18+
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({
19+
getDocument: vi.fn(() => ({ promise: Promise.resolve(pdfDocument) })),
20+
}));
21+
22+
vi.mock("@napi-rs/canvas", () => ({
23+
createCanvas: vi.fn((width: number, height: number) => {
24+
canvasSizes.push({ width, height });
25+
return {
26+
toBuffer: vi.fn(() => Buffer.from("png")),
27+
};
28+
}),
29+
}));
30+
31+
import { createPdfDocumentExtractor } from "./document-extractor.js";
32+
33+
describe("PDF document extractor", () => {
34+
beforeEach(() => {
35+
canvasSizes.length = 0;
36+
pdfDocument.getPage.mockClear();
37+
});
38+
39+
it("declares PDF support", () => {
40+
const extractor = createPdfDocumentExtractor();
41+
expect(extractor).toMatchObject({
42+
id: "pdf",
43+
label: "PDF",
44+
mimeTypes: ["application/pdf"],
45+
});
46+
});
47+
48+
it("treats maxPixels as a hard total image rendering budget", async () => {
49+
const extractor = createPdfDocumentExtractor();
50+
51+
const result = await extractor.extract({
52+
buffer: Buffer.from("%PDF-1.4"),
53+
mimeType: "application/pdf",
54+
maxPages: 2,
55+
maxPixels: 100,
56+
minTextChars: 10,
57+
});
58+
59+
expect(result?.images).toHaveLength(1);
60+
expect(canvasSizes).toEqual([{ width: 10, height: 10 }]);
61+
});
62+
});
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import type {
2+
DocumentExtractedImage,
3+
DocumentExtractionRequest,
4+
DocumentExtractionResult,
5+
DocumentExtractorPlugin,
6+
} from "openclaw/plugin-sdk/document-extractor";
7+
8+
type CanvasLike = {
9+
toBuffer(type: "image/png"): Buffer;
10+
};
11+
12+
type CanvasModule = {
13+
createCanvas(width: number, height: number): CanvasLike;
14+
};
15+
16+
type PdfTextItem = {
17+
str: string;
18+
};
19+
20+
type PdfTextContent = {
21+
items: Array<PdfTextItem | object>;
22+
};
23+
24+
type PdfViewport = {
25+
width: number;
26+
height: number;
27+
};
28+
29+
type PdfPage = {
30+
getTextContent(): Promise<PdfTextContent>;
31+
getViewport(params: { scale: number }): PdfViewport;
32+
render(params: { canvas: unknown; viewport: PdfViewport }): { promise: Promise<void> };
33+
};
34+
35+
type PdfDocument = {
36+
numPages: number;
37+
getPage(pageNumber: number): Promise<PdfPage>;
38+
};
39+
40+
type PdfJsModule = {
41+
getDocument(params: { data: Uint8Array; disableWorker?: boolean }): {
42+
promise: Promise<PdfDocument>;
43+
};
44+
};
45+
46+
const CANVAS_MODULE = "@napi-rs/canvas";
47+
const PDFJS_MODULE = "pdfjs-dist/legacy/build/pdf.mjs";
48+
const MAX_EXTRACTED_TEXT_CHARS = 200_000;
49+
const MAX_RENDER_DIMENSION = 10_000;
50+
51+
let canvasModulePromise: Promise<CanvasModule> | null = null;
52+
let pdfJsModulePromise: Promise<PdfJsModule> | null = null;
53+
54+
async function loadCanvasModule(): Promise<CanvasModule> {
55+
if (!canvasModulePromise) {
56+
canvasModulePromise = (import(CANVAS_MODULE) as Promise<CanvasModule>).catch((err) => {
57+
canvasModulePromise = null;
58+
throw new Error("Optional dependency @napi-rs/canvas is required for PDF image extraction", {
59+
cause: err,
60+
});
61+
});
62+
}
63+
return canvasModulePromise;
64+
}
65+
66+
async function loadPdfJsModule(): Promise<PdfJsModule> {
67+
if (!pdfJsModulePromise) {
68+
pdfJsModulePromise = (import(PDFJS_MODULE) as Promise<PdfJsModule>).catch((err) => {
69+
pdfJsModulePromise = null;
70+
throw new Error("Optional dependency pdfjs-dist is required for PDF extraction", {
71+
cause: err,
72+
});
73+
});
74+
}
75+
return pdfJsModulePromise;
76+
}
77+
78+
function appendTextWithinLimit(parts: string[], pageText: string, currentLength: number): number {
79+
if (!pageText) {
80+
return currentLength;
81+
}
82+
const remaining = MAX_EXTRACTED_TEXT_CHARS - currentLength;
83+
if (remaining <= 0) {
84+
return currentLength;
85+
}
86+
const nextText = pageText.length > remaining ? pageText.slice(0, remaining) : pageText;
87+
parts.push(nextText);
88+
return currentLength + nextText.length;
89+
}
90+
91+
function resolveRenderPlan(
92+
viewport: PdfViewport,
93+
remainingPixels: number,
94+
): { scale: number; width: number; height: number; pixels: number } | null {
95+
if (
96+
remainingPixels <= 0 ||
97+
!Number.isFinite(viewport.width) ||
98+
!Number.isFinite(viewport.height) ||
99+
viewport.width <= 0 ||
100+
viewport.height <= 0
101+
) {
102+
return null;
103+
}
104+
105+
const pagePixels = Math.max(1, viewport.width * viewport.height);
106+
const maxScale = Math.min(
107+
1,
108+
Math.sqrt(remainingPixels / pagePixels),
109+
MAX_RENDER_DIMENSION / viewport.width,
110+
MAX_RENDER_DIMENSION / viewport.height,
111+
);
112+
if (!Number.isFinite(maxScale) || maxScale <= 0) {
113+
return null;
114+
}
115+
116+
let best: { scale: number; width: number; height: number; pixels: number } | null = null;
117+
let low = 0;
118+
let high = maxScale;
119+
for (let i = 0; i < 32; i += 1) {
120+
const scale = (low + high) / 2;
121+
const width = Math.max(1, Math.ceil(viewport.width * scale));
122+
const height = Math.max(1, Math.ceil(viewport.height * scale));
123+
const pixels = width * height;
124+
if (
125+
width <= MAX_RENDER_DIMENSION &&
126+
height <= MAX_RENDER_DIMENSION &&
127+
pixels <= remainingPixels
128+
) {
129+
best = { scale, width, height, pixels };
130+
low = scale;
131+
} else {
132+
high = scale;
133+
}
134+
}
135+
return best;
136+
}
137+
138+
async function extractPdfContent(
139+
request: DocumentExtractionRequest,
140+
): Promise<DocumentExtractionResult> {
141+
const pdfJsModule = await loadPdfJsModule();
142+
const pdf = await pdfJsModule.getDocument({
143+
data: new Uint8Array(request.buffer),
144+
disableWorker: true,
145+
}).promise;
146+
147+
const effectivePages: number[] = request.pageNumbers
148+
? request.pageNumbers.filter((p) => p >= 1 && p <= pdf.numPages).slice(0, request.maxPages)
149+
: Array.from({ length: Math.min(pdf.numPages, request.maxPages) }, (_, i) => i + 1);
150+
151+
const textParts: string[] = [];
152+
let extractedTextLength = 0;
153+
for (const pageNum of effectivePages) {
154+
const page = await pdf.getPage(pageNum);
155+
const textContent = await page.getTextContent();
156+
const pageText = textContent.items
157+
.map((item) => ("str" in item ? item.str : ""))
158+
.filter(Boolean)
159+
.join(" ");
160+
if (pageText) {
161+
extractedTextLength = appendTextWithinLimit(textParts, pageText, extractedTextLength);
162+
if (extractedTextLength >= MAX_EXTRACTED_TEXT_CHARS) {
163+
break;
164+
}
165+
}
166+
}
167+
168+
const text = textParts.join("\n\n");
169+
if (text.trim().length >= request.minTextChars) {
170+
return { text, images: [] };
171+
}
172+
173+
let canvasModule: CanvasModule;
174+
try {
175+
canvasModule = await loadCanvasModule();
176+
} catch (err) {
177+
request.onImageExtractionError?.(err);
178+
return { text, images: [] };
179+
}
180+
181+
const images: DocumentExtractedImage[] = [];
182+
let remainingPixels = Math.max(1, Math.floor(request.maxPixels));
183+
184+
for (const pageNum of effectivePages) {
185+
if (remainingPixels <= 0) {
186+
break;
187+
}
188+
const page = await pdf.getPage(pageNum);
189+
const viewport = page.getViewport({ scale: 1 });
190+
const plan = resolveRenderPlan(viewport, remainingPixels);
191+
if (!plan) {
192+
break;
193+
}
194+
const scaled = page.getViewport({ scale: plan.scale });
195+
const canvas = canvasModule.createCanvas(plan.width, plan.height);
196+
await page.render({
197+
canvas: canvas as unknown as HTMLCanvasElement,
198+
viewport: scaled,
199+
}).promise;
200+
const png = canvas.toBuffer("image/png");
201+
images.push({ type: "image", data: png.toString("base64"), mimeType: "image/png" });
202+
remainingPixels -= plan.pixels;
203+
}
204+
205+
return { text, images };
206+
}
207+
208+
export function createPdfDocumentExtractor(): DocumentExtractorPlugin {
209+
return {
210+
id: "pdf",
211+
label: "PDF",
212+
mimeTypes: ["application/pdf"],
213+
autoDetectOrder: 10,
214+
extract: extractPdfContent,
215+
};
216+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
2+
3+
export default definePluginEntry({
4+
id: "document-extract",
5+
name: "Document Extraction",
6+
description: "Extract text and fallback page images from local document attachments.",
7+
register() {
8+
// Runtime is exposed through document-extractor.ts so document hot paths can
9+
// load only the narrow extractor artifact instead of the full plugin entrypoint.
10+
},
11+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"id": "document-extract",
3+
"enabledByDefault": true,
4+
"name": "Document Extraction",
5+
"description": "Extract text and fallback page images from local document attachments.",
6+
"contracts": {
7+
"documentExtractors": ["pdf"]
8+
},
9+
"configSchema": {
10+
"type": "object",
11+
"additionalProperties": false,
12+
"properties": {}
13+
}
14+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@openclaw/document-extract-plugin",
3+
"version": "2026.4.24",
4+
"private": true,
5+
"description": "OpenClaw local document extraction plugin",
6+
"type": "module",
7+
"dependencies": {
8+
"pdfjs-dist": "^5.6.205"
9+
},
10+
"devDependencies": {
11+
"@openclaw/plugin-sdk": "workspace:*"
12+
},
13+
"peerDependencies": {
14+
"@napi-rs/canvas": "^0.1.89"
15+
},
16+
"peerDependenciesMeta": {
17+
"@napi-rs/canvas": {
18+
"optional": true
19+
}
20+
},
21+
"openclaw": {
22+
"extensions": [
23+
"./index.ts"
24+
]
25+
}
26+
}

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1125,6 +1125,10 @@
11251125
"types": "./dist/plugin-sdk/web-content-extractor.d.ts",
11261126
"default": "./dist/plugin-sdk/web-content-extractor.js"
11271127
},
1128+
"./plugin-sdk/document-extractor": {
1129+
"types": "./dist/plugin-sdk/document-extractor.d.ts",
1130+
"default": "./dist/plugin-sdk/document-extractor.js"
1131+
},
11281132
"./plugin-sdk/provider-web-fetch-contract": {
11291133
"types": "./dist/plugin-sdk/provider-web-fetch-contract.d.ts",
11301134
"default": "./dist/plugin-sdk/provider-web-fetch-contract.js"
@@ -1609,7 +1613,6 @@
16091613
"markdown-it": "14.1.1",
16101614
"openai": "^6.34.0",
16111615
"osc-progress": "^0.3.0",
1612-
"pdfjs-dist": "^5.6.205",
16131616
"proxy-agent": "^8.0.1",
16141617
"semver": "7.7.4",
16151618
"sharp": "^0.34.5",
@@ -1647,7 +1650,6 @@
16471650
"vitest": "^4.1.5"
16481651
},
16491652
"peerDependencies": {
1650-
"@napi-rs/canvas": "^0.1.89",
16511653
"node-llama-cpp": "3.18.1"
16521654
},
16531655
"peerDependenciesMeta": {
@@ -1693,7 +1695,6 @@
16931695
"onlyBuiltDependencies": [
16941696
"@lydell/node-pty",
16951697
"@matrix-org/matrix-sdk-crypto-nodejs",
1696-
"@napi-rs/canvas",
16971698
"@tloncorp/api",
16981699
"@tloncorp/tlon-skill",
16991700
"@whiskeysockets/baileys",

0 commit comments

Comments
 (0)