/**
* 极简 DOCX 生成器(零依赖)
*
* 只实现导出单据所需的 WordprocessingML 子集:页面尺寸/页边距、段落
* (缩进、行距、制表位、对齐、字体字号加粗)、表格(固定列宽、合并单元格、
* 边框、行高、单元格垂直对齐)。
*
* 文档模型:
* buildDocxBlob({
* page: { width, height, margin: { top, right, bottom, left } },
* blocks: [
* { type: "paragraph", indentLeft, spacing: { before, line, lineRule },
* align, tabs: [2310], runs: [{ text, size, bold }, { tab: true }] },
* { type: "table", width, indent, grid: [821, ...],
* rows: [{ height, heightRule, cells: [
* { text, width, gridSpan, align, bold, size, vAlign } ] }] },
* ],
* })
*
* 尺寸单位统一使用 twips(1/20 磅),字号使用半磅(sz=21 即 10.5 磅)。
*/
import { createZip, utf8 } from "./zipStore";
const DOCX_MIME =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const escapeXml = (value) =>
String(value)
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
const ATTR_ORDER = ["val", "color", "sz", "space", "w", "type", "before", "after", "line", "lineRule", "left", "right", "firstLine", "hanging"];
const el = (tag, attrs, children) => {
const keys = attrs ? Object.keys(attrs).filter((k) => attrs[k] !== undefined && attrs[k] !== null) : [];
keys.sort((a, b) => {
const ia = ATTR_ORDER.indexOf(a);
const ib = ATTR_ORDER.indexOf(b);
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
});
const attrText = keys.map((k) => ` w:${k}="${escapeXml(attrs[k])}"`).join("");
if (children === undefined) return ``;
return `${children}`;
};
/** 生成 run 的属性节点(顺序必须符合 CT_RPr 的 schema 定义) */
const buildRunProps = (run) => {
const parts = [];
if (run.font) {
parts.push(
el("rFonts", { ascii: run.font, hAnsi: run.font, eastAsia: run.font, cs: run.font })
);
}
if (run.bold) {
parts.push(el("b"), el("bCs"));
}
if (run.color) parts.push(el("color", { val: run.color }));
if (run.size) {
const sz = String(run.size);
parts.push(el("sz", { val: sz }), el("szCs", { val: sz }));
}
return parts.length ? el("rPr", null, parts.join("")) : "";
};
const buildRuns = (runs = []) =>
runs
.map((run) => {
const rPr = buildRunProps(run);
if (run.tab) return `${rPr}`;
const text = run.text === undefined || run.text === null ? "" : String(run.text);
if (!text) return rPr ? `${rPr}` : "";
return `${rPr}${escapeXml(text)}`;
})
.join("");
/** 生成段落属性节点,子元素顺序必须符合 CT_PPr */
const buildParaProps = (block) => {
const parts = [];
if (block.tabs && block.tabs.length) {
parts.push(
el(
"tabs",
null,
block.tabs.map((pos) => el("tab", { val: "left", pos: String(pos) })).join("")
)
);
}
if (block.spacing) {
parts.push(
el("spacing", {
before: block.spacing.before,
after: block.spacing.after,
line: block.spacing.line,
lineRule: block.spacing.lineRule,
})
);
}
if (block.indentLeft || block.indentRight || block.indentFirstLine) {
parts.push(
el("ind", {
left: block.indentLeft,
right: block.indentRight,
firstLine: block.indentFirstLine,
})
);
}
if (block.align) parts.push(el("jc", { val: block.align }));
if (block.runs && block.runs.length) {
const rPr = buildRunProps(block.runs[0]);
if (rPr) parts.push(rPr);
}
return parts.length ? el("pPr", null, parts.join("")) : "";
};
const buildParagraph = (block) => {
if (block.type === "empty") return "";
return `${buildParaProps(block)}${buildRuns(block.runs)}`;
};
const BORDER_SIDES = ["top", "left", "bottom", "right", "insideH", "insideV"];
const buildTableBorders = (border) => {
if (!border) return "";
const sides = BORDER_SIDES.map((side) => {
if (!border[side]) return "";
return el(side, {
val: border[side].val || "single",
color: border[side].color || "000000",
sz: String(border[side].sz || 6),
space: "0",
});
}).join("");
return sides ? el("tblBorders", null, sides) : "";
};
const buildCell = (cell) => {
const parts = [];
if (cell.width) parts.push(el("tcW", { w: String(cell.width), type: "dxa" }));
if (cell.gridSpan && cell.gridSpan > 1) {
parts.push(el("gridSpan", { val: String(cell.gridSpan) }));
}
if (cell.vAlign) parts.push(el("vAlign", { val: cell.vAlign }));
// 空单元格也保留段落属性(对齐/缩进),便于后续在 Word 中继续编辑
const paragraph = buildParagraph({
align: cell.align,
indentLeft: cell.indentLeft,
spacing: cell.spacing,
runs: cell.text
? [{ text: cell.text, size: cell.size, bold: cell.bold, font: cell.font }]
: [],
});
return `${parts.length ? el("tcPr", null, parts.join("")) : ""}${paragraph}`;
};
const buildTable = (table) => {
const tblPrParts = [];
if (table.width) tblPrParts.push(el("tblW", { w: String(table.width), type: "dxa" }));
if (table.indent) tblPrParts.push(el("tblInd", { w: String(table.indent), type: "dxa" }));
const borders = buildTableBorders(table.border);
if (borders) tblPrParts.push(borders);
tblPrParts.push(el("tblLayout", { type: "fixed" }));
if (table.cellMargin !== undefined && table.cellMargin !== null) {
const m = table.cellMargin;
tblPrParts.push(
el(
"tblCellMar",
null,
["top", "left", "bottom", "right"]
.map((side) => el(side, { w: String(m), type: "dxa" }))
.join("")
)
);
}
const grid = (table.grid || [])
.map((w) => el("gridCol", { w: String(w) }))
.join("");
const rows = (table.rows || [])
.map((row) => {
const trPr = row.height
? el("trPr", null, el("trHeight", { val: String(row.height), hRule: row.heightRule || "atLeast" }))
: "";
const cells = (row.cells || []).map(buildCell).join("");
return `${trPr}${cells}`;
})
.join("");
return `${el("tblPr", null, tblPrParts.join(""))}${el(
"tblGrid",
null,
grid
)}${rows}`;
};
const buildSectPr = (page) => {
const margin = page.margin || {};
return el(
"sectPr",
null,
[
el("pgSz", { w: String(page.width), h: String(page.height) }),
el("pgMar", {
top: String(margin.top || 0),
right: String(margin.right || 0),
bottom: String(margin.bottom || 0),
left: String(margin.left || 0),
header: "0",
footer: "0",
gutter: "0",
}),
el("cols", { space: "720", num: "1" }),
].join("")
);
};
const buildDocumentXml = (model) => {
const body = (model.blocks || [])
.map((block) => (block.type === "table" ? buildTable(block) : buildParagraph(block)))
.join("");
return [
'',
``,
`${body}${buildSectPr(model.page || {})}`,
"",
].join("");
};
const buildStylesXml = (defaultFont, defaultSize) => {
const fonts = el("rFonts", {
ascii: defaultFont,
hAnsi: defaultFont,
eastAsia: defaultFont,
cs: defaultFont,
});
const sz = String(defaultSize);
return [
'',
``,
el(
"docDefaults",
null,
el(
"rPrDefault",
null,
el("rPr", null, `${fonts}${el("sz", { val: sz })}${el("szCs", { val: sz })}`)
) +
el(
"pPrDefault",
null,
el("pPr", null, el("spacing", { line: "240", lineRule: "auto" }))
)
),
'',
"",
].join("");
};
/**
* 生成 docx 文件的 Blob。
* @param {{page?: object, blocks?: Array, defaultFont?: string, defaultSize?: number}} model
* @returns {Blob}
*/
export function buildDocxBlob(model = {}) {
const font = model.defaultFont || "宋体";
const size = model.defaultSize || 21;
const contentTypes = [
'',
'',
'',
'',
'',
'',
"",
].join("");
const files = [
{ name: "[Content_Types].xml", data: utf8(contentTypes) },
{
name: "_rels/.rels",
data: utf8(
[
'',
'',
``,
"",
].join("")
),
},
{ name: "word/document.xml", data: utf8(buildDocumentXml(model)) },
{
name: "word/_rels/document.xml.rels",
data: utf8(
[
'',
'',
``,
"",
].join("")
),
},
{ name: "word/styles.xml", data: utf8(buildStylesXml(font, size)) },
];
return createZip(files, DOCX_MIME);
}
export default buildDocxBlob;