xlsx.js 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. 'use strict';
  2. /**
  3. * 最小 xlsx 读取器(零依赖)
  4. * ----------------------------------------------------------------------------
  5. * xlsx 是 ZIP 容器 + XML。这里只做一件事:把第一个工作表的单元格读成二维数组,
  6. * 供解析器使用。够用即可,不做通用电子表格库。
  7. *
  8. * 支持:共享字符串(t="s")、内联字符串(inlineStr)、缓存值(t="str")、数字、布尔。
  9. * 不支持:.xls、公式计算(读缓存值)、样式/颜色、图表、合并单元格语义
  10. * (合并区只取左上角,其余为空)、多工作表遍历(只读第一个)。
  11. */
  12. const zlib = require('zlib');
  13. const EOCD_SIG = 0x06054b50;
  14. const CEN_SIG = 0x02014b50;
  15. const LOC_SIG = 0x04034b50;
  16. function fail(message, cause) {
  17. const err = new Error(message);
  18. err.code = 'XLSX_PARSE_FAILED';
  19. if (cause) err.cause = cause;
  20. return err;
  21. }
  22. // ---------- ZIP ----------
  23. /** 从尾部找中央目录结束记录(EOCD 可能带注释,最多回退 65557 字节) */
  24. function findEocd(buf) {
  25. const stop = Math.max(0, buf.length - 65557);
  26. for (let i = buf.length - 22; i >= stop; i--) {
  27. if (buf.readUInt32LE(i) === EOCD_SIG) return i;
  28. }
  29. return -1;
  30. }
  31. function readCentralDirectory(buf) {
  32. const eocd = findEocd(buf);
  33. if (eocd < 0) throw fail('不是有效的 zip/xlsx 文件');
  34. const count = buf.readUInt16LE(eocd + 10);
  35. const cdOffset = buf.readUInt32LE(eocd + 16);
  36. if (cdOffset <= 0 || cdOffset >= buf.length) throw fail('zip 中央目录偏移非法');
  37. const entries = new Map();
  38. let p = cdOffset;
  39. for (let i = 0; i < count; i++) {
  40. if (p + 46 > buf.length) throw fail('zip 中央目录被截断');
  41. if (buf.readUInt32LE(p) !== CEN_SIG) throw fail('zip 中央目录签名不符');
  42. const method = buf.readUInt16LE(p + 10);
  43. const compSize = buf.readUInt32LE(p + 20);
  44. const nameLen = buf.readUInt16LE(p + 28);
  45. const extraLen = buf.readUInt16LE(p + 30);
  46. const commentLen = buf.readUInt16LE(p + 32);
  47. const localOffset = buf.readUInt32LE(p + 42);
  48. const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
  49. entries.set(name, { method, compSize, localOffset });
  50. p += 46 + nameLen + extraLen + commentLen;
  51. }
  52. return entries;
  53. }
  54. function readEntry(buf, entries, name) {
  55. const entry = entries.get(name);
  56. if (!entry) return null;
  57. const p = entry.localOffset;
  58. if (p + 30 > buf.length || buf.readUInt32LE(p) !== LOC_SIG) throw fail('zip 本地文件头损坏');
  59. // 本地头的 name/extra 长度以本地头为准(中央目录里可能带额外字段)
  60. const nameLen = buf.readUInt16LE(p + 26);
  61. const extraLen = buf.readUInt16LE(p + 28);
  62. const start = p + 30 + nameLen + extraLen;
  63. const end = start + entry.compSize;
  64. if (end > buf.length) throw fail('zip 条目被截断');
  65. const raw = buf.subarray(start, end);
  66. if (entry.method === 0) return raw;
  67. if (entry.method === 8) {
  68. try {
  69. return zlib.inflateRawSync(raw);
  70. } catch (e) {
  71. throw fail('zip 解压失败', e);
  72. }
  73. }
  74. throw fail(`不支持的 zip 压缩方式:${entry.method}`);
  75. }
  76. // ---------- XML ----------
  77. function decodeXml(text) {
  78. return String(text).replace(/&(#x?[0-9a-fA-F]+|lt|gt|amp|quot|apos);/g, (whole, ent) => {
  79. switch (ent) {
  80. case 'lt': return '<';
  81. case 'gt': return '>';
  82. case 'amp': return '&';
  83. case 'quot': return '"';
  84. case 'apos': return "'";
  85. default: break;
  86. }
  87. if (ent[0] === '#') {
  88. const code = ent[1] === 'x' || ent[1] === 'X'
  89. ? parseInt(ent.slice(2), 16)
  90. : parseInt(ent.slice(1), 10);
  91. return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
  92. }
  93. return whole;
  94. });
  95. }
  96. function attrOf(attrs, name) {
  97. const m = new RegExp(`\\b${name}="([^"]*)"`).exec(attrs || '');
  98. return m ? m[1] : '';
  99. }
  100. function parseSharedStrings(xml) {
  101. const out = [];
  102. if (!xml) return out;
  103. const siRe = /<si\b[^>]*?(?:\/>|>([\s\S]*?)<\/si>)/g;
  104. let m;
  105. while ((m = siRe.exec(xml))) {
  106. const inner = m[1] || '';
  107. let text = '';
  108. const tRe = /<t\b[^>]*>([\s\S]*?)<\/t>/g;
  109. let t;
  110. while ((t = tRe.exec(inner))) text += decodeXml(t[1]);
  111. out.push(text);
  112. }
  113. return out;
  114. }
  115. function columnIndex(ref) {
  116. const m = /^([A-Za-z]+)/.exec(ref || '');
  117. if (!m) return -1;
  118. let n = 0;
  119. for (const ch of m[1].toUpperCase()) n = n * 26 + (ch.charCodeAt(0) - 64);
  120. return n - 1;
  121. }
  122. function parseCell(attrs, inner, shared) {
  123. const type = attrOf(attrs, 't');
  124. if (type === 'inlineStr') {
  125. let text = '';
  126. const tRe = /<t\b[^>]*>([\s\S]*?)<\/t>/g;
  127. let t;
  128. while ((t = tRe.exec(inner))) text += decodeXml(t[1]);
  129. return text === '' ? null : text;
  130. }
  131. const v = /<v\b[^>]*>([\s\S]*?)<\/v>/.exec(inner);
  132. if (!v) return null;
  133. const raw = decodeXml(v[1]);
  134. if (type === 's') {
  135. const idx = Number(raw);
  136. return Number.isInteger(idx) && idx >= 0 && idx < shared.length ? shared[idx] : null;
  137. }
  138. if (type === 'str') return raw === '' ? null : raw;
  139. if (type === 'b') return raw === '1';
  140. if (type === 'e') return null;
  141. if (raw === '') return null;
  142. const num = Number(raw);
  143. return Number.isFinite(num) ? num : raw;
  144. }
  145. function parseSheet(xml, shared) {
  146. const rows = [];
  147. const rowRe = /<row\b([^>]*?)(?:\/>|>([\s\S]*?)<\/row>)/g;
  148. let m;
  149. while ((m = rowRe.exec(xml))) {
  150. const rAttr = attrOf(m[1], 'r');
  151. const rowIdx = rAttr ? Number(rAttr) - 1 : rows.length;
  152. const cells = [];
  153. const cellRe = /<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g;
  154. let c;
  155. while ((c = cellRe.exec(m[2] || ''))) {
  156. const ci = columnIndex(attrOf(c[1], 'r'));
  157. if (ci < 0) continue;
  158. cells[ci] = parseCell(c[1], c[2] || '', shared);
  159. }
  160. for (let i = 0; i < cells.length; i++) if (cells[i] === undefined) cells[i] = null;
  161. rows[rowIdx] = cells;
  162. }
  163. const out = [];
  164. for (let i = 0; i < rows.length; i++) {
  165. const row = rows[i] || [];
  166. for (let j = 0; j < row.length; j++) if (row[j] === undefined) row[j] = null;
  167. out.push(row);
  168. }
  169. return out;
  170. }
  171. /** 工作表路径:优先读 workbook.xml + rels,失败则回退到第一个 sheetN.xml */
  172. function resolveFirstSheet(buf, entries) {
  173. const workbook = readEntry(buf, entries, 'xl/workbook.xml');
  174. const rels = readEntry(buf, entries, 'xl/_rels/workbook.xml.rels');
  175. if (workbook && rels) {
  176. const sheetTag = /<sheet\b([^>]*)\/?>/.exec(workbook.toString('utf8'));
  177. const rid = sheetTag ? attrOf(sheetTag[1], 'r:id') : '';
  178. if (rid) {
  179. const relRe = /<Relationship\b([^>]*)\/?>/g;
  180. let m;
  181. const relsText = rels.toString('utf8');
  182. while ((m = relRe.exec(relsText))) {
  183. if (attrOf(m[1], 'Id') === rid) {
  184. let target = attrOf(m[1], 'Target');
  185. if (!target) break;
  186. target = target.replace(/^\/?xl\//, '').replace(/^\//, '');
  187. return { path: 'xl/' + target, name: '' };
  188. }
  189. }
  190. }
  191. }
  192. const names = [...entries.keys()]
  193. .filter((n) => /^xl\/worksheets\/sheet\d+\.xml$/.test(n))
  194. .sort((a, b) => Number(/(\d+)/.exec(a)[1]) - Number(/(\d+)/.exec(b)[1]));
  195. if (!names.length) throw fail('xlsx 中没有工作表');
  196. return { path: names[0], name: '' };
  197. }
  198. /**
  199. * 读取第一个工作表
  200. * @param {Buffer} buffer
  201. * @returns {{ sheets: Array<{ name: string, rows: Array<Array<string|number|boolean|null>> }> }}
  202. */
  203. function readXlsx(buffer) {
  204. if (!Buffer.isBuffer(buffer) || buffer.length < 22) throw fail('文件内容为空或不是 xlsx');
  205. const entries = readCentralDirectory(buffer);
  206. const sheetRef = resolveFirstSheet(buffer, entries);
  207. const sheetXml = readEntry(buffer, entries, sheetRef.path);
  208. if (!sheetXml) throw fail('未找到工作表数据');
  209. const sharedXml = readEntry(buffer, entries, 'xl/sharedStrings.xml');
  210. const shared = parseSharedStrings(sharedXml ? sharedXml.toString('utf8') : '');
  211. let rows;
  212. try {
  213. rows = parseSheet(sheetXml.toString('utf8'), shared);
  214. } catch (e) {
  215. if (e.code === 'XLSX_PARSE_FAILED') throw e;
  216. throw fail('工作表 XML 解析失败', e);
  217. }
  218. return { sheets: [{ name: sheetRef.name || 'Sheet1', rows }] };
  219. }
  220. module.exports = { readXlsx };