210 lines
8.3 KiB
JavaScript
210 lines
8.3 KiB
JavaScript
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 ``.';
|
|
} catch (err) {
|
|
throw new ToolError(`pdf_read: failed to render PDF to PNG: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
},
|
|
});
|
|
}
|