/**
* 极简 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}>` : `<${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;