/** * 极简 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;