|
| 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 | +} |
0 commit comments