/**
|
* 极简 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, """)
|
.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 = ["<font>"];
|
if (font.bold) parts.push("<b/>");
|
if (font.italic) parts.push("<i/>");
|
if (font.strike) parts.push("<strike/>");
|
if (font.underline) {
|
parts.push(font.underline === "double" ? '<u val="double"/>' : "<u/>");
|
}
|
parts.push(`<sz val="${font.size || 11}"/>`);
|
if (font.color) parts.push(`<color rgb="${font.color}"/>`);
|
parts.push(`<name val="${escapeXml(font.name || "宋体")}"/>`);
|
if (font.charset) parts.push(`<charset val="${font.charset}"/>`);
|
parts.push("</font>");
|
return parts.join("");
|
};
|
|
const buildFillXml = (fill) => {
|
if (!fill) return '<fill><patternFill patternType="none"/></fill>';
|
const patternType = fill.patternType || "solid";
|
const fg = fill.color ? `<fgColor rgb="${fill.color}"/>` : "<fgColor indexed=\"64\"/>";
|
return `<fill><patternFill patternType="${patternType}">${fg}<bgColor indexed="64"/></patternFill></fill>`;
|
};
|
|
const buildBorderXml = (border) => {
|
const side = (name) => {
|
const style = resolveBorderStyle(border[name]);
|
return style ? `<${name} style="${style}"><color indexed="64"/></${name}>` : `<${name}/>`;
|
};
|
return `<border>${side("left")}${side("right")}${side("top")}${side("bottom")}<diagonal/></border>`;
|
};
|
|
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 `<xf ${attrs.join(" ")}><alignment ${alignAttrs.join(" ")}/></xf>`;
|
}
|
return `<xf ${attrs.join(" ")}/>`;
|
};
|
|
const buildStylesXml = (styleTable) => {
|
const { fonts, fills, borders, numFmts, xfs } = styleTable;
|
const numFmtXml = numFmts.length
|
? `<numFmts count="${numFmts.length}">${numFmts
|
.map((item) => `<numFmt numFmtId="${item.id}" formatCode="${escapeXml(item.code)}"/>`)
|
.join("")}</numFmts>`
|
: "";
|
|
return [
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">',
|
numFmtXml,
|
`<fonts count="${fonts.length}">${fonts.map(buildFontXml).join("")}</fonts>`,
|
`<fills count="${fills.length}">${fills.map(buildFillXml).join("")}</fills>`,
|
`<borders count="${borders.length}">${borders.map(buildBorderXml).join("")}</borders>`,
|
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>',
|
`<cellXfs count="${xfs.length}">${xfs.map(buildXfXml).join("")}</cellXfs>`,
|
'<cellStyles count="1"><cellStyle name="常规" xfId="0" builtinId="0"/></cellStyles>',
|
"</styleSheet>",
|
].join("");
|
};
|
|
const buildSheetXml = (sheet, styleTable) => {
|
const rows = sheet.rows || [];
|
const columns = sheet.columns || [];
|
|
const colsXml = columns.length
|
? `<cols>${columns
|
.map((column, index) => {
|
const width = typeof column === "number" ? column : column.width;
|
if (!width) return "";
|
return `<col min="${index + 1}" max="${index + 1}" width="${width}" customWidth="1"/>`;
|
})
|
.join("")}</cols>`
|
: "";
|
|
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 `<c r="${cellRef(rowIndex, colIndex)}" s="${cell.style}"/>`;
|
}
|
const ref = cellRef(rowIndex, colIndex);
|
const styleAttr = cell.style === undefined ? "" : ` s="${cell.style}"`;
|
if (typeof cell.value === "number" && Number.isFinite(cell.value)) {
|
return `<c r="${ref}"${styleAttr}><v>${cell.value}</v></c>`;
|
}
|
if (typeof cell.value === "boolean") {
|
return `<c r="${ref}"${styleAttr} t="b"><v>${cell.value ? 1 : 0}</v></c>`;
|
}
|
return `<c r="${ref}"${styleAttr} t="inlineStr"><is><t xml:space="preserve">${escapeXml(
|
cell.value
|
)}</t></is></c>`;
|
})
|
.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 `<row r="${rowIndex + 1}"${heightAttr}>${cellsXml}</row>`;
|
})
|
.filter(Boolean)
|
.join("");
|
|
const merges = sheet.merges || [];
|
const mergesXml = merges.length
|
? `<mergeCells count="${merges.length}">${merges
|
.map(
|
(merge) =>
|
`<mergeCell ref="${cellRef(merge.r1, merge.c1)}:${cellRef(merge.r2, merge.c2)}"/>`
|
)
|
.join("")}</mergeCells>`
|
: "";
|
|
const lastRow = rows.length;
|
const dimension = lastRow && maxCol ? `A1:${cellRef(lastRow - 1, maxCol - 1)}` : "A1";
|
|
return [
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">',
|
`<dimension ref="${dimension}"/>`,
|
'<sheetViews><sheetView workbookViewId="0"/></sheetViews>',
|
'<sheetFormatPr defaultRowHeight="14.25"/>',
|
colsXml,
|
`<sheetData>${rowXml}</sheetData>`,
|
mergesXml,
|
"</worksheet>",
|
]
|
.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) =>
|
`<sheet name="${escapeXml(sheet.name)}" sheetId="${index + 1}" r:id="rId${index + 1}"/>`
|
)
|
.join("");
|
|
const workbookRels = [
|
...preparedSheets.map(
|
(sheet, index) =>
|
`<Relationship Id="rId${index + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${
|
index + 1
|
}.xml"/>`
|
),
|
`<Relationship Id="rId${preparedSheets.length + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`,
|
].join("");
|
|
const contentTypes = [
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">',
|
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>',
|
'<Default Extension="xml" ContentType="application/xml"/>',
|
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>',
|
...preparedSheets.map(
|
(sheet, index) =>
|
`<Override PartName="/xl/worksheets/sheet${
|
index + 1
|
}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`
|
),
|
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>',
|
"</Types>",
|
].join("");
|
|
const files = [
|
{
|
name: "[Content_Types].xml",
|
data: utf8(contentTypes),
|
},
|
{
|
name: "_rels/.rels",
|
data: utf8(
|
[
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
|
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>',
|
"</Relationships>",
|
].join("")
|
),
|
},
|
{
|
name: "xl/workbook.xml",
|
data: utf8(
|
[
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">',
|
`<sheets>${workbookSheets}</sheets>`,
|
"</workbook>",
|
].join("")
|
),
|
},
|
{
|
name: "xl/_rels/workbook.xml.rels",
|
data: utf8(
|
[
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
|
workbookRels,
|
"</Relationships>",
|
].join("")
|
),
|
},
|
{
|
name: "xl/styles.xml",
|
data: utf8(buildStylesXml(styleTable)),
|
},
|
...sheetFiles,
|
];
|
|
return createZip(files, XLSX_MIME);
|
}
|
|
export default buildXlsxBlob;
|