file-parser.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import { inflateRawSync } from 'node:zlib';
  2. /**
  3. * 简易文件解析工具
  4. * 支持从上传的 Excel/Word 文件中提取文本内容
  5. * 注意:完整实现需要 xlsx / mammoth 等库,此处提供基础文本提取
  6. */
  7. /**
  8. * 从文件 Buffer 提取可读文本
  9. * 对于 .docx 文件提取 XML 内的文本节点
  10. * 对于 .xlsx 文件提取 sharedStrings 中的文本
  11. */
  12. export function extractTextFromBuffer(buffer: Buffer, fileName: string): string {
  13. const ext = fileName.substring(fileName.lastIndexOf('.')).toLowerCase();
  14. console.log(`[FileParser] 文件: ${fileName}, 扩展名: ${ext}, buffer大小: ${buffer.length} bytes`);
  15. let result: string;
  16. if (ext === '.docx') {
  17. result = extractDocxText(buffer);
  18. } else if (ext === '.xlsx' || ext === '.xls') {
  19. result = extractXlsxText(buffer);
  20. } else {
  21. // Fallback: try to read as plain text
  22. result = buffer.toString('utf-8').slice(0, 50000);
  23. }
  24. console.log(`[FileParser] 提取文本长度: ${result.length} 字符`);
  25. console.log(`[FileParser] 文本前300字: ${result.substring(0, 300)}`);
  26. return result;
  27. }
  28. export interface WorkbookSheetTable {
  29. name: string;
  30. rows: string[][];
  31. }
  32. export function extractWorkbookTablesFromBuffer(buffer: Buffer): WorkbookSheetTable[] {
  33. const entries = listZipEntries(buffer);
  34. if (entries.length === 0) return [];
  35. const sharedStrings = parseSharedStrings(readZipEntry(buffer, 'xl/sharedStrings.xml'));
  36. const sheetNames = parseWorkbookSheetNames(readZipEntry(buffer, 'xl/workbook.xml'));
  37. return entries
  38. .filter((entry) => /^xl\/worksheets\/sheet\d+\.xml$/.test(entry.name))
  39. .sort((a, b) => a.name.localeCompare(b.name))
  40. .map((entry, index) => ({
  41. name: sheetNames[index] || `Sheet${index + 1}`,
  42. rows: parseWorksheetRows(readZipEntry(buffer, entry.name), sharedStrings),
  43. }))
  44. .filter((sheet) => sheet.rows.length > 0);
  45. }
  46. /**
  47. * 从 .docx (ZIP 内的 XML) 提取纯文本
  48. * 简易实现:查找 <w:t> 标签内容
  49. */
  50. function extractDocxText(buffer: Buffer): string {
  51. const documentXml = readZipEntry(buffer, 'word/document.xml');
  52. const raw = documentXml ? documentXml.toString('utf-8') : buffer.toString('utf-8');
  53. const textParts: string[] = [];
  54. // Extract text between <w:t> tags
  55. const regex = /<w:t[^>]*>([^<]*)<\/w:t>/g;
  56. let match: RegExpExecArray | null;
  57. while ((match = regex.exec(raw)) !== null) {
  58. if (match[1]) {
  59. textParts.push(match[1]);
  60. }
  61. }
  62. if (textParts.length > 0) {
  63. return textParts.join(' ');
  64. }
  65. // Fallback: extract any readable text segments
  66. return extractReadableText(buffer);
  67. }
  68. /**
  69. * 从 .xlsx (ZIP 内的 sharedStrings.xml) 提取文本
  70. */
  71. function extractXlsxText(buffer: Buffer): string {
  72. const entries = listZipEntries(buffer);
  73. if (entries.length === 0) {
  74. return extractReadableText(buffer);
  75. }
  76. const sharedStrings = parseSharedStrings(readZipEntry(buffer, 'xl/sharedStrings.xml'));
  77. const sheetEntries = entries
  78. .filter((entry) => /^xl\/worksheets\/sheet\d+\.xml$/.test(entry.name))
  79. .sort((a, b) => a.name.localeCompare(b.name));
  80. const sheetTexts = sheetEntries
  81. .map((entry, index) => parseWorksheetText(readZipEntry(buffer, entry.name), sharedStrings, index + 1))
  82. .filter(Boolean);
  83. return sheetTexts.length > 0 ? sheetTexts.join('\n\n') : extractReadableText(buffer);
  84. }
  85. /**
  86. * 从二进制数据提取可读文本段
  87. */
  88. function extractReadableText(buffer: Buffer): string {
  89. const text = buffer.toString('utf-8');
  90. // Filter to only printable characters and common Chinese
  91. const readable = text.replace(/[^\u4e00-\u9fff\u0020-\u007e\u00a0-\u00ff\n\r\t]/g, ' ');
  92. // Collapse whitespace
  93. return readable.replace(/\s+/g, ' ').trim().slice(0, 30000);
  94. }
  95. interface ZipEntry {
  96. name: string;
  97. method: number;
  98. compressedSize: number;
  99. localHeaderOffset: number;
  100. }
  101. function listZipEntries(buffer: Buffer): ZipEntry[] {
  102. const entries: ZipEntry[] = [];
  103. let offset = 0;
  104. while (offset < buffer.length - 46) {
  105. if (buffer.readUInt32LE(offset) !== 0x02014b50) {
  106. offset += 1;
  107. continue;
  108. }
  109. const method = buffer.readUInt16LE(offset + 10);
  110. const compressedSize = buffer.readUInt32LE(offset + 20);
  111. const fileNameLength = buffer.readUInt16LE(offset + 28);
  112. const extraLength = buffer.readUInt16LE(offset + 30);
  113. const commentLength = buffer.readUInt16LE(offset + 32);
  114. const localHeaderOffset = buffer.readUInt32LE(offset + 42);
  115. const name = buffer.toString('utf-8', offset + 46, offset + 46 + fileNameLength);
  116. entries.push({ name, method, compressedSize, localHeaderOffset });
  117. offset += 46 + fileNameLength + extraLength + commentLength;
  118. }
  119. return entries;
  120. }
  121. function readZipEntry(buffer: Buffer, entryName: string): Buffer | null {
  122. const entry = listZipEntries(buffer).find((item) => item.name === entryName);
  123. if (!entry) return null;
  124. const localOffset = entry.localHeaderOffset;
  125. if (buffer.readUInt32LE(localOffset) !== 0x04034b50) return null;
  126. const fileNameLength = buffer.readUInt16LE(localOffset + 26);
  127. const extraLength = buffer.readUInt16LE(localOffset + 28);
  128. const dataStart = localOffset + 30 + fileNameLength + extraLength;
  129. const compressed = buffer.subarray(dataStart, dataStart + entry.compressedSize);
  130. if (entry.method === 0) return compressed;
  131. if (entry.method === 8) return inflateRawSync(compressed);
  132. return null;
  133. }
  134. function parseSharedStrings(xmlBuffer: Buffer | null): string[] {
  135. if (!xmlBuffer) return [];
  136. const xml = xmlBuffer.toString('utf-8');
  137. const strings: string[] = [];
  138. const itemRegex = /<si\b[^>]*>([\s\S]*?)<\/si>/g;
  139. let itemMatch: RegExpExecArray | null;
  140. while ((itemMatch = itemRegex.exec(xml)) !== null) {
  141. strings.push(extractXmlText(itemMatch[1]));
  142. }
  143. return strings;
  144. }
  145. function parseWorksheetText(xmlBuffer: Buffer | null, sharedStrings: string[], sheetIndex: number): string {
  146. const rows = parseWorksheetRows(xmlBuffer, sharedStrings)
  147. .map((row) => row.map((value) => value.trim()).filter(Boolean));
  148. const textRows = rows.filter((row) => row.length > 0).map((row) => row.join(' | '));
  149. return textRows.length > 0 ? `Sheet${sheetIndex}\n${textRows.join('\n')}` : '';
  150. }
  151. function parseWorksheetRows(xmlBuffer: Buffer | null, sharedStrings: string[]): string[][] {
  152. if (!xmlBuffer) return [];
  153. const xml = xmlBuffer.toString('utf-8');
  154. const rows: string[][] = [];
  155. const rowRegex = /<row\b[^>]*>([\s\S]*?)<\/row>/g;
  156. let rowMatch: RegExpExecArray | null;
  157. while ((rowMatch = rowRegex.exec(xml)) !== null) {
  158. const values: string[] = [];
  159. const cellRegex = /<c\b([^>]*)>([\s\S]*?)<\/c>/g;
  160. let cellMatch: RegExpExecArray | null;
  161. while ((cellMatch = cellRegex.exec(rowMatch[1])) !== null) {
  162. const attrs = cellMatch[1];
  163. const body = cellMatch[2];
  164. const ref = attrs.match(/\br="([A-Z]+\d+)"/)?.[1] || '';
  165. const colIndex = ref ? columnNameToIndex(ref.replace(/\d+/g, '')) : values.length;
  166. const type = attrs.match(/\bt="([^"]+)"/)?.[1];
  167. const rawValue = body.match(/<v[^>]*>([\s\S]*?)<\/v>/)?.[1] || '';
  168. let value = '';
  169. if (type === 's') {
  170. value = sharedStrings[Number(rawValue)] || '';
  171. } else if (type === 'inlineStr') {
  172. value = extractXmlText(body);
  173. } else {
  174. value = decodeXml(rawValue);
  175. }
  176. values[colIndex] = value.trim();
  177. }
  178. if (values.some(Boolean)) rows.push(values.map((value) => value || ''));
  179. }
  180. return rows;
  181. }
  182. function parseWorkbookSheetNames(xmlBuffer: Buffer | null): string[] {
  183. if (!xmlBuffer) return [];
  184. const xml = xmlBuffer.toString('utf-8');
  185. const names: string[] = [];
  186. const sheetRegex = /<sheet\b[^>]*\bname="([^"]+)"/g;
  187. let match: RegExpExecArray | null;
  188. while ((match = sheetRegex.exec(xml)) !== null) {
  189. names.push(decodeXml(match[1]));
  190. }
  191. return names;
  192. }
  193. function columnNameToIndex(name: string): number {
  194. let result = 0;
  195. for (const char of name) {
  196. result = result * 26 + (char.charCodeAt(0) - 64);
  197. }
  198. return Math.max(0, result - 1);
  199. }
  200. function extractXmlText(xml: string): string {
  201. const parts: string[] = [];
  202. const textRegex = /<t\b[^>]*>([\s\S]*?)<\/t>/g;
  203. let textMatch: RegExpExecArray | null;
  204. while ((textMatch = textRegex.exec(xml)) !== null) {
  205. parts.push(decodeXml(textMatch[1]));
  206. }
  207. return parts.join('').trim();
  208. }
  209. function decodeXml(value: string): string {
  210. return value
  211. .replace(/&lt;/g, '<')
  212. .replace(/&gt;/g, '>')
  213. .replace(/&quot;/g, '"')
  214. .replace(/&apos;/g, "'")
  215. .replace(/&amp;/g, '&');
  216. }