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, 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(' 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: "" }) でレンダリング表示' : '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 || ''})。`; } // 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: "" }) でレンダリング表示してください。`; 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 = { 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>(); 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>(); 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, ctx: ToolContext, ): Promise { 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((resolve) => setTimeout(() => resolve({ output: 'Processing timeout: file took longer than 30 seconds', isError: true }), PROCESSING_TIMEOUT_MS) ); const mainPromise = (async (): Promise => { 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, ctx: ToolContext, ): Promise { 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, ctx: ToolContext, ): Promise { 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 = /]*>([\s\S]*?)<\/table>/gi; let tableMatch: RegExpExecArray | null; while ((tableMatch = tableRegex.exec(html)) !== null) { const tableContent = tableMatch[1] ?? ''; const rows: string[][] = []; const rowRegex = /]*>([\s\S]*?)<\/tr>/gi; let rowMatch: RegExpExecArray | null; while ((rowMatch = rowRegex.exec(tableContent)) !== null) { const rowContent = rowMatch[1] ?? ''; const cells: string[] = []; const cellRegex = /]*>([\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, ctx: ToolContext, ): Promise { 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((resolve) => setTimeout(() => resolve({ output: 'Processing timeout: file took longer than 30 seconds', isError: true }), PROCESSING_TIMEOUT_MS) ); const mainPromise = (async (): Promise => { 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 }> = []; 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, ctx: ToolContext, ): Promise { 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((resolve) => setTimeout(() => resolve({ output: 'Processing timeout: file took longer than 30 seconds', isError: true }), PROCESSING_TIMEOUT_MS) ); const mainPromise = (async (): Promise => { 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, ctx: ToolContext, ): Promise { 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((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 { 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)['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 { 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)['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)['txBody']; if (!txBody || typeof txBody !== 'object') { rowTexts.push(''); continue; } const paras = (txBody as Record)['p']; const cellText = Array.isArray(paras) ? paras.map((p) => extractParagraphText(p as Record)).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; try { parsed = xmlParser.parse(xmlContent) as Record; } 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 | undefined; if (!sld) return { texts }; const cSld = sld['cSld'] as Record | undefined; if (!cSld) return { texts }; const spTree = cSld['spTree'] as Record | 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; const shapeId = (() => { const nvSpPr = shape['nvSpPr'] as Record | undefined; if (!nvSpPr) return undefined; const cNvPr = nvSpPr['cNvPr'] as Record | undefined; return cNvPr ? toStr(cNvPr['@_id']) : undefined; })(); const txBody = shape['txBody'] as Record | undefined; if (!txBody) continue; const paras = txBody['p']; if (!Array.isArray(paras)) continue; const paraTexts = paras .map((p) => extractParagraphText(p as Record)) .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; const graphic = gfObj['graphic'] as Record | undefined; if (!graphic) continue; const graphicData = graphic['graphicData'] as Record | undefined; if (!graphicData) continue; const tbl = graphicData['tbl'] as Record | 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; try { parsed = xmlParser.parse(xmlContent) as Record; } catch { return ''; } const notesSld = parsed['notes'] as Record | undefined; if (!notesSld) return ''; const cSld = notesSld['cSld'] as Record | undefined; if (!cSld) return ''; const spTree = cSld['spTree'] as Record | 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; // ph type="body" がノート本文(idx=1)。type="dt","ftr","sldNum" は除外 const nvSpPr = shape['nvSpPr'] as Record | undefined; const nvPr = nvSpPr?.['nvPr'] as Record | undefined; const ph = nvPr?.['ph'] as Record | 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 | undefined; if (!txBody) continue; const paras = txBody['p']; if (!Array.isArray(paras)) continue; const paraTexts = paras .map((p) => extractParagraphText(p as Record)) .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; try { parsed = xmlParser.parse(presXml) as Record; } catch { return []; } // rId の順序を sldIdLst から取得 const presentation = parsed['presentation'] as Record | undefined; if (!presentation) return []; const sldIdLst = presentation['sldIdLst'] as Record | undefined; if (!sldIdLst) return []; const sldIds = sldIdLst['sldId']; if (!Array.isArray(sldIds)) return []; const rIds: string[] = sldIds.map((s) => { const obj = s as Record; 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; try { relsParsed = xmlParser.parse(relsXml) as Record; } catch { return []; } const relationships = (relsParsed['Relationships'] as Record | undefined); if (!relationships) return []; // Relationship は配列またはオブジェクトの場合がある const rels = relationships['Relationship']; const relArray = Array.isArray(rels) ? rels : [rels]; // rId → target パスのマップを構築 const rIdToTarget = new Map(); for (const rel of relArray) { if (!rel || typeof rel !== 'object') continue; const r = rel as Record; 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; try { parsed = xmlParser.parse(relsXml) as Record; } catch { return null; } const relationships = parsed['Relationships'] as Record | 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; 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, ctx: ToolContext, ): Promise { 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((resolve) => setTimeout(() => resolve({ output: 'Processing timeout: file took longer than 30 seconds', isError: true }), PROCESSING_TIMEOUT_MS) ); const mainPromise = (async (): Promise => { 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, ctx: ToolContext, ): Promise { 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; } }