2297 lines
83 KiB
TypeScript
2297 lines
83 KiB
TypeScript
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import * as child_process from 'child_process';
|
||
import ExcelJS from 'exceljs';
|
||
import mammoth from 'mammoth';
|
||
import AdmZip from 'adm-zip';
|
||
import { XMLParser } from 'fast-xml-parser';
|
||
import { PDFParse } from 'pdf-parse';
|
||
import { ToolDef } from '../../llm/openai-compat.js';
|
||
import type { ToolContext, ToolResult } from './core.js';
|
||
import { resolveAndGuard, resolveOutputPathWithin, truncateToBudget, getToolOutputBudgetTokens } from './core.js';
|
||
import { resolveThemePalette, extractSheetStyles } from './excel-styles.js';
|
||
import { logger } from '../../logger.js';
|
||
import { callVisionModel, resolveImagePath } from './image.js';
|
||
import type {
|
||
OfficeDocument,
|
||
OfficeWarning,
|
||
ExcelSheetBlock,
|
||
SheetCell,
|
||
DocxParagraphBlock,
|
||
DocxTableBlock,
|
||
PptxSlideBlock,
|
||
PdfPageBlock,
|
||
} from './office-types.js';
|
||
|
||
// --- Constants ---
|
||
|
||
const MAX_OUTPUT_CHARS = 8000;
|
||
const PROCESSING_TIMEOUT_MS = 30_000; // 30 seconds
|
||
const PDF_RENDER_TIMEOUT_MS = 120_000; // 120 秒(大きな PDF の変換に対応)
|
||
|
||
// --- Security helpers ---
|
||
|
||
const DEFAULT_EXCEL_MAX_SIZE_MB = 10;
|
||
const DEFAULT_DOCX_MAX_SIZE_MB = 10;
|
||
const DEFAULT_PDF_MAX_SIZE_MB = 10;
|
||
const DEFAULT_PPTX_MAX_SIZE_MB = 50;
|
||
const DEFAULT_PPTX_MAX_UNCOMPRESSED_MB = 200;
|
||
const MACRO_EXTENSIONS = new Set(['.xlsm', '.docm', '.pptm', '.xlsb']);
|
||
|
||
function mbToBytes(mb: number): number {
|
||
return Math.max(1, Math.floor(mb)) * 1024 * 1024;
|
||
}
|
||
|
||
function resolveMaxSize(ctx: ToolContext, key: keyof NonNullable<ToolContext['toolsConfig']>, defaultMb: number): number {
|
||
const v = ctx.toolsConfig?.[key];
|
||
const mb = typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : defaultMb;
|
||
return mbToBytes(mb);
|
||
}
|
||
|
||
// 拡張子と magic byte sniff の組み合わせで、Office/PDF 系 tool に
|
||
// **別フォーマットのファイル**が渡されたケースを早期検出する。
|
||
// JSZip / pdf-parse は内部で cryptic な error ("Can't find end of central
|
||
// directory : is this a zip file ?" など) を投げ、LLM エージェントが
|
||
// 復帰策を判断できず同じ tool を再呼び出しするループに陥りやすい。
|
||
//
|
||
// この helper は **mismatch 時に agent-actionable な error を返す**
|
||
// ことが目的。マッチした場合は `null` を返し、呼び出し側は通常の
|
||
// パイプラインを続行する。
|
||
type DetectedFormat = 'ooxml' | 'cfb' | 'pdf' | 'html' | 'text' | 'empty' | 'unknown';
|
||
|
||
function detectFileFormat(filePath: string): DetectedFormat {
|
||
let buf: Buffer;
|
||
try {
|
||
const fd = fs.openSync(filePath, 'r');
|
||
try {
|
||
buf = Buffer.alloc(16);
|
||
const bytesRead = fs.readSync(fd, buf, 0, 16, 0);
|
||
if (bytesRead === 0) return 'empty';
|
||
buf = buf.slice(0, bytesRead);
|
||
} finally {
|
||
fs.closeSync(fd);
|
||
}
|
||
} catch {
|
||
return 'unknown';
|
||
}
|
||
if (buf.length === 0) return 'empty';
|
||
// OOXML / generic ZIP: "PK\x03\x04" or empty-archive "PK\x05\x06"
|
||
if (buf[0] === 0x50 && buf[1] === 0x4B && (buf[2] === 0x03 || buf[2] === 0x05) && (buf[3] === 0x04 || buf[3] === 0x06)) {
|
||
return 'ooxml';
|
||
}
|
||
// CFB (old binary .xls/.doc/.ppt): D0 CF 11 E0 A1 B1 1A E1
|
||
if (
|
||
buf.length >= 8 &&
|
||
buf[0] === 0xD0 && buf[1] === 0xCF && buf[2] === 0x11 && buf[3] === 0xE0 &&
|
||
buf[4] === 0xA1 && buf[5] === 0xB1 && buf[6] === 0x1A && buf[7] === 0xE1
|
||
) {
|
||
return 'cfb';
|
||
}
|
||
// PDF: "%PDF-"
|
||
if (buf.length >= 5 && buf[0] === 0x25 && buf[1] === 0x50 && buf[2] === 0x44 && buf[3] === 0x46 && buf[4] === 0x2D) {
|
||
return 'pdf';
|
||
}
|
||
// HTML: leading "<" optionally after whitespace/BOM
|
||
const asText = buf.toString('utf-8').replace(/^/, '').trimStart().toLowerCase();
|
||
if (asText.startsWith('<!doctype html') || asText.startsWith('<html') || asText.startsWith('<?xml')) {
|
||
return 'html';
|
||
}
|
||
// Plain text heuristic: ASCII printable + common whitespace, no NUL bytes
|
||
const looksLikeText = !buf.includes(0) && buf.every((b) => b === 0x09 || b === 0x0A || b === 0x0D || (b >= 0x20 && b < 0x7F) || b >= 0x80);
|
||
if (looksLikeText) return 'text';
|
||
return 'unknown';
|
||
}
|
||
|
||
interface FormatValidationOpts {
|
||
/** 期待する extension の集合 (lowercase, leading dot) */
|
||
expectedExts: string[];
|
||
/** 期待する magic-byte 形式 */
|
||
expectedFormat: 'ooxml' | 'pdf';
|
||
/** Tool 名 (error メッセージで参照される) */
|
||
toolName: string;
|
||
}
|
||
|
||
/**
|
||
* mismatch (拡張子 or magic byte) の場合に **agent 向けエラー** を返す。
|
||
* 一致または skip 可能なら null。CFB / HTML / text などの detected format ごとに
|
||
* 「次にどうすればよいか」を error メッセージに含める。
|
||
*/
|
||
function validateFileFormat(filePath: string, opts: FormatValidationOpts): string | null {
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
const expectedExtsText = opts.expectedExts.join(', ');
|
||
|
||
// Step 1: 拡張子が明らかに別物 (Read で十分なテキスト系) なら早期 reject
|
||
const textyExts = new Set(['.md', '.markdown', '.txt', '.csv', '.tsv', '.log', '.json', '.yaml', '.yml', '.xml', '.html', '.htm']);
|
||
if (textyExts.has(ext)) {
|
||
const alt = (ext === '.html' || ext === '.htm')
|
||
? 'Read({ file_path }) でテキスト取得、または BrowseWeb({ url: "<path>" }) でレンダリング表示'
|
||
: 'Read({ file_path })';
|
||
return `${opts.toolName} には "${ext}" 形式のファイルを渡せません (拡張子: ${ext}, 期待: ${expectedExtsText})。代わりに ${alt} を使ってください。`;
|
||
}
|
||
|
||
// Step 2: 期待拡張子に含まれていない場合の警告 (macro 系は許可)
|
||
if (!opts.expectedExts.includes(ext) && !MACRO_EXTENSIONS.has(ext)) {
|
||
return `${opts.toolName} には ${expectedExtsText} を渡してください (受け取った拡張子: ${ext || '<none>'})。`;
|
||
}
|
||
|
||
// Step 3: 拡張子は OK でも実体が違う場合 → magic byte で判定
|
||
const detected = detectFileFormat(filePath);
|
||
if (detected === opts.expectedFormat) return null;
|
||
if (detected === 'unknown') return null; // 不明なものはツール本体に委ねる
|
||
|
||
if (opts.expectedFormat === 'ooxml') {
|
||
switch (detected) {
|
||
case 'cfb':
|
||
return `${opts.toolName} は旧バイナリ形式 (.xls/.doc/.ppt の CFB) を読めません。Excel/Word/PowerPoint で .xlsx/.docx/.pptx として保存し直すか、CSV エクスポートして Read で読んでください。`;
|
||
case 'pdf':
|
||
return `${opts.toolName} に渡されたファイルは実体が PDF です。代わりに ReadPdf({ path }) を使ってください。`;
|
||
case 'html':
|
||
return `${opts.toolName} に渡されたファイルは実体が HTML です。Read({ file_path }) で読むか、BrowseWeb({ url: "<path>" }) でレンダリング表示してください。`;
|
||
case 'text':
|
||
return `${opts.toolName} に渡されたファイルは実体がテキスト/CSV です。Read({ file_path }) を使ってください。`;
|
||
case 'empty':
|
||
return `${opts.toolName} に渡されたファイルは空です (0 bytes)。ファイルを再生成するか別のパスを指定してください。`;
|
||
}
|
||
} else if (opts.expectedFormat === 'pdf') {
|
||
switch (detected) {
|
||
case 'ooxml':
|
||
return `${opts.toolName} に渡されたファイルは実体が Office (OOXML/.xlsx/.docx/.pptx) です。拡張子に応じて ReadExcel / ReadDocx / ReadPPTX を使ってください。`;
|
||
case 'cfb':
|
||
return `${opts.toolName} に渡されたファイルは旧バイナリ Office (.xls/.doc/.ppt) です。Office で PDF/xlsx として保存し直してください。`;
|
||
case 'html':
|
||
return `${opts.toolName} に渡されたファイルは実体が HTML です。Read({ file_path }) で読むか、BrowseWeb で表示してください。`;
|
||
case 'text':
|
||
return `${opts.toolName} に渡されたファイルは実体がテキストです。Read({ file_path }) を使ってください。`;
|
||
case 'empty':
|
||
return `${opts.toolName} に渡されたファイルは空です (0 bytes)。`;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function validateOfficeFile(filePath: string, expectedExts: string[], maxSize: number): OfficeWarning[] {
|
||
const warnings: OfficeWarning[] = [];
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
|
||
if (!expectedExts.includes(ext) && !MACRO_EXTENSIONS.has(ext)) {
|
||
warnings.push({ code: 'UNSUPPORTED_FORMAT', message: `Unexpected extension: ${ext}` });
|
||
}
|
||
|
||
if (MACRO_EXTENSIONS.has(ext)) {
|
||
warnings.push({ code: 'MACRO_DISABLED', message: 'Macro-enabled file detected. Macros will not be executed.' });
|
||
}
|
||
|
||
let stats: fs.Stats;
|
||
try {
|
||
stats = fs.statSync(filePath);
|
||
} catch (e) {
|
||
return warnings;
|
||
}
|
||
|
||
if (stats.size > maxSize) {
|
||
const limitMB = (maxSize / 1024 / 1024).toFixed(0);
|
||
warnings.push({
|
||
code: 'FILE_TOO_LARGE',
|
||
message: `File size ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds limit of ${limitMB}MB`,
|
||
});
|
||
}
|
||
|
||
return warnings;
|
||
}
|
||
|
||
// --- Tool definitions ---
|
||
|
||
const READ_EXCEL_DEF: ToolDef = {
|
||
type: 'function',
|
||
function: {
|
||
name: 'ReadExcel',
|
||
description: 'Excel (.xlsx) を読み取りテキストで返す。include_styles:true でセル装飾も取得可。詳細は ReadToolDoc({ name: "ReadExcel" })。',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
path: { type: 'string', description: 'Excel ファイルのパス' },
|
||
sheet: { type: 'string', description: 'シート名(省略時は全シート)' },
|
||
range: { type: 'string', description: 'セル範囲(例: A1:D10、省略時はシート全体)' },
|
||
max_cells: { type: 'number', description: '最大セル数(デフォルト: 1000)' },
|
||
include_styles: { type: 'boolean', description: 'true でセル装飾(背景色/フォント/罫線/書式/結合)を ### Styles として追記。デフォルト false' },
|
||
max_style_ranges: { type: 'number', description: 'include_styles 時に出力する style range の上限(デフォルト 250)' },
|
||
},
|
||
required: ['path'],
|
||
},
|
||
},
|
||
};
|
||
|
||
const READ_DOCX_DEF: ToolDef = {
|
||
type: 'function',
|
||
function: {
|
||
name: 'ReadDocx',
|
||
description: 'Word (.docx) を読み取り本文+表を抽出する。詳細は ReadToolDoc({ name: "ReadDocx" })。',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
path: { type: 'string', description: 'Word ファイルのパス' },
|
||
mode: { type: 'string', enum: ['text', 'text+tables'], description: '出力モード(デフォルト: text+tables)' },
|
||
max_paragraphs: { type: 'number', description: '最大段落数(デフォルト: 200)' },
|
||
},
|
||
required: ['path'],
|
||
},
|
||
},
|
||
};
|
||
|
||
const SPLIT_EXCEL_SHEETS_DEF: ToolDef = {
|
||
type: 'function',
|
||
function: {
|
||
name: 'SplitExcelSheets',
|
||
description: 'Excel をシート単位で .md 分割+ manifest 生成。詳細は ReadToolDoc({ name: "SplitExcelSheets" })。',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
path: { type: 'string', description: 'Excel ファイルのパス' },
|
||
output_dir: { type: 'string', description: '出力ディレクトリ(省略時: output/preprocessed)' },
|
||
max_cells_per_sheet: { type: 'number', description: 'シートごとの最大セル数(デフォルト: 5000)' },
|
||
},
|
||
required: ['path'],
|
||
},
|
||
},
|
||
};
|
||
|
||
const SPLIT_DOCX_SECTIONS_DEF: ToolDef = {
|
||
type: 'function',
|
||
function: {
|
||
name: 'SplitDocxSections',
|
||
description: 'Word を見出しベースで章分割+ manifest 生成。詳細は ReadToolDoc({ name: "SplitDocxSections" })。',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
path: { type: 'string', description: 'Word ファイルのパス' },
|
||
output_dir: { type: 'string', description: '出力ディレクトリ(省略時: output/preprocessed)' },
|
||
},
|
||
required: ['path'],
|
||
},
|
||
},
|
||
};
|
||
|
||
const READ_PPTX_DEF: ToolDef = {
|
||
type: 'function',
|
||
function: {
|
||
name: 'ReadPPTX',
|
||
description: 'PowerPoint (.pptx) からスライドテキスト・表・ノートを抽出。詳細は ReadToolDoc({ name: "ReadPPTX" })。',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
path: { type: 'string', description: 'PowerPoint ファイルのパス' },
|
||
slideRange: { type: 'string', description: 'スライド範囲(例: "1-5" または "3"、省略時は全スライド)' },
|
||
},
|
||
required: ['path'],
|
||
},
|
||
},
|
||
};
|
||
|
||
const READ_PDF_DEF: ToolDef = {
|
||
type: 'function',
|
||
function: {
|
||
name: 'ReadPdf',
|
||
description: 'PDF からページごとのテキストを抽出する(スキャン PDF は PdfToImages → ReadImage 等に切替、query 指定で grep -n 風の検索結果を返す)。詳細は ReadToolDoc({ name: "ReadPdf" })。',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
path: { type: 'string', description: 'PDF ファイルのパス' },
|
||
page_range: { type: 'string', description: 'ページ範囲(例: "1-5" または "3"、省略時は全ページ)' },
|
||
pageRange: { type: 'string', description: 'page_range の別名' },
|
||
max_pages: { type: 'number', description: '抽出する最大ページ数(省略時は制限なし)' },
|
||
max_chars: { type: 'number', description: `返却する最大文字数(デフォルト: ${MAX_OUTPUT_CHARS})` },
|
||
query: {
|
||
type: 'string',
|
||
description: '指定するとマッチしたページのみを grep -n 風 (周辺行付き) で返す。省略時は全文抽出。',
|
||
},
|
||
query_mode: {
|
||
type: 'string',
|
||
enum: ['substring', 'regex', 'iregex'],
|
||
description: 'query の解釈: substring (デフォルト、大小無視の部分一致) / regex (大小区別の正規表現) / iregex (大小無視の正規表現)',
|
||
},
|
||
context_lines: {
|
||
type: 'number',
|
||
description: 'query マッチ時にマッチ行前後で出力するコンテキスト行数(デフォルト: 2、最大: 20)',
|
||
},
|
||
},
|
||
required: ['path'],
|
||
},
|
||
},
|
||
};
|
||
|
||
const PDF_TO_IMAGES_DEF: ToolDef = {
|
||
type: 'function',
|
||
function: {
|
||
name: 'PdfToImages',
|
||
description: 'PDF の各ページを PNG に変換し output/ReadPdf/ に保存する。詳細は ReadToolDoc({ name: "PdfToImages" })。',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
path: { type: 'string', description: 'workspace 内の PDF ファイルパス' },
|
||
output_dir: {
|
||
type: 'string',
|
||
description: '出力先ディレクトリ(省略時: output/ReadPdf/{basename}、output/ReadPdf/ 配下のみ指定可能)',
|
||
},
|
||
dpi: {
|
||
type: 'number',
|
||
description: '解像度 DPI(省略時: 150、最大: 300)。OCR 用途には 150〜200 が適切。',
|
||
},
|
||
page_range: {
|
||
type: 'string',
|
||
description: 'ページ範囲(例: "1-5")。省略時は全ページ。',
|
||
},
|
||
},
|
||
required: ['path'],
|
||
},
|
||
},
|
||
};
|
||
|
||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||
ReadExcel: READ_EXCEL_DEF,
|
||
ReadDocx: READ_DOCX_DEF,
|
||
ReadPdf: READ_PDF_DEF,
|
||
ReadPPTX: READ_PPTX_DEF,
|
||
SplitExcelSheets: SPLIT_EXCEL_SHEETS_DEF,
|
||
SplitDocxSections: SPLIT_DOCX_SECTIONS_DEF,
|
||
PdfToImages: PDF_TO_IMAGES_DEF,
|
||
};
|
||
|
||
// --- Excel column letter helpers ---
|
||
|
||
function colIndexToLetter(colIdx: number): string {
|
||
let letter = '';
|
||
let n = colIdx;
|
||
while (n > 0) {
|
||
const rem = (n - 1) % 26;
|
||
letter = String.fromCharCode(65 + rem) + letter;
|
||
n = Math.floor((n - 1) / 26);
|
||
}
|
||
return letter;
|
||
}
|
||
|
||
// Parse range string like "A1:D10" into row/col bounds (1-indexed)
|
||
function parseRange(rangeStr: string): { minRow: number; maxRow: number; minCol: number; maxCol: number } | null {
|
||
const match = rangeStr.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i);
|
||
if (!match) return null;
|
||
|
||
function letterToCol(letters: string): number {
|
||
let col = 0;
|
||
for (const ch of letters.toUpperCase()) {
|
||
col = col * 26 + (ch.charCodeAt(0) - 64);
|
||
}
|
||
return col;
|
||
}
|
||
|
||
return {
|
||
minRow: parseInt(match[2]!, 10),
|
||
maxRow: parseInt(match[4]!, 10),
|
||
minCol: letterToCol(match[1]!),
|
||
maxCol: letterToCol(match[3]!),
|
||
};
|
||
}
|
||
|
||
// Format cells as a readable text table
|
||
function sanitizeFilename(name: string): string {
|
||
return name.replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, '_').slice(0, 80) || 'untitled';
|
||
}
|
||
|
||
function normalizePdfPageText(text: string): string {
|
||
return text
|
||
.replace(/\n?--\s+\d+\s+of\s+\d+\s+--\s*$/g, '')
|
||
.replace(/\r\n/g, '\n')
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim();
|
||
}
|
||
|
||
function parsePageRange(rangeStr: string | undefined): { min: number; max: number } | null {
|
||
if (!rangeStr) return null;
|
||
const rangeMatch = rangeStr.match(/^(\d+)-(\d+)$/);
|
||
if (rangeMatch) {
|
||
const min = parseInt(rangeMatch[1]!, 10);
|
||
const max = parseInt(rangeMatch[2]!, 10);
|
||
if (min <= 0 || max <= 0 || min > max) return null;
|
||
return { min, max };
|
||
}
|
||
const singleMatch = rangeStr.match(/^(\d+)$/);
|
||
if (singleMatch) {
|
||
const n = parseInt(singleMatch[1]!, 10);
|
||
if (n <= 0) return null;
|
||
return { min: n, max: n };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function formatCellsAsTable(cells: SheetCell[], sheetName: string, range: string): string {
|
||
if (cells.length === 0) return `## Sheet: ${sheetName}\n\n(empty)\n`;
|
||
|
||
const rows = new Map<number, Map<number, string>>();
|
||
let minRow = Infinity, maxRow = -Infinity, minCol = Infinity, maxCol = -Infinity;
|
||
|
||
for (const cell of cells) {
|
||
if (!rows.has(cell.row)) rows.set(cell.row, new Map());
|
||
const displayVal = cell.formula
|
||
? (cell.value != null ? String(cell.value) : '')
|
||
: cell.value != null ? String(cell.value) : '';
|
||
rows.get(cell.row)!.set(cell.col, displayVal);
|
||
if (cell.row < minRow) minRow = cell.row;
|
||
if (cell.row > maxRow) maxRow = cell.row;
|
||
if (cell.col < minCol) minCol = cell.col;
|
||
if (cell.col > maxCol) maxCol = cell.col;
|
||
}
|
||
|
||
// Build column widths
|
||
const colWidths: number[] = [];
|
||
for (let c = minCol; c <= maxCol; c++) {
|
||
let maxLen = colIndexToLetter(c).length;
|
||
for (let r = minRow; r <= maxRow; r++) {
|
||
const val = rows.get(r)?.get(c) ?? '';
|
||
if (val.length > maxLen) maxLen = val.length;
|
||
}
|
||
colWidths[c - minCol] = maxLen;
|
||
}
|
||
|
||
const colHeaders = [];
|
||
for (let c = minCol; c <= maxCol; c++) {
|
||
colHeaders.push(colIndexToLetter(c).padEnd(colWidths[c - minCol]!));
|
||
}
|
||
|
||
const separator = colWidths.map((w) => '-'.repeat(w)).join('-+-');
|
||
const lines: string[] = [];
|
||
lines.push(`## Sheet: ${sheetName} (${range})`);
|
||
lines.push('');
|
||
lines.push(colHeaders.join(' | '));
|
||
lines.push(separator);
|
||
|
||
for (let r = minRow; r <= maxRow; r++) {
|
||
const rowCols = [];
|
||
for (let c = minCol; c <= maxCol; c++) {
|
||
const val = rows.get(r)?.get(c) ?? '';
|
||
rowCols.push(val.padEnd(colWidths[c - minCol]!));
|
||
}
|
||
lines.push(rowCols.join(' | '));
|
||
}
|
||
|
||
lines.push('');
|
||
lines.push(`(${cells.length} cells read)`);
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function renderSheetMarkdownForHumans(cells: SheetCell[], sheetName: string, range: string, maxRows: number = 120): string {
|
||
if (cells.length === 0) return `# ${sheetName}\n\n> empty sheet`;
|
||
|
||
const rowMap = new Map<number, Map<number, string>>();
|
||
let minRow = Infinity, maxRow = -Infinity, minCol = Infinity, maxCol = -Infinity;
|
||
for (const c of cells) {
|
||
if (!rowMap.has(c.row)) rowMap.set(c.row, new Map());
|
||
rowMap.get(c.row)!.set(c.col, c.value == null ? '' : String(c.value));
|
||
minRow = Math.min(minRow, c.row); maxRow = Math.max(maxRow, c.row);
|
||
minCol = Math.min(minCol, c.col); maxCol = Math.max(maxCol, c.col);
|
||
}
|
||
|
||
const colCount = Math.max(0, maxCol - minCol + 1);
|
||
const rowCount = Math.max(0, maxRow - minRow + 1);
|
||
const headerRowNo = minRow;
|
||
const headerVals: string[] = [];
|
||
for (let c = minCol; c <= maxCol; c++) {
|
||
const raw = rowMap.get(headerRowNo)?.get(c)?.trim() ?? '';
|
||
headerVals.push(raw.length > 0 ? raw : colIndexToLetter(c));
|
||
}
|
||
|
||
const md: string[] = [];
|
||
md.push(`# ${sheetName}`);
|
||
md.push('');
|
||
md.push(`- Range: \`${range}\``);
|
||
md.push(`- Rows: ${rowCount}`);
|
||
md.push(`- Cols: ${colCount}`);
|
||
md.push(`- Cells Read: ${cells.length}`);
|
||
md.push('');
|
||
md.push('## Preview');
|
||
md.push('');
|
||
md.push(`| ${headerVals.join(' | ')} |`);
|
||
md.push(`| ${headerVals.map(() => '---').join(' | ')} |`);
|
||
|
||
let shown = 0;
|
||
for (let r = headerRowNo + 1; r <= maxRow; r++) {
|
||
if (shown >= maxRows) break;
|
||
const rowVals: string[] = [];
|
||
for (let c = minCol; c <= maxCol; c++) {
|
||
const v = (rowMap.get(r)?.get(c) ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ').trim();
|
||
rowVals.push(v);
|
||
}
|
||
if (rowVals.every(v => v === '')) continue;
|
||
md.push(`| ${rowVals.join(' | ')} |`);
|
||
shown++;
|
||
}
|
||
|
||
if ((maxRow - headerRowNo) > shown) {
|
||
md.push('');
|
||
md.push(`> preview truncated: showing ${shown} row(s)`);
|
||
}
|
||
|
||
return md.join('\n');
|
||
}
|
||
|
||
// --- ReadExcel implementation ---
|
||
|
||
async function executeReadExcel(
|
||
input: Record<string, unknown>,
|
||
ctx: ToolContext,
|
||
): Promise<ToolResult> {
|
||
const filePath = input['path'] as string;
|
||
const sheetFilter = typeof input['sheet'] === 'string' ? input['sheet'] : undefined;
|
||
const rangeFilter = typeof input['range'] === 'string' ? input['range'] : undefined;
|
||
const maxCells = typeof input['max_cells'] === 'number' ? input['max_cells'] : 1000;
|
||
const includeStyles = input['include_styles'] === true;
|
||
const maxStyleRanges = typeof input['max_style_ranges'] === 'number' ? input['max_style_ranges'] : 250;
|
||
|
||
let resolved: string;
|
||
try {
|
||
resolved = resolveAndGuard(ctx.workspacePath, filePath);
|
||
} catch (e) {
|
||
return { output: (e as Error).message, isError: true };
|
||
}
|
||
|
||
if (!fs.existsSync(resolved)) {
|
||
return { output: `File not found: ${filePath}`, isError: true };
|
||
}
|
||
|
||
const formatError = validateFileFormat(resolved, {
|
||
expectedExts: ['.xlsx', '.xlsm'],
|
||
expectedFormat: 'ooxml',
|
||
toolName: 'ReadExcel',
|
||
});
|
||
if (formatError) {
|
||
return { output: formatError, isError: true };
|
||
}
|
||
|
||
const warnings = validateOfficeFile(resolved, ['.xlsx', '.xls', '.xlsm', '.xlsb'], resolveMaxSize(ctx, 'officeExcelMaxSizeMb', DEFAULT_EXCEL_MAX_SIZE_MB));
|
||
const hasTooLarge = warnings.some((w) => w.code === 'FILE_TOO_LARGE');
|
||
if (hasTooLarge) {
|
||
return { output: warnings.map((w) => w.message).join('\n'), isError: true };
|
||
}
|
||
|
||
const fileSizeBytes = (() => {
|
||
try { return fs.statSync(resolved).size; } catch { return undefined; }
|
||
})();
|
||
|
||
const timeoutPromise = new Promise<ToolResult>((resolve) =>
|
||
setTimeout(() => resolve({ output: 'Processing timeout: file took longer than 30 seconds', isError: true }), PROCESSING_TIMEOUT_MS)
|
||
);
|
||
|
||
const mainPromise = (async (): Promise<ToolResult> => {
|
||
const startMs = Date.now();
|
||
|
||
const wb = new ExcelJS.Workbook();
|
||
try {
|
||
await wb.xlsx.readFile(resolved);
|
||
} catch (e) {
|
||
return { output: `Failed to read Excel file: ${(e as Error).message}`, isError: true };
|
||
}
|
||
|
||
const themeXml = includeStyles ? ((wb.model as unknown as { themes?: { theme1?: string } }).themes?.theme1) : undefined;
|
||
const palette = includeStyles ? resolveThemePalette(themeXml) : [];
|
||
|
||
const filename = path.basename(resolved);
|
||
const sheetBlocks: ExcelSheetBlock[] = [];
|
||
let totalCells = 0;
|
||
let truncated = false;
|
||
|
||
const doc: OfficeDocument = {
|
||
kind: 'excel',
|
||
source: { path: resolved, filename },
|
||
metadata: { sheetCount: wb.worksheets.length, fileSizeBytes, processingTimeMs: Date.now() - startMs },
|
||
blocks: [],
|
||
warnings,
|
||
};
|
||
|
||
for (const ws of wb.worksheets) {
|
||
if (sheetFilter && ws.name !== sheetFilter) continue;
|
||
if (truncated) break;
|
||
|
||
const rangeBounds = rangeFilter ? parseRange(rangeFilter) : null;
|
||
const cells: SheetCell[] = [];
|
||
|
||
ws.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
||
if (truncated) return;
|
||
if (rangeBounds && (rowNumber < rangeBounds.minRow || rowNumber > rangeBounds.maxRow)) return;
|
||
|
||
(row as ExcelJS.Row).eachCell({ includeEmpty: false }, (cell, colNumber) => {
|
||
if (truncated) return;
|
||
if (rangeBounds && (colNumber < rangeBounds.minCol || colNumber > rangeBounds.maxCol)) return;
|
||
if (totalCells >= maxCells) {
|
||
truncated = true;
|
||
return;
|
||
}
|
||
|
||
const address = `${colIndexToLetter(colNumber)}${rowNumber}`;
|
||
let value: string | number | boolean | null = null;
|
||
let formula: string | undefined;
|
||
|
||
if (cell.type === ExcelJS.ValueType.Formula) {
|
||
formula = typeof cell.formula === 'string' ? cell.formula : undefined;
|
||
const result = cell.result;
|
||
if (typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean') {
|
||
value = result;
|
||
} else if (result instanceof Date) {
|
||
value = result.toISOString();
|
||
} else {
|
||
value = null;
|
||
}
|
||
} else if (cell.value instanceof Date) {
|
||
value = cell.value.toISOString();
|
||
} else if (
|
||
typeof cell.value === 'string' ||
|
||
typeof cell.value === 'number' ||
|
||
typeof cell.value === 'boolean'
|
||
) {
|
||
value = cell.value;
|
||
} else if (cell.value === null || cell.value === undefined) {
|
||
value = null;
|
||
} else {
|
||
// Rich text or other complex types
|
||
value = String(cell.text ?? '');
|
||
}
|
||
|
||
const sheetCell: SheetCell = {
|
||
address,
|
||
value,
|
||
row: rowNumber,
|
||
col: colNumber,
|
||
};
|
||
if (formula) sheetCell.formula = formula;
|
||
|
||
cells.push(sheetCell);
|
||
totalCells++;
|
||
});
|
||
});
|
||
|
||
// Determine actual range from cells
|
||
let rangeStr = rangeFilter ?? 'A1';
|
||
if (cells.length > 0) {
|
||
const minR = Math.min(...cells.map((c) => c.row));
|
||
const maxR = Math.max(...cells.map((c) => c.row));
|
||
const minC = Math.min(...cells.map((c) => c.col));
|
||
const maxC = Math.max(...cells.map((c) => c.col));
|
||
rangeStr = `${colIndexToLetter(minC)}${minR}:${colIndexToLetter(maxC)}${maxR}`;
|
||
}
|
||
|
||
const block: ExcelSheetBlock = {
|
||
type: 'excel.sheet',
|
||
sheetName: ws.name,
|
||
range: rangeStr,
|
||
cells,
|
||
};
|
||
sheetBlocks.push(block);
|
||
doc.blocks.push(block);
|
||
}
|
||
|
||
if (truncated) {
|
||
doc.warnings.push({
|
||
code: 'PARSE_PARTIAL_SUCCESS',
|
||
message: `Cell limit reached: only first ${maxCells} cells were read`,
|
||
});
|
||
}
|
||
|
||
if (sheetBlocks.length === 0) {
|
||
const msg = sheetFilter
|
||
? `Sheet "${sheetFilter}" not found in ${filename}`
|
||
: `No sheets found in ${filename}`;
|
||
return { output: msg, isError: true };
|
||
}
|
||
|
||
const parts: string[] = [`# ${filename}\n`];
|
||
for (const block of sheetBlocks) {
|
||
parts.push(formatCellsAsTable(block.cells, block.sheetName, block.range));
|
||
parts.push('');
|
||
}
|
||
|
||
if (includeStyles) {
|
||
let rangeBudget = maxStyleRanges;
|
||
for (const ws of wb.worksheets) {
|
||
if (sheetFilter && ws.name !== sheetFilter) continue;
|
||
const styleBounds = rangeFilter ? parseRange(rangeFilter) : null;
|
||
const styles = extractSheetStyles(ws, palette, rangeBudget, styleBounds ?? undefined);
|
||
rangeBudget -= styles.assignments.reduce((n, a) => n + a.ranges.length, 0);
|
||
if (styles.legend.length === 0 && styles.merges.length === 0 && !styles.conditionalFormatting) continue;
|
||
parts.push(`### Styles — ${ws.name}`);
|
||
for (const { id, sig } of styles.legend) parts.push(`${id} = ${sig}`);
|
||
parts.push('');
|
||
for (const { id, ranges } of styles.assignments) parts.push(`${id}: ${ranges.join(',')}`);
|
||
if (styles.merges.length) parts.push(`merges: ${styles.merges.join(',')}`);
|
||
if (styles.conditionalFormatting) parts.push('conditionalFormatting: present (effective styles not evaluated)');
|
||
if (styles.truncated || rangeBudget <= 0) parts.push(`[styles truncated: max_style_ranges=${maxStyleRanges} reached]`);
|
||
parts.push('');
|
||
if (rangeBudget <= 0) break;
|
||
}
|
||
}
|
||
|
||
if (doc.warnings.length > 0) {
|
||
parts.push('### Warnings');
|
||
for (const w of doc.warnings) {
|
||
parts.push(`- [${w.code}] ${w.message}`);
|
||
}
|
||
}
|
||
|
||
doc.metadata.processingTimeMs = Date.now() - startMs;
|
||
logger.debug(`[ReadExcel] ${filename}: ${sheetBlocks.length} sheet(s), ${totalCells} cells`);
|
||
|
||
let output = parts.join('\n');
|
||
if (output.length > MAX_OUTPUT_CHARS) {
|
||
output = output.slice(0, MAX_OUTPUT_CHARS) + `\n\n[truncated: output exceeded ${MAX_OUTPUT_CHARS} characters]`;
|
||
}
|
||
output = truncateToBudget(output, getToolOutputBudgetTokens(ctx), {
|
||
sourceLabel: `ReadExcel(${filename})`,
|
||
continuationHint: 'セル範囲を絞るか、SplitExcelSheets で分割してください',
|
||
}).text;
|
||
return { output, isError: false };
|
||
})();
|
||
|
||
return Promise.race([mainPromise, timeoutPromise]);
|
||
}
|
||
|
||
async function executeSplitExcelSheets(
|
||
input: Record<string, unknown>,
|
||
ctx: ToolContext,
|
||
): Promise<ToolResult> {
|
||
const filePath = input['path'] as string;
|
||
const outputDirInput = typeof input['output_dir'] === 'string' ? input['output_dir'] : 'output/preprocessed';
|
||
const maxCellsPerSheet = typeof input['max_cells_per_sheet'] === 'number' ? input['max_cells_per_sheet'] : 5000;
|
||
|
||
let resolved: string;
|
||
let outputDir: string;
|
||
try {
|
||
resolved = resolveAndGuard(ctx.workspacePath, filePath);
|
||
outputDir = resolveOutputPathWithin(ctx.workspacePath, outputDirInput, ['output/preprocessed']);
|
||
} catch (e) {
|
||
return { output: (e as Error).message, isError: true };
|
||
}
|
||
|
||
if (!fs.existsSync(resolved)) return { output: `File not found: ${filePath}`, isError: true };
|
||
fs.mkdirSync(outputDir, { recursive: true });
|
||
|
||
const wb = new ExcelJS.Workbook();
|
||
await wb.xlsx.readFile(resolved);
|
||
|
||
const base = sanitizeFilename(path.basename(resolved, path.extname(resolved)));
|
||
const records: Array<{ sheet: string; file: string; rows: number; cols: number; cells: number; truncated: boolean }> = [];
|
||
|
||
for (const ws of wb.worksheets) {
|
||
const cells: SheetCell[] = [];
|
||
let truncated = false;
|
||
|
||
ws.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
||
if (truncated) return;
|
||
(row as ExcelJS.Row).eachCell({ includeEmpty: false }, (cell, colNumber) => {
|
||
if (cells.length >= maxCellsPerSheet) {
|
||
truncated = true;
|
||
return;
|
||
}
|
||
const address = `${colIndexToLetter(colNumber)}${rowNumber}`;
|
||
let value: string | number | boolean | null = null;
|
||
if (cell.value instanceof Date) value = cell.value.toISOString();
|
||
else if (typeof cell.value === 'string' || typeof cell.value === 'number' || typeof cell.value === 'boolean') value = cell.value;
|
||
else if (cell.value == null) value = null;
|
||
else value = String(cell.text ?? '');
|
||
cells.push({ address, value, row: rowNumber, col: colNumber });
|
||
});
|
||
});
|
||
|
||
const range = cells.length
|
||
? `${colIndexToLetter(Math.min(...cells.map(c => c.col)))}${Math.min(...cells.map(c => c.row))}:${colIndexToLetter(Math.max(...cells.map(c => c.col)))}${Math.max(...cells.map(c => c.row))}`
|
||
: 'A1';
|
||
|
||
const content = renderSheetMarkdownForHumans(cells, ws.name, range) + (truncated ? `\n\n> truncated at ${maxCellsPerSheet} cells` : '');
|
||
const outName = `${base}__${sanitizeFilename(ws.name)}.md`;
|
||
const outPath = path.join(outputDir, outName);
|
||
fs.writeFileSync(outPath, content, 'utf-8');
|
||
|
||
records.push({
|
||
sheet: ws.name,
|
||
file: path.relative(ctx.workspacePath, outPath),
|
||
rows: ws.rowCount,
|
||
cols: ws.columnCount,
|
||
cells: cells.length,
|
||
truncated,
|
||
});
|
||
}
|
||
|
||
const manifestPath = path.join(outputDir, `${base}__manifest.json`);
|
||
fs.writeFileSync(manifestPath, JSON.stringify({ kind: 'excel-split', source: filePath, generatedAt: new Date().toISOString(), sheets: records }, null, 2), 'utf-8');
|
||
|
||
return {
|
||
output: `SplitExcelSheets done. sheets=${records.length}\nmanifest=${path.relative(ctx.workspacePath, manifestPath)}\nfiles=\n${records.map(r => `- ${r.file}`).join('\n')}`,
|
||
isError: false,
|
||
};
|
||
}
|
||
|
||
async function executeSplitDocxSections(
|
||
input: Record<string, unknown>,
|
||
ctx: ToolContext,
|
||
): Promise<ToolResult> {
|
||
const filePath = input['path'] as string;
|
||
const outputDirInput = typeof input['output_dir'] === 'string' ? input['output_dir'] : 'output/preprocessed';
|
||
|
||
let resolved: string;
|
||
let outputDir: string;
|
||
try {
|
||
resolved = resolveAndGuard(ctx.workspacePath, filePath);
|
||
outputDir = resolveOutputPathWithin(ctx.workspacePath, outputDirInput, ['output/preprocessed']);
|
||
} catch (e) {
|
||
return { output: (e as Error).message, isError: true };
|
||
}
|
||
|
||
if (!fs.existsSync(resolved)) return { output: `File not found: ${filePath}`, isError: true };
|
||
fs.mkdirSync(outputDir, { recursive: true });
|
||
|
||
const raw = await mammoth.extractRawText({ path: resolved });
|
||
const lines = raw.value.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
|
||
|
||
const isHeading = (s: string) => /^(#{1,6}\s+|第[0-90-9一二三四五六七八九十]+[章節]|[0-9]+[\.|\)]\s+)/.test(s);
|
||
const sections: Array<{ title: string; lines: string[] }> = [];
|
||
let current: { title: string; lines: string[] } = { title: 'intro', lines: [] };
|
||
|
||
for (const line of lines) {
|
||
if (isHeading(line) && current.lines.length > 0) {
|
||
sections.push(current);
|
||
current = { title: line.replace(/^#+\s*/, ''), lines: [] };
|
||
} else {
|
||
current.lines.push(line);
|
||
}
|
||
}
|
||
if (current.lines.length > 0) sections.push(current);
|
||
|
||
const base = sanitizeFilename(path.basename(resolved, path.extname(resolved)));
|
||
const records: Array<{ section: string; file: string; paragraphs: number }> = [];
|
||
|
||
sections.forEach((s, idx) => {
|
||
const name = `${base}__section_${String(idx + 1).padStart(2, '0')}__${sanitizeFilename(s.title)}.md`;
|
||
const outPath = path.join(outputDir, name);
|
||
fs.writeFileSync(outPath, `# ${s.title}\n\n${s.lines.join('\n\n')}`, 'utf-8');
|
||
records.push({ section: s.title, file: path.relative(ctx.workspacePath, outPath), paragraphs: s.lines.length });
|
||
});
|
||
|
||
const manifestPath = path.join(outputDir, `${base}__manifest.json`);
|
||
fs.writeFileSync(manifestPath, JSON.stringify({ kind: 'docx-split', source: filePath, generatedAt: new Date().toISOString(), sections: records }, null, 2), 'utf-8');
|
||
|
||
return {
|
||
output: `SplitDocxSections done. sections=${records.length}\nmanifest=${path.relative(ctx.workspacePath, manifestPath)}\nfiles=\n${records.map(r => `- ${r.file}`).join('\n')}`,
|
||
isError: false,
|
||
};
|
||
}
|
||
|
||
// --- HTML table parser for mammoth output ---
|
||
|
||
function parseTablesFromHtml(html: string): string[][][] {
|
||
const tables: string[][][] = [];
|
||
const tableRegex = /<table[^>]*>([\s\S]*?)<\/table>/gi;
|
||
let tableMatch: RegExpExecArray | null;
|
||
|
||
while ((tableMatch = tableRegex.exec(html)) !== null) {
|
||
const tableContent = tableMatch[1] ?? '';
|
||
const rows: string[][] = [];
|
||
const rowRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
||
let rowMatch: RegExpExecArray | null;
|
||
|
||
while ((rowMatch = rowRegex.exec(tableContent)) !== null) {
|
||
const rowContent = rowMatch[1] ?? '';
|
||
const cells: string[] = [];
|
||
const cellRegex = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi;
|
||
let cellMatch: RegExpExecArray | null;
|
||
|
||
while ((cellMatch = cellRegex.exec(rowContent)) !== null) {
|
||
// Strip any remaining HTML tags from cell content
|
||
const cellText = (cellMatch[1] ?? '').replace(/<[^>]+>/g, '').trim();
|
||
cells.push(cellText);
|
||
}
|
||
|
||
if (cells.length > 0) {
|
||
rows.push(cells);
|
||
}
|
||
}
|
||
|
||
if (rows.length > 0) {
|
||
tables.push(rows);
|
||
}
|
||
}
|
||
|
||
return tables;
|
||
}
|
||
|
||
function formatDocxTable(rows: string[][], tableIndex: number): string {
|
||
if (rows.length === 0) return '';
|
||
|
||
const colWidths = rows[0]!.map((_, ci) => {
|
||
return rows.reduce((max, row) => {
|
||
const val = row[ci] ?? '';
|
||
return Math.max(max, val.length);
|
||
}, 3);
|
||
});
|
||
|
||
const lines: string[] = [`### Table ${tableIndex + 1}`, ''];
|
||
const headerRow = rows[0]!.map((cell, i) => cell.padEnd(colWidths[i]!)).join(' | ');
|
||
const separator = colWidths.map((w) => '-'.repeat(w)).join('-|-');
|
||
lines.push(headerRow);
|
||
lines.push(separator);
|
||
|
||
for (let r = 1; r < rows.length; r++) {
|
||
const rowLine = rows[r]!.map((cell, i) => cell.padEnd(colWidths[i]!)).join(' | ');
|
||
lines.push(rowLine);
|
||
}
|
||
|
||
return lines.join('\n');
|
||
}
|
||
|
||
// --- ReadDocx implementation ---
|
||
|
||
async function executeReadDocx(
|
||
input: Record<string, unknown>,
|
||
ctx: ToolContext,
|
||
): Promise<ToolResult> {
|
||
const filePath = input['path'] as string;
|
||
const mode = typeof input['mode'] === 'string' ? input['mode'] : 'text+tables';
|
||
const maxParagraphs = typeof input['max_paragraphs'] === 'number' ? input['max_paragraphs'] : 200;
|
||
|
||
let resolved: string;
|
||
try {
|
||
resolved = resolveAndGuard(ctx.workspacePath, filePath);
|
||
} catch (e) {
|
||
return { output: (e as Error).message, isError: true };
|
||
}
|
||
|
||
if (!fs.existsSync(resolved)) {
|
||
return { output: `File not found: ${filePath}`, isError: true };
|
||
}
|
||
|
||
const formatError = validateFileFormat(resolved, {
|
||
expectedExts: ['.docx', '.docm'],
|
||
expectedFormat: 'ooxml',
|
||
toolName: 'ReadDocx',
|
||
});
|
||
if (formatError) {
|
||
return { output: formatError, isError: true };
|
||
}
|
||
|
||
const warnings = validateOfficeFile(resolved, ['.docx', '.docm'], resolveMaxSize(ctx, 'officeDocxMaxSizeMb', DEFAULT_DOCX_MAX_SIZE_MB));
|
||
const hasTooLarge = warnings.some((w) => w.code === 'FILE_TOO_LARGE');
|
||
if (hasTooLarge) {
|
||
return { output: warnings.map((w) => w.message).join('\n'), isError: true };
|
||
}
|
||
|
||
const fileSizeBytes = (() => {
|
||
try { return fs.statSync(resolved).size; } catch { return undefined; }
|
||
})();
|
||
|
||
const timeoutPromise = new Promise<ToolResult>((resolve) =>
|
||
setTimeout(() => resolve({ output: 'Processing timeout: file took longer than 30 seconds', isError: true }), PROCESSING_TIMEOUT_MS)
|
||
);
|
||
|
||
const mainPromise = (async (): Promise<ToolResult> => {
|
||
const startMs = Date.now();
|
||
const filename = path.basename(resolved);
|
||
|
||
let rawText = '';
|
||
let htmlOutput = '';
|
||
|
||
try {
|
||
const textResult = await mammoth.extractRawText({ path: resolved });
|
||
rawText = textResult.value;
|
||
if (textResult.messages.length > 0) {
|
||
warnings.push({
|
||
code: 'PARSE_PARTIAL_SUCCESS',
|
||
message: `mammoth warnings: ${textResult.messages.map((m) => m.message).join('; ')}`,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
return { output: `Failed to read Word file: ${(e as Error).message}`, isError: true };
|
||
}
|
||
|
||
if (mode === 'text+tables') {
|
||
try {
|
||
const htmlResult = await mammoth.convertToHtml({ path: resolved });
|
||
htmlOutput = htmlResult.value;
|
||
} catch (e) {
|
||
// Non-fatal: fall back to text only
|
||
warnings.push({
|
||
code: 'PARSE_PARTIAL_SUCCESS',
|
||
message: `HTML conversion failed, table extraction skipped: ${(e as Error).message}`,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Build paragraph blocks from raw text
|
||
let paragraphBlocks: DocxParagraphBlock[] = rawText
|
||
.split('\n')
|
||
.map((line, i) => ({ type: 'docx.paragraph' as const, index: i, text: line }))
|
||
.filter((b) => b.text.trim().length > 0);
|
||
|
||
// Apply maxParagraphs limit
|
||
if (paragraphBlocks.length > maxParagraphs) {
|
||
paragraphBlocks = paragraphBlocks.slice(0, maxParagraphs);
|
||
warnings.push({
|
||
code: 'PARSE_PARTIAL_SUCCESS',
|
||
message: `Paragraph limit reached: only first ${maxParagraphs} paragraphs were read`,
|
||
});
|
||
}
|
||
|
||
// Build table blocks from HTML if available
|
||
const tableBlocks: DocxTableBlock[] = [];
|
||
if (htmlOutput) {
|
||
const parsedTables = parseTablesFromHtml(htmlOutput);
|
||
for (let i = 0; i < parsedTables.length; i++) {
|
||
tableBlocks.push({ type: 'docx.table', index: i, rows: parsedTables[i]! });
|
||
}
|
||
}
|
||
|
||
const doc: OfficeDocument = {
|
||
kind: 'docx',
|
||
source: { path: resolved, filename },
|
||
metadata: { fileSizeBytes, processingTimeMs: Date.now() - startMs },
|
||
blocks: [...paragraphBlocks, ...tableBlocks],
|
||
warnings,
|
||
};
|
||
|
||
// Format output
|
||
const parts: string[] = [`## Document: ${filename}`, '', '### Content', ''];
|
||
|
||
const paragraphTexts = paragraphBlocks.map((b) => b.text);
|
||
parts.push(paragraphTexts.join('\n\n'));
|
||
|
||
if (tableBlocks.length > 0) {
|
||
parts.push('');
|
||
for (let i = 0; i < tableBlocks.length; i++) {
|
||
parts.push('');
|
||
parts.push(formatDocxTable(tableBlocks[i]!.rows, i));
|
||
}
|
||
}
|
||
|
||
const wordCount = rawText.split(/\s+/).filter((w) => w.length > 0).length;
|
||
parts.push('');
|
||
parts.push(`(${wordCount} words extracted)`);
|
||
|
||
if (doc.warnings.length > 0) {
|
||
parts.push('');
|
||
parts.push('### Warnings');
|
||
for (const w of doc.warnings) {
|
||
parts.push(`- [${w.code}] ${w.message}`);
|
||
}
|
||
}
|
||
|
||
doc.metadata.processingTimeMs = Date.now() - startMs;
|
||
logger.debug(`[ReadDocx] ${filename}: ${paragraphBlocks.length} paragraphs, ${tableBlocks.length} tables, ${wordCount} words`);
|
||
|
||
let output = parts.join('\n');
|
||
if (output.length > MAX_OUTPUT_CHARS) {
|
||
output = output.slice(0, MAX_OUTPUT_CHARS) + `\n\n[truncated: output exceeded ${MAX_OUTPUT_CHARS} characters]`;
|
||
}
|
||
output = truncateToBudget(output, getToolOutputBudgetTokens(ctx), {
|
||
sourceLabel: `ReadDocx(${filename})`,
|
||
continuationHint: 'SplitDocxSections で章ごとに分割するか、max_paragraphs を絞ってください',
|
||
}).text;
|
||
return { output, isError: false };
|
||
})();
|
||
|
||
return Promise.race([mainPromise, timeoutPromise]);
|
||
}
|
||
|
||
// --- ReadPdf query / search helper ---
|
||
|
||
type ReadPdfQueryMode = 'substring' | 'regex' | 'iregex';
|
||
|
||
interface ReadPdfPageMatch {
|
||
pageNo: number;
|
||
matchCount: number;
|
||
/** Grep-style snippet block — line-numbered, with N lines of context
|
||
* around each match. Multiple match clusters per page are separated
|
||
* by ` …`. */
|
||
snippet: string;
|
||
}
|
||
|
||
interface ReadPdfQueryResult {
|
||
matches: ReadPdfPageMatch[];
|
||
totalMatches: number;
|
||
/** Set when `regex` / `iregex` mode received an invalid pattern. */
|
||
regexError?: string;
|
||
}
|
||
|
||
/**
|
||
* Decide whether a query mode triggers regex semantics.
|
||
*/
|
||
function isRegexQueryMode(mode: ReadPdfQueryMode): boolean {
|
||
return mode === 'regex' || mode === 'iregex';
|
||
}
|
||
|
||
/**
|
||
* Compile the query into a regex usable with String.prototype.matchAll.
|
||
* For substring mode we escape the pattern + add the `i` flag so the
|
||
* cheapest user input ("find this string") behaves the way operators
|
||
* expect (case-insensitive substring, no regex surprises). regex /
|
||
* iregex preserve user-supplied syntax.
|
||
*
|
||
* Returns `{ regex }` on success or `{ error }` for unparseable
|
||
* regex patterns — never throws so the caller can surface a friendly
|
||
* error in the ReadPdf output instead of a 500.
|
||
*/
|
||
function compileReadPdfQuery(query: string, mode: ReadPdfQueryMode): { regex: RegExp } | { error: string } {
|
||
if (!isRegexQueryMode(mode)) {
|
||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
return { regex: new RegExp(escaped, 'gi') };
|
||
}
|
||
try {
|
||
const flags = mode === 'iregex' ? 'gi' : 'g';
|
||
return { regex: new RegExp(query, flags) };
|
||
} catch (e) {
|
||
return { error: `invalid regex: ${(e as Error).message}` };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Filter PDF pages to the ones containing the query, returning a
|
||
* grep-style snippet per page. Empty queries (after trimming) bypass
|
||
* the filter — the caller is expected to check before invoking. Pages
|
||
* with zero matches are dropped. Match overflow per page is capped at
|
||
* `MAX_MATCHES_PER_PAGE` to avoid runaway output on tokens like "the".
|
||
*/
|
||
const MAX_MATCHES_PER_PAGE = 50;
|
||
|
||
function searchPdfPages(
|
||
pages: { pageNo: number; text: string }[],
|
||
query: string,
|
||
mode: ReadPdfQueryMode,
|
||
contextLines: number,
|
||
): ReadPdfQueryResult {
|
||
const compiled = compileReadPdfQuery(query, mode);
|
||
if ('error' in compiled) {
|
||
return { matches: [], totalMatches: 0, regexError: compiled.error };
|
||
}
|
||
const regex = compiled.regex;
|
||
const matches: ReadPdfPageMatch[] = [];
|
||
let totalMatches = 0;
|
||
const ctx = Math.max(0, Math.min(20, Math.floor(contextLines)));
|
||
|
||
for (const page of pages) {
|
||
const lines = page.text.split('\n');
|
||
const matchedLineNos: number[] = [];
|
||
for (let i = 0; i < lines.length; i++) {
|
||
// Recreate the regex per-line so the stateful `lastIndex` from
|
||
// an earlier line never bleeds into the next match — using the
|
||
// same RegExp object across lines would skip lines after a
|
||
// partial-line match because `g` flag remembers position.
|
||
const perLine = new RegExp(regex.source, regex.flags);
|
||
if (perLine.test(lines[i]!)) {
|
||
matchedLineNos.push(i);
|
||
if (matchedLineNos.length >= MAX_MATCHES_PER_PAGE) break;
|
||
}
|
||
}
|
||
if (matchedLineNos.length === 0) continue;
|
||
totalMatches += matchedLineNos.length;
|
||
|
||
// Collapse overlapping context windows into single clusters so
|
||
// adjacent matches don't repeat the same surrounding lines.
|
||
const clusters: Array<{ from: number; to: number; matchLines: Set<number> }> = [];
|
||
for (const m of matchedLineNos) {
|
||
const from = Math.max(0, m - ctx);
|
||
const to = Math.min(lines.length - 1, m + ctx);
|
||
const last = clusters[clusters.length - 1];
|
||
if (last && from <= last.to + 1) {
|
||
last.to = Math.max(last.to, to);
|
||
last.matchLines.add(m);
|
||
} else {
|
||
clusters.push({ from, to, matchLines: new Set([m]) });
|
||
}
|
||
}
|
||
|
||
const lineNoWidth = String(lines.length).length;
|
||
const snippetParts: string[] = [];
|
||
clusters.forEach((cluster, idx) => {
|
||
if (idx > 0) snippetParts.push(' …');
|
||
for (let i = cluster.from; i <= cluster.to; i++) {
|
||
const marker = cluster.matchLines.has(i) ? '>' : ' ';
|
||
const lineLabel = String(i + 1).padStart(lineNoWidth, ' ');
|
||
snippetParts.push(`${marker} ${lineLabel}: ${lines[i]}`);
|
||
}
|
||
});
|
||
matches.push({
|
||
pageNo: page.pageNo,
|
||
matchCount: matchedLineNos.length,
|
||
snippet: snippetParts.join('\n'),
|
||
});
|
||
}
|
||
return { matches, totalMatches };
|
||
}
|
||
|
||
// --- ReadPdf implementation ---
|
||
|
||
async function executeReadPdf(
|
||
input: Record<string, unknown>,
|
||
ctx: ToolContext,
|
||
): Promise<ToolResult> {
|
||
const filePath = input['path'] as string;
|
||
const pageRangeInput = typeof input['page_range'] === 'string'
|
||
? input['page_range']
|
||
: typeof input['pageRange'] === 'string'
|
||
? input['pageRange']
|
||
: undefined;
|
||
const pageRange = parsePageRange(pageRangeInput);
|
||
const maxPages = typeof input['max_pages'] === 'number' && Number.isFinite(input['max_pages'])
|
||
? Math.max(1, Math.floor(input['max_pages']))
|
||
: undefined;
|
||
const maxChars = typeof input['max_chars'] === 'number' && Number.isFinite(input['max_chars'])
|
||
? Math.max(200, Math.floor(input['max_chars']))
|
||
: MAX_OUTPUT_CHARS;
|
||
const outputLimit = Math.min(MAX_OUTPUT_CHARS, maxChars);
|
||
|
||
// Query is opt-in: when present and non-empty after trim the handler
|
||
// switches to grep -n style output (matched pages + context lines).
|
||
// Otherwise the full-text path is taken — same as before this param.
|
||
const rawQuery = typeof input['query'] === 'string' ? input['query'] : null;
|
||
const query = rawQuery && rawQuery.trim().length > 0 ? rawQuery : null;
|
||
const rawQueryMode = typeof input['query_mode'] === 'string' ? input['query_mode'] : 'substring';
|
||
const queryMode: ReadPdfQueryMode = rawQueryMode === 'regex' || rawQueryMode === 'iregex' || rawQueryMode === 'substring'
|
||
? rawQueryMode
|
||
: 'substring';
|
||
const contextLines = typeof input['context_lines'] === 'number' && Number.isFinite(input['context_lines'])
|
||
? Math.max(0, Math.min(20, Math.floor(input['context_lines'])))
|
||
: 2;
|
||
|
||
if (pageRangeInput && !pageRange) {
|
||
return { output: `Invalid page_range: ${pageRangeInput}`, isError: true };
|
||
}
|
||
|
||
let resolved: string;
|
||
try {
|
||
resolved = resolveAndGuard(ctx.workspacePath, filePath);
|
||
} catch (e) {
|
||
return { output: (e as Error).message, isError: true };
|
||
}
|
||
|
||
if (!fs.existsSync(resolved)) {
|
||
return { output: `File not found: ${filePath}`, isError: true };
|
||
}
|
||
|
||
const formatError = validateFileFormat(resolved, {
|
||
expectedExts: ['.pdf'],
|
||
expectedFormat: 'pdf',
|
||
toolName: 'ReadPdf',
|
||
});
|
||
if (formatError) {
|
||
return { output: formatError, isError: true };
|
||
}
|
||
|
||
const warnings = validateOfficeFile(resolved, ['.pdf'], resolveMaxSize(ctx, 'officePdfMaxSizeMb', DEFAULT_PDF_MAX_SIZE_MB));
|
||
const hasTooLarge = warnings.some((w) => w.code === 'FILE_TOO_LARGE');
|
||
if (hasTooLarge) {
|
||
return { output: warnings.map((w) => w.message).join('\n'), isError: true };
|
||
}
|
||
|
||
const fileSizeBytes = (() => {
|
||
try { return fs.statSync(resolved).size; } catch { return undefined; }
|
||
})();
|
||
|
||
const timeoutPromise = new Promise<ToolResult>((resolve) =>
|
||
setTimeout(() => resolve({ output: 'Processing timeout: file took longer than 30 seconds', isError: true }), PROCESSING_TIMEOUT_MS)
|
||
);
|
||
|
||
const mainPromise = (async (): Promise<ToolResult> => {
|
||
const startMs = Date.now();
|
||
const filename = path.basename(resolved);
|
||
const parser = new PDFParse({ data: fs.readFileSync(resolved) });
|
||
|
||
try {
|
||
const info = await parser.getInfo();
|
||
const totalPages = info.total;
|
||
|
||
let selectedPages: number[] | undefined;
|
||
if (pageRange) {
|
||
selectedPages = [];
|
||
for (let pageNo = pageRange.min; pageNo <= Math.min(pageRange.max, totalPages); pageNo++) {
|
||
selectedPages.push(pageNo);
|
||
}
|
||
if (selectedPages.length === 0) {
|
||
return { output: `No pages matched page_range=${pageRangeInput} (document has ${totalPages} page(s))`, isError: true };
|
||
}
|
||
}
|
||
|
||
if (maxPages !== undefined) {
|
||
selectedPages = (selectedPages ?? Array.from({ length: totalPages }, (_, i) => i + 1)).slice(0, maxPages);
|
||
if (selectedPages.length < totalPages) {
|
||
warnings.push({
|
||
code: 'PARSE_PARTIAL_SUCCESS',
|
||
message: `Page limit applied: extracted ${selectedPages.length} page(s) out of ${totalPages}`,
|
||
});
|
||
}
|
||
}
|
||
|
||
const textResult = await parser.getText(selectedPages ? { partial: selectedPages } : undefined);
|
||
const pageBlocks: PdfPageBlock[] = textResult.pages.map((page) => ({
|
||
type: 'pdf.page',
|
||
pageNo: page.num,
|
||
text: normalizePdfPageText(page.text),
|
||
}));
|
||
|
||
if (pageBlocks.length === 0) {
|
||
warnings.push({
|
||
code: 'PARSE_PARTIAL_SUCCESS',
|
||
message: 'No extractable text pages were found in the PDF',
|
||
});
|
||
}
|
||
|
||
if (pageBlocks.every((page) => page.text.length === 0) && pageBlocks.length > 0) {
|
||
// テキスト抽出失敗 → PdfToImages + Vision OCR に自動フォールバック
|
||
if (ctx.editAllowed) {
|
||
logger.info(`[ReadPdf] ${filename}: text extraction empty, falling back to image-based OCR`);
|
||
await parser.destroy().catch(() => undefined);
|
||
|
||
// PdfToImages 実行
|
||
const pdfImagesResult = await executePdfToImages({ path: filePath, dpi: 150 }, ctx);
|
||
if (pdfImagesResult.isError) {
|
||
return { output: `ReadPdf text extraction returned empty. Image fallback also failed: ${pdfImagesResult.output}`, isError: true };
|
||
}
|
||
|
||
// 生成された画像を Vision OCR で解析。query モードでは
|
||
// OCR で起こしたテキストにも同じ filter を適用するので、
|
||
// ページごとに「ヘッダ付き markdown 用」「query 用 plain
|
||
// text」の 2 形式を並行して保持する。
|
||
const imageLines = pdfImagesResult.output.split('\n').filter(l => l.startsWith('- '));
|
||
const imagePaths = imageLines.map(l => l.replace('- ', '').trim());
|
||
const ocrResults: string[] = [];
|
||
const ocrPages: { pageNo: number; text: string }[] = [];
|
||
for (let i = 0; i < imagePaths.length; i++) {
|
||
const pageNo = i + 1;
|
||
const imgResult = resolveImagePath(imagePaths[i], ctx);
|
||
if ('isError' in imgResult) {
|
||
const msg = `(image OCR failed: ${imgResult.output})`;
|
||
ocrResults.push(`## Page ${pageNo}\n\n${msg}`);
|
||
ocrPages.push({ pageNo, text: '' });
|
||
continue;
|
||
}
|
||
const visionResult = await callVisionModel(imgResult.dataUrl, 'この画像のテキストを全て正確に書き起こしてください。', ctx);
|
||
const text = visionResult.isError ? '' : visionResult.output;
|
||
ocrResults.push(`## Page ${pageNo}\n\n${visionResult.isError ? `(OCR error: ${visionResult.output})` : text}`);
|
||
ocrPages.push({ pageNo, text });
|
||
}
|
||
|
||
const header = [
|
||
`# ${filename} (image-based OCR${query ? `, query: "${query}"` : ''})`,
|
||
'',
|
||
`スキャン PDF のため画像ベース OCR にフォールバックしました。`,
|
||
`- Total pages: ${totalPages}`,
|
||
`- OCR pages: ${imagePaths.length}`,
|
||
];
|
||
|
||
let body: string[];
|
||
if (query) {
|
||
const queryResult = searchPdfPages(ocrPages, query, queryMode, contextLines);
|
||
if (queryResult.regexError) {
|
||
return { output: `ReadPdf query error: ${queryResult.regexError}`, isError: true };
|
||
}
|
||
body = [
|
||
`- Pages with match: ${queryResult.matches.length}`,
|
||
`- Total matches: ${queryResult.totalMatches}`,
|
||
'',
|
||
'### Matches (from OCR)',
|
||
'',
|
||
];
|
||
if (queryResult.matches.length === 0) {
|
||
body.push(`(no matches for "${query}")`);
|
||
} else {
|
||
for (const m of queryResult.matches) {
|
||
body.push(`## Page ${m.pageNo} — ${m.matchCount} match${m.matchCount === 1 ? '' : 'es'}${m.matchCount === MAX_MATCHES_PER_PAGE ? ' (capped)' : ''}`);
|
||
body.push('');
|
||
body.push('```');
|
||
body.push(m.snippet);
|
||
body.push('```');
|
||
body.push('');
|
||
}
|
||
}
|
||
} else {
|
||
body = ['', ...ocrResults];
|
||
}
|
||
|
||
const output = [...header, ...body].join('\n');
|
||
|
||
let truncated = output;
|
||
if (truncated.length > outputLimit) {
|
||
truncated = truncated.slice(0, outputLimit) + `\n\n[truncated: output exceeded ${outputLimit} characters]`;
|
||
}
|
||
truncated = truncateToBudget(truncated, getToolOutputBudgetTokens(ctx), {
|
||
sourceLabel: `ReadPdf(${filename}, OCR)`,
|
||
continuationHint: 'page_range / max_pages / max_chars で範囲を絞ってください',
|
||
}).text;
|
||
return { output: truncated, isError: false };
|
||
}
|
||
|
||
warnings.push({
|
||
code: 'PARSE_PARTIAL_SUCCESS',
|
||
message: 'PDF text extraction returned empty text on all extracted pages. If this is a scanned PDF, use image-based OCR instead.',
|
||
});
|
||
}
|
||
|
||
const doc: OfficeDocument = {
|
||
kind: 'pdf',
|
||
source: { path: resolved, filename },
|
||
metadata: { pageCount: totalPages, fileSizeBytes, processingTimeMs: Date.now() - startMs },
|
||
blocks: pageBlocks,
|
||
warnings,
|
||
};
|
||
|
||
const extractedPageNumbers = pageBlocks.map((page) => page.pageNo);
|
||
const extractedSummary = extractedPageNumbers.length > 0
|
||
? extractedPageNumbers.length === 1
|
||
? String(extractedPageNumbers[0])
|
||
: `${extractedPageNumbers[0]}-${extractedPageNumbers[extractedPageNumbers.length - 1]}`
|
||
: 'none';
|
||
|
||
const queryHeader = query ? `, query: "${query}"${queryMode !== 'substring' ? ` mode=${queryMode}` : ''}` : '';
|
||
const parts: string[] = [
|
||
`# ${filename}${queryHeader}`,
|
||
'',
|
||
'### Summary',
|
||
`- Total pages: ${totalPages}`,
|
||
`- Extracted pages: ${extractedSummary}`,
|
||
];
|
||
|
||
if (query) {
|
||
// Query mode: filter to matched pages + grep-style snippet.
|
||
// Full-text output is intentionally suppressed because the
|
||
// operator who passed `query` is signalling "I only want
|
||
// matches, not the whole document."
|
||
const queryResult = searchPdfPages(pageBlocks, query, queryMode, contextLines);
|
||
if (queryResult.regexError) {
|
||
return { output: `ReadPdf query error: ${queryResult.regexError}`, isError: true };
|
||
}
|
||
parts.push(`- Pages with match: ${queryResult.matches.length}`);
|
||
parts.push(`- Total matches: ${queryResult.totalMatches}`);
|
||
parts.push('');
|
||
parts.push('### Matches');
|
||
parts.push('');
|
||
if (queryResult.matches.length === 0) {
|
||
parts.push(`(no matches for "${query}")`);
|
||
} else {
|
||
for (const m of queryResult.matches) {
|
||
parts.push(`## Page ${m.pageNo} — ${m.matchCount} match${m.matchCount === 1 ? '' : 'es'}${m.matchCount === MAX_MATCHES_PER_PAGE ? ' (capped)' : ''}`);
|
||
parts.push('');
|
||
parts.push('```');
|
||
parts.push(m.snippet);
|
||
parts.push('```');
|
||
parts.push('');
|
||
}
|
||
}
|
||
} else {
|
||
parts.push('');
|
||
parts.push('### Content');
|
||
parts.push('');
|
||
if (pageBlocks.length === 0) {
|
||
parts.push('(no extractable text)');
|
||
} else {
|
||
for (const page of pageBlocks) {
|
||
parts.push(`## Page ${page.pageNo}`);
|
||
parts.push('');
|
||
parts.push(page.text || '(no extractable text on this page)');
|
||
parts.push('');
|
||
}
|
||
}
|
||
}
|
||
|
||
if (doc.warnings.length > 0) {
|
||
parts.push('### Warnings');
|
||
for (const warning of doc.warnings) {
|
||
parts.push(`- [${warning.code}] ${warning.message}`);
|
||
}
|
||
}
|
||
|
||
doc.metadata.processingTimeMs = Date.now() - startMs;
|
||
logger.debug(`[ReadPdf] ${filename}: ${pageBlocks.length} page(s) extracted from ${totalPages}`);
|
||
|
||
let output = parts.join('\n');
|
||
if (output.length > outputLimit) {
|
||
output = output.slice(0, outputLimit) + `\n\n[truncated: output exceeded ${outputLimit} characters]`;
|
||
}
|
||
output = truncateToBudget(output, getToolOutputBudgetTokens(ctx), {
|
||
sourceLabel: `ReadPdf(${filename})`,
|
||
continuationHint: 'page_range / max_pages / max_chars で範囲を絞ってください',
|
||
}).text;
|
||
return { output, isError: false };
|
||
} catch (e) {
|
||
return { output: `Failed to read PDF file: ${(e as Error).message}`, isError: true };
|
||
} finally {
|
||
await parser.destroy().catch(() => undefined);
|
||
}
|
||
})();
|
||
|
||
return Promise.race([mainPromise, timeoutPromise]);
|
||
}
|
||
|
||
// --- PdfToImages Python script ---
|
||
|
||
const PDF_TO_IMAGES_PYTHON = `
|
||
import sys, json, os
|
||
|
||
def main():
|
||
try:
|
||
import fitz
|
||
except ImportError:
|
||
print(json.dumps({'error': 'PyMuPDF (fitz) is not installed. Run: pip install pymupdf'}))
|
||
sys.exit(1)
|
||
|
||
raw = os.environ.get('PDF_TO_IMAGES_ARGS', '')
|
||
if not raw:
|
||
print(json.dumps({'error': 'PDF_TO_IMAGES_ARGS env var is not set'}))
|
||
sys.exit(1)
|
||
|
||
args = json.loads(raw)
|
||
pdf_path = args['pdf_path']
|
||
output_dir = args['output_dir']
|
||
dpi = int(args.get('dpi', 150))
|
||
page_range = args.get('page_range') # [min_1indexed, max_1indexed] or null
|
||
|
||
os.makedirs(output_dir, exist_ok=True)
|
||
|
||
try:
|
||
doc = fitz.open(pdf_path)
|
||
except Exception as e:
|
||
print(json.dumps({'error': f'Failed to open PDF: {e}'}))
|
||
sys.exit(1)
|
||
|
||
total_pages = len(doc)
|
||
|
||
if page_range:
|
||
start_idx = max(0, page_range[0] - 1)
|
||
end_idx = min(total_pages, page_range[1])
|
||
indices = list(range(start_idx, end_idx))
|
||
else:
|
||
indices = list(range(total_pages))
|
||
|
||
# DPI を scale factor に変換(PDF の基本単位は 72 DPI)
|
||
scale = dpi / 72
|
||
mat = fitz.Matrix(scale, scale)
|
||
generated = []
|
||
|
||
for idx in indices:
|
||
page = doc[idx]
|
||
pix = page.get_pixmap(matrix=mat)
|
||
out_path = os.path.join(output_dir, f'page-{idx + 1:04d}.png')
|
||
pix.save(out_path)
|
||
generated.append(out_path)
|
||
|
||
print(json.dumps({'total_pages': total_pages, 'pages': generated}))
|
||
|
||
main()
|
||
`;
|
||
|
||
// --- PdfToImages implementation ---
|
||
|
||
async function executePdfToImages(
|
||
input: Record<string, unknown>,
|
||
ctx: ToolContext,
|
||
): Promise<ToolResult> {
|
||
if (!ctx.editAllowed) {
|
||
return { output: 'PdfToImages is not allowed: edit flag is false', isError: true };
|
||
}
|
||
|
||
const filePath = input['path'] as string;
|
||
if (!filePath) {
|
||
return { output: 'path is required', isError: true };
|
||
}
|
||
|
||
// ページ範囲パース(既存の parsePageRange を流用)
|
||
const pageRangeInput = typeof input['page_range'] === 'string' ? input['page_range'] : undefined;
|
||
const pageRange = pageRangeInput ? parsePageRange(pageRangeInput) : undefined;
|
||
if (pageRangeInput && !pageRange) {
|
||
return { output: `Invalid page_range: ${pageRangeInput}`, isError: true };
|
||
}
|
||
|
||
const dpiRaw = typeof input['dpi'] === 'number' ? input['dpi'] : 150;
|
||
const dpi = Math.min(300, Math.max(72, Math.floor(dpiRaw)));
|
||
|
||
// ファイルパス解決
|
||
let resolved: string;
|
||
try {
|
||
resolved = resolveAndGuard(ctx.workspacePath, filePath);
|
||
} catch (e) {
|
||
return { output: (e as Error).message, isError: true };
|
||
}
|
||
if (!fs.existsSync(resolved)) {
|
||
return { output: `File not found: ${filePath}`, isError: true };
|
||
}
|
||
|
||
// 出力ディレクトリ決定
|
||
const basename = path.parse(resolved).name;
|
||
const outputDirRel = typeof input['output_dir'] === 'string'
|
||
? input['output_dir']
|
||
: path.posix.join('output', 'ReadPdf', basename);
|
||
|
||
let resolvedOutputDir: string;
|
||
try {
|
||
resolvedOutputDir = resolveOutputPathWithin(ctx.workspacePath, outputDirRel, ['output/ReadPdf']);
|
||
} catch (e) {
|
||
return { output: (e as Error).message, isError: true };
|
||
}
|
||
fs.mkdirSync(resolvedOutputDir, { recursive: true });
|
||
|
||
// Python に渡す引数(環境変数経由)
|
||
const pyArgs = {
|
||
pdf_path: resolved,
|
||
output_dir: resolvedOutputDir,
|
||
dpi,
|
||
page_range: pageRange ? [pageRange.min, pageRange.max] : null,
|
||
};
|
||
|
||
logger.debug(`[PdfToImages] calling python3 for ${filePath} dpi=${dpi}`);
|
||
|
||
// Python 実行: スクリプトを stdin で渡し、引数は環境変数で渡す
|
||
const result = await new Promise<ToolResult>((resolve) => {
|
||
let settled = false;
|
||
const settle = (r: ToolResult) => { if (settled) return; settled = true; resolve(r); };
|
||
|
||
const proc = child_process.spawn('python3', ['-'], {
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
env: { ...process.env, PDF_TO_IMAGES_ARGS: JSON.stringify(pyArgs) },
|
||
});
|
||
|
||
let stdout = '';
|
||
let stderr = '';
|
||
proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
|
||
proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
|
||
|
||
proc.on('error', (err) => {
|
||
clearTimeout(timer);
|
||
settle({ output: `PdfToImages process error: ${err.message}`, isError: true });
|
||
});
|
||
|
||
const timer = setTimeout(() => {
|
||
proc.kill();
|
||
settle({ output: `PdfToImages timed out after ${PDF_RENDER_TIMEOUT_MS / 1000}s`, isError: true });
|
||
}, PDF_RENDER_TIMEOUT_MS);
|
||
|
||
proc.on('close', (code) => {
|
||
clearTimeout(timer);
|
||
if (code !== 0) {
|
||
settle({ output: `PdfToImages failed (exit ${code}): ${stderr.trim() || stdout.trim()}`, isError: true });
|
||
return;
|
||
}
|
||
try {
|
||
const json = JSON.parse(stdout.trim()) as { error?: string; total_pages?: number; pages?: string[] };
|
||
if (json.error) {
|
||
settle({ output: `PdfToImages error: ${json.error}`, isError: true });
|
||
return;
|
||
}
|
||
const totalPages = json.total_pages ?? 0;
|
||
const pages = json.pages ?? [];
|
||
|
||
// ワークスペース相対パスに変換
|
||
const relPages = pages.map((p) => path.relative(ctx.workspacePath, p));
|
||
|
||
const lines = [
|
||
`PDF を ${pages.length} ページ分の画像に変換しました(合計 ${totalPages} ページ中)。`,
|
||
'',
|
||
'生成された画像:',
|
||
...relPages.map((p) => `- ${p}`),
|
||
'',
|
||
`次のステップ: ReadImage で各画像を確認してください。`,
|
||
];
|
||
settle({ output: lines.join('\n'), isError: false });
|
||
} catch {
|
||
settle({ output: `PdfToImages: failed to parse python output: ${stdout}`, isError: true });
|
||
}
|
||
});
|
||
|
||
proc.stdin.write(PDF_TO_IMAGES_PYTHON);
|
||
proc.stdin.end();
|
||
});
|
||
|
||
logger.debug(`[PdfToImages] done: isError=${result.isError}`);
|
||
return result;
|
||
}
|
||
|
||
// --- PPTX helpers ---
|
||
|
||
/** "1-5" → {min:1, max:5}, "3" → {min:3, max:3}, undefined → null */
|
||
function parseSlideRange(rangeStr: string | undefined): { min: number; max: number } | null {
|
||
if (!rangeStr) return null;
|
||
const rangeMatch = rangeStr.match(/^(\d+)-(\d+)$/);
|
||
if (rangeMatch) {
|
||
return { min: parseInt(rangeMatch[1]!, 10), max: parseInt(rangeMatch[2]!, 10) };
|
||
}
|
||
const singleMatch = rangeStr.match(/^(\d+)$/);
|
||
if (singleMatch) {
|
||
const n = parseInt(singleMatch[1]!, 10);
|
||
return { min: n, max: n };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* XML パーサー(名前空間プレフィックスを除去)
|
||
* fast-xml-parser で removeNSPrefix: true を使うと "a:t" → "t" になる。
|
||
*/
|
||
const xmlParser = new XMLParser({
|
||
ignoreAttributes: false,
|
||
attributeNamePrefix: '@_',
|
||
removeNSPrefix: true,
|
||
isArray: (name) => ['p', 'r', 'sp', 'graphicFrame', 'tr', 'tc', 'br', 'sldId'].includes(name),
|
||
textNodeName: '#text',
|
||
});
|
||
|
||
/** 任意の値を文字列に変換(number / boolean / null 対応) */
|
||
function toStr(v: unknown): string {
|
||
if (v == null) return '';
|
||
if (typeof v === 'string') return v;
|
||
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* `a:p` ノードからテキストを抽出する。
|
||
* a:r → a:t の連結、a:br は改行として扱う。
|
||
*/
|
||
function extractParagraphText(para: Record<string, unknown>): string {
|
||
const parts: string[] = [];
|
||
|
||
// runs: para.r (配列)
|
||
const runs = para['r'];
|
||
const brs = para['br'];
|
||
|
||
// br の位置を考慮するため、元の XML 順序に近い処理が必要。
|
||
// fast-xml-parser は配列にまとめてしまうので、br があれば runs の間に改行を挿入する形にする。
|
||
// 実用上、runs を連結し、br が存在する場合は末尾に改行を付ける。
|
||
if (Array.isArray(runs)) {
|
||
for (const run of runs) {
|
||
if (run && typeof run === 'object') {
|
||
const t = (run as Record<string, unknown>)['t'];
|
||
parts.push(toStr(t));
|
||
}
|
||
}
|
||
}
|
||
|
||
// br(改行要素)が存在する場合は段落内改行として扱う
|
||
if (Array.isArray(brs) && brs.length > 0) {
|
||
parts.push('\n');
|
||
}
|
||
|
||
return parts.join('');
|
||
}
|
||
|
||
/** `a:tbl` テーブルノードからセルテキストを抽出してフォーマットする */
|
||
function extractTableText(tbl: Record<string, unknown>): string {
|
||
const rows = tbl['tr'];
|
||
if (!Array.isArray(rows) || rows.length === 0) return '';
|
||
|
||
const tableRows: string[][] = [];
|
||
for (const row of rows) {
|
||
if (!row || typeof row !== 'object') continue;
|
||
const cells = (row as Record<string, unknown>)['tc'];
|
||
if (!Array.isArray(cells)) continue;
|
||
const rowTexts: string[] = [];
|
||
for (const cell of cells) {
|
||
if (!cell || typeof cell !== 'object') continue;
|
||
const txBody = (cell as Record<string, unknown>)['txBody'];
|
||
if (!txBody || typeof txBody !== 'object') {
|
||
rowTexts.push('');
|
||
continue;
|
||
}
|
||
const paras = (txBody as Record<string, unknown>)['p'];
|
||
const cellText = Array.isArray(paras)
|
||
? paras.map((p) => extractParagraphText(p as Record<string, unknown>)).join('\n')
|
||
: '';
|
||
rowTexts.push(cellText.trim());
|
||
}
|
||
tableRows.push(rowTexts);
|
||
}
|
||
|
||
if (tableRows.length === 0) return '';
|
||
|
||
// シンプルなテキスト形式で出力(列を " | " で区切る)
|
||
return tableRows.map((row) => row.join(' | ')).join('\n');
|
||
}
|
||
|
||
/**
|
||
* スライド XML(slide.xml の中身)からテキストを抽出する。
|
||
* p:sp(テキストボックス・プレースホルダー)と graphicFrame(テーブル)を対象とする。
|
||
*/
|
||
function extractTextsFromSlideXml(xmlContent: string): { texts: { shapeId?: string; text: string }[]; error?: string } {
|
||
let parsed: Record<string, unknown>;
|
||
try {
|
||
parsed = xmlParser.parse(xmlContent) as Record<string, unknown>;
|
||
} catch (e) {
|
||
return { texts: [], error: `XML parse error: ${(e as Error).message}` };
|
||
}
|
||
|
||
const texts: { shapeId?: string; text: string }[] = [];
|
||
|
||
// パスを辿る: sld > cSld > spTree
|
||
const sld = parsed['sld'] as Record<string, unknown> | undefined;
|
||
if (!sld) return { texts };
|
||
const cSld = sld['cSld'] as Record<string, unknown> | undefined;
|
||
if (!cSld) return { texts };
|
||
const spTree = cSld['spTree'] as Record<string, unknown> | undefined;
|
||
if (!spTree) return { texts };
|
||
|
||
// テキストボックス・プレースホルダー (p:sp)
|
||
const shapes = spTree['sp'];
|
||
if (Array.isArray(shapes)) {
|
||
for (const sp of shapes) {
|
||
if (!sp || typeof sp !== 'object') continue;
|
||
const shape = sp as Record<string, unknown>;
|
||
const shapeId = (() => {
|
||
const nvSpPr = shape['nvSpPr'] as Record<string, unknown> | undefined;
|
||
if (!nvSpPr) return undefined;
|
||
const cNvPr = nvSpPr['cNvPr'] as Record<string, unknown> | undefined;
|
||
return cNvPr ? toStr(cNvPr['@_id']) : undefined;
|
||
})();
|
||
|
||
const txBody = shape['txBody'] as Record<string, unknown> | undefined;
|
||
if (!txBody) continue;
|
||
const paras = txBody['p'];
|
||
if (!Array.isArray(paras)) continue;
|
||
|
||
const paraTexts = paras
|
||
.map((p) => extractParagraphText(p as Record<string, unknown>))
|
||
.filter((t) => t.length > 0);
|
||
|
||
if (paraTexts.length > 0) {
|
||
texts.push({ shapeId, text: paraTexts.join('\n') });
|
||
}
|
||
}
|
||
}
|
||
|
||
// テーブル (p:graphicFrame)
|
||
const graphicFrames = spTree['graphicFrame'];
|
||
if (Array.isArray(graphicFrames)) {
|
||
for (const gf of graphicFrames) {
|
||
if (!gf || typeof gf !== 'object') continue;
|
||
const gfObj = gf as Record<string, unknown>;
|
||
const graphic = gfObj['graphic'] as Record<string, unknown> | undefined;
|
||
if (!graphic) continue;
|
||
const graphicData = graphic['graphicData'] as Record<string, unknown> | undefined;
|
||
if (!graphicData) continue;
|
||
const tbl = graphicData['tbl'] as Record<string, unknown> | undefined;
|
||
if (!tbl) continue;
|
||
|
||
const tableText = extractTableText(tbl);
|
||
if (tableText) {
|
||
texts.push({ text: `[Table]\n${tableText}` });
|
||
}
|
||
}
|
||
}
|
||
|
||
return { texts };
|
||
}
|
||
|
||
/** notesSlide XML からノートテキストを抽出する */
|
||
function extractNotesFromXml(xmlContent: string): string {
|
||
let parsed: Record<string, unknown>;
|
||
try {
|
||
parsed = xmlParser.parse(xmlContent) as Record<string, unknown>;
|
||
} catch {
|
||
return '';
|
||
}
|
||
|
||
const notesSld = parsed['notes'] as Record<string, unknown> | undefined;
|
||
if (!notesSld) return '';
|
||
const cSld = notesSld['cSld'] as Record<string, unknown> | undefined;
|
||
if (!cSld) return '';
|
||
const spTree = cSld['spTree'] as Record<string, unknown> | undefined;
|
||
if (!spTree) return '';
|
||
|
||
const shapes = spTree['sp'];
|
||
if (!Array.isArray(shapes)) return '';
|
||
|
||
const noteParts: string[] = [];
|
||
for (const sp of shapes) {
|
||
if (!sp || typeof sp !== 'object') continue;
|
||
const shape = sp as Record<string, unknown>;
|
||
|
||
// ph type="body" がノート本文(idx=1)。type="dt","ftr","sldNum" は除外
|
||
const nvSpPr = shape['nvSpPr'] as Record<string, unknown> | undefined;
|
||
const nvPr = nvSpPr?.['nvPr'] as Record<string, unknown> | undefined;
|
||
const ph = nvPr?.['ph'] as Record<string, unknown> | undefined;
|
||
if (ph) {
|
||
const phType = toStr(ph['@_type']);
|
||
// "dt", "ftr", "sldNum" はスキップ(日付・フッター・スライド番号)
|
||
if (phType === 'dt' || phType === 'ftr' || phType === 'sldNum') continue;
|
||
}
|
||
|
||
const txBody = shape['txBody'] as Record<string, unknown> | undefined;
|
||
if (!txBody) continue;
|
||
const paras = txBody['p'];
|
||
if (!Array.isArray(paras)) continue;
|
||
|
||
const paraTexts = paras
|
||
.map((p) => extractParagraphText(p as Record<string, unknown>))
|
||
.filter((t) => t.length > 0);
|
||
if (paraTexts.length > 0) {
|
||
noteParts.push(paraTexts.join('\n'));
|
||
}
|
||
}
|
||
|
||
return noteParts.join('\n').trim();
|
||
}
|
||
|
||
/**
|
||
* ppt/presentation.xml からスライドの順序(rId → slide path)を解決する。
|
||
* 返り値: スライドの ZIP 内パス配列(順序通り)
|
||
*/
|
||
function resolveSlideOrder(zip: AdmZip): string[] {
|
||
const presEntry = zip.getEntry('ppt/presentation.xml');
|
||
if (!presEntry) return [];
|
||
|
||
let presXml: string;
|
||
try {
|
||
presXml = presEntry.getData().toString('utf8');
|
||
} catch {
|
||
return [];
|
||
}
|
||
|
||
let parsed: Record<string, unknown>;
|
||
try {
|
||
parsed = xmlParser.parse(presXml) as Record<string, unknown>;
|
||
} catch {
|
||
return [];
|
||
}
|
||
|
||
// rId の順序を sldIdLst から取得
|
||
const presentation = parsed['presentation'] as Record<string, unknown> | undefined;
|
||
if (!presentation) return [];
|
||
const sldIdLst = presentation['sldIdLst'] as Record<string, unknown> | undefined;
|
||
if (!sldIdLst) return [];
|
||
const sldIds = sldIdLst['sldId'];
|
||
if (!Array.isArray(sldIds)) return [];
|
||
|
||
const rIds: string[] = sldIds.map((s) => {
|
||
const obj = s as Record<string, unknown>;
|
||
return toStr(obj['@_r:id'] ?? obj['@_id']);
|
||
}).filter((id) => id.startsWith('rId'));
|
||
|
||
// presentation.xml.rels からrId → slide パスを解決
|
||
const relsEntry = zip.getEntry('ppt/_rels/presentation.xml.rels');
|
||
if (!relsEntry) return [];
|
||
|
||
let relsXml: string;
|
||
try {
|
||
relsXml = relsEntry.getData().toString('utf8');
|
||
} catch {
|
||
return [];
|
||
}
|
||
|
||
let relsParsed: Record<string, unknown>;
|
||
try {
|
||
relsParsed = xmlParser.parse(relsXml) as Record<string, unknown>;
|
||
} catch {
|
||
return [];
|
||
}
|
||
|
||
const relationships = (relsParsed['Relationships'] as Record<string, unknown> | undefined);
|
||
if (!relationships) return [];
|
||
|
||
// Relationship は配列またはオブジェクトの場合がある
|
||
const rels = relationships['Relationship'];
|
||
const relArray = Array.isArray(rels) ? rels : [rels];
|
||
|
||
// rId → target パスのマップを構築
|
||
const rIdToTarget = new Map<string, string>();
|
||
for (const rel of relArray) {
|
||
if (!rel || typeof rel !== 'object') continue;
|
||
const r = rel as Record<string, unknown>;
|
||
const id = toStr(r['@_Id']);
|
||
const target = toStr(r['@_Target']);
|
||
const type = toStr(r['@_Type']);
|
||
if (type.endsWith('/slide') && id && target) {
|
||
// target は "../slides/slide1.xml" 形式なのでパスを正規化
|
||
const normalized = target.startsWith('../')
|
||
? `ppt/${target.slice(3)}`
|
||
: `ppt/${target}`;
|
||
rIdToTarget.set(id, normalized);
|
||
}
|
||
}
|
||
|
||
// sldIdLst の順序に従って並べる
|
||
const slidePaths: string[] = [];
|
||
for (const rId of rIds) {
|
||
const slidePath = rIdToTarget.get(rId);
|
||
if (slidePath) slidePaths.push(slidePath);
|
||
}
|
||
|
||
return slidePaths;
|
||
}
|
||
|
||
/**
|
||
* スライドの .rels ファイルから notesSlide のパスを取得する。
|
||
* 例: ppt/slides/slide1.xml → ppt/slides/_rels/slide1.xml.rels
|
||
*/
|
||
function resolveNotesPath(zip: AdmZip, slidePath: string): string | null {
|
||
// slidePath: "ppt/slides/slide1.xml"
|
||
const dir = path.dirname(slidePath); // "ppt/slides"
|
||
const base = path.basename(slidePath); // "slide1.xml"
|
||
const relsPath = `${dir}/_rels/${base}.rels`;
|
||
|
||
const relsEntry = zip.getEntry(relsPath);
|
||
if (!relsEntry) return null;
|
||
|
||
let relsXml: string;
|
||
try {
|
||
relsXml = relsEntry.getData().toString('utf8');
|
||
} catch {
|
||
return null;
|
||
}
|
||
|
||
let parsed: Record<string, unknown>;
|
||
try {
|
||
parsed = xmlParser.parse(relsXml) as Record<string, unknown>;
|
||
} catch {
|
||
return null;
|
||
}
|
||
|
||
const relationships = parsed['Relationships'] as Record<string, unknown> | undefined;
|
||
if (!relationships) return null;
|
||
|
||
const rels = relationships['Relationship'];
|
||
const relArray = Array.isArray(rels) ? rels : [rels];
|
||
|
||
for (const rel of relArray) {
|
||
if (!rel || typeof rel !== 'object') continue;
|
||
const r = rel as Record<string, unknown>;
|
||
const type = toStr(r['@_Type']);
|
||
const target = toStr(r['@_Target']);
|
||
if (type.endsWith('/notesSlide') && target) {
|
||
// target は "../notesSlides/notesSlide1.xml" 形式
|
||
const normalized = target.startsWith('../')
|
||
? `ppt/${target.slice(3)}`
|
||
: `${dir}/${target}`;
|
||
return normalized;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// --- ReadPPTX implementation ---
|
||
|
||
async function executeReadPptx(
|
||
input: Record<string, unknown>,
|
||
ctx: ToolContext,
|
||
): Promise<ToolResult> {
|
||
const filePath = input['path'] as string;
|
||
const slideRangeStr = typeof input['slideRange'] === 'string' ? input['slideRange'] : undefined;
|
||
|
||
let resolved: string;
|
||
try {
|
||
resolved = resolveAndGuard(ctx.workspacePath, filePath);
|
||
} catch (e) {
|
||
return { output: (e as Error).message, isError: true };
|
||
}
|
||
|
||
if (!fs.existsSync(resolved)) {
|
||
return { output: `File not found: ${filePath}`, isError: true };
|
||
}
|
||
|
||
const formatError = validateFileFormat(resolved, {
|
||
expectedExts: ['.pptx', '.pptm'],
|
||
expectedFormat: 'ooxml',
|
||
toolName: 'ReadPPTX',
|
||
});
|
||
if (formatError) {
|
||
return { output: formatError, isError: true };
|
||
}
|
||
|
||
const warnings = validateOfficeFile(resolved, ['.pptx', '.pptm'], resolveMaxSize(ctx, 'officePptxMaxSizeMb', DEFAULT_PPTX_MAX_SIZE_MB));
|
||
const hasTooLarge = warnings.some((w) => w.code === 'FILE_TOO_LARGE');
|
||
if (hasTooLarge) {
|
||
return { output: warnings.map((w) => w.message).join('\n'), isError: true };
|
||
}
|
||
|
||
const fileSizeBytes = (() => {
|
||
try { return fs.statSync(resolved).size; } catch { return undefined; }
|
||
})();
|
||
|
||
const timeoutPromise = new Promise<ToolResult>((resolve) =>
|
||
setTimeout(() => resolve({ output: 'Processing timeout: file took longer than 30 seconds', isError: true }), PROCESSING_TIMEOUT_MS)
|
||
);
|
||
|
||
const mainPromise = (async (): Promise<ToolResult> => {
|
||
const startMs = Date.now();
|
||
const filename = path.basename(resolved);
|
||
const slideRange = parseSlideRange(slideRangeStr);
|
||
|
||
let zip: AdmZip;
|
||
try {
|
||
zip = new AdmZip(resolved);
|
||
} catch (e) {
|
||
return { output: `Failed to open PPTX file: ${(e as Error).message}`, isError: true };
|
||
}
|
||
|
||
// ZIP bomb detection: check total uncompressed size
|
||
const maxUncompressed = resolveMaxSize(ctx, 'officePptxMaxUncompressedMb', DEFAULT_PPTX_MAX_UNCOMPRESSED_MB);
|
||
const totalUncompressed = zip.getEntries().reduce((sum, e) => sum + e.header.size, 0);
|
||
if (totalUncompressed > maxUncompressed) {
|
||
const limitMB = (maxUncompressed / 1024 / 1024).toFixed(0);
|
||
return {
|
||
output: `ZIP bomb detected: total uncompressed size ${(totalUncompressed / 1024 / 1024).toFixed(1)}MB exceeds limit of ${limitMB}MB`,
|
||
isError: true,
|
||
};
|
||
}
|
||
|
||
// スライドの順序を presentation.xml から解決
|
||
const orderedSlidePaths = resolveSlideOrder(zip);
|
||
|
||
// フォールバック: presentation.xml が読めなかった場合は ZIP 内の slide ファイルを列挙
|
||
let slidePaths = orderedSlidePaths;
|
||
if (slidePaths.length === 0) {
|
||
slidePaths = zip.getEntries()
|
||
.map((e) => e.entryName)
|
||
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name))
|
||
.sort((a, b) => {
|
||
const na = parseInt(a.match(/(\d+)/)![1]!, 10);
|
||
const nb = parseInt(b.match(/(\d+)/)![1]!, 10);
|
||
return na - nb;
|
||
});
|
||
}
|
||
|
||
const slideBlocks: PptxSlideBlock[] = [];
|
||
|
||
for (let i = 0; i < slidePaths.length; i++) {
|
||
const slideNo = i + 1;
|
||
|
||
// スライド範囲フィルタ
|
||
if (slideRange && (slideNo < slideRange.min || slideNo > slideRange.max)) continue;
|
||
|
||
const slidePath = slidePaths[i]!;
|
||
const entry = zip.getEntry(slidePath);
|
||
if (!entry) {
|
||
warnings.push({
|
||
code: 'PARSE_PARTIAL_SUCCESS',
|
||
message: `Slide ${slideNo}: entry not found in ZIP (${slidePath})`,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
let slideXml: string;
|
||
try {
|
||
slideXml = entry.getData().toString('utf8');
|
||
} catch (e) {
|
||
warnings.push({
|
||
code: 'PARSE_PARTIAL_SUCCESS',
|
||
message: `Slide ${slideNo}: failed to read XML: ${(e as Error).message}`,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
const { texts, error } = extractTextsFromSlideXml(slideXml);
|
||
if (error) {
|
||
warnings.push({ code: 'PARSE_PARTIAL_SUCCESS', message: `Slide ${slideNo}: ${error}` });
|
||
}
|
||
|
||
// ノートの取得(.rels 経由で解決)
|
||
let notes: string | undefined;
|
||
const notesPath = resolveNotesPath(zip, slidePath);
|
||
if (notesPath) {
|
||
const notesEntry = zip.getEntry(notesPath);
|
||
if (notesEntry) {
|
||
try {
|
||
const notesXml = notesEntry.getData().toString('utf8');
|
||
const notesText = extractNotesFromXml(notesXml);
|
||
if (notesText) notes = notesText;
|
||
} catch (e) {
|
||
warnings.push({
|
||
code: 'PARSE_PARTIAL_SUCCESS',
|
||
message: `Slide ${slideNo}: failed to read notes: ${(e as Error).message}`,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
slideBlocks.push({ type: 'pptx.slide', slideNo, texts, notes });
|
||
}
|
||
|
||
const doc: OfficeDocument = {
|
||
kind: 'pptx',
|
||
source: { path: resolved, filename },
|
||
metadata: { slideCount: slidePaths.length, fileSizeBytes, processingTimeMs: Date.now() - startMs },
|
||
blocks: slideBlocks,
|
||
warnings,
|
||
};
|
||
|
||
// フォーマット出力
|
||
const parts: string[] = [`# ${filename}`, ''];
|
||
|
||
if (slideBlocks.length === 0) {
|
||
const msg = slideRange
|
||
? `No slides found in range ${slideRangeStr}`
|
||
: 'No slides found in file';
|
||
parts.push(msg);
|
||
} else {
|
||
for (const slide of slideBlocks) {
|
||
// スライドタイトルは最初のテキスト要素から推測
|
||
const titleText = slide.texts[0]?.text ?? '';
|
||
const titleLine = titleText
|
||
? `## Slide ${slide.slideNo}: ${titleText.split('\n')[0]}`
|
||
: `## Slide ${slide.slideNo}`;
|
||
parts.push(titleLine);
|
||
parts.push('');
|
||
|
||
// タイトル以降のテキスト(最初の要素はタイトルとして使ったが全文も出力)
|
||
for (const shape of slide.texts) {
|
||
const lines = shape.text.split('\n').filter((l) => l.trim());
|
||
if (lines.length > 0) {
|
||
parts.push(lines.join('\n'));
|
||
parts.push('');
|
||
}
|
||
}
|
||
|
||
if (slide.notes) {
|
||
parts.push(`**Notes:** ${slide.notes}`);
|
||
parts.push('');
|
||
}
|
||
}
|
||
}
|
||
|
||
if (doc.warnings.length > 0) {
|
||
parts.push('### Warnings');
|
||
for (const w of doc.warnings) {
|
||
parts.push(`- [${w.code}] ${w.message}`);
|
||
}
|
||
}
|
||
|
||
doc.metadata.processingTimeMs = Date.now() - startMs;
|
||
logger.debug(`[ReadPPTX] ${filename}: ${slideBlocks.length} slide(s) extracted`);
|
||
|
||
let output = parts.join('\n');
|
||
if (output.length > MAX_OUTPUT_CHARS) {
|
||
output = output.slice(0, MAX_OUTPUT_CHARS) + `\n\n[truncated: output exceeded ${MAX_OUTPUT_CHARS} characters]`;
|
||
}
|
||
output = truncateToBudget(output, getToolOutputBudgetTokens(ctx), {
|
||
sourceLabel: `ReadPPTX(${filename})`,
|
||
continuationHint: 'スライド範囲を絞るか、max_slides を調整してください',
|
||
}).text;
|
||
return { output, isError: false };
|
||
})();
|
||
|
||
return Promise.race([mainPromise, timeoutPromise]);
|
||
}
|
||
|
||
// --- Exports ---
|
||
|
||
export async function executeTool(
|
||
name: string,
|
||
input: Record<string, unknown>,
|
||
ctx: ToolContext,
|
||
): Promise<ToolResult | null> {
|
||
switch (name) {
|
||
case 'ReadExcel':
|
||
return executeReadExcel(input, ctx);
|
||
case 'ReadDocx':
|
||
return executeReadDocx(input, ctx);
|
||
case 'ReadPdf':
|
||
return executeReadPdf(input, ctx);
|
||
case 'ReadPPTX':
|
||
return executeReadPptx(input, ctx);
|
||
case 'SplitExcelSheets':
|
||
return executeSplitExcelSheets(input, ctx);
|
||
case 'SplitDocxSections':
|
||
return executeSplitDocxSections(input, ctx);
|
||
case 'PdfToImages':
|
||
return executePdfToImages(input, ctx);
|
||
default:
|
||
return null;
|
||
}
|
||
}
|