/**
|
* 极简 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, """)
|
.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 `<w:${tag}${attrText}/>`;
|
return `<w:${tag}${attrText}>${children}</w:${tag}>`;
|
};
|
|
/** 生成 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 `<w:r>${rPr}<w:tab/></w:r>`;
|
const text = run.text === undefined || run.text === null ? "" : String(run.text);
|
if (!text) return rPr ? `<w:r>${rPr}</w:r>` : "";
|
return `<w:r>${rPr}<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r>`;
|
})
|
.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 "<w:p/>";
|
return `<w:p>${buildParaProps(block)}${buildRuns(block.runs)}</w:p>`;
|
};
|
|
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 `<w:tc>${parts.length ? el("tcPr", null, parts.join("")) : ""}${paragraph}</w:tc>`;
|
};
|
|
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 `<w:tr>${trPr}${cells}</w:tr>`;
|
})
|
.join("");
|
|
return `<w:tbl>${el("tblPr", null, tblPrParts.join(""))}${el(
|
"tblGrid",
|
null,
|
grid
|
)}${rows}</w:tbl>`;
|
};
|
|
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 [
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
`<w:document xmlns:w="${W_NS}" xmlns:r="${R_NS}">`,
|
`<w:body>${body}${buildSectPr(model.page || {})}</w:body>`,
|
"</w:document>",
|
].join("");
|
};
|
|
const buildStylesXml = (defaultFont, defaultSize) => {
|
const fonts = el("rFonts", {
|
ascii: defaultFont,
|
hAnsi: defaultFont,
|
eastAsia: defaultFont,
|
cs: defaultFont,
|
});
|
const sz = String(defaultSize);
|
return [
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
`<w:styles xmlns:w="${W_NS}">`,
|
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" }))
|
)
|
),
|
'<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>',
|
"</w:styles>",
|
].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 = [
|
'<?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="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>',
|
'<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.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="${R_NS}/officeDocument" Target="word/document.xml"/>`,
|
"</Relationships>",
|
].join("")
|
),
|
},
|
{ name: "word/document.xml", data: utf8(buildDocumentXml(model)) },
|
{
|
name: "word/_rels/document.xml.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="${R_NS}/styles" Target="styles.xml"/>`,
|
"</Relationships>",
|
].join("")
|
),
|
},
|
{ name: "word/styles.xml", data: utf8(buildStylesXml(font, size)) },
|
];
|
|
return createZip(files, DOCX_MIME);
|
}
|
|
export default buildDocxBlob;
|