236 lines
8.2 KiB
TypeScript
236 lines
8.2 KiB
TypeScript
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<ExcelSheetRows[]> {
|
|
try {
|
|
return await this.loadWithExcelJs(buffer);
|
|
} catch {
|
|
return this.loadWithFallback(buffer);
|
|
}
|
|
}
|
|
|
|
async extractText(buffer: Buffer): Promise<string> {
|
|
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<ExcelSheetRows[]> {
|
|
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<ExcelSheetRows[]> {
|
|
const zip = await JSZip.loadAsync(buffer);
|
|
const readEntry = async (name: string): Promise<string | null> => {
|
|
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<string, string>();
|
|
for (const match of relsXml.matchAll(
|
|
/<Relationship[^>]*\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(/<sheet\b[^>]*\/?>/g)) {
|
|
const tag = match[0].replace(/<sheet\b/, '<sheet').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<string | null>,
|
|
): Promise<string[]> {
|
|
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(/<si\b[^>]*>([\s\S]*?)<\/si>/gs)) {
|
|
const texts = [...match[1].matchAll(/<t\b[^>]*>([\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(/<row\b[^>]*>([\s\S]*?)<\/row>/gs)) {
|
|
const cells = new Map<number, string>();
|
|
let maxColumn = -1;
|
|
for (const cellMatch of rowMatch[1].matchAll(/<c\b([^>]*)\/?>([\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>([^<]*)<\/v>/)?.[1] ?? '');
|
|
value = Number.isInteger(index) ? (sharedStrings[index] ?? '') : '';
|
|
} else if (type === 'inlineStr') {
|
|
const texts = [...body.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) =>
|
|
this.unescapeXml(part[1]),
|
|
);
|
|
value = texts.join('');
|
|
} else {
|
|
value = this.unescapeXml(body.match(/<v>([\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 '';
|
|
}
|
|
}
|
|
}
|