import { Injectable } from '@nestjs/common'; import ExcelJS from 'exceljs'; import JSZip from 'jszip'; export interface ExcelSheetInfo { name: string; rowCount: number; columns: string[]; } export interface ExcelSheetRows { name: string; rows: string[][]; } /** * Structured Excel reader used by the AI chat. ExcelJS handles standard * files; a direct OOXML fallback handles WPS-style files that prefix * every element with a namespace. The agent reads sheets on demand * (`list_excel_sheets` / `read_excel_rows`) instead of receiving one * fixed text dump. */ @Injectable() export class AiExcelReaderService { async loadSheets(buffer: Buffer): Promise { try { return await this.loadWithExcelJs(buffer); } catch { return this.loadWithFallback(buffer); } } async extractText(buffer: Buffer): Promise { const sheets = await this.loadSheets(buffer); return sheets .map((sheet) => `# ${sheet.name}\n${sheet.rows.map((row) => row.join('\t')).join('\n')}`) .join('\n'); } /** Sheet list + row counts + a short sample, small enough for prompts. */ async overview( buffer: Buffer, sampleRows = 12, ): Promise<{ sheets: ExcelSheetInfo[]; text: string }> { const sheets = await this.loadSheets(buffer); const info = sheets.map((sheet) => ({ name: sheet.name, rowCount: sheet.rows.length, columns: sheet.rows[0] ?? [], })); const lines: string[] = []; for (const sheet of sheets) { lines.push(`# ${sheet.name}(共 ${sheet.rows.length} 行)`); for (const row of sheet.rows.slice(0, sampleRows)) { lines.push(row.join('\t')); } if (sheet.rows.length > sampleRows) { lines.push(`…(其余 ${sheet.rows.length - sampleRows} 行未显示)`); } } return { sheets: info, text: lines.join('\n') }; } async readRows( buffer: Buffer, sheetName: string | undefined, startRow: number, rowCount: number, maxColumns: number, ): Promise<{ sheet: string; rowCount: number; startRow: number; rows: string[][]; truncated: boolean; }> { const sheets = await this.loadSheets(buffer); const sheet = sheets.find((item) => item.name === sheetName) ?? sheets[0]; if (!sheet) { return { sheet: sheetName ?? '', rowCount: 0, startRow, rows: [], truncated: false }; } const from = Math.max(0, startRow - 1); const limit = Math.min(rowCount, 200); const slice = sheet.rows.slice(from, from + limit); const rows = slice.map((row) => row.slice(0, Math.min(maxColumns, 50))); return { sheet: sheet.name, rowCount: sheet.rows.length, startRow: from + 1, rows, truncated: slice.length < limit, }; } private async loadWithExcelJs(buffer: Buffer): Promise { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(buffer as unknown as ExcelJS.Buffer); const sheets: ExcelSheetRows[] = []; workbook.eachSheet((sheet) => { const rows: string[][] = []; sheet.eachRow((row) => { const values = Array.isArray(row.values) ? row.values.slice(1) : []; rows.push(values.map((value) => this.stringifyCellValue(value))); }); sheets.push({ name: sheet.name, rows }); }); return sheets; } private async loadWithFallback(buffer: Buffer): Promise { const zip = await JSZip.loadAsync(buffer); const readEntry = async (name: string): Promise => { const entry = zip.file(name); return entry ? entry.async('string') : null; }; const workbookXml = await readEntry('xl/workbook.xml'); if (!workbookXml) throw new Error('workbook.xml missing'); const stripPrefixes = (value: string): string => value.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1'); const relsXml = stripPrefixes((await readEntry('xl/_rels/workbook.xml.rels')) ?? ''); const relTargets = new Map(); for (const match of relsXml.matchAll( /]*\bId="([^"]+)"[^>]*\bTarget="([^"]+)"/g, )) { const target = match[2].replace(/^\/+/, ''); relTargets.set(match[1], target.startsWith('xl/') ? target : `xl/${target}`); } const sharedStrings = await this.parseSharedStringsFallback(readEntry); const sheets: ExcelSheetRows[] = []; const cleanWorkbook = stripPrefixes(workbookXml); for (const match of cleanWorkbook.matchAll(/]*\/?>/g)) { const tag = match[0].replace(/$/, '>'); const name = tag.match(/\bname="([^"]+)"/)?.[1]; const rid = tag.match(/\br:id="([^"]+)"/)?.[1]; if (!name || !rid) continue; const target = relTargets.get(rid); const sheetXml = target ? await readEntry(target) : null; if (!sheetXml) continue; sheets.push({ name: this.unescapeXml(name), rows: this.sheetRowsFromXmlFallback(sheetXml, sharedStrings), }); } return sheets; } private async parseSharedStringsFallback( readEntry: (name: string) => Promise, ): Promise { const xml = await readEntry('xl/sharedStrings.xml'); if (!xml) return []; const clean = xml.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1'); const strings: string[] = []; for (const match of clean.matchAll(/]*>([\s\S]*?)<\/si>/gs)) { const texts = [...match[1].matchAll(/]*>([\s\S]*?)<\/t>/g)].map((part) => this.unescapeXml(part[1]), ); strings.push(texts.join('')); } return strings; } private sheetRowsFromXmlFallback(sheetXml: string, sharedStrings: string[]): string[][] { const rows: string[][] = []; const xml = sheetXml.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1'); for (const rowMatch of xml.matchAll(/]*>([\s\S]*?)<\/row>/gs)) { const cells = new Map(); let maxColumn = -1; for (const cellMatch of rowMatch[1].matchAll(/]*)\/?>([\s\S]*?)<\/c>/gs)) { const attrs = cellMatch[1]; const refMatch = attrs.match(/\br="([A-Z]+)\d+"/); const column = refMatch ? this.columnIndex(refMatch[1]) : -1; const type = attrs.match(/\bt="([^"]+)"/)?.[1] ?? 'n'; const body = cellMatch[2] ?? ''; let value = ''; if (type === 's') { const index = Number(body.match(/([^<]*)<\/v>/)?.[1] ?? ''); value = Number.isInteger(index) ? (sharedStrings[index] ?? '') : ''; } else if (type === 'inlineStr') { const texts = [...body.matchAll(/]*>([\s\S]*?)<\/t>/g)].map((part) => this.unescapeXml(part[1]), ); value = texts.join(''); } else { value = this.unescapeXml(body.match(/([\s\S]*?)<\/v>/)?.[1] ?? ''); if (type === 'b') value = value === '1' ? 'true' : 'false'; } if (column >= 0) { cells.set(column, value); maxColumn = Math.max(maxColumn, column); } } if (maxColumn < 0) continue; const values = Array.from({ length: maxColumn + 1 }, (_, index) => cells.get(index) ?? ''); if (values.every((value) => value === '')) continue; rows.push(values); } return rows; } private columnIndex(letters: string): number { let index = 0; for (const char of letters.toUpperCase()) { index = index * 26 + (char.charCodeAt(0) - 64); } return index - 1; } private unescapeXml(value: string): string { return value .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&') .replace(/&#x([0-9a-fA-F]+);/g, (_all, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16)), ) .replace(/&#(\d+);/g, (_all, dec: string) => String.fromCodePoint(Number(dec))); } private stringifyCellValue(value: unknown): string { if (value === null || value === undefined) return ''; if (value instanceof Date) return value.toISOString(); if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return String(value); } try { return JSON.stringify(value) || ''; } catch { return ''; } } }