(args: z.infer<typeof PdfReadArgs>, _ctx: ToolContext)
| 243 | argsSchema = PdfReadArgs; |
| 244 | |
| 245 | async execute(args: z.infer<typeof PdfReadArgs>, _ctx: ToolContext): Promise<ToolResult> { |
| 246 | try { |
| 247 | const data = await fs.readFile(args.path); |
| 248 | // Sanity check: starts with %PDF- |
| 249 | if (data.subarray(0, 5).toString('latin1') !== '%PDF-') { |
| 250 | return { content: `[PDF_ERROR] Not a PDF: ${args.path}`, isError: true }; |
| 251 | } |
| 252 | // Encryption: presence of /Encrypt in trailer dict |
| 253 | const tail = data.subarray(Math.max(0, data.length - 4096)).toString('latin1'); |
| 254 | if (tail.includes('/Encrypt')) { |
| 255 | return { |
| 256 | content: `[PDF_ENCRYPTED] ${args.path} is encrypted. Decrypt it first with: qpdf --decrypt input.pdf output.pdf`, |
| 257 | isError: true, |
| 258 | }; |
| 259 | } |
| 260 | |
| 261 | const objects = parseObjects(data); |
| 262 | logger.debug(`pdf_read parsed ${objects.length} objects from ${args.path}`); |
| 263 | |
| 264 | // Find pages — objects whose dict contains /Type /Page (not /Pages) |
| 265 | const pageObjects = objects.filter(o => /\/Type\s*\/Page\b(?!s)/.test(o.dict)); |
| 266 | const totalPages = pageObjects.length; |
| 267 | const wanted = args.pages ? parsePageRange(args.pages, totalPages) : null; |
| 268 | |
| 269 | // For each page, follow /Contents reference(s) and decode the stream |
| 270 | const pageText: string[] = []; |
| 271 | let imagesOnly = 0; |
| 272 | for (let i = 0; i < pageObjects.length; i++) { |
| 273 | const pageNum = i + 1; |
| 274 | if (wanted && !wanted.has(pageNum)) continue; |
| 275 | const pageObj = pageObjects[i]!; |
| 276 | // /Contents N 0 R OR /Contents [ N 0 R M 0 R ... ] |
| 277 | const contentsMatch = pageObj.dict.match(/\/Contents\s+(\d+)\s+\d+\s+R|\/Contents\s*\[([^\]]+)\]/); |
| 278 | if (!contentsMatch) { pageText.push(`\n[Page ${pageNum}: no contents]\n`); continue; } |
| 279 | const refs: number[] = []; |
| 280 | if (contentsMatch[1]) refs.push(parseInt(contentsMatch[1], 10)); |
| 281 | else if (contentsMatch[2]) { |
| 282 | const refRe = /(\d+)\s+\d+\s+R/g; |
| 283 | let r: RegExpExecArray | null; |
| 284 | while ((r = refRe.exec(contentsMatch[2]))) refs.push(parseInt(r[1]!, 10)); |
| 285 | } |
| 286 | const pageTextParts: string[] = []; |
| 287 | for (const ref of refs) { |
| 288 | const streamObj = objects.find(o => o.num === ref); |
| 289 | if (!streamObj || streamObj.streamStart === undefined || streamObj.streamEnd === undefined) continue; |
| 290 | const raw = data.subarray(streamObj.streamStart, streamObj.streamEnd); |
| 291 | const decoded = await decodeStream(raw, streamObj.dict); |
| 292 | if (!decoded) continue; |
| 293 | const streamStr = decoded.toString('latin1'); |
| 294 | const text = extractTextFromStream(streamStr); |
| 295 | pageTextParts.push(text); |
| 296 | } |
| 297 | const joined = pageTextParts.join('\n').replace(/\n{3,}/g, '\n\n').trim(); |
| 298 | if (joined.length < 20) imagesOnly++; // probably a scanned page |
| 299 | pageText.push(`\n--- Page ${pageNum} ---\n${joined || '[no extractable text — likely scanned image]'}\n`); |
| 300 | } |
| 301 | |
| 302 | if (imagesOnly === pageObjects.length && pageObjects.length > 0) { |
nothing calls this directly
no test coverage detected