@v1hz/md2docx 1.6.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,653 +0,0 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
2
- import { DOMParser } from "@xmldom/xmldom";
3
- import { useNamespaces } from "xpath";
4
- import PizZip from "pizzip";
5
-
6
- const W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
7
-
8
- // 注册命名空间,创建带 w: 前缀的 xpath 选择器
9
- const x = useNamespaces({ w: W });
10
-
11
- type XmlNode = Node;
12
-
13
- /** 选择单个元素 */
14
- function one(node: XmlNode, expr: string): Element | null {
15
- const result = x(expr, node) as Node | Node[] | null;
16
- if (!result) return null;
17
- if (Array.isArray(result)) return (result[0] as Element) ?? null;
18
- return result as Element;
19
- }
20
-
21
- /** 选择元素列表 */
22
- function all(node: XmlNode, expr: string): Element[] {
23
- const result = x(expr, node);
24
- if (!result) return [];
25
- if (Array.isArray(result)) return result as Element[];
26
- return [result as Element];
27
- }
28
-
29
- /** 取 w: 命名空间下的属性值 */
30
- function wAttr(el: Element | null, local: string): string | null {
31
- if (!el) return null;
32
- return el.getAttributeNS(W, local) ?? null;
33
- }
34
-
35
- function wNumber(el: Element | null, local = "val"): number | null {
36
- const value = wAttr(el, local);
37
- if (value === null || value.trim() === "") return null;
38
- const number = Number(value);
39
- return Number.isFinite(number) ? number : null;
40
- }
41
-
42
- /** 读取 OOXML ON/OFF 属性;省略 val 表示 true。 */
43
- function wBool(el: Element | null, local: string): boolean | null {
44
- if (!el) return null;
45
- const found = one(el, `w:${local}`);
46
- if (!found) return null;
47
- const v = wAttr(found, "val");
48
- return v === null || !["0", "false", "off", "no"].includes(v.toLowerCase());
49
- }
50
-
51
- // ── 通用映射函数 ──────────────────────────────────────────
52
-
53
- /** 将 w:shd 元素映射为 shading 对象 */
54
- function mapShd(shd: Element | null): Record<string, string> | undefined {
55
- if (!shd) return undefined;
56
- const sh: Record<string, string> = {};
57
- for (const a of [
58
- "val",
59
- "color",
60
- "fill",
61
- "themeColor",
62
- "themeTint",
63
- "themeFill",
64
- "themeFillShade",
65
- "themeShade",
66
- ]) {
67
- const v = wAttr(shd, a);
68
- if (v !== null) sh[a] = v;
69
- }
70
- return Object.keys(sh).length > 0 ? sh : undefined;
71
- }
72
-
73
- /** 将 w:border 元素映射为 border 对象 */
74
- function mapBorder(bdr: Element | null): Record<string, string | number> | undefined {
75
- if (!bdr) return undefined;
76
- const b: Record<string, string | number> = {};
77
- for (const a of ["val", "color", "sz", "space", "themeColor", "themeShade"]) {
78
- const v = wAttr(bdr, a);
79
- if (v !== null) b[a] = /^\d+$/.test(v) ? Number(v) : v;
80
- }
81
- return Object.keys(b).length > 0 ? b : undefined;
82
- }
83
-
84
- // ── 映射函数 ─────────────────────────────────────────────
85
-
86
- function mapRunProps(rPr: Element | null): Record<string, unknown> {
87
- if (!rPr) return {};
88
- const out: Record<string, unknown> = {};
89
-
90
- // 字体
91
- const rFonts = one(rPr, "w:rFonts");
92
- if (rFonts) {
93
- const font: Record<string, string> = {};
94
- for (const a of [
95
- "ascii",
96
- "hAnsi",
97
- "cs",
98
- "eastAsia",
99
- "asciiTheme",
100
- "hAnsiTheme",
101
- "eastAsiaTheme",
102
- "cstheme",
103
- ]) {
104
- const v = wAttr(rFonts, a);
105
- if (v) font[a] = v;
106
- }
107
- if (Object.keys(font).length) out.font = font;
108
- }
109
-
110
- // 字号
111
- const sz = one(rPr, "w:sz");
112
- if (sz) {
113
- const v = wNumber(sz);
114
- if (v !== null) out.size = v;
115
- }
116
- const szCs = one(rPr, "w:szCs");
117
- if (szCs) {
118
- const v = wNumber(szCs);
119
- if (v !== null) out.sizeComplexScript = v;
120
- }
121
-
122
- // 颜色(仅 val,docx 包不支持主题属性)
123
- const color = one(rPr, "w:color");
124
- if (color) {
125
- const v = wAttr(color, "val");
126
- if (v) out.color = v.toUpperCase();
127
- }
128
-
129
- // 开关属性
130
- const boolMap: [string, string][] = [
131
- ["b", "bold"],
132
- ["bCs", "boldComplexScript"],
133
- ["i", "italics"],
134
- ["iCs", "italicsComplexScript"],
135
- ["strike", "strike"],
136
- ["dstrike", "doubleStrike"],
137
- ["smallCaps", "smallCaps"],
138
- ["caps", "allCaps"],
139
- ["vanish", "vanish"],
140
- ["emboss", "emboss"],
141
- ["imprint", "imprint"],
142
- ];
143
- for (const [tag, key] of boolMap) {
144
- const v = wBool(rPr, tag);
145
- if (v !== null) out[key] = v;
146
- }
147
-
148
- // 上标/下标
149
- const va = one(rPr, "w:vertAlign");
150
- if (va) {
151
- const v = wAttr(va, "val");
152
- if (v === "superscript") out.superScript = true;
153
- else if (v === "subscript") out.subScript = true;
154
- }
155
-
156
- // 下划线
157
- const u = one(rPr, "w:u");
158
- if (u) {
159
- const ul: Record<string, string> = {};
160
- const ut = wAttr(u, "val");
161
- if (ut) ul.type = ut;
162
- const uc = wAttr(u, "color");
163
- if (uc) ul.color = uc;
164
- out.underline = ul;
165
- }
166
-
167
- // 字符间距
168
- const sp = one(rPr, "w:spacing");
169
- if (sp) {
170
- const v = wNumber(sp);
171
- if (v !== null) out.characterSpacing = v;
172
- }
173
-
174
- // 语言
175
- const lang = one(rPr, "w:lang");
176
- if (lang) {
177
- const lo: Record<string, string> = {};
178
- for (const a of ["val", "eastAsia", "bidi"]) {
179
- const v = wAttr(lang, a);
180
- if (v) lo[a] = v;
181
- }
182
- if (Object.keys(lo).length) out.language = lo;
183
- }
184
-
185
- // 底纹(背景色)
186
- const shd = mapShd(one(rPr, "w:shd"));
187
- if (shd) out.shading = shd;
188
-
189
- // 边框
190
- const bdr = one(rPr, "w:bdr");
191
- if (bdr) {
192
- const b = mapBorder(bdr);
193
- if (b) out.border = b;
194
- }
195
-
196
- // 校对标记
197
- if (one(rPr, "w:noProof")) out.noProof = true;
198
-
199
- return out;
200
- }
201
-
202
- function mapParagraphProps(pPr: Element | null): Record<string, unknown> {
203
- if (!pPr) return {};
204
- const out: Record<string, unknown> = {};
205
-
206
- const ALIGN_MAP: Record<string, string> = {
207
- left: "left",
208
- center: "center",
209
- right: "right",
210
- both: "both",
211
- start: "start",
212
- end: "end",
213
- };
214
-
215
- // 对齐
216
- const jc = one(pPr, "w:jc");
217
- if (jc) {
218
- const v = wAttr(jc, "val");
219
- if (v && v in ALIGN_MAP) out.alignment = ALIGN_MAP[v];
220
- }
221
-
222
- // 间距
223
- const spacing = one(pPr, "w:spacing");
224
- if (spacing) {
225
- const sp: Record<string, number | string> = {};
226
- for (const [tag, key] of [
227
- ["before", "before"],
228
- ["after", "after"],
229
- ["line", "line"],
230
- ] as [string, string][]) {
231
- const v = wNumber(spacing, tag);
232
- if (v !== null) sp[key] = v;
233
- }
234
- const lr = wAttr(spacing, "lineRule");
235
- if (lr !== null) sp.lineRule = lr;
236
- // 也提取 beforeLines / afterLines(Word 有时用这种)
237
- for (const tag of ["beforeLines", "afterLines", "beforeAutospacing", "afterAutospacing"]) {
238
- const v = wAttr(spacing, tag);
239
- if (v !== null) sp[tag] = v;
240
- }
241
- if (Object.keys(sp).length) out.spacing = sp;
242
- }
243
-
244
- // 缩进
245
- const ind = one(pPr, "w:ind");
246
- if (ind) {
247
- const id: Record<string, number | string> = {};
248
- for (const a of [
249
- "left",
250
- "right",
251
- "firstLine",
252
- "hanging",
253
- "start",
254
- "end",
255
- "leftChars",
256
- "rightChars",
257
- "firstLineChars",
258
- "hangingChars",
259
- ]) {
260
- const v = wNumber(ind, a);
261
- if (v !== null) id[a] = v;
262
- }
263
- if (Object.keys(id).length) out.indent = id;
264
- }
265
-
266
- // 开关
267
- for (const tag of [
268
- "keepNext",
269
- "keepLines",
270
- "pageBreakBefore",
271
- "widowControl",
272
- "contextualSpacing",
273
- "wordWrap",
274
- "suppressLineNumbers",
275
- "suppressAutoHyphens",
276
- ]) {
277
- const v = wBool(pPr, tag);
278
- if (v !== null) out[tag] = v;
279
- }
280
-
281
- // 大纲级别
282
- const ol = one(pPr, "w:outlineLvl");
283
- if (ol) {
284
- const v = wNumber(ol);
285
- if (v !== null) out.outlineLevel = v;
286
- }
287
-
288
- // 段落底纹
289
- const shd = mapShd(one(pPr, "w:shd"));
290
- if (shd) out.shading = shd;
291
-
292
- // 段落边框
293
- const pBdr = one(pPr, "w:pBdr");
294
- if (pBdr) {
295
- const borders: Record<string, unknown> = {};
296
- for (const side of ["top", "left", "bottom", "right"]) {
297
- const b = mapBorder(one(pBdr, `w:${side}`));
298
- if (b) borders[side] = b;
299
- }
300
- if (Object.keys(borders).length) out.border = borders;
301
- }
302
-
303
- // 制表位
304
- const tabs = one(pPr, "w:tabs");
305
- if (tabs) {
306
- const tabList: Record<string, unknown>[] = [];
307
- for (const tab of all(tabs, "w:tab")) {
308
- const t: Record<string, string | number> = {};
309
- const v = wAttr(tab, "val");
310
- const pos = wNumber(tab, "pos");
311
- if (v) t.val = v;
312
- if (pos !== null) t.pos = pos;
313
- if (Object.keys(t).length) tabList.push(t);
314
- }
315
- if (tabList.length) out.tabs = tabList;
316
- }
317
-
318
- return out;
319
- }
320
-
321
- function mapTableProps(tblPr: Element | null): Record<string, unknown> | undefined {
322
- if (!tblPr) return undefined;
323
- const out: Record<string, unknown> = {};
324
-
325
- // 表格对齐
326
- const jc = one(tblPr, "w:jc");
327
- if (jc) {
328
- const v = wAttr(jc, "val");
329
- if (v) out.alignment = v;
330
- }
331
-
332
- // 表格缩进
333
- const tblInd = one(tblPr, "w:tblInd");
334
- if (tblInd) {
335
- const id: Record<string, number | string> = {};
336
- const w = wNumber(tblInd, "w");
337
- if (w !== null) id.width = w;
338
- const t = wAttr(tblInd, "type");
339
- if (t !== null) id.type = t;
340
- if (Object.keys(id).length) out.indent = id;
341
- }
342
-
343
- // 表格边框
344
- const tblBorders = one(tblPr, "w:tblBorders");
345
- if (tblBorders) {
346
- const borders: Record<string, unknown> = {};
347
- for (const side of ["top", "left", "bottom", "right", "insideH", "insideV"]) {
348
- const b = mapBorder(one(tblBorders, `w:${side}`));
349
- if (b) borders[side] = b;
350
- }
351
- if (Object.keys(borders).length) out.borders = borders;
352
- }
353
-
354
- // 单元格边距
355
- const tblCellMar = one(tblPr, "w:tblCellMar");
356
- if (tblCellMar) {
357
- const margins: Record<string, number> = {};
358
- for (const side of ["top", "left", "bottom", "right"]) {
359
- const v = wNumber(one(tblCellMar, `w:${side}`), "w");
360
- if (v !== null) margins[side] = v;
361
- }
362
- if (Object.keys(margins).length) out.cellMargin = margins;
363
- }
364
-
365
- return Object.keys(out).length > 0 ? out : undefined;
366
- }
367
-
368
- function mapTableStylePr(tblStylePr: Element | null): Record<string, unknown> | undefined {
369
- if (!tblStylePr) return undefined;
370
-
371
- const type = wAttr(tblStylePr, "type");
372
- const out: Record<string, unknown> = {};
373
- if (type) out.type = type;
374
-
375
- // rPr
376
- const rPr = mapRunProps(one(tblStylePr, "w:rPr"));
377
- if (Object.keys(rPr).length) out.run = rPr;
378
-
379
- // pPr(用于 firstRow 段落格式)
380
- const pPr = mapParagraphProps(one(tblStylePr, "w:pPr"));
381
- if (Object.keys(pPr).length) out.paragraph = pPr;
382
-
383
- // tblPr
384
- const tblPr = one(tblStylePr, "w:tblPr");
385
- if (tblPr) {
386
- const tbl = mapTableProps(tblPr);
387
- if (tbl) out.table = tbl;
388
- }
389
-
390
- // tcPr
391
- const tcPr = one(tblStylePr, "w:tcPr");
392
- if (tcPr) {
393
- const tc: Record<string, unknown> = {};
394
- const tcBorders = one(tcPr, "w:tcBorders");
395
- if (tcBorders) {
396
- const borders: Record<string, unknown> = {};
397
- for (const side of ["top", "left", "bottom", "right", "insideH", "insideV"]) {
398
- const b = mapBorder(one(tcBorders, `w:${side}`));
399
- if (b) borders[side] = b;
400
- }
401
- if (Object.keys(borders).length) tc.borders = borders;
402
- }
403
- const vAlign = one(tcPr, "w:vAlign");
404
- if (vAlign) {
405
- const v = wAttr(vAlign, "val");
406
- if (v) tc.verticalAlign = v;
407
- }
408
- if (Object.keys(tc).length) out.cell = tc;
409
- }
410
-
411
- return Object.keys(out).length > 0 ? out : undefined;
412
- }
413
-
414
- // ── 导出类型 ─────────────────────────────────────────────
415
-
416
- export interface StyleEntry {
417
- id: string;
418
- name: string;
419
- basedOn?: string;
420
- next?: string;
421
- link?: string;
422
- quickFormat?: boolean;
423
- semiHidden?: boolean;
424
- unhideWhenUsed?: boolean;
425
- uiPriority?: number;
426
- run?: Record<string, unknown>;
427
- paragraph?: Record<string, unknown>;
428
- table?: Record<string, unknown>;
429
- tableStyleParts?: Record<string, unknown>[];
430
- }
431
-
432
- export interface ExtractedStyles {
433
- default?: Record<string, unknown>;
434
- paragraphStyles?: StyleEntry[];
435
- characterStyles?: StyleEntry[];
436
- tableStyles?: StyleEntry[];
437
- /** 表格样式的原始 XML 字符串,用于注入到生成的模板 docx */
438
- tableStylesXml?: string;
439
- }
440
-
441
- /** 将 XML 元素序列化为字符串 */
442
- function serializeElement(el: Element): string {
443
- let result = "<" + el.tagName;
444
- for (let i = 0; i < el.attributes.length; i++) {
445
- const attr = el.attributes[i]!;
446
- result += ` ${attr.name}="${attr.value.replace(/"/g, "&quot;")}"`;
447
- }
448
- if (el.childNodes.length === 0) {
449
- result += "/>";
450
- } else {
451
- result += ">";
452
- for (let i = 0; i < el.childNodes.length; i++) {
453
- const child = el.childNodes[i]!;
454
- if (child.nodeType === 1) {
455
- result += serializeElement(child as Element);
456
- } else if (child.nodeType === 3) {
457
- result += (child as Text).data;
458
- }
459
- }
460
- result += "</" + el.tagName + ">";
461
- }
462
- return result;
463
- }
464
-
465
- const BUILTIN_PARA: Record<string, string> = {
466
- Title: "title",
467
- Heading1: "heading1",
468
- Heading2: "heading2",
469
- Heading3: "heading3",
470
- Heading4: "heading4",
471
- Heading5: "heading5",
472
- Heading6: "heading6",
473
- Strong: "strong",
474
- ListParagraph: "listParagraph",
475
- FootnoteText: "footnoteText",
476
- EndnoteText: "endnoteText",
477
- };
478
-
479
- const BUILTIN_CHAR: Record<string, string> = {
480
- Hyperlink: "hyperlink",
481
- FootnoteReference: "footnoteReference",
482
- FootnoteTextChar: "footnoteTextChar",
483
- EndnoteReference: "endnoteReference",
484
- EndnoteTextChar: "endnoteTextChar",
485
- };
486
-
487
- /** 从 docx 提取 styles 配置,返回可直接给 `new Document({ styles })` 的对象 */
488
- export function extractStylesFromDocx(docxPath: string): ExtractedStyles {
489
- const zip = new PizZip(readFileSync(docxPath));
490
- const stylesFile = zip.file("word/styles.xml");
491
- if (!stylesFile) throw new Error("word/styles.xml not found in docx");
492
-
493
- const xml = stylesFile.asText();
494
- const doc = new DOMParser().parseFromString(xml, "text/xml");
495
- const parseError = doc.getElementsByTagName("parsererror").item(0);
496
- if (parseError) throw new Error(`Invalid word/styles.xml: ${parseError.textContent}`);
497
-
498
- const defaultDoc: Record<string, unknown> = {};
499
- const defaultOverrides: Record<string, unknown> = {};
500
- const paragraphStyles: StyleEntry[] = [];
501
- const characterStyles: StyleEntry[] = [];
502
- const tableStyles: StyleEntry[] = [];
503
-
504
- // 1. docDefaults
505
- const xmlDocument = doc as unknown as XmlNode;
506
- const dd = one(xmlDocument, "//w:docDefaults");
507
- if (dd) {
508
- const rPrDef = one(dd, "w:rPrDefault/w:rPr");
509
- if (rPrDef) {
510
- const run = mapRunProps(rPrDef);
511
- if (Object.keys(run).length) defaultDoc.run = run;
512
- }
513
- const pPrDef = one(dd, "w:pPrDefault/w:pPr");
514
- if (pPrDef) {
515
- const para = mapParagraphProps(pPrDef);
516
- if (Object.keys(para).length) defaultDoc.paragraph = para;
517
- }
518
- }
519
-
520
- // 2. 样式
521
- for (const style of all(xmlDocument, "//w:style")) {
522
- const styleId = wAttr(style, "styleId") ?? "";
523
- const styleType = wAttr(style, "type") ?? "";
524
- if (!styleId || !["paragraph", "character", "table"].includes(styleType)) continue;
525
- const name = wAttr(one(style, "w:name"), "val") ?? styleId;
526
-
527
- // 跳过隐式样式和 token
528
- if (
529
- styleType !== "table" &&
530
- ["Normal", "DefaultParagraphFont", "TableNormal"].includes(styleId)
531
- )
532
- continue;
533
- if (styleId.endsWith("Tok") && styleType === "character") continue;
534
-
535
- const entry: StyleEntry = { id: styleId, name };
536
-
537
- // basedOn
538
- const bo = one(style, "w:basedOn");
539
- if (bo) {
540
- const v = wAttr(bo, "val");
541
- if (v && !["Normal", "DefaultParagraphFont"].includes(v)) entry.basedOn = v;
542
- }
543
-
544
- // next
545
- const next = one(style, "w:next");
546
- if (next) {
547
- const v = wAttr(next, "val");
548
- if (v && v !== styleId) entry.next = v;
549
- }
550
-
551
- // link
552
- const link = one(style, "w:link");
553
- if (link) {
554
- const v = wAttr(link, "val");
555
- if (v) entry.link = v;
556
- }
557
-
558
- // 标志位
559
- if (one(style, "w:qFormat")) entry.quickFormat = true;
560
- if (one(style, "w:semiHidden")) entry.semiHidden = true;
561
- if (one(style, "w:unhideWhenUsed")) entry.unhideWhenUsed = true;
562
-
563
- const ui = one(style, "w:uiPriority");
564
- if (ui) {
565
- const v = wNumber(ui);
566
- if (v !== null) entry.uiPriority = v;
567
- }
568
-
569
- // 段落/字符样式:run + paragraph
570
- if (styleType !== "table") {
571
- const run = mapRunProps(one(style, "w:rPr"));
572
- if (Object.keys(run).length) entry.run = run;
573
- const para = mapParagraphProps(one(style, "w:pPr"));
574
- if (Object.keys(para).length) entry.paragraph = para;
575
- }
576
-
577
- // 表格样式:table + tableStyleParts
578
- if (styleType === "table") {
579
- const tblPr = one(style, "w:tblPr");
580
- if (tblPr) {
581
- const tbl = mapTableProps(tblPr);
582
- if (tbl) entry.table = tbl;
583
- }
584
-
585
- const parts: Record<string, unknown>[] = [];
586
- for (const tsp of all(style, "w:tblStylePr")) {
587
- const mapped = mapTableStylePr(tsp);
588
- if (mapped) parts.push(mapped);
589
- }
590
- if (parts.length) entry.tableStyleParts = parts;
591
-
592
- // 表格样式的 run(用于单元格默认字体)
593
- const run = mapRunProps(one(style, "w:rPr"));
594
- if (Object.keys(run).length) entry.run = run;
595
- const pPr = mapParagraphProps(one(style, "w:pPr"));
596
- if (Object.keys(pPr).length) entry.paragraph = pPr;
597
- }
598
-
599
- // 分发
600
- if (styleType === "paragraph") {
601
- const key = BUILTIN_PARA[styleId];
602
- if (key) {
603
- const { id: _, ...rest } = entry;
604
- defaultOverrides[key] = rest;
605
- } else {
606
- paragraphStyles.push(entry);
607
- }
608
- } else if (styleType === "character") {
609
- const key = BUILTIN_CHAR[styleId];
610
- if (key) {
611
- const { id: _, ...rest } = entry;
612
- defaultOverrides[key] = rest;
613
- } else {
614
- characterStyles.push(entry);
615
- }
616
- } else if (styleType === "table") {
617
- tableStyles.push(entry);
618
- }
619
- }
620
-
621
- // 3. 收集表格样式的原始 XML(用于注入模板)
622
- let tableXmlFragments = "";
623
- for (const el of all(
624
- xmlDocument,
625
- "//w:style[@w:type='table' and not(@w:default='1') and @w:customStyle='1']",
626
- )) {
627
- // 手动序列化 XML 元素
628
- const xmlStr = serializeElement(el);
629
- tableXmlFragments += xmlStr;
630
- }
631
-
632
- const result: ExtractedStyles = {};
633
- if (Object.keys(defaultDoc).length > 0 || Object.keys(defaultOverrides).length > 0) {
634
- result.default = {
635
- ...(Object.keys(defaultDoc).length > 0 ? { document: defaultDoc } : {}),
636
- ...defaultOverrides,
637
- };
638
- }
639
- if (paragraphStyles.length) result.paragraphStyles = paragraphStyles;
640
- if (characterStyles.length) result.characterStyles = characterStyles;
641
- if (tableStyles.length) result.tableStyles = tableStyles;
642
- if (tableXmlFragments) result.tableStylesXml = tableXmlFragments;
643
-
644
- return result;
645
- }
646
-
647
- if (import.meta.main) {
648
- const result = extractStylesFromDocx("test.docx");
649
- writeFileSync("config/style.json", JSON.stringify(result, null, 2));
650
- console.log(
651
- `Extracted ${result.paragraphStyles?.length ?? 0} paragraph, ${result.characterStyles?.length ?? 0} character, ${result.tableStyles?.length ?? 0} table styles`,
652
- );
653
- }
@@ -1,65 +0,0 @@
1
- import { readFileSync, mkdirSync } from "fs";
2
- import { Document, Packer } from "docx";
3
- import { dirname } from "path";
4
- import { STYLE_TEMPLATE_DOCX } from "../paths";
5
-
6
- /**
7
- * 根据样式 JSON 文件生成模板 docx。
8
- *
9
- * @param styleJsonPath - 样式 JSON 文件路径(如 config/style.json)
10
- */
11
- export async function generateTemplateDocx(
12
- styleJsonPath: string,
13
- outputPath: string = STYLE_TEMPLATE_DOCX,
14
- ): Promise<void> {
15
- const raw = JSON.parse(readFileSync(styleJsonPath, "utf-8")) as Record<string, unknown>;
16
- const tableStylesXml = raw.tableStylesXml as string | undefined;
17
- // 移除 tableStylesXml,docx 包不识别它
18
- const { tableStylesXml: _, ...styles } = raw;
19
-
20
- const doc = new Document({
21
- styles: styles as Record<string, unknown>,
22
- sections: [],
23
- });
24
- const buf = await Packer.toBuffer(doc);
25
- mkdirSync(dirname(outputPath), { recursive: true });
26
-
27
- // 如果有表格样式 XML,注入到生成的 docx 中
28
- if (tableStylesXml) {
29
- const PizZip = (await import("pizzip")).default;
30
- const zip = new PizZip(buf);
31
- const stylesFile = zip.file("word/styles.xml");
32
- if (stylesFile) {
33
- const originalXml = stylesFile.asText();
34
- // 在 </w:styles> 之前插入表格样式
35
- const injectedXml = originalXml.replace("</w:styles>", tableStylesXml + "</w:styles>");
36
- zip.file("word/styles.xml", injectedXml);
37
- const modifiedBuf = zip.generate({ type: "nodebuffer" }) as Buffer;
38
- await Bun.write(outputPath, modifiedBuf);
39
- return;
40
- }
41
- }
42
-
43
- await Bun.write(outputPath, buf);
44
- }
45
-
46
- let _generatePromise: Promise<void> | null = null;
47
-
48
- /**
49
- * 获取模板 docx 路径。如果模板不存在,则自动生成。
50
- *
51
- * @param styleJsonPath - 样式 JSON 文件路径(如 config/style.json)
52
- */
53
- export async function ensureTemplateDocx(styleJsonPath: string): Promise<string> {
54
- const exists = await Bun.file(STYLE_TEMPLATE_DOCX).exists();
55
- if (!exists) {
56
- if (_generatePromise === null) {
57
- _generatePromise = generateTemplateDocx(styleJsonPath).catch((e) => {
58
- _generatePromise = null;
59
- throw e;
60
- });
61
- }
62
- await _generatePromise;
63
- }
64
- return STYLE_TEMPLATE_DOCX;
65
- }