@v1hz/md2docx 1.4.0 → 1.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@v1hz/md2docx",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "基于 pandoc 的 Markdown 转 Word 工具,自动格式化 Markdown,支持自定义样式,AI 友好,用户友好。",
5
5
  "keywords": [
6
6
  "converter",
@@ -27,6 +27,7 @@
27
27
  "src",
28
28
  "config/config.json",
29
29
  "config/config.schema.json",
30
+ "config/style.json",
30
31
  "README.md",
31
32
  "LICENSE"
32
33
  ],
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  CONFIG_PATH,
11
11
  CONFIG_SCHEMA_PATH,
12
12
  PKG_DIR,
13
+ STYLE_CONFIG,
13
14
  TMP_DIR,
14
15
  formattedMdPath,
15
16
  preprocessDir,
@@ -70,12 +71,14 @@ export async function run(args: string[]): Promise<number> {
70
71
  writeFileSync(mdOutput, formattedMd, "utf-8");
71
72
 
72
73
  if (cfg.pandoc.enabled) {
73
- const templatePath = await ensureTemplateDocx();
74
+ const templatePath = await ensureTemplateDocx(STYLE_CONFIG);
74
75
  const configuredName = cfg.pandoc.outputName.replaceAll("{file_name}", baseName);
75
76
  const docxOutput = resolve(cli.outputPath ?? configuredName);
76
77
  mkdirSync(dirname(docxOutput), { recursive: true });
78
+ const luaFilter = resolve(PKG_DIR, "config/lua/add-inline-code.lua");
79
+ const luaFlag = existsSync(luaFilter) ? `--lua-filter=${luaFilter}` : "";
77
80
  const result =
78
- await $`pandoc ${mdOutput} -o ${docxOutput} --reference-doc=${templatePath}`.nothrow();
81
+ await $`pandoc ${mdOutput} -o ${docxOutput} --reference-doc=${templatePath} ${luaFlag}`.nothrow();
79
82
  if (result.exitCode !== 0) {
80
83
  console.error(`pandoc 转换失败 (exit code ${result.exitCode}):`, result.stderr.toString());
81
84
  return 1;
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { DOMParser } from "@xmldom/xmldom";
3
3
  import { useNamespaces } from "xpath";
4
4
  import PizZip from "pizzip";
@@ -48,6 +48,39 @@ function wBool(el: Element | null, local: string): boolean | null {
48
48
  return v === null || !["0", "false", "off", "no"].includes(v.toLowerCase());
49
49
  }
50
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
+
51
84
  // ── 映射函数 ─────────────────────────────────────────────
52
85
 
53
86
  function mapRunProps(rPr: Element | null): Record<string, unknown> {
@@ -58,7 +91,16 @@ function mapRunProps(rPr: Element | null): Record<string, unknown> {
58
91
  const rFonts = one(rPr, "w:rFonts");
59
92
  if (rFonts) {
60
93
  const font: Record<string, string> = {};
61
- for (const a of ["ascii", "hAnsi", "cs", "eastAsia"]) {
94
+ for (const a of [
95
+ "ascii",
96
+ "hAnsi",
97
+ "cs",
98
+ "eastAsia",
99
+ "asciiTheme",
100
+ "hAnsiTheme",
101
+ "eastAsiaTheme",
102
+ "cstheme",
103
+ ]) {
62
104
  const v = wAttr(rFonts, a);
63
105
  if (v) font[a] = v;
64
106
  }
@@ -77,7 +119,7 @@ function mapRunProps(rPr: Element | null): Record<string, unknown> {
77
119
  if (v !== null) out.sizeComplexScript = v;
78
120
  }
79
121
 
80
- // 颜色
122
+ // 颜色(仅 val,docx 包不支持主题属性)
81
123
  const color = one(rPr, "w:color");
82
124
  if (color) {
83
125
  const v = wAttr(color, "val");
@@ -140,6 +182,20 @@ function mapRunProps(rPr: Element | null): Record<string, unknown> {
140
182
  if (Object.keys(lo).length) out.language = lo;
141
183
  }
142
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
+
143
199
  return out;
144
200
  }
145
201
 
@@ -177,14 +233,30 @@ function mapParagraphProps(pPr: Element | null): Record<string, unknown> {
177
233
  }
178
234
  const lr = wAttr(spacing, "lineRule");
179
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
+ }
180
241
  if (Object.keys(sp).length) out.spacing = sp;
181
242
  }
182
243
 
183
244
  // 缩进
184
245
  const ind = one(pPr, "w:ind");
185
246
  if (ind) {
186
- const id: Record<string, number> = {};
187
- for (const a of ["left", "right", "firstLine", "hanging", "start", "end"]) {
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
+ ]) {
188
260
  const v = wNumber(ind, a);
189
261
  if (v !== null) id[a] = v;
190
262
  }
@@ -198,6 +270,9 @@ function mapParagraphProps(pPr: Element | null): Record<string, unknown> {
198
270
  "pageBreakBefore",
199
271
  "widowControl",
200
272
  "contextualSpacing",
273
+ "wordWrap",
274
+ "suppressLineNumbers",
275
+ "suppressAutoHyphens",
201
276
  ]) {
202
277
  const v = wBool(pPr, tag);
203
278
  if (v !== null) out[tag] = v;
@@ -210,9 +285,132 @@ function mapParagraphProps(pPr: Element | null): Record<string, unknown> {
210
285
  if (v !== null) out.outlineLevel = v;
211
286
  }
212
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
+
213
318
  return out;
214
319
  }
215
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
+
216
414
  // ── 导出类型 ─────────────────────────────────────────────
217
415
 
218
416
  export interface StyleEntry {
@@ -227,12 +425,41 @@ export interface StyleEntry {
227
425
  uiPriority?: number;
228
426
  run?: Record<string, unknown>;
229
427
  paragraph?: Record<string, unknown>;
428
+ table?: Record<string, unknown>;
429
+ tableStyleParts?: Record<string, unknown>[];
230
430
  }
231
431
 
232
432
  export interface ExtractedStyles {
233
433
  default?: Record<string, unknown>;
234
434
  paragraphStyles?: StyleEntry[];
235
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;
236
463
  }
237
464
 
238
465
  const BUILTIN_PARA: Record<string, string> = {
@@ -272,6 +499,7 @@ export function extractStylesFromDocx(docxPath: string): ExtractedStyles {
272
499
  const defaultOverrides: Record<string, unknown> = {};
273
500
  const paragraphStyles: StyleEntry[] = [];
274
501
  const characterStyles: StyleEntry[] = [];
502
+ const tableStyles: StyleEntry[] = [];
275
503
 
276
504
  // 1. docDefaults
277
505
  const xmlDocument = doc as unknown as XmlNode;
@@ -293,11 +521,15 @@ export function extractStylesFromDocx(docxPath: string): ExtractedStyles {
293
521
  for (const style of all(xmlDocument, "//w:style")) {
294
522
  const styleId = wAttr(style, "styleId") ?? "";
295
523
  const styleType = wAttr(style, "type") ?? "";
296
- if (!styleId || !["paragraph", "character"].includes(styleType)) continue;
524
+ if (!styleId || !["paragraph", "character", "table"].includes(styleType)) continue;
297
525
  const name = wAttr(one(style, "w:name"), "val") ?? styleId;
298
526
 
299
527
  // 跳过隐式样式和 token
300
- if (["Normal", "DefaultParagraphFont", "TableNormal"].includes(styleId)) continue;
528
+ if (
529
+ styleType !== "table" &&
530
+ ["Normal", "DefaultParagraphFont", "TableNormal"].includes(styleId)
531
+ )
532
+ continue;
301
533
  if (styleId.endsWith("Tok") && styleType === "character") continue;
302
534
 
303
535
  const entry: StyleEntry = { id: styleId, name };
@@ -334,11 +566,35 @@ export function extractStylesFromDocx(docxPath: string): ExtractedStyles {
334
566
  if (v !== null) entry.uiPriority = v;
335
567
  }
336
568
 
337
- // run / paragraph
338
- const run = mapRunProps(one(style, "w:rPr"));
339
- if (Object.keys(run).length) entry.run = run;
340
- const para = mapParagraphProps(one(style, "w:pPr"));
341
- if (Object.keys(para).length) entry.paragraph = para;
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
+ }
342
598
 
343
599
  // 分发
344
600
  if (styleType === "paragraph") {
@@ -357,9 +613,22 @@ export function extractStylesFromDocx(docxPath: string): ExtractedStyles {
357
613
  } else {
358
614
  characterStyles.push(entry);
359
615
  }
616
+ } else if (styleType === "table") {
617
+ tableStyles.push(entry);
360
618
  }
361
619
  }
362
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
+
363
632
  const result: ExtractedStyles = {};
364
633
  if (Object.keys(defaultDoc).length > 0 || Object.keys(defaultOverrides).length > 0) {
365
634
  result.default = {
@@ -369,6 +638,16 @@ export function extractStylesFromDocx(docxPath: string): ExtractedStyles {
369
638
  }
370
639
  if (paragraphStyles.length) result.paragraphStyles = paragraphStyles;
371
640
  if (characterStyles.length) result.characterStyles = characterStyles;
641
+ if (tableStyles.length) result.tableStyles = tableStyles;
642
+ if (tableXmlFragments) result.tableStylesXml = tableXmlFragments;
372
643
 
373
644
  return result;
374
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,27 +1,58 @@
1
1
  import { readFileSync, mkdirSync } from "fs";
2
2
  import { Document, Packer } from "docx";
3
3
  import { dirname } from "path";
4
- import { STYLE_CONFIG, STYLE_TEMPLATE_DOCX } from "../paths";
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(styleJsonPath: string): Promise<void> {
12
+ const raw = JSON.parse(readFileSync(styleJsonPath, "utf-8")) as Record<string, unknown>;
13
+ const tableStylesXml = raw.tableStylesXml as string | undefined;
14
+ // 移除 tableStylesXml,docx 包不识别它
15
+ const { tableStylesXml: _, ...styles } = raw;
5
16
 
6
- export async function generateTemplateDocx(): Promise<void> {
7
- const styles = JSON.parse(readFileSync(STYLE_CONFIG, "utf-8"));
8
17
  const doc = new Document({
9
- styles: styles,
18
+ styles: styles as Record<string, unknown>,
10
19
  sections: [],
11
20
  });
12
21
  const buf = await Packer.toBuffer(doc);
13
22
  mkdirSync(dirname(STYLE_TEMPLATE_DOCX), { recursive: true });
23
+
24
+ // 如果有表格样式 XML,注入到生成的 docx 中
25
+ if (tableStylesXml) {
26
+ const PizZip = (await import("pizzip")).default;
27
+ const zip = new PizZip(buf);
28
+ const stylesFile = zip.file("word/styles.xml");
29
+ if (stylesFile) {
30
+ const originalXml = stylesFile.asText();
31
+ // 在 </w:styles> 之前插入表格样式
32
+ const injectedXml = originalXml.replace("</w:styles>", tableStylesXml + "</w:styles>");
33
+ zip.file("word/styles.xml", injectedXml);
34
+ const modifiedBuf = zip.generate({ type: "nodebuffer" }) as Buffer;
35
+ await Bun.write(STYLE_TEMPLATE_DOCX, modifiedBuf);
36
+ return;
37
+ }
38
+ }
39
+
14
40
  await Bun.write(STYLE_TEMPLATE_DOCX, buf);
15
41
  }
16
42
 
17
43
  let _generatePromise: Promise<void> | null = null;
18
44
 
19
- export async function ensureTemplateDocx(): Promise<string> {
45
+ /**
46
+ * 获取模板 docx 路径。如果模板不存在,则自动生成。
47
+ *
48
+ * @param styleJsonPath - 样式 JSON 文件路径(如 config/style.json)
49
+ */
50
+ export async function ensureTemplateDocx(styleJsonPath: string): Promise<string> {
20
51
  const exists = await Bun.file(STYLE_TEMPLATE_DOCX).exists();
21
52
  if (!exists) {
22
53
  if (_generatePromise === null) {
23
- _generatePromise = generateTemplateDocx().catch((e) => {
24
- _generatePromise = null; // 重置,允许下次重试
54
+ _generatePromise = generateTemplateDocx(styleJsonPath).catch((e) => {
55
+ _generatePromise = null;
25
56
  throw e;
26
57
  });
27
58
  }