/** * 极简 XLSX 生成器(零依赖) * * 只实现导出场景所需的 OOXML 子集:列宽、行高、合并单元格、字体、边框、 * 对齐方式、数字格式、纯色填充。数据一律写为 inlineStr / number, * 不生成 sharedStrings,避免额外的部件与索引维护。 * * 用法: * const blob = buildXlsxBlob([{ * name: "Sheet1", * columns: [{ width: 14 }], * rows: [{ height: 20, cells: [{ value: "标题", style: { ... } }] }], * merges: [{ r1: 0, c1: 0, r2: 0, c2: 7 }], * }]); */ import { createZip, utf8 } from "./zipStore"; const escapeXml = (value) => String(value) // 去除 XML 1.0 不允许的控制字符 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); const columnName = (index) => { let name = ""; let n = index + 1; while (n > 0) { const remainder = (n - 1) % 26; name = String.fromCharCode(65 + remainder) + name; n = Math.floor((n - 1) / 26); } return name; }; const cellRef = (rowIndex, colIndex) => `${columnName(colIndex)}${rowIndex + 1}`; const XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; const BORDER_STYLE_BY_CODE = { 1: "thin", 2: "medium", 3: "dashed", 4: "dotted", 5: "thick", 6: "double", 7: "hair", 8: "mediumDashed", 9: "dashDot", 10: "mediumDashDot", 11: "dashDotDot", 12: "mediumDashDotDot", 13: "slantDashDot", }; const resolveBorderStyle = (value) => { if (!value) return ""; if (typeof value === "number") return BORDER_STYLE_BY_CODE[value] || "thin"; return value === true ? "thin" : String(value); }; /** 把样式对象归一化成可比较的 key,并收集各子表 */ const collectStyles = (styleList) => { const fonts = []; const fills = []; const borders = []; const numFmts = []; const xfs = []; const fontIndex = new Map(); const fillIndex = new Map(); const borderIndex = new Map(); const numFmtIndex = new Map(); // 默认字体 / 填充 / 边框占位(fill 0=none、1=gray125 是 Excel 的固定约定) const internFont = (font) => { const key = JSON.stringify(font || {}); if (fontIndex.has(key)) return fontIndex.get(key); const index = fonts.length; fonts.push(font || {}); fontIndex.set(key, index); return index; }; const internFill = (fill) => { const key = JSON.stringify(fill || null); if (fillIndex.has(key)) return fillIndex.get(key); const index = fills.length; fills.push(fill || null); fillIndex.set(key, index); return index; }; const internBorder = (border) => { const normalized = border || {}; const key = JSON.stringify(normalized); if (borderIndex.has(key)) return borderIndex.get(key); const index = borders.length; borders.push(normalized); borderIndex.set(key, index); return index; }; const internNumFmt = (formatCode) => { if (!formatCode) return 0; if (numFmtIndex.has(formatCode)) return numFmtIndex.get(formatCode); const id = 164 + numFmts.length; numFmts.push({ id, code: formatCode }); numFmtIndex.set(formatCode, id); return id; }; // 预留默认条目 internFont({ name: "宋体", size: 11 }); internFill(null); internFill({ patternType: "gray125" }); internBorder({}); styleList.forEach((style) => { const source = style || {}; xfs.push({ fontId: internFont(source.font), fillId: internFill(source.fill), borderId: internBorder(source.border), numFmtId: internNumFmt(source.numFmt), align: source.align || null, }); }); return { fonts, fills, borders, numFmts, xfs }; }; const buildFontXml = (font) => { const parts = [""]; if (font.bold) parts.push(""); if (font.italic) parts.push(""); if (font.strike) parts.push(""); if (font.underline) { parts.push(font.underline === "double" ? '' : ""); } parts.push(``); if (font.color) parts.push(``); parts.push(``); if (font.charset) parts.push(``); parts.push(""); return parts.join(""); }; const buildFillXml = (fill) => { if (!fill) return ''; const patternType = fill.patternType || "solid"; const fg = fill.color ? `` : ""; return `${fg}`; }; const buildBorderXml = (border) => { const side = (name) => { const style = resolveBorderStyle(border[name]); return style ? `<${name} style="${style}">` : `<${name}/>`; }; return `${side("left")}${side("right")}${side("top")}${side("bottom")}`; }; const buildXfXml = (xf) => { const attrs = [ `numFmtId="${xf.numFmtId}"`, `fontId="${xf.fontId}"`, `fillId="${xf.fillId}"`, `borderId="${xf.borderId}"`, 'xfId="0"', ]; if (xf.numFmtId) attrs.push('applyNumberFormat="1"'); if (xf.fontId) attrs.push('applyFont="1"'); if (xf.fillId > 1) attrs.push('applyFill="1"'); if (xf.borderId) attrs.push('applyBorder="1"'); const align = xf.align || {}; const alignAttrs = []; if (align.horizontal) alignAttrs.push(`horizontal="${align.horizontal}"`); if (align.vertical) alignAttrs.push(`vertical="${align.vertical}"`); if (align.wrapText) alignAttrs.push('wrapText="1"'); if (align.textRotation) alignAttrs.push(`textRotation="${align.textRotation}"`); if (alignAttrs.length) { attrs.push('applyAlignment="1"'); return ``; } return ``; }; const buildStylesXml = (styleTable) => { const { fonts, fills, borders, numFmts, xfs } = styleTable; const numFmtXml = numFmts.length ? `${numFmts .map((item) => ``) .join("")}` : ""; return [ '', '', numFmtXml, `${fonts.map(buildFontXml).join("")}`, `${fills.map(buildFillXml).join("")}`, `${borders.map(buildBorderXml).join("")}`, '', `${xfs.map(buildXfXml).join("")}`, '', "", ].join(""); }; const buildSheetXml = (sheet, styleTable) => { const rows = sheet.rows || []; const columns = sheet.columns || []; const colsXml = columns.length ? `${columns .map((column, index) => { const width = typeof column === "number" ? column : column.width; if (!width) return ""; return ``; }) .join("")}` : ""; let maxCol = 0; const rowXml = rows .map((row, rowIndex) => { const cells = row.cells || []; const cellsXml = cells .map((cell, colIndex) => { if (!cell || cell.value === undefined || cell.value === null || cell.value === "") { if (!cell || cell.style === undefined) return ""; return ``; } const ref = cellRef(rowIndex, colIndex); const styleAttr = cell.style === undefined ? "" : ` s="${cell.style}"`; if (typeof cell.value === "number" && Number.isFinite(cell.value)) { return `${cell.value}`; } if (typeof cell.value === "boolean") { return `${cell.value ? 1 : 0}`; } return `${escapeXml( cell.value )}`; }) .filter(Boolean) .join(""); const cellCount = cells.length; if (cellCount > maxCol) maxCol = cellCount; if (!cellsXml) return ""; const heightAttr = row.height ? ` ht="${row.height}" customHeight="1"` : ""; return `${cellsXml}`; }) .filter(Boolean) .join(""); const merges = sheet.merges || []; const mergesXml = merges.length ? `${merges .map( (merge) => `` ) .join("")}` : ""; const lastRow = rows.length; const dimension = lastRow && maxCol ? `A1:${cellRef(lastRow - 1, maxCol - 1)}` : "A1"; return [ '', '', ``, '', '', colsXml, `${rowXml}`, mergesXml, "", ] .filter(Boolean) .join(""); }; /** * 生成 xlsx 文件的 Blob。 * @param {Array<{name:string, columns?:Array, rows?:Array, merges?:Array}>} sheets * @returns {Blob} */ export function buildXlsxBlob(sheets) { const sheetList = Array.isArray(sheets) ? sheets : [sheets]; // 样式全表去重:所有工作表的样式共用一张表 const flatStyles = []; const styleIds = new Map(); const internStyle = (style) => { const key = JSON.stringify(style || {}); if (styleIds.has(key)) return styleIds.get(key); const id = flatStyles.length; flatStyles.push(style || {}); styleIds.set(key, id); return id; }; // 先把每个单元格的样式对象替换成全局样式下标 const preparedSheets = sheetList.map((sheet, sheetIndex) => ({ name: (sheet.name || `Sheet${sheetIndex + 1}`).replace(/[\\/*?:[\]]/g, "").slice(0, 31), columns: sheet.columns || [], merges: sheet.merges || [], rows: (sheet.rows || []).map((row) => ({ height: row.height, cells: (row.cells || []).map((cell) => { if (!cell) return null; if (cell.style === undefined || cell.style === null) { return { value: cell.value, style: 0 }; } if (typeof cell.style === "number") return { value: cell.value, style: cell.style }; return { value: cell.value, style: internStyle(cell.style) }; }), })), })); // 样式表首项必须是默认样式,保证空的 style=0 可用 if (flatStyles.length === 0) flatStyles.push({}); const styleTable = collectStyles(flatStyles); const sheetFiles = preparedSheets.map((sheet, index) => ({ name: `xl/worksheets/sheet${index + 1}.xml`, data: utf8(buildSheetXml(sheet, styleTable)), })); const workbookSheets = preparedSheets .map( (sheet, index) => `` ) .join(""); const workbookRels = [ ...preparedSheets.map( (sheet, index) => `` ), ``, ].join(""); const contentTypes = [ '', '', '', '', '', ...preparedSheets.map( (sheet, index) => `` ), '', "", ].join(""); const files = [ { name: "[Content_Types].xml", data: utf8(contentTypes), }, { name: "_rels/.rels", data: utf8( [ '', '', '', "", ].join("") ), }, { name: "xl/workbook.xml", data: utf8( [ '', '', `${workbookSheets}`, "", ].join("") ), }, { name: "xl/_rels/workbook.xml.rels", data: utf8( [ '', '', workbookRels, "", ].join("") ), }, { name: "xl/styles.xml", data: utf8(buildStylesXml(styleTable)), }, ...sheetFiles, ]; return createZip(files, XLSX_MIME); } export default buildXlsxBlob;