Files
maestro/src/engine/tools/office.ts
T

2297 lines
83 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}