import fs from 'fs'; import path from 'path'; import { defineTool, ToolError } from 'libllmorbiter'; import { pdfToText, splitPdfPages, pdfToPng } from 'pdfconvert-wasm'; import { prepareVfs, syncVfsToDb } from './vfs.js'; const IMAGE_FOLDER = 'images'; const DEFAULT_DPI = 150; /** * Normalizes format input to 'txt', 'png', or 'both'. * @param {string} [format] * @returns {'txt'|'png'|'both'} */ function normalizeFormat(format) { if (!format || typeof format !== 'string') return 'txt'; const f = format.trim().toLowerCase(); if (f === 'png' || f === 'image' || f === 'images') return 'png'; if (f === 'both' || f === 'all') return 'both'; return 'txt'; } /** * Slugifies a filename base for safe image output names. * @param {string} filename * @returns {string} */ function slugifyBase(filename) { const base = path.basename(filename, path.extname(filename)); const cleaned = String(base || '') .toLowerCase() .replace(/[^a-z0-9.]+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); return cleaned.slice(0, 40) || 'document'; } /** * Creates the pdf_read tool. * @param {{ googleAuthId: number }} ctx */ export function createPdfReadTool({ googleAuthId }) { return defineTool({ name: 'pdf_read', description: 'Read and convert PDF/text documents to text or VFS PNG images (stored in "images/").', strict: false, parameters: { type: 'object', additionalProperties: false, required: ['path'], properties: { path: { type: 'string', description: 'VFS path or HTTP(S) URL.', }, format: { type: 'string', enum: ['txt', 'png', 'both'], description: 'Output format: "txt" (layout text), "png" (page images), "both". Default "txt".', }, page: { type: 'number', description: '1-based page number. Omit for all pages.', }, dpi: { type: 'number', description: 'PNG render DPI. Default 150.', }, }, }, invoke: async ({ path: inputPath, file, format, type, page, dpi = DEFAULT_DPI } = {}) => { const rawPath = typeof inputPath === 'string' ? inputPath.trim() : typeof file === 'string' ? file.trim() : ''; if (!rawPath) { throw new ToolError('pdf_read requires a non-empty "path" (e.g. "document.pdf").'); } const targetFormat = normalizeFormat(format || type); const targetPage = Number.isInteger(Number(page)) && Number(page) >= 1 ? Number(page) : null; const targetDpi = Number.isInteger(Number(dpi)) && Number(dpi) >= 50 && Number(dpi) <= 600 ? Number(dpi) : DEFAULT_DPI; const vfsRoot = await prepareVfs({ googleAuthId }); let buffer = null; let displayPath = rawPath; if (rawPath.startsWith('http://') || rawPath.startsWith('https://')) { try { const res = await fetch(rawPath, { signal: AbortSignal.timeout(15_000) }); if (!res.ok) { throw new Error(`${res.status} ${res.statusText}`); } buffer = Buffer.from(await res.arrayBuffer()); } catch (err) { throw new ToolError(`pdf_read: network failure downloading URL (${rawPath}): ${err.message}`); } } else { const clean = rawPath.replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, ''); const abs = path.resolve(vfsRoot, clean); const rel = path.relative(vfsRoot, abs); if (rel.startsWith('..') || path.isAbsolute(rel)) { throw new ToolError(`Access denied: path "${rawPath}" escapes virtual filesystem boundary.`); } displayPath = rel; if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) { throw new ToolError(`File not found: "${rawPath}". Check the path with find_files.`); } buffer = fs.readFileSync(abs); } if (!buffer || buffer.length === 0) { throw new ToolError(`File is empty: "${rawPath}".`); } const isPdf = buffer.subarray(0, 5).toString('ascii').startsWith('%PDF') || rawPath.toLowerCase().endsWith('.pdf'); // Plain text fallback if non-PDF if (!isPdf) { const text = buffer.toString('utf-8'); return { path: displayPath, format: 'txt', totalPages: 1, text, }; } // PDF processing via pdfconvert-wasm // Always clone as isolated Uint8Array so WASM worker does not detach shared buffers. const pdfBytes = Uint8Array.from(buffer); const result = { path: displayPath, format: targetFormat, }; // 1. Text conversion if (targetFormat === 'txt' || targetFormat === 'both') { try { if (targetPage) { const pageText = await pdfToText(pdfBytes, { layout: true, firstPage: targetPage, lastPage: targetPage, }); result.page = targetPage; result.text = pageText; } else { const text = await pdfToText(pdfBytes, { layout: true }); const pages = splitPdfPages(text); result.totalPages = pages.length; result.text = text; result.pages = pages.map((t, idx) => ({ pageNumber: idx + 1, text: t })); } } catch (err) { throw new ToolError(`pdf_read: failed to extract text from PDF: ${err.message}`); } } // 2. PNG conversion if (targetFormat === 'png' || targetFormat === 'both') { try { const options = { dpi: targetDpi }; if (targetPage) { options.firstPage = targetPage; options.lastPage = targetPage; } const images = await pdfToPng(Uint8Array.from(buffer), options); const imagesDir = path.join(vfsRoot, IMAGE_FOLDER); fs.mkdirSync(imagesDir, { recursive: true }); const baseSlug = slugifyBase(displayPath); const savedImages = []; for (const img of images) { const filename = `${baseSlug}-page-${img.pageNumber}.png`; const absImgPath = path.join(imagesDir, filename); const relImgPath = `${IMAGE_FOLDER}/${filename}`; fs.writeFileSync(absImgPath, Buffer.from(img.data)); savedImages.push({ pageNumber: img.pageNumber, path: relImgPath, bytes: img.data.length, }); } if (savedImages.length > 0) { try { await syncVfsToDb({ googleAuthId, vfsRoot }); } catch (err) { console.warn('[pdf_read] failed to sync rendered images to VFS DB:', err?.message || err); } } result.images = savedImages; if (!result.totalPages) result.totalPages = images.length; result.note = 'Rendered page PNGs saved to VFS. Use analyze_image to inspect them or embed them with markdown `![Page](path)`.'; } catch (err) { throw new ToolError(`pdf_read: failed to render PDF to PNG: ${err.message}`); } } return result; }, }); }