adspecs 0.1.19 → 0.1.21
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/.adspecs/feature.json +16 -16
- package/.adspecs/feature.yml +28 -28
- package/.adspecs/paths.json +17 -17
- package/.adspecs/templates/04-/345/211/215/347/253/257/345/212/237/350/203/275/350/256/276/350/256/241/346/250/241/346/235/277.md +1 -1
- package/.adspecs/templates/05-/345/220/216/347/253/257/344/273/273/345/212/241/346/270/205/345/215/225/346/250/241/346/235/277.md +613 -724
- package/.adspecs/templates/05b-/345/211/215/347/253/257/344/273/273/345/212/241/346/270/205/345/215/225/346/250/241/346/235/277.md +51 -51
- package/.claude-plugin/marketplace.json +23 -23
- package/.claude-plugin/plugin.json +18 -18
- package/.qoder-plugin/plugin.json +31 -31
- package/CLAUDE.md +1 -5
- package/INSTALL.md +3 -3
- package/README.md +395 -395
- package/bin/adspecs.js +129 -129
- package/hooks/commit-queue.js +245 -245
- package/hooks/hooks.json +63 -63
- package/hooks/session-start.js +44 -44
- package/hooks/wiki-queue.js +127 -127
- package/package.json +61 -61
- package/references/ant6-front-standard/index.md +99 -99
- package/references/antd-front-demo/public/mockServiceWorker.js +361 -361
- package/references/ecp-end-standard/index.md +63 -63
- package/references/python-end-standard/01-Python/345/220/216/347/253/257/347/274/226/347/240/201/350/247/204/350/214/203.md +372 -372
- package/references/python-end-standard/02-/346/225/260/346/215/256/345/272/223/350/256/276/350/256/241/344/270/216/344/275/277/347/224/250/350/247/204/350/214/203.md +226 -226
- package/references/python-end-standard/03-Celery/345/274/202/346/255/245/344/273/273/345/212/241/350/247/204/350/214/203.md +237 -237
- package/references/python-end-standard/04-Redis/344/275/277/347/224/250/350/247/204/350/214/203.md +231 -231
- package/scripts/postinstall.js +107 -107
- package/scripts/sync-version.js +105 -105
- package/skills/.claude/.wiki-update-queue +26 -26
- package/skills/adspecs-constitution/SKILL.md +157 -0
- package/skills/adspecs-export-word/SKILL.md +498 -498
- package/skills/adspecs-export-word/references/md-to-docx.js +862 -862
- package/skills/adspecs-export-word/references/package-lock.json +220 -220
- package/skills/adspecs-export-word/references/package.json +10 -10
- package/skills/adspecs-front-prototype/SKILL.md +405 -405
- package/skills/adspecs-front-spec/SKILL.md +4 -4
- package/skills/adspecs-front-tasks/SKILL.md +213 -173
- package/skills/adspecs-plan/SKILL.md +59 -69
- package/skills/adspecs-prd/SKILL.md +13 -5
- package/skills/adspecs-prd-to-demo/SKILL.md +532 -0
- package/skills/adspecs-tasks/SKILL.md +175 -204
- package/skills/adspecs-update-status/SKILL.md +382 -382
- package/skills/adspecs-utest/SKILL.md +107 -116
- package/skills/grill-me/SKILL.md +7 -0
- package/skills/grill-me/agents/openai.yaml +5 -0
- package/skills/playwright-cli/SKILL.md +420 -0
- package/skills/playwright-cli/references/element-attributes.md +23 -0
- package/skills/playwright-cli/references/playwright-tests.md +39 -0
- package/skills/playwright-cli/references/request-mocking.md +87 -0
- package/skills/playwright-cli/references/running-code.md +241 -0
- package/skills/playwright-cli/references/session-management.md +225 -0
- package/skills/playwright-cli/references/storage-state.md +275 -0
- package/skills/playwright-cli/references/test-generation.md +433 -0
- package/skills/playwright-cli/references/tracing.md +139 -0
- package/skills/playwright-cli/references/video-recording.md +143 -0
- package/skills/playwright-trace/SKILL.md +171 -0
- package/skills/project-init/SKILL.md +93 -22
- package/skills/project-init/references/front-demo/.claude/settings.local.json +9 -0
- package/skills/wiki-update/SKILL.md +232 -232
- package/src/commands/doctor.js +197 -197
- package/src/commands/init.js +83 -83
- package/src/commands/plugin.js +165 -165
- package/src/commands/update.js +87 -87
- package/src/lib/area-scanner.js +129 -129
- package/src/lib/copier.js +104 -104
- package/src/lib/dir-utils.js +161 -133
- package/src/lib/json-merge.js +114 -114
- package/src/lib/paths-defaults.js +37 -37
- package/src/lib/prompts.js +428 -347
- package/src/lib/readme-gen.js +143 -143
- package/src/lib/report.js +338 -327
- package/src/lib/scaffolder.js +551 -518
- package/src/lib/short-name.js +36 -36
- package/src/utils.js +80 -80
- package/references/antd-front-demo/.env +0 -15
|
@@ -1,862 +1,862 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* md-to-docx.js — Markdown to Word (.docx) converter
|
|
4
|
-
*
|
|
5
|
-
* Features:
|
|
6
|
-
* - Auto-generated cover page (company logo, project info, document metadata)
|
|
7
|
-
* - Table of contents (Heading 1-3)
|
|
8
|
-
* - Full Markdown element conversion (headings, tables, code blocks, lists, images, blockquotes)
|
|
9
|
-
*
|
|
10
|
-
* Usage:
|
|
11
|
-
* node md-to-docx.js --input <file> --output <file> --meta '<json>'
|
|
12
|
-
* node md-to-docx.js --dir <dir> --output <dir> --meta '<json>' [--recursive]
|
|
13
|
-
* [--no-cover] [--no-toc]
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
const fs = require("fs");
|
|
17
|
-
const path = require("path");
|
|
18
|
-
const {
|
|
19
|
-
Document,
|
|
20
|
-
Packer,
|
|
21
|
-
Paragraph,
|
|
22
|
-
TextRun,
|
|
23
|
-
HeadingLevel,
|
|
24
|
-
AlignmentType,
|
|
25
|
-
TableOfContents,
|
|
26
|
-
StyleLevel,
|
|
27
|
-
Table,
|
|
28
|
-
TableRow,
|
|
29
|
-
TableCell,
|
|
30
|
-
WidthType,
|
|
31
|
-
BorderStyle,
|
|
32
|
-
ImageRun,
|
|
33
|
-
PageBreak,
|
|
34
|
-
ShadingType,
|
|
35
|
-
} = require("docx");
|
|
36
|
-
const { marked } = require("marked");
|
|
37
|
-
|
|
38
|
-
// ── Constants ──────────────────────────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
const DARK_BLUE = "1F4E79";
|
|
41
|
-
const GRAY = "808080";
|
|
42
|
-
const LIGHT_GRAY = "F2F2F2";
|
|
43
|
-
|
|
44
|
-
const FONT_BODY = "DengXian";
|
|
45
|
-
const FONT_HEADING = "Microsoft YaHei";
|
|
46
|
-
const FONT_CODE = "Consolas";
|
|
47
|
-
|
|
48
|
-
const HEADING_STYLES = {
|
|
49
|
-
1: { heading: HeadingLevel.HEADING_1, size: 44, font: FONT_HEADING },
|
|
50
|
-
2: { heading: HeadingLevel.HEADING_2, size: 32, font: FONT_HEADING },
|
|
51
|
-
3: { heading: HeadingLevel.HEADING_3, size: 28, font: FONT_HEADING },
|
|
52
|
-
4: { heading: HeadingLevel.HEADING_4, size: 24, font: FONT_HEADING },
|
|
53
|
-
5: { heading: HeadingLevel.HEADING_5, size: 22, font: FONT_HEADING },
|
|
54
|
-
6: { heading: HeadingLevel.HEADING_6, size: 20, font: FONT_HEADING },
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
// ── CLI Arguments ──────────────────────────────────────────────────────
|
|
58
|
-
|
|
59
|
-
function parseArgs() {
|
|
60
|
-
const args = process.argv.slice(2);
|
|
61
|
-
const result = {
|
|
62
|
-
input: null,
|
|
63
|
-
dir: null,
|
|
64
|
-
output: null,
|
|
65
|
-
meta: "{}",
|
|
66
|
-
noCover: false,
|
|
67
|
-
noToc: false,
|
|
68
|
-
recursive: false,
|
|
69
|
-
};
|
|
70
|
-
for (let i = 0; i < args.length; i++) {
|
|
71
|
-
switch (args[i]) {
|
|
72
|
-
case "--input":
|
|
73
|
-
result.input = args[++i];
|
|
74
|
-
break;
|
|
75
|
-
case "--dir":
|
|
76
|
-
result.dir = args[++i];
|
|
77
|
-
break;
|
|
78
|
-
case "--output":
|
|
79
|
-
result.output = args[++i];
|
|
80
|
-
break;
|
|
81
|
-
case "--meta":
|
|
82
|
-
result.meta = args[++i];
|
|
83
|
-
break;
|
|
84
|
-
case "--no-cover":
|
|
85
|
-
result.noCover = true;
|
|
86
|
-
break;
|
|
87
|
-
case "--no-toc":
|
|
88
|
-
result.noToc = true;
|
|
89
|
-
break;
|
|
90
|
-
case "--recursive":
|
|
91
|
-
result.recursive = true;
|
|
92
|
-
break;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
return result;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// ── Markdown Reader ────────────────────────────────────────────────────
|
|
99
|
-
|
|
100
|
-
function readMarkdown(filePath) {
|
|
101
|
-
const content = fs.readFileSync(filePath, "utf-8");
|
|
102
|
-
|
|
103
|
-
// Try YAML frontmatter
|
|
104
|
-
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
105
|
-
let frontmatter = {};
|
|
106
|
-
let body = content;
|
|
107
|
-
|
|
108
|
-
if (fmMatch) {
|
|
109
|
-
for (const line of fmMatch[1].split("\n")) {
|
|
110
|
-
const m = line.match(/^(\S+):\s*(.*)$/);
|
|
111
|
-
if (m) frontmatter[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
|
|
112
|
-
}
|
|
113
|
-
body = fmMatch[2];
|
|
114
|
-
} else {
|
|
115
|
-
// Try blockquote metadata: > **版本**: v1.0
|
|
116
|
-
const metaLines = [];
|
|
117
|
-
const restLines = [];
|
|
118
|
-
let inMeta = true;
|
|
119
|
-
for (const line of content.split("\n")) {
|
|
120
|
-
if (inMeta && line.startsWith(">")) {
|
|
121
|
-
metaLines.push(line);
|
|
122
|
-
} else {
|
|
123
|
-
inMeta = false;
|
|
124
|
-
restLines.push(line);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
body = restLines.join("\n");
|
|
128
|
-
for (const line of metaLines) {
|
|
129
|
-
const m = line.match(/\*\*(.+?)\*\*[::]\s*(.+)/);
|
|
130
|
-
if (m) frontmatter[m[1].trim()] = m[2].trim();
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return { body, frontmatter };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// ── Inline Token Converter ─────────────────────────────────────────────
|
|
138
|
-
|
|
139
|
-
function convertInline(tokens, overrides = {}) {
|
|
140
|
-
if (!tokens || !Array.isArray(tokens)) return [];
|
|
141
|
-
const runs = [];
|
|
142
|
-
const baseFont = overrides.font || FONT_BODY;
|
|
143
|
-
const baseSize = overrides.size || 24;
|
|
144
|
-
|
|
145
|
-
for (const token of tokens) {
|
|
146
|
-
switch (token.type) {
|
|
147
|
-
case "text": {
|
|
148
|
-
const children = token.tokens ? convertInline(token.tokens, overrides) : [];
|
|
149
|
-
if (children.length > 0) {
|
|
150
|
-
runs.push(...children);
|
|
151
|
-
} else {
|
|
152
|
-
runs.push(new TextRun({ text: token.text || "", font: baseFont, size: baseSize, ...overrides }));
|
|
153
|
-
}
|
|
154
|
-
break;
|
|
155
|
-
}
|
|
156
|
-
case "strong":
|
|
157
|
-
runs.push(...convertInline(token.tokens, { ...overrides, bold: true }));
|
|
158
|
-
break;
|
|
159
|
-
case "em":
|
|
160
|
-
runs.push(...convertInline(token.tokens, { ...overrides, italics: true }));
|
|
161
|
-
break;
|
|
162
|
-
case "del":
|
|
163
|
-
runs.push(...convertInline(token.tokens, { ...overrides, strike: true }));
|
|
164
|
-
break;
|
|
165
|
-
case "codespan":
|
|
166
|
-
runs.push(
|
|
167
|
-
new TextRun({ text: token.text || "", font: FONT_CODE, size: Math.min(baseSize, 20), ...overrides })
|
|
168
|
-
);
|
|
169
|
-
break;
|
|
170
|
-
case "link":
|
|
171
|
-
runs.push(
|
|
172
|
-
new TextRun({
|
|
173
|
-
text: token.text || (token.tokens ? token.tokens.map(t => t.text || t.raw || "").join("") : ""),
|
|
174
|
-
font: baseFont,
|
|
175
|
-
size: baseSize,
|
|
176
|
-
color: "0563C1",
|
|
177
|
-
underline: { type: "single" },
|
|
178
|
-
...overrides,
|
|
179
|
-
})
|
|
180
|
-
);
|
|
181
|
-
break;
|
|
182
|
-
case "image":
|
|
183
|
-
try {
|
|
184
|
-
const imgPath = token.href;
|
|
185
|
-
if (imgPath && fs.existsSync(imgPath)) {
|
|
186
|
-
const buf = fs.readFileSync(imgPath);
|
|
187
|
-
runs.push(
|
|
188
|
-
new ImageRun({
|
|
189
|
-
data: buf,
|
|
190
|
-
transformation: { width: 200, height: 150 },
|
|
191
|
-
altText: { title: token.text || "Image", description: token.text || "Image", name: "image" },
|
|
192
|
-
})
|
|
193
|
-
);
|
|
194
|
-
} else {
|
|
195
|
-
runs.push(new TextRun({ text: `[Image: ${token.text || token.href}]`, font: baseFont, size: baseSize, italics: true, color: GRAY }));
|
|
196
|
-
}
|
|
197
|
-
} catch {
|
|
198
|
-
runs.push(new TextRun({ text: `[Image: ${token.text || token.href}]`, font: baseFont, size: baseSize, italics: true, color: GRAY }));
|
|
199
|
-
}
|
|
200
|
-
break;
|
|
201
|
-
case "br":
|
|
202
|
-
runs.push(new TextRun({ text: "", break: 1, font: baseFont, size: baseSize }));
|
|
203
|
-
break;
|
|
204
|
-
default:
|
|
205
|
-
if (token.text || token.raw) {
|
|
206
|
-
runs.push(new TextRun({ text: token.text || token.raw || "", font: baseFont, size: baseSize, ...overrides }));
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
return runs;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// ── Cover Page Builder ─────────────────────────────────────────────────
|
|
215
|
-
|
|
216
|
-
function buildCoverPage(meta) {
|
|
217
|
-
const blocks = [];
|
|
218
|
-
const spacer = (n) => {
|
|
219
|
-
for (let i = 0; i < n; i++) blocks.push(new Paragraph({ children: [new TextRun("")] }));
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
// 1. Top spacing
|
|
223
|
-
spacer(3);
|
|
224
|
-
|
|
225
|
-
// 2. Company logo
|
|
226
|
-
if (meta.company_logo && fs.existsSync(meta.company_logo)) {
|
|
227
|
-
try {
|
|
228
|
-
const logoBuf = fs.readFileSync(meta.company_logo);
|
|
229
|
-
blocks.push(
|
|
230
|
-
new Paragraph({
|
|
231
|
-
alignment: AlignmentType.CENTER,
|
|
232
|
-
children: [
|
|
233
|
-
new ImageRun({
|
|
234
|
-
data: logoBuf,
|
|
235
|
-
transformation: { width: 200, height: 200 },
|
|
236
|
-
altText: { title: "Company Logo", description: "Company Logo", name: "logo" },
|
|
237
|
-
}),
|
|
238
|
-
],
|
|
239
|
-
})
|
|
240
|
-
);
|
|
241
|
-
spacer(2);
|
|
242
|
-
} catch {
|
|
243
|
-
// Skip logo on error
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// 3. Company name
|
|
248
|
-
if (meta.company_name) {
|
|
249
|
-
blocks.push(
|
|
250
|
-
new Paragraph({
|
|
251
|
-
alignment: AlignmentType.CENTER,
|
|
252
|
-
children: [new TextRun({ text: meta.company_name, bold: true, font: FONT_HEADING, size: 32, color: DARK_BLUE })],
|
|
253
|
-
})
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
spacer(1);
|
|
258
|
-
|
|
259
|
-
// 4. Project name
|
|
260
|
-
if (meta.project_name) {
|
|
261
|
-
blocks.push(
|
|
262
|
-
new Paragraph({
|
|
263
|
-
alignment: AlignmentType.CENTER,
|
|
264
|
-
children: [new TextRun({ text: meta.project_name, bold: true, font: FONT_HEADING, size: 36, color: DARK_BLUE })],
|
|
265
|
-
})
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
spacer(2);
|
|
270
|
-
|
|
271
|
-
// 5. Document title
|
|
272
|
-
if (meta.title) {
|
|
273
|
-
blocks.push(
|
|
274
|
-
new Paragraph({
|
|
275
|
-
alignment: AlignmentType.CENTER,
|
|
276
|
-
children: [new TextRun({ text: meta.title, bold: true, font: FONT_HEADING, size: 56, color: DARK_BLUE })],
|
|
277
|
-
})
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// 6. Subtitle
|
|
282
|
-
if (meta.subtitle) {
|
|
283
|
-
blocks.push(
|
|
284
|
-
new Paragraph({
|
|
285
|
-
alignment: AlignmentType.CENTER,
|
|
286
|
-
spacing: { before: 200 },
|
|
287
|
-
children: [new TextRun({ text: meta.subtitle, font: FONT_HEADING, size: 28, color: GRAY })],
|
|
288
|
-
})
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
spacer(3);
|
|
293
|
-
|
|
294
|
-
// 7. Info table
|
|
295
|
-
const fields = [
|
|
296
|
-
["文档编号", meta.document_number],
|
|
297
|
-
["版本号", meta.version],
|
|
298
|
-
["创建日期", meta.date],
|
|
299
|
-
["作者", meta.author],
|
|
300
|
-
["审核人", meta.reviewer],
|
|
301
|
-
["批准人", meta.approver],
|
|
302
|
-
["保密等级", meta.confidentiality],
|
|
303
|
-
].filter(([, v]) => v);
|
|
304
|
-
|
|
305
|
-
if (fields.length > 0) {
|
|
306
|
-
const borderStyle = { style: BorderStyle.SINGLE, size: 1, color: "BBBBBB" };
|
|
307
|
-
const borders = { top: borderStyle, bottom: borderStyle, left: borderStyle, right: borderStyle };
|
|
308
|
-
|
|
309
|
-
const rows = fields.map(
|
|
310
|
-
([label, value]) =>
|
|
311
|
-
new TableRow({
|
|
312
|
-
children: [
|
|
313
|
-
new TableCell({
|
|
314
|
-
borders,
|
|
315
|
-
width: { size: 3000, type: WidthType.DXA },
|
|
316
|
-
children: [
|
|
317
|
-
new Paragraph({
|
|
318
|
-
alignment: AlignmentType.CENTER,
|
|
319
|
-
children: [new TextRun({ text: label, bold: true, font: FONT_HEADING, size: 22 })],
|
|
320
|
-
}),
|
|
321
|
-
],
|
|
322
|
-
}),
|
|
323
|
-
new TableCell({
|
|
324
|
-
borders,
|
|
325
|
-
width: { size: 5000, type: WidthType.DXA },
|
|
326
|
-
children: [
|
|
327
|
-
new Paragraph({
|
|
328
|
-
children: [new TextRun({ text: value, font: FONT_BODY, size: 22 })],
|
|
329
|
-
}),
|
|
330
|
-
],
|
|
331
|
-
}),
|
|
332
|
-
],
|
|
333
|
-
})
|
|
334
|
-
);
|
|
335
|
-
|
|
336
|
-
blocks.push(
|
|
337
|
-
new Table({
|
|
338
|
-
rows,
|
|
339
|
-
width: { size: 8000, type: WidthType.DXA },
|
|
340
|
-
})
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// 8. Page break
|
|
345
|
-
blocks.push(
|
|
346
|
-
new Paragraph({
|
|
347
|
-
children: [new PageBreak()],
|
|
348
|
-
})
|
|
349
|
-
);
|
|
350
|
-
|
|
351
|
-
return blocks;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// ── TOC Builder ────────────────────────────────────────────────────────
|
|
355
|
-
|
|
356
|
-
function buildTOC() {
|
|
357
|
-
return [
|
|
358
|
-
new Paragraph({
|
|
359
|
-
heading: HeadingLevel.HEADING_1,
|
|
360
|
-
children: [new TextRun({ text: "目录", font: FONT_HEADING, size: 44, bold: true, color: DARK_BLUE })],
|
|
361
|
-
}),
|
|
362
|
-
new TableOfContents("目录", {
|
|
363
|
-
hyperlink: true,
|
|
364
|
-
headingStyleRange: "1-3",
|
|
365
|
-
stylesWithLevels: [
|
|
366
|
-
new StyleLevel("Heading1", 1),
|
|
367
|
-
new StyleLevel("Heading2", 2),
|
|
368
|
-
new StyleLevel("Heading3", 3),
|
|
369
|
-
],
|
|
370
|
-
}),
|
|
371
|
-
new Paragraph({
|
|
372
|
-
children: [new PageBreak()],
|
|
373
|
-
}),
|
|
374
|
-
];
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// ── Markdown → docx Converter ──────────────────────────────────────────
|
|
378
|
-
|
|
379
|
-
function convertTokens(tokens) {
|
|
380
|
-
const blocks = [];
|
|
381
|
-
|
|
382
|
-
for (const token of tokens) {
|
|
383
|
-
switch (token.type) {
|
|
384
|
-
case "heading": {
|
|
385
|
-
const style = HEADING_STYLES[token.depth] || HEADING_STYLES[6];
|
|
386
|
-
const runs = token.tokens
|
|
387
|
-
? convertInline(token.tokens, { font: style.font, size: style.size, bold: true, color: DARK_BLUE })
|
|
388
|
-
: [new TextRun({ text: token.text || "", font: style.font, size: style.size, bold: true, color: DARK_BLUE })];
|
|
389
|
-
blocks.push(
|
|
390
|
-
new Paragraph({
|
|
391
|
-
heading: style.heading,
|
|
392
|
-
spacing: { before: 300, after: 100 },
|
|
393
|
-
children: runs,
|
|
394
|
-
})
|
|
395
|
-
);
|
|
396
|
-
break;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
case "paragraph": {
|
|
400
|
-
const runs = convertInline(token.tokens);
|
|
401
|
-
blocks.push(
|
|
402
|
-
new Paragraph({
|
|
403
|
-
spacing: { after: 120 },
|
|
404
|
-
children: runs,
|
|
405
|
-
})
|
|
406
|
-
);
|
|
407
|
-
break;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
case "code": {
|
|
411
|
-
const lines = (token.text || "").split("\n");
|
|
412
|
-
for (const line of lines) {
|
|
413
|
-
blocks.push(
|
|
414
|
-
new Paragraph({
|
|
415
|
-
spacing: { after: 0 },
|
|
416
|
-
indent: { left: 400 },
|
|
417
|
-
shading: { type: ShadingType.CLEAR, color: "auto", fill: LIGHT_GRAY },
|
|
418
|
-
children: [new TextRun({ text: line || " ", font: FONT_CODE, size: 20 })],
|
|
419
|
-
})
|
|
420
|
-
);
|
|
421
|
-
}
|
|
422
|
-
// Add spacing after code block
|
|
423
|
-
blocks.push(new Paragraph({ spacing: { after: 120 }, children: [new TextRun("")] }));
|
|
424
|
-
break;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
case "table": {
|
|
428
|
-
const borderStyle = { style: BorderStyle.SINGLE, size: 1, color: "999999" };
|
|
429
|
-
const borders = { top: borderStyle, bottom: borderStyle, left: borderStyle, right: borderStyle };
|
|
430
|
-
|
|
431
|
-
// Header row
|
|
432
|
-
const headerRow = new TableRow({
|
|
433
|
-
tableHeader: true,
|
|
434
|
-
children: (token.header || []).map(
|
|
435
|
-
(h) =>
|
|
436
|
-
new TableCell({
|
|
437
|
-
borders,
|
|
438
|
-
shading: { type: ShadingType.CLEAR, color: "auto", fill: "D9E2F3" },
|
|
439
|
-
children: [
|
|
440
|
-
new Paragraph({
|
|
441
|
-
children: [
|
|
442
|
-
new TextRun({
|
|
443
|
-
text: h.text || "",
|
|
444
|
-
bold: true,
|
|
445
|
-
font: FONT_HEADING,
|
|
446
|
-
size: 22,
|
|
447
|
-
}),
|
|
448
|
-
],
|
|
449
|
-
}),
|
|
450
|
-
],
|
|
451
|
-
})
|
|
452
|
-
),
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
// Data rows
|
|
456
|
-
const dataRows = (token.rows || []).map(
|
|
457
|
-
(row) =>
|
|
458
|
-
new TableRow({
|
|
459
|
-
children: row.map(
|
|
460
|
-
(cell) =>
|
|
461
|
-
new TableCell({
|
|
462
|
-
borders,
|
|
463
|
-
children: [
|
|
464
|
-
new Paragraph({
|
|
465
|
-
children: cell.tokens
|
|
466
|
-
? convertInline(cell.tokens)
|
|
467
|
-
: [new TextRun({ text: cell.text || "", font: FONT_BODY, size: 22 })],
|
|
468
|
-
}),
|
|
469
|
-
],
|
|
470
|
-
})
|
|
471
|
-
),
|
|
472
|
-
})
|
|
473
|
-
);
|
|
474
|
-
|
|
475
|
-
blocks.push(new Table({ rows: [headerRow, ...dataRows] }));
|
|
476
|
-
// Spacing after table
|
|
477
|
-
blocks.push(new Paragraph({ spacing: { after: 120 }, children: [new TextRun("")] }));
|
|
478
|
-
break;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
case "list": {
|
|
482
|
-
blocks.push(...convertList(token, 0));
|
|
483
|
-
break;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
case "blockquote": {
|
|
487
|
-
const innerTokens = token.tokens || [];
|
|
488
|
-
for (const inner of innerTokens) {
|
|
489
|
-
if (inner.type === "paragraph") {
|
|
490
|
-
const runs = convertInline(inner.tokens, { italics: true, color: "555555" });
|
|
491
|
-
blocks.push(
|
|
492
|
-
new Paragraph({
|
|
493
|
-
indent: { left: 720 },
|
|
494
|
-
spacing: { after: 120 },
|
|
495
|
-
border: {
|
|
496
|
-
left: { style: BorderStyle.SINGLE, size: 6, color: DARK_BLUE, space: 10 },
|
|
497
|
-
},
|
|
498
|
-
shading: { type: ShadingType.CLEAR, color: "auto", fill: LIGHT_GRAY },
|
|
499
|
-
children: runs,
|
|
500
|
-
})
|
|
501
|
-
);
|
|
502
|
-
} else {
|
|
503
|
-
// Recursively handle other block types inside blockquote
|
|
504
|
-
const nested = convertTokens([inner]);
|
|
505
|
-
blocks.push(...nested);
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
break;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
case "hr":
|
|
512
|
-
blocks.push(
|
|
513
|
-
new Paragraph({
|
|
514
|
-
spacing: { before: 200, after: 200 },
|
|
515
|
-
border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" } },
|
|
516
|
-
children: [new TextRun("")],
|
|
517
|
-
})
|
|
518
|
-
);
|
|
519
|
-
break;
|
|
520
|
-
|
|
521
|
-
case "html":
|
|
522
|
-
// Skip HTML blocks or render as plain text
|
|
523
|
-
blocks.push(
|
|
524
|
-
new Paragraph({
|
|
525
|
-
spacing: { after: 120 },
|
|
526
|
-
children: [new TextRun({ text: token.text || "", font: FONT_BODY, size: 24, color: GRAY })],
|
|
527
|
-
})
|
|
528
|
-
);
|
|
529
|
-
break;
|
|
530
|
-
|
|
531
|
-
case "space":
|
|
532
|
-
break;
|
|
533
|
-
|
|
534
|
-
default:
|
|
535
|
-
if (token.raw && token.text) {
|
|
536
|
-
blocks.push(
|
|
537
|
-
new Paragraph({
|
|
538
|
-
spacing: { after: 120 },
|
|
539
|
-
children: [new TextRun({ text: token.text || token.raw || "", font: FONT_BODY, size: 24 })],
|
|
540
|
-
})
|
|
541
|
-
);
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
return blocks;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// ── List Converter (handles nesting) ───────────────────────────────────
|
|
550
|
-
|
|
551
|
-
function convertList(token, level) {
|
|
552
|
-
const blocks = [];
|
|
553
|
-
const isOrdered = token.ordered;
|
|
554
|
-
const items = token.items || [];
|
|
555
|
-
|
|
556
|
-
let numIndex = 1;
|
|
557
|
-
|
|
558
|
-
for (const item of items) {
|
|
559
|
-
const numbering = isOrdered
|
|
560
|
-
? { reference: "numbered-list", level }
|
|
561
|
-
: { reference: "bullet-list", level };
|
|
562
|
-
|
|
563
|
-
// Process item children (paragraphs, nested lists, code blocks)
|
|
564
|
-
const itemChildren = item.tokens || [];
|
|
565
|
-
|
|
566
|
-
for (const child of itemChildren) {
|
|
567
|
-
if (child.type === "list") {
|
|
568
|
-
// Nested list
|
|
569
|
-
blocks.push(...convertList(child, level + 1));
|
|
570
|
-
} else if (child.type === "text" && !child.tokens) {
|
|
571
|
-
// Plain text item
|
|
572
|
-
const prefix = item.task ? (item.checked ? "☑ " : "☐ ") : "";
|
|
573
|
-
blocks.push(
|
|
574
|
-
new Paragraph({
|
|
575
|
-
numbering,
|
|
576
|
-
spacing: { after: 60 },
|
|
577
|
-
children: [new TextRun({ text: prefix + (child.text || child.raw || ""), font: FONT_BODY, size: 24 })],
|
|
578
|
-
})
|
|
579
|
-
);
|
|
580
|
-
} else if (child.type === "text" && child.tokens) {
|
|
581
|
-
// Text with inline formatting
|
|
582
|
-
const runs = convertInline(child.tokens);
|
|
583
|
-
if (item.task) {
|
|
584
|
-
const prefix = item.checked ? "☑ " : "☐ ";
|
|
585
|
-
runs.unshift(new TextRun({ text: prefix, font: FONT_BODY, size: 24 }));
|
|
586
|
-
}
|
|
587
|
-
blocks.push(
|
|
588
|
-
new Paragraph({
|
|
589
|
-
numbering,
|
|
590
|
-
spacing: { after: 60 },
|
|
591
|
-
children: runs,
|
|
592
|
-
})
|
|
593
|
-
);
|
|
594
|
-
} else if (child.type === "paragraph") {
|
|
595
|
-
const runs = convertInline(child.tokens);
|
|
596
|
-
if (item.task) {
|
|
597
|
-
const prefix = item.checked ? "☑ " : "☐ ";
|
|
598
|
-
runs.unshift(new TextRun({ text: prefix, font: FONT_BODY, size: 24 }));
|
|
599
|
-
}
|
|
600
|
-
blocks.push(
|
|
601
|
-
new Paragraph({
|
|
602
|
-
numbering,
|
|
603
|
-
spacing: { after: 60 },
|
|
604
|
-
children: runs,
|
|
605
|
-
})
|
|
606
|
-
);
|
|
607
|
-
} else if (child.type === "code") {
|
|
608
|
-
// Code block inside list item
|
|
609
|
-
const lines = (child.text || "").split("\n");
|
|
610
|
-
for (const line of lines) {
|
|
611
|
-
blocks.push(
|
|
612
|
-
new Paragraph({
|
|
613
|
-
spacing: { after: 0 },
|
|
614
|
-
indent: { left: 400 + level * 720 },
|
|
615
|
-
shading: { type: ShadingType.CLEAR, color: "auto", fill: LIGHT_GRAY },
|
|
616
|
-
children: [new TextRun({ text: line || " ", font: FONT_CODE, size: 20 })],
|
|
617
|
-
})
|
|
618
|
-
);
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// If no children processed, add a fallback
|
|
624
|
-
if (itemChildren.length === 0) {
|
|
625
|
-
const prefix = item.task ? (item.checked ? "☑ " : "☐ ") : "";
|
|
626
|
-
blocks.push(
|
|
627
|
-
new Paragraph({
|
|
628
|
-
numbering,
|
|
629
|
-
spacing: { after: 60 },
|
|
630
|
-
children: [new TextRun({ text: prefix + (item.text || item.raw || ""), font: FONT_BODY, size: 24 })],
|
|
631
|
-
})
|
|
632
|
-
);
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
numIndex++;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
return blocks;
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
// ── Document Assembler ─────────────────────────────────────────────────
|
|
642
|
-
|
|
643
|
-
function assembleDocument(coverBlocks, tocBlocks, contentBlocks, meta) {
|
|
644
|
-
const sections = [];
|
|
645
|
-
|
|
646
|
-
// Cover page section (no header/footer)
|
|
647
|
-
if (coverBlocks.length > 0) {
|
|
648
|
-
sections.push({
|
|
649
|
-
properties: {
|
|
650
|
-
page: {
|
|
651
|
-
size: { width: 11906, height: 16838 },
|
|
652
|
-
margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
|
|
653
|
-
},
|
|
654
|
-
},
|
|
655
|
-
children: coverBlocks,
|
|
656
|
-
});
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// TOC + Content in main section
|
|
660
|
-
const mainChildren = [...tocBlocks, ...contentBlocks];
|
|
661
|
-
|
|
662
|
-
sections.push({
|
|
663
|
-
properties: {
|
|
664
|
-
page: {
|
|
665
|
-
size: { width: 11906, height: 16838 },
|
|
666
|
-
margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
|
|
667
|
-
},
|
|
668
|
-
},
|
|
669
|
-
children: mainChildren,
|
|
670
|
-
});
|
|
671
|
-
|
|
672
|
-
return new Document({
|
|
673
|
-
features: { updateFields: true },
|
|
674
|
-
styles: {
|
|
675
|
-
default: {
|
|
676
|
-
document: {
|
|
677
|
-
run: { font: FONT_BODY, size: 24 },
|
|
678
|
-
paragraph: { spacing: { line: 360 } },
|
|
679
|
-
},
|
|
680
|
-
heading1: {
|
|
681
|
-
run: { font: FONT_HEADING, size: 44, bold: true, color: DARK_BLUE },
|
|
682
|
-
paragraph: { spacing: { before: 400, after: 200 } },
|
|
683
|
-
},
|
|
684
|
-
heading2: {
|
|
685
|
-
run: { font: FONT_HEADING, size: 32, bold: true, color: DARK_BLUE },
|
|
686
|
-
paragraph: { spacing: { before: 300, after: 150 } },
|
|
687
|
-
},
|
|
688
|
-
heading3: {
|
|
689
|
-
run: { font: FONT_HEADING, size: 28, bold: true, color: DARK_BLUE },
|
|
690
|
-
paragraph: { spacing: { before: 240, after: 120 } },
|
|
691
|
-
},
|
|
692
|
-
heading4: {
|
|
693
|
-
run: { font: FONT_HEADING, size: 24, bold: true, color: DARK_BLUE },
|
|
694
|
-
paragraph: { spacing: { before: 200, after: 100 } },
|
|
695
|
-
},
|
|
696
|
-
},
|
|
697
|
-
},
|
|
698
|
-
numbering: {
|
|
699
|
-
config: [
|
|
700
|
-
{
|
|
701
|
-
reference: "bullet-list",
|
|
702
|
-
levels: [
|
|
703
|
-
{ level: 0, format: "bullet", text: "•", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 720, hanging: 360 } } } },
|
|
704
|
-
{ level: 1, format: "bullet", text: "◦", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 1440, hanging: 360 } } } },
|
|
705
|
-
{ level: 2, format: "bullet", text: "▪", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 2160, hanging: 360 } } } },
|
|
706
|
-
{ level: 3, format: "bullet", text: "•", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 2880, hanging: 360 } } } },
|
|
707
|
-
],
|
|
708
|
-
},
|
|
709
|
-
{
|
|
710
|
-
reference: "numbered-list",
|
|
711
|
-
levels: [
|
|
712
|
-
{ level: 0, format: "decimal", text: "%1.", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 720, hanging: 360 } } } },
|
|
713
|
-
{ level: 1, format: "lowerLetter", text: "%2)", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 1440, hanging: 360 } } } },
|
|
714
|
-
{ level: 2, format: "lowerRoman", text: "%3.", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 2160, hanging: 360 } } } },
|
|
715
|
-
{ level: 3, format: "decimal", text: "%4.", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 2880, hanging: 360 } } } },
|
|
716
|
-
],
|
|
717
|
-
},
|
|
718
|
-
],
|
|
719
|
-
},
|
|
720
|
-
sections,
|
|
721
|
-
});
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// ── Directory Scanner ──────────────────────────────────────────────────
|
|
725
|
-
|
|
726
|
-
function scanDirectory(dir, recursive) {
|
|
727
|
-
const files = [];
|
|
728
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
729
|
-
|
|
730
|
-
for (const entry of entries) {
|
|
731
|
-
const fullPath = path.join(dir, entry.name);
|
|
732
|
-
|
|
733
|
-
if (entry.isDirectory()) {
|
|
734
|
-
// Skip hidden directories
|
|
735
|
-
if (entry.name.startsWith(".")) continue;
|
|
736
|
-
if (recursive) {
|
|
737
|
-
files.push(...scanDirectory(fullPath, true));
|
|
738
|
-
}
|
|
739
|
-
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
740
|
-
// Skip README.md
|
|
741
|
-
if (entry.name.toLowerCase() === "readme.md") continue;
|
|
742
|
-
files.push(fullPath);
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
return files;
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
// ── Single File Processor ──────────────────────────────────────────────
|
|
750
|
-
|
|
751
|
-
async function processFile(inputFile, outputFile, meta, options) {
|
|
752
|
-
const { body, frontmatter } = readMarkdown(inputFile);
|
|
753
|
-
|
|
754
|
-
// Merge frontmatter into meta (meta takes priority)
|
|
755
|
-
const mergedMeta = { ...frontmatter, ...meta };
|
|
756
|
-
if (!mergedMeta.title) {
|
|
757
|
-
// Use first H1 or filename
|
|
758
|
-
const h1Match = body.match(/^#\s+(.+)$/m);
|
|
759
|
-
mergedMeta.title = h1Match ? h1Match[1] : path.basename(inputFile, ".md");
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
// Parse markdown
|
|
763
|
-
const tokens = marked.lexer(body);
|
|
764
|
-
|
|
765
|
-
// Build sections
|
|
766
|
-
const coverBlocks = options.noCover ? [] : buildCoverPage(mergedMeta);
|
|
767
|
-
const tocBlocks = options.noToc ? [] : buildTOC();
|
|
768
|
-
const contentBlocks = convertTokens(tokens);
|
|
769
|
-
|
|
770
|
-
// Assemble document
|
|
771
|
-
const doc = assembleDocument(coverBlocks, tocBlocks, contentBlocks, mergedMeta);
|
|
772
|
-
|
|
773
|
-
// Ensure output directory exists
|
|
774
|
-
const outDir = path.dirname(outputFile);
|
|
775
|
-
if (!fs.existsSync(outDir)) {
|
|
776
|
-
fs.mkdirSync(outDir, { recursive: true });
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
// Write file
|
|
780
|
-
const buffer = await Packer.toBuffer(doc);
|
|
781
|
-
fs.writeFileSync(outputFile, buffer);
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// ── Main ───────────────────────────────────────────────────────────────
|
|
785
|
-
|
|
786
|
-
async function main() {
|
|
787
|
-
const args = parseArgs();
|
|
788
|
-
const meta = JSON.parse(args.meta || "{}");
|
|
789
|
-
const options = { noCover: args.noCover, noToc: args.noToc };
|
|
790
|
-
const results = [];
|
|
791
|
-
|
|
792
|
-
try {
|
|
793
|
-
if (args.dir) {
|
|
794
|
-
// Directory mode
|
|
795
|
-
const dirPath = path.resolve(args.dir);
|
|
796
|
-
if (!fs.existsSync(dirPath)) {
|
|
797
|
-
throw new Error(`目录不存在: ${dirPath}`);
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
const files = scanDirectory(dirPath, args.recursive);
|
|
801
|
-
if (files.length === 0) {
|
|
802
|
-
throw new Error(`目录中未找到 .md 文件: ${dirPath}`);
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
const outDir = args.output ? path.resolve(args.output) : dirPath;
|
|
806
|
-
|
|
807
|
-
for (const file of files) {
|
|
808
|
-
const baseName = path.basename(file, ".md");
|
|
809
|
-
const outputFile = path.join(outDir, `${baseName}.docx`);
|
|
810
|
-
|
|
811
|
-
try {
|
|
812
|
-
await processFile(file, outputFile, { ...meta }, options);
|
|
813
|
-
results.push({ input: file, output: outputFile, status: "ok" });
|
|
814
|
-
} catch (err) {
|
|
815
|
-
results.push({ input: file, output: outputFile, status: "error", error: err.message });
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
} else if (args.input) {
|
|
819
|
-
// Single file mode
|
|
820
|
-
const inputFile = path.resolve(args.input);
|
|
821
|
-
if (!fs.existsSync(inputFile)) {
|
|
822
|
-
throw new Error(`文件不存在: ${inputFile}`);
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
const baseName = path.basename(inputFile, ".md");
|
|
826
|
-
const outputFile = args.output
|
|
827
|
-
? path.resolve(args.output)
|
|
828
|
-
: path.join(path.dirname(inputFile), `${baseName}.docx`);
|
|
829
|
-
|
|
830
|
-
await processFile(inputFile, outputFile, meta, options);
|
|
831
|
-
results.push({ input: inputFile, output: outputFile, status: "ok" });
|
|
832
|
-
} else {
|
|
833
|
-
throw new Error("必须指定 --input 或 --dir 参数");
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
const failed = results.filter((r) => r.status === "error");
|
|
837
|
-
const status = failed.length === 0 ? "success" : failed.length === results.length ? "error" : "partial";
|
|
838
|
-
|
|
839
|
-
console.log(
|
|
840
|
-
JSON.stringify({
|
|
841
|
-
status,
|
|
842
|
-
files: results,
|
|
843
|
-
cover: !args.noCover,
|
|
844
|
-
toc: !args.noToc,
|
|
845
|
-
total: results.length,
|
|
846
|
-
success: results.length - failed.length,
|
|
847
|
-
failed: failed.length,
|
|
848
|
-
})
|
|
849
|
-
);
|
|
850
|
-
} catch (err) {
|
|
851
|
-
console.error(
|
|
852
|
-
JSON.stringify({
|
|
853
|
-
status: "error",
|
|
854
|
-
message: err.message,
|
|
855
|
-
files: results,
|
|
856
|
-
})
|
|
857
|
-
);
|
|
858
|
-
process.exit(1);
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
main();
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* md-to-docx.js — Markdown to Word (.docx) converter
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - Auto-generated cover page (company logo, project info, document metadata)
|
|
7
|
+
* - Table of contents (Heading 1-3)
|
|
8
|
+
* - Full Markdown element conversion (headings, tables, code blocks, lists, images, blockquotes)
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node md-to-docx.js --input <file> --output <file> --meta '<json>'
|
|
12
|
+
* node md-to-docx.js --dir <dir> --output <dir> --meta '<json>' [--recursive]
|
|
13
|
+
* [--no-cover] [--no-toc]
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const {
|
|
19
|
+
Document,
|
|
20
|
+
Packer,
|
|
21
|
+
Paragraph,
|
|
22
|
+
TextRun,
|
|
23
|
+
HeadingLevel,
|
|
24
|
+
AlignmentType,
|
|
25
|
+
TableOfContents,
|
|
26
|
+
StyleLevel,
|
|
27
|
+
Table,
|
|
28
|
+
TableRow,
|
|
29
|
+
TableCell,
|
|
30
|
+
WidthType,
|
|
31
|
+
BorderStyle,
|
|
32
|
+
ImageRun,
|
|
33
|
+
PageBreak,
|
|
34
|
+
ShadingType,
|
|
35
|
+
} = require("docx");
|
|
36
|
+
const { marked } = require("marked");
|
|
37
|
+
|
|
38
|
+
// ── Constants ──────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const DARK_BLUE = "1F4E79";
|
|
41
|
+
const GRAY = "808080";
|
|
42
|
+
const LIGHT_GRAY = "F2F2F2";
|
|
43
|
+
|
|
44
|
+
const FONT_BODY = "DengXian";
|
|
45
|
+
const FONT_HEADING = "Microsoft YaHei";
|
|
46
|
+
const FONT_CODE = "Consolas";
|
|
47
|
+
|
|
48
|
+
const HEADING_STYLES = {
|
|
49
|
+
1: { heading: HeadingLevel.HEADING_1, size: 44, font: FONT_HEADING },
|
|
50
|
+
2: { heading: HeadingLevel.HEADING_2, size: 32, font: FONT_HEADING },
|
|
51
|
+
3: { heading: HeadingLevel.HEADING_3, size: 28, font: FONT_HEADING },
|
|
52
|
+
4: { heading: HeadingLevel.HEADING_4, size: 24, font: FONT_HEADING },
|
|
53
|
+
5: { heading: HeadingLevel.HEADING_5, size: 22, font: FONT_HEADING },
|
|
54
|
+
6: { heading: HeadingLevel.HEADING_6, size: 20, font: FONT_HEADING },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// ── CLI Arguments ──────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
function parseArgs() {
|
|
60
|
+
const args = process.argv.slice(2);
|
|
61
|
+
const result = {
|
|
62
|
+
input: null,
|
|
63
|
+
dir: null,
|
|
64
|
+
output: null,
|
|
65
|
+
meta: "{}",
|
|
66
|
+
noCover: false,
|
|
67
|
+
noToc: false,
|
|
68
|
+
recursive: false,
|
|
69
|
+
};
|
|
70
|
+
for (let i = 0; i < args.length; i++) {
|
|
71
|
+
switch (args[i]) {
|
|
72
|
+
case "--input":
|
|
73
|
+
result.input = args[++i];
|
|
74
|
+
break;
|
|
75
|
+
case "--dir":
|
|
76
|
+
result.dir = args[++i];
|
|
77
|
+
break;
|
|
78
|
+
case "--output":
|
|
79
|
+
result.output = args[++i];
|
|
80
|
+
break;
|
|
81
|
+
case "--meta":
|
|
82
|
+
result.meta = args[++i];
|
|
83
|
+
break;
|
|
84
|
+
case "--no-cover":
|
|
85
|
+
result.noCover = true;
|
|
86
|
+
break;
|
|
87
|
+
case "--no-toc":
|
|
88
|
+
result.noToc = true;
|
|
89
|
+
break;
|
|
90
|
+
case "--recursive":
|
|
91
|
+
result.recursive = true;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Markdown Reader ────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
function readMarkdown(filePath) {
|
|
101
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
102
|
+
|
|
103
|
+
// Try YAML frontmatter
|
|
104
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
105
|
+
let frontmatter = {};
|
|
106
|
+
let body = content;
|
|
107
|
+
|
|
108
|
+
if (fmMatch) {
|
|
109
|
+
for (const line of fmMatch[1].split("\n")) {
|
|
110
|
+
const m = line.match(/^(\S+):\s*(.*)$/);
|
|
111
|
+
if (m) frontmatter[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
|
|
112
|
+
}
|
|
113
|
+
body = fmMatch[2];
|
|
114
|
+
} else {
|
|
115
|
+
// Try blockquote metadata: > **版本**: v1.0
|
|
116
|
+
const metaLines = [];
|
|
117
|
+
const restLines = [];
|
|
118
|
+
let inMeta = true;
|
|
119
|
+
for (const line of content.split("\n")) {
|
|
120
|
+
if (inMeta && line.startsWith(">")) {
|
|
121
|
+
metaLines.push(line);
|
|
122
|
+
} else {
|
|
123
|
+
inMeta = false;
|
|
124
|
+
restLines.push(line);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
body = restLines.join("\n");
|
|
128
|
+
for (const line of metaLines) {
|
|
129
|
+
const m = line.match(/\*\*(.+?)\*\*[::]\s*(.+)/);
|
|
130
|
+
if (m) frontmatter[m[1].trim()] = m[2].trim();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { body, frontmatter };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Inline Token Converter ─────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
function convertInline(tokens, overrides = {}) {
|
|
140
|
+
if (!tokens || !Array.isArray(tokens)) return [];
|
|
141
|
+
const runs = [];
|
|
142
|
+
const baseFont = overrides.font || FONT_BODY;
|
|
143
|
+
const baseSize = overrides.size || 24;
|
|
144
|
+
|
|
145
|
+
for (const token of tokens) {
|
|
146
|
+
switch (token.type) {
|
|
147
|
+
case "text": {
|
|
148
|
+
const children = token.tokens ? convertInline(token.tokens, overrides) : [];
|
|
149
|
+
if (children.length > 0) {
|
|
150
|
+
runs.push(...children);
|
|
151
|
+
} else {
|
|
152
|
+
runs.push(new TextRun({ text: token.text || "", font: baseFont, size: baseSize, ...overrides }));
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case "strong":
|
|
157
|
+
runs.push(...convertInline(token.tokens, { ...overrides, bold: true }));
|
|
158
|
+
break;
|
|
159
|
+
case "em":
|
|
160
|
+
runs.push(...convertInline(token.tokens, { ...overrides, italics: true }));
|
|
161
|
+
break;
|
|
162
|
+
case "del":
|
|
163
|
+
runs.push(...convertInline(token.tokens, { ...overrides, strike: true }));
|
|
164
|
+
break;
|
|
165
|
+
case "codespan":
|
|
166
|
+
runs.push(
|
|
167
|
+
new TextRun({ text: token.text || "", font: FONT_CODE, size: Math.min(baseSize, 20), ...overrides })
|
|
168
|
+
);
|
|
169
|
+
break;
|
|
170
|
+
case "link":
|
|
171
|
+
runs.push(
|
|
172
|
+
new TextRun({
|
|
173
|
+
text: token.text || (token.tokens ? token.tokens.map(t => t.text || t.raw || "").join("") : ""),
|
|
174
|
+
font: baseFont,
|
|
175
|
+
size: baseSize,
|
|
176
|
+
color: "0563C1",
|
|
177
|
+
underline: { type: "single" },
|
|
178
|
+
...overrides,
|
|
179
|
+
})
|
|
180
|
+
);
|
|
181
|
+
break;
|
|
182
|
+
case "image":
|
|
183
|
+
try {
|
|
184
|
+
const imgPath = token.href;
|
|
185
|
+
if (imgPath && fs.existsSync(imgPath)) {
|
|
186
|
+
const buf = fs.readFileSync(imgPath);
|
|
187
|
+
runs.push(
|
|
188
|
+
new ImageRun({
|
|
189
|
+
data: buf,
|
|
190
|
+
transformation: { width: 200, height: 150 },
|
|
191
|
+
altText: { title: token.text || "Image", description: token.text || "Image", name: "image" },
|
|
192
|
+
})
|
|
193
|
+
);
|
|
194
|
+
} else {
|
|
195
|
+
runs.push(new TextRun({ text: `[Image: ${token.text || token.href}]`, font: baseFont, size: baseSize, italics: true, color: GRAY }));
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
runs.push(new TextRun({ text: `[Image: ${token.text || token.href}]`, font: baseFont, size: baseSize, italics: true, color: GRAY }));
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
case "br":
|
|
202
|
+
runs.push(new TextRun({ text: "", break: 1, font: baseFont, size: baseSize }));
|
|
203
|
+
break;
|
|
204
|
+
default:
|
|
205
|
+
if (token.text || token.raw) {
|
|
206
|
+
runs.push(new TextRun({ text: token.text || token.raw || "", font: baseFont, size: baseSize, ...overrides }));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return runs;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Cover Page Builder ─────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
function buildCoverPage(meta) {
|
|
217
|
+
const blocks = [];
|
|
218
|
+
const spacer = (n) => {
|
|
219
|
+
for (let i = 0; i < n; i++) blocks.push(new Paragraph({ children: [new TextRun("")] }));
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// 1. Top spacing
|
|
223
|
+
spacer(3);
|
|
224
|
+
|
|
225
|
+
// 2. Company logo
|
|
226
|
+
if (meta.company_logo && fs.existsSync(meta.company_logo)) {
|
|
227
|
+
try {
|
|
228
|
+
const logoBuf = fs.readFileSync(meta.company_logo);
|
|
229
|
+
blocks.push(
|
|
230
|
+
new Paragraph({
|
|
231
|
+
alignment: AlignmentType.CENTER,
|
|
232
|
+
children: [
|
|
233
|
+
new ImageRun({
|
|
234
|
+
data: logoBuf,
|
|
235
|
+
transformation: { width: 200, height: 200 },
|
|
236
|
+
altText: { title: "Company Logo", description: "Company Logo", name: "logo" },
|
|
237
|
+
}),
|
|
238
|
+
],
|
|
239
|
+
})
|
|
240
|
+
);
|
|
241
|
+
spacer(2);
|
|
242
|
+
} catch {
|
|
243
|
+
// Skip logo on error
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 3. Company name
|
|
248
|
+
if (meta.company_name) {
|
|
249
|
+
blocks.push(
|
|
250
|
+
new Paragraph({
|
|
251
|
+
alignment: AlignmentType.CENTER,
|
|
252
|
+
children: [new TextRun({ text: meta.company_name, bold: true, font: FONT_HEADING, size: 32, color: DARK_BLUE })],
|
|
253
|
+
})
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
spacer(1);
|
|
258
|
+
|
|
259
|
+
// 4. Project name
|
|
260
|
+
if (meta.project_name) {
|
|
261
|
+
blocks.push(
|
|
262
|
+
new Paragraph({
|
|
263
|
+
alignment: AlignmentType.CENTER,
|
|
264
|
+
children: [new TextRun({ text: meta.project_name, bold: true, font: FONT_HEADING, size: 36, color: DARK_BLUE })],
|
|
265
|
+
})
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
spacer(2);
|
|
270
|
+
|
|
271
|
+
// 5. Document title
|
|
272
|
+
if (meta.title) {
|
|
273
|
+
blocks.push(
|
|
274
|
+
new Paragraph({
|
|
275
|
+
alignment: AlignmentType.CENTER,
|
|
276
|
+
children: [new TextRun({ text: meta.title, bold: true, font: FONT_HEADING, size: 56, color: DARK_BLUE })],
|
|
277
|
+
})
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 6. Subtitle
|
|
282
|
+
if (meta.subtitle) {
|
|
283
|
+
blocks.push(
|
|
284
|
+
new Paragraph({
|
|
285
|
+
alignment: AlignmentType.CENTER,
|
|
286
|
+
spacing: { before: 200 },
|
|
287
|
+
children: [new TextRun({ text: meta.subtitle, font: FONT_HEADING, size: 28, color: GRAY })],
|
|
288
|
+
})
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
spacer(3);
|
|
293
|
+
|
|
294
|
+
// 7. Info table
|
|
295
|
+
const fields = [
|
|
296
|
+
["文档编号", meta.document_number],
|
|
297
|
+
["版本号", meta.version],
|
|
298
|
+
["创建日期", meta.date],
|
|
299
|
+
["作者", meta.author],
|
|
300
|
+
["审核人", meta.reviewer],
|
|
301
|
+
["批准人", meta.approver],
|
|
302
|
+
["保密等级", meta.confidentiality],
|
|
303
|
+
].filter(([, v]) => v);
|
|
304
|
+
|
|
305
|
+
if (fields.length > 0) {
|
|
306
|
+
const borderStyle = { style: BorderStyle.SINGLE, size: 1, color: "BBBBBB" };
|
|
307
|
+
const borders = { top: borderStyle, bottom: borderStyle, left: borderStyle, right: borderStyle };
|
|
308
|
+
|
|
309
|
+
const rows = fields.map(
|
|
310
|
+
([label, value]) =>
|
|
311
|
+
new TableRow({
|
|
312
|
+
children: [
|
|
313
|
+
new TableCell({
|
|
314
|
+
borders,
|
|
315
|
+
width: { size: 3000, type: WidthType.DXA },
|
|
316
|
+
children: [
|
|
317
|
+
new Paragraph({
|
|
318
|
+
alignment: AlignmentType.CENTER,
|
|
319
|
+
children: [new TextRun({ text: label, bold: true, font: FONT_HEADING, size: 22 })],
|
|
320
|
+
}),
|
|
321
|
+
],
|
|
322
|
+
}),
|
|
323
|
+
new TableCell({
|
|
324
|
+
borders,
|
|
325
|
+
width: { size: 5000, type: WidthType.DXA },
|
|
326
|
+
children: [
|
|
327
|
+
new Paragraph({
|
|
328
|
+
children: [new TextRun({ text: value, font: FONT_BODY, size: 22 })],
|
|
329
|
+
}),
|
|
330
|
+
],
|
|
331
|
+
}),
|
|
332
|
+
],
|
|
333
|
+
})
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
blocks.push(
|
|
337
|
+
new Table({
|
|
338
|
+
rows,
|
|
339
|
+
width: { size: 8000, type: WidthType.DXA },
|
|
340
|
+
})
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 8. Page break
|
|
345
|
+
blocks.push(
|
|
346
|
+
new Paragraph({
|
|
347
|
+
children: [new PageBreak()],
|
|
348
|
+
})
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
return blocks;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── TOC Builder ────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
function buildTOC() {
|
|
357
|
+
return [
|
|
358
|
+
new Paragraph({
|
|
359
|
+
heading: HeadingLevel.HEADING_1,
|
|
360
|
+
children: [new TextRun({ text: "目录", font: FONT_HEADING, size: 44, bold: true, color: DARK_BLUE })],
|
|
361
|
+
}),
|
|
362
|
+
new TableOfContents("目录", {
|
|
363
|
+
hyperlink: true,
|
|
364
|
+
headingStyleRange: "1-3",
|
|
365
|
+
stylesWithLevels: [
|
|
366
|
+
new StyleLevel("Heading1", 1),
|
|
367
|
+
new StyleLevel("Heading2", 2),
|
|
368
|
+
new StyleLevel("Heading3", 3),
|
|
369
|
+
],
|
|
370
|
+
}),
|
|
371
|
+
new Paragraph({
|
|
372
|
+
children: [new PageBreak()],
|
|
373
|
+
}),
|
|
374
|
+
];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── Markdown → docx Converter ──────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
function convertTokens(tokens) {
|
|
380
|
+
const blocks = [];
|
|
381
|
+
|
|
382
|
+
for (const token of tokens) {
|
|
383
|
+
switch (token.type) {
|
|
384
|
+
case "heading": {
|
|
385
|
+
const style = HEADING_STYLES[token.depth] || HEADING_STYLES[6];
|
|
386
|
+
const runs = token.tokens
|
|
387
|
+
? convertInline(token.tokens, { font: style.font, size: style.size, bold: true, color: DARK_BLUE })
|
|
388
|
+
: [new TextRun({ text: token.text || "", font: style.font, size: style.size, bold: true, color: DARK_BLUE })];
|
|
389
|
+
blocks.push(
|
|
390
|
+
new Paragraph({
|
|
391
|
+
heading: style.heading,
|
|
392
|
+
spacing: { before: 300, after: 100 },
|
|
393
|
+
children: runs,
|
|
394
|
+
})
|
|
395
|
+
);
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
case "paragraph": {
|
|
400
|
+
const runs = convertInline(token.tokens);
|
|
401
|
+
blocks.push(
|
|
402
|
+
new Paragraph({
|
|
403
|
+
spacing: { after: 120 },
|
|
404
|
+
children: runs,
|
|
405
|
+
})
|
|
406
|
+
);
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
case "code": {
|
|
411
|
+
const lines = (token.text || "").split("\n");
|
|
412
|
+
for (const line of lines) {
|
|
413
|
+
blocks.push(
|
|
414
|
+
new Paragraph({
|
|
415
|
+
spacing: { after: 0 },
|
|
416
|
+
indent: { left: 400 },
|
|
417
|
+
shading: { type: ShadingType.CLEAR, color: "auto", fill: LIGHT_GRAY },
|
|
418
|
+
children: [new TextRun({ text: line || " ", font: FONT_CODE, size: 20 })],
|
|
419
|
+
})
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
// Add spacing after code block
|
|
423
|
+
blocks.push(new Paragraph({ spacing: { after: 120 }, children: [new TextRun("")] }));
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
case "table": {
|
|
428
|
+
const borderStyle = { style: BorderStyle.SINGLE, size: 1, color: "999999" };
|
|
429
|
+
const borders = { top: borderStyle, bottom: borderStyle, left: borderStyle, right: borderStyle };
|
|
430
|
+
|
|
431
|
+
// Header row
|
|
432
|
+
const headerRow = new TableRow({
|
|
433
|
+
tableHeader: true,
|
|
434
|
+
children: (token.header || []).map(
|
|
435
|
+
(h) =>
|
|
436
|
+
new TableCell({
|
|
437
|
+
borders,
|
|
438
|
+
shading: { type: ShadingType.CLEAR, color: "auto", fill: "D9E2F3" },
|
|
439
|
+
children: [
|
|
440
|
+
new Paragraph({
|
|
441
|
+
children: [
|
|
442
|
+
new TextRun({
|
|
443
|
+
text: h.text || "",
|
|
444
|
+
bold: true,
|
|
445
|
+
font: FONT_HEADING,
|
|
446
|
+
size: 22,
|
|
447
|
+
}),
|
|
448
|
+
],
|
|
449
|
+
}),
|
|
450
|
+
],
|
|
451
|
+
})
|
|
452
|
+
),
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// Data rows
|
|
456
|
+
const dataRows = (token.rows || []).map(
|
|
457
|
+
(row) =>
|
|
458
|
+
new TableRow({
|
|
459
|
+
children: row.map(
|
|
460
|
+
(cell) =>
|
|
461
|
+
new TableCell({
|
|
462
|
+
borders,
|
|
463
|
+
children: [
|
|
464
|
+
new Paragraph({
|
|
465
|
+
children: cell.tokens
|
|
466
|
+
? convertInline(cell.tokens)
|
|
467
|
+
: [new TextRun({ text: cell.text || "", font: FONT_BODY, size: 22 })],
|
|
468
|
+
}),
|
|
469
|
+
],
|
|
470
|
+
})
|
|
471
|
+
),
|
|
472
|
+
})
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
blocks.push(new Table({ rows: [headerRow, ...dataRows] }));
|
|
476
|
+
// Spacing after table
|
|
477
|
+
blocks.push(new Paragraph({ spacing: { after: 120 }, children: [new TextRun("")] }));
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
case "list": {
|
|
482
|
+
blocks.push(...convertList(token, 0));
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
case "blockquote": {
|
|
487
|
+
const innerTokens = token.tokens || [];
|
|
488
|
+
for (const inner of innerTokens) {
|
|
489
|
+
if (inner.type === "paragraph") {
|
|
490
|
+
const runs = convertInline(inner.tokens, { italics: true, color: "555555" });
|
|
491
|
+
blocks.push(
|
|
492
|
+
new Paragraph({
|
|
493
|
+
indent: { left: 720 },
|
|
494
|
+
spacing: { after: 120 },
|
|
495
|
+
border: {
|
|
496
|
+
left: { style: BorderStyle.SINGLE, size: 6, color: DARK_BLUE, space: 10 },
|
|
497
|
+
},
|
|
498
|
+
shading: { type: ShadingType.CLEAR, color: "auto", fill: LIGHT_GRAY },
|
|
499
|
+
children: runs,
|
|
500
|
+
})
|
|
501
|
+
);
|
|
502
|
+
} else {
|
|
503
|
+
// Recursively handle other block types inside blockquote
|
|
504
|
+
const nested = convertTokens([inner]);
|
|
505
|
+
blocks.push(...nested);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
case "hr":
|
|
512
|
+
blocks.push(
|
|
513
|
+
new Paragraph({
|
|
514
|
+
spacing: { before: 200, after: 200 },
|
|
515
|
+
border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" } },
|
|
516
|
+
children: [new TextRun("")],
|
|
517
|
+
})
|
|
518
|
+
);
|
|
519
|
+
break;
|
|
520
|
+
|
|
521
|
+
case "html":
|
|
522
|
+
// Skip HTML blocks or render as plain text
|
|
523
|
+
blocks.push(
|
|
524
|
+
new Paragraph({
|
|
525
|
+
spacing: { after: 120 },
|
|
526
|
+
children: [new TextRun({ text: token.text || "", font: FONT_BODY, size: 24, color: GRAY })],
|
|
527
|
+
})
|
|
528
|
+
);
|
|
529
|
+
break;
|
|
530
|
+
|
|
531
|
+
case "space":
|
|
532
|
+
break;
|
|
533
|
+
|
|
534
|
+
default:
|
|
535
|
+
if (token.raw && token.text) {
|
|
536
|
+
blocks.push(
|
|
537
|
+
new Paragraph({
|
|
538
|
+
spacing: { after: 120 },
|
|
539
|
+
children: [new TextRun({ text: token.text || token.raw || "", font: FONT_BODY, size: 24 })],
|
|
540
|
+
})
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return blocks;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ── List Converter (handles nesting) ───────────────────────────────────
|
|
550
|
+
|
|
551
|
+
function convertList(token, level) {
|
|
552
|
+
const blocks = [];
|
|
553
|
+
const isOrdered = token.ordered;
|
|
554
|
+
const items = token.items || [];
|
|
555
|
+
|
|
556
|
+
let numIndex = 1;
|
|
557
|
+
|
|
558
|
+
for (const item of items) {
|
|
559
|
+
const numbering = isOrdered
|
|
560
|
+
? { reference: "numbered-list", level }
|
|
561
|
+
: { reference: "bullet-list", level };
|
|
562
|
+
|
|
563
|
+
// Process item children (paragraphs, nested lists, code blocks)
|
|
564
|
+
const itemChildren = item.tokens || [];
|
|
565
|
+
|
|
566
|
+
for (const child of itemChildren) {
|
|
567
|
+
if (child.type === "list") {
|
|
568
|
+
// Nested list
|
|
569
|
+
blocks.push(...convertList(child, level + 1));
|
|
570
|
+
} else if (child.type === "text" && !child.tokens) {
|
|
571
|
+
// Plain text item
|
|
572
|
+
const prefix = item.task ? (item.checked ? "☑ " : "☐ ") : "";
|
|
573
|
+
blocks.push(
|
|
574
|
+
new Paragraph({
|
|
575
|
+
numbering,
|
|
576
|
+
spacing: { after: 60 },
|
|
577
|
+
children: [new TextRun({ text: prefix + (child.text || child.raw || ""), font: FONT_BODY, size: 24 })],
|
|
578
|
+
})
|
|
579
|
+
);
|
|
580
|
+
} else if (child.type === "text" && child.tokens) {
|
|
581
|
+
// Text with inline formatting
|
|
582
|
+
const runs = convertInline(child.tokens);
|
|
583
|
+
if (item.task) {
|
|
584
|
+
const prefix = item.checked ? "☑ " : "☐ ";
|
|
585
|
+
runs.unshift(new TextRun({ text: prefix, font: FONT_BODY, size: 24 }));
|
|
586
|
+
}
|
|
587
|
+
blocks.push(
|
|
588
|
+
new Paragraph({
|
|
589
|
+
numbering,
|
|
590
|
+
spacing: { after: 60 },
|
|
591
|
+
children: runs,
|
|
592
|
+
})
|
|
593
|
+
);
|
|
594
|
+
} else if (child.type === "paragraph") {
|
|
595
|
+
const runs = convertInline(child.tokens);
|
|
596
|
+
if (item.task) {
|
|
597
|
+
const prefix = item.checked ? "☑ " : "☐ ";
|
|
598
|
+
runs.unshift(new TextRun({ text: prefix, font: FONT_BODY, size: 24 }));
|
|
599
|
+
}
|
|
600
|
+
blocks.push(
|
|
601
|
+
new Paragraph({
|
|
602
|
+
numbering,
|
|
603
|
+
spacing: { after: 60 },
|
|
604
|
+
children: runs,
|
|
605
|
+
})
|
|
606
|
+
);
|
|
607
|
+
} else if (child.type === "code") {
|
|
608
|
+
// Code block inside list item
|
|
609
|
+
const lines = (child.text || "").split("\n");
|
|
610
|
+
for (const line of lines) {
|
|
611
|
+
blocks.push(
|
|
612
|
+
new Paragraph({
|
|
613
|
+
spacing: { after: 0 },
|
|
614
|
+
indent: { left: 400 + level * 720 },
|
|
615
|
+
shading: { type: ShadingType.CLEAR, color: "auto", fill: LIGHT_GRAY },
|
|
616
|
+
children: [new TextRun({ text: line || " ", font: FONT_CODE, size: 20 })],
|
|
617
|
+
})
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// If no children processed, add a fallback
|
|
624
|
+
if (itemChildren.length === 0) {
|
|
625
|
+
const prefix = item.task ? (item.checked ? "☑ " : "☐ ") : "";
|
|
626
|
+
blocks.push(
|
|
627
|
+
new Paragraph({
|
|
628
|
+
numbering,
|
|
629
|
+
spacing: { after: 60 },
|
|
630
|
+
children: [new TextRun({ text: prefix + (item.text || item.raw || ""), font: FONT_BODY, size: 24 })],
|
|
631
|
+
})
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
numIndex++;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return blocks;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ── Document Assembler ─────────────────────────────────────────────────
|
|
642
|
+
|
|
643
|
+
function assembleDocument(coverBlocks, tocBlocks, contentBlocks, meta) {
|
|
644
|
+
const sections = [];
|
|
645
|
+
|
|
646
|
+
// Cover page section (no header/footer)
|
|
647
|
+
if (coverBlocks.length > 0) {
|
|
648
|
+
sections.push({
|
|
649
|
+
properties: {
|
|
650
|
+
page: {
|
|
651
|
+
size: { width: 11906, height: 16838 },
|
|
652
|
+
margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
children: coverBlocks,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// TOC + Content in main section
|
|
660
|
+
const mainChildren = [...tocBlocks, ...contentBlocks];
|
|
661
|
+
|
|
662
|
+
sections.push({
|
|
663
|
+
properties: {
|
|
664
|
+
page: {
|
|
665
|
+
size: { width: 11906, height: 16838 },
|
|
666
|
+
margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
children: mainChildren,
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
return new Document({
|
|
673
|
+
features: { updateFields: true },
|
|
674
|
+
styles: {
|
|
675
|
+
default: {
|
|
676
|
+
document: {
|
|
677
|
+
run: { font: FONT_BODY, size: 24 },
|
|
678
|
+
paragraph: { spacing: { line: 360 } },
|
|
679
|
+
},
|
|
680
|
+
heading1: {
|
|
681
|
+
run: { font: FONT_HEADING, size: 44, bold: true, color: DARK_BLUE },
|
|
682
|
+
paragraph: { spacing: { before: 400, after: 200 } },
|
|
683
|
+
},
|
|
684
|
+
heading2: {
|
|
685
|
+
run: { font: FONT_HEADING, size: 32, bold: true, color: DARK_BLUE },
|
|
686
|
+
paragraph: { spacing: { before: 300, after: 150 } },
|
|
687
|
+
},
|
|
688
|
+
heading3: {
|
|
689
|
+
run: { font: FONT_HEADING, size: 28, bold: true, color: DARK_BLUE },
|
|
690
|
+
paragraph: { spacing: { before: 240, after: 120 } },
|
|
691
|
+
},
|
|
692
|
+
heading4: {
|
|
693
|
+
run: { font: FONT_HEADING, size: 24, bold: true, color: DARK_BLUE },
|
|
694
|
+
paragraph: { spacing: { before: 200, after: 100 } },
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
},
|
|
698
|
+
numbering: {
|
|
699
|
+
config: [
|
|
700
|
+
{
|
|
701
|
+
reference: "bullet-list",
|
|
702
|
+
levels: [
|
|
703
|
+
{ level: 0, format: "bullet", text: "•", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 720, hanging: 360 } } } },
|
|
704
|
+
{ level: 1, format: "bullet", text: "◦", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 1440, hanging: 360 } } } },
|
|
705
|
+
{ level: 2, format: "bullet", text: "▪", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 2160, hanging: 360 } } } },
|
|
706
|
+
{ level: 3, format: "bullet", text: "•", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 2880, hanging: 360 } } } },
|
|
707
|
+
],
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
reference: "numbered-list",
|
|
711
|
+
levels: [
|
|
712
|
+
{ level: 0, format: "decimal", text: "%1.", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 720, hanging: 360 } } } },
|
|
713
|
+
{ level: 1, format: "lowerLetter", text: "%2)", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 1440, hanging: 360 } } } },
|
|
714
|
+
{ level: 2, format: "lowerRoman", text: "%3.", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 2160, hanging: 360 } } } },
|
|
715
|
+
{ level: 3, format: "decimal", text: "%4.", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 2880, hanging: 360 } } } },
|
|
716
|
+
],
|
|
717
|
+
},
|
|
718
|
+
],
|
|
719
|
+
},
|
|
720
|
+
sections,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ── Directory Scanner ──────────────────────────────────────────────────
|
|
725
|
+
|
|
726
|
+
function scanDirectory(dir, recursive) {
|
|
727
|
+
const files = [];
|
|
728
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
729
|
+
|
|
730
|
+
for (const entry of entries) {
|
|
731
|
+
const fullPath = path.join(dir, entry.name);
|
|
732
|
+
|
|
733
|
+
if (entry.isDirectory()) {
|
|
734
|
+
// Skip hidden directories
|
|
735
|
+
if (entry.name.startsWith(".")) continue;
|
|
736
|
+
if (recursive) {
|
|
737
|
+
files.push(...scanDirectory(fullPath, true));
|
|
738
|
+
}
|
|
739
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
740
|
+
// Skip README.md
|
|
741
|
+
if (entry.name.toLowerCase() === "readme.md") continue;
|
|
742
|
+
files.push(fullPath);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return files;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ── Single File Processor ──────────────────────────────────────────────
|
|
750
|
+
|
|
751
|
+
async function processFile(inputFile, outputFile, meta, options) {
|
|
752
|
+
const { body, frontmatter } = readMarkdown(inputFile);
|
|
753
|
+
|
|
754
|
+
// Merge frontmatter into meta (meta takes priority)
|
|
755
|
+
const mergedMeta = { ...frontmatter, ...meta };
|
|
756
|
+
if (!mergedMeta.title) {
|
|
757
|
+
// Use first H1 or filename
|
|
758
|
+
const h1Match = body.match(/^#\s+(.+)$/m);
|
|
759
|
+
mergedMeta.title = h1Match ? h1Match[1] : path.basename(inputFile, ".md");
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Parse markdown
|
|
763
|
+
const tokens = marked.lexer(body);
|
|
764
|
+
|
|
765
|
+
// Build sections
|
|
766
|
+
const coverBlocks = options.noCover ? [] : buildCoverPage(mergedMeta);
|
|
767
|
+
const tocBlocks = options.noToc ? [] : buildTOC();
|
|
768
|
+
const contentBlocks = convertTokens(tokens);
|
|
769
|
+
|
|
770
|
+
// Assemble document
|
|
771
|
+
const doc = assembleDocument(coverBlocks, tocBlocks, contentBlocks, mergedMeta);
|
|
772
|
+
|
|
773
|
+
// Ensure output directory exists
|
|
774
|
+
const outDir = path.dirname(outputFile);
|
|
775
|
+
if (!fs.existsSync(outDir)) {
|
|
776
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Write file
|
|
780
|
+
const buffer = await Packer.toBuffer(doc);
|
|
781
|
+
fs.writeFileSync(outputFile, buffer);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// ── Main ───────────────────────────────────────────────────────────────
|
|
785
|
+
|
|
786
|
+
async function main() {
|
|
787
|
+
const args = parseArgs();
|
|
788
|
+
const meta = JSON.parse(args.meta || "{}");
|
|
789
|
+
const options = { noCover: args.noCover, noToc: args.noToc };
|
|
790
|
+
const results = [];
|
|
791
|
+
|
|
792
|
+
try {
|
|
793
|
+
if (args.dir) {
|
|
794
|
+
// Directory mode
|
|
795
|
+
const dirPath = path.resolve(args.dir);
|
|
796
|
+
if (!fs.existsSync(dirPath)) {
|
|
797
|
+
throw new Error(`目录不存在: ${dirPath}`);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const files = scanDirectory(dirPath, args.recursive);
|
|
801
|
+
if (files.length === 0) {
|
|
802
|
+
throw new Error(`目录中未找到 .md 文件: ${dirPath}`);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const outDir = args.output ? path.resolve(args.output) : dirPath;
|
|
806
|
+
|
|
807
|
+
for (const file of files) {
|
|
808
|
+
const baseName = path.basename(file, ".md");
|
|
809
|
+
const outputFile = path.join(outDir, `${baseName}.docx`);
|
|
810
|
+
|
|
811
|
+
try {
|
|
812
|
+
await processFile(file, outputFile, { ...meta }, options);
|
|
813
|
+
results.push({ input: file, output: outputFile, status: "ok" });
|
|
814
|
+
} catch (err) {
|
|
815
|
+
results.push({ input: file, output: outputFile, status: "error", error: err.message });
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
} else if (args.input) {
|
|
819
|
+
// Single file mode
|
|
820
|
+
const inputFile = path.resolve(args.input);
|
|
821
|
+
if (!fs.existsSync(inputFile)) {
|
|
822
|
+
throw new Error(`文件不存在: ${inputFile}`);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const baseName = path.basename(inputFile, ".md");
|
|
826
|
+
const outputFile = args.output
|
|
827
|
+
? path.resolve(args.output)
|
|
828
|
+
: path.join(path.dirname(inputFile), `${baseName}.docx`);
|
|
829
|
+
|
|
830
|
+
await processFile(inputFile, outputFile, meta, options);
|
|
831
|
+
results.push({ input: inputFile, output: outputFile, status: "ok" });
|
|
832
|
+
} else {
|
|
833
|
+
throw new Error("必须指定 --input 或 --dir 参数");
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const failed = results.filter((r) => r.status === "error");
|
|
837
|
+
const status = failed.length === 0 ? "success" : failed.length === results.length ? "error" : "partial";
|
|
838
|
+
|
|
839
|
+
console.log(
|
|
840
|
+
JSON.stringify({
|
|
841
|
+
status,
|
|
842
|
+
files: results,
|
|
843
|
+
cover: !args.noCover,
|
|
844
|
+
toc: !args.noToc,
|
|
845
|
+
total: results.length,
|
|
846
|
+
success: results.length - failed.length,
|
|
847
|
+
failed: failed.length,
|
|
848
|
+
})
|
|
849
|
+
);
|
|
850
|
+
} catch (err) {
|
|
851
|
+
console.error(
|
|
852
|
+
JSON.stringify({
|
|
853
|
+
status: "error",
|
|
854
|
+
message: err.message,
|
|
855
|
+
files: results,
|
|
856
|
+
})
|
|
857
|
+
);
|
|
858
|
+
process.exit(1);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
main();
|