@v1hz/md2docx 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025–2026 WXY-V1hZ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # md2docx
2
+
3
+ 基于 pandoc 的 Markdown 转 Word (.docx) 工具,在转换前执行 AST 变换以提升文档输出质量。
4
+
5
+ ## 功能
6
+
7
+ 将 Markdown 转换为格式精美的 Word 文档,自动处理:
8
+
9
+ - **文档标题** — 从 frontmatter、H1 或文件名自动提取文档标题
10
+ - **标题层级归一化** — 自动修正标题层级跳跃,确保从 H1 开始连续递增
11
+ - **标题编号** — 为标题添加多级编号(1、1.1、1.1.1 …),支持剥离已有编号
12
+ - **表格编号** — 自动检测表格并添加"表 1"、"表 2"……编号
13
+ - **图片编号** — 为独立图片添加"图 1"、"图 2"……编号
14
+ - **Mermaid 图表** — 将 Mermaid 代码块渲染为 PNG 图片嵌入文档
15
+ - **Word 导出** — 集成 pandoc,一键生成 .docx 文件
16
+
17
+ ## 安装
18
+
19
+ ### 前置依赖
20
+
21
+ - [Bun](https://bun.sh) ≥ 1.3
22
+ - [pandoc](https://pandoc.org)(可选,需导出 Word 时安装)
23
+
24
+ ### 克隆并安装
25
+
26
+ ```bash
27
+ git clone https://github.com/WXY-V1hZ/md2docx.git
28
+ cd md2docx
29
+ bun install
30
+ ```
31
+
32
+ ## 快速开始
33
+
34
+ ```bash
35
+ # 处理 Markdown 并生成 Word 文档
36
+ bun run src/index.ts docs/example.md
37
+
38
+ # 只预处理,不调用 pandoc
39
+ bun run src/index.ts docs/example.md --pandoc.enabled false
40
+
41
+ # 指定输出路径
42
+ bun run src/index.ts docs/example.md -o output/example.docx
43
+
44
+ # 使用自定义配置文件
45
+ bun run src/index.ts docs/example.md --config ./my-config.json
46
+ ```
47
+
48
+ ## 配置
49
+
50
+ 配置文件位于 `config/config.json`,通过 JSON Schema 提供 IDE 校验。
51
+
52
+ 所有配置项均可通过命令行覆盖:
53
+
54
+ ```bash
55
+ bun run src/index.ts docs/example.md --figureCaption.enabled false
56
+ ```
57
+
58
+ ### 配置项概览
59
+
60
+ | 配置项 | 说明 | 默认值 |
61
+ | --------------------------- | ----------------- | ------------ |
62
+ | `detectTitle.enabled` | 自定设置文档标题 | `true` |
63
+ | `detectTitle.strategy` | 标题来源策略 | `"first-h1"` |
64
+ | `normalizeHeadings.enabled` | 自动修正标题层级 | `true` |
65
+ | `numberHeadings.enabled` | 自动为标题编号 | `true` |
66
+ | `figureCaption.enabled` | 自动为图片编号 | `true` |
67
+ | `tableCaption.enabled` | 自动为表格编号 | `true` |
68
+ | `renderMermaid.enabled` | 渲染 Mermaid 图表 | `true` |
69
+ | `pandoc.enabled` | 生成 Word 文档 | `true` |
70
+
71
+ ## Web 配置编辑器
72
+
73
+ ```bash
74
+ bun run src/web.ts
75
+ ```
76
+
77
+ 在浏览器中可视化编辑配置,实时校验并保存。
78
+
79
+ ## 处理流水线
80
+
81
+ ```
82
+ Markdown
83
+
84
+ 解析 AST
85
+
86
+ addTitle() ← 提取文档标题
87
+
88
+ normalizeHeadings() ← 修正标题层级
89
+
90
+ numberHeadings() ← 添加标题编号
91
+
92
+ numberTables() ← 添加表格编号
93
+
94
+ renderMermaid() ← Mermaid → PNG
95
+
96
+ numberPictures() ← 添加图片编号
97
+
98
+ 序列化 Markdown
99
+
100
+ pandoc → DOCX
101
+ ```
102
+
103
+ ## 开发
104
+
105
+ ```bash
106
+ # 测试
107
+ bun test
108
+
109
+ # 类型检查 + 代码检查 + 格式检查
110
+ bun check
111
+ ```
112
+
113
+ ## 许可
114
+
115
+ 本项目基于 MIT 许可证开源 — 详见 [LICENSE](LICENSE)。
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "./config.schema.json",
3
+ "figureCaption": {
4
+ "enabled": true,
5
+ "format": "图 {n}",
6
+ "separator": ":"
7
+ },
8
+ "tableCaption": {
9
+ "enabled": true,
10
+ "format": "表 {n}",
11
+ "separator": ""
12
+ },
13
+ "normalizeHeadings": {
14
+ "enabled": true
15
+ },
16
+ "numberHeadings": {
17
+ "enabled": true,
18
+ "detectExisting": true,
19
+ "useBuiltinRules": true
20
+ },
21
+ "renderMermaid": {
22
+ "enabled": true,
23
+ "theme": "tokyo-night-light",
24
+ "density": 200
25
+ },
26
+ "detectTitle": {
27
+ "enabled": true,
28
+ "strategy": "first-h1"
29
+ },
30
+ "pandoc": {
31
+ "enabled": true,
32
+ "outputName": "{file_name}.docx"
33
+ }
34
+ }
@@ -0,0 +1,173 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://raw.githubusercontent.com/WXY-V1hZ/md2docx/main/config.schema.json",
4
+ "title": "md2docx 配置",
5
+ "description": "md2docx 默认设置",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "figureCaption": {
10
+ "description": "图片编号",
11
+ "type": "object",
12
+ "additionalProperties": false,
13
+ "properties": {
14
+ "enabled": {
15
+ "description": "自动为图片编号",
16
+ "type": "boolean",
17
+ "default": true
18
+ },
19
+ "format": {
20
+ "description": "编号格式,{n} 代表序号",
21
+ "type": "string",
22
+ "default": "图 {n}"
23
+ },
24
+ "separator": {
25
+ "description": "编号与图片标题之间的分隔符",
26
+ "type": "string",
27
+ "default": ":"
28
+ }
29
+ },
30
+ "required": ["enabled", "format", "separator"]
31
+ },
32
+ "tableCaption": {
33
+ "description": "表格编号",
34
+ "type": "object",
35
+ "additionalProperties": false,
36
+ "properties": {
37
+ "enabled": {
38
+ "description": "自动为表格编号",
39
+ "type": "boolean",
40
+ "default": true
41
+ },
42
+ "format": {
43
+ "description": "编号格式,{n} 代表序号",
44
+ "type": "string",
45
+ "default": "表 {n}"
46
+ },
47
+ "separator": {
48
+ "description": "编号与表格标题之间的分隔符",
49
+ "type": "string",
50
+ "default": ""
51
+ }
52
+ },
53
+ "required": ["enabled", "format", "separator"]
54
+ },
55
+ "normalizeHeadings": {
56
+ "description": "标题层级",
57
+ "type": "object",
58
+ "additionalProperties": false,
59
+ "properties": {
60
+ "enabled": {
61
+ "description": "自动修正标题层级(标题编号开启时无法关闭)",
62
+ "type": "boolean",
63
+ "default": true
64
+ }
65
+ },
66
+ "required": ["enabled"]
67
+ },
68
+ "numberHeadings": {
69
+ "description": "标题编号",
70
+ "type": "object",
71
+ "additionalProperties": false,
72
+ "properties": {
73
+ "enabled": {
74
+ "description": "自动为标题添加层级编号(同时修正标题层级)",
75
+ "type": "boolean",
76
+ "default": true
77
+ },
78
+ "detectExisting": {
79
+ "description": "重新编号前移除已有编号",
80
+ "type": "boolean",
81
+ "default": true
82
+ },
83
+ "existingPattern": {
84
+ "description": "识别已有编号的正则表达式(可选)",
85
+ "type": "string",
86
+ "default": ""
87
+ },
88
+ "useBuiltinRules": {
89
+ "description": "识别常见的中文和数字编号",
90
+ "type": "boolean",
91
+ "default": true
92
+ }
93
+ },
94
+ "required": ["enabled", "detectExisting"]
95
+ },
96
+ "renderMermaid": {
97
+ "description": "Mermaid 图表",
98
+ "type": "object",
99
+ "additionalProperties": false,
100
+ "properties": {
101
+ "enabled": {
102
+ "description": "将 Mermaid 代码块转换为图片",
103
+ "type": "boolean",
104
+ "default": true
105
+ },
106
+ "theme": {
107
+ "description": "图表主题",
108
+ "type": "string",
109
+ "default": "tokyo-night-light"
110
+ },
111
+ "density": {
112
+ "description": "图片清晰度(DPI)",
113
+ "type": "integer",
114
+ "minimum": 72,
115
+ "default": 200
116
+ }
117
+ },
118
+ "required": ["enabled", "theme", "density"]
119
+ },
120
+ "pandoc": {
121
+ "description": "Word 导出",
122
+ "type": "object",
123
+ "additionalProperties": false,
124
+ "properties": {
125
+ "enabled": {
126
+ "description": "生成 Word 文档",
127
+ "type": "boolean",
128
+ "default": true
129
+ },
130
+ "outputName": {
131
+ "description": "输出文件名,{file_name} 代表源文件名",
132
+ "type": "string",
133
+ "default": "{file_name}.docx"
134
+ }
135
+ },
136
+ "required": ["enabled", "outputName"]
137
+ },
138
+ "detectTitle": {
139
+ "description": "文档标题",
140
+ "type": "object",
141
+ "additionalProperties": false,
142
+ "properties": {
143
+ "enabled": {
144
+ "description": "自动设置文档标题",
145
+ "type": "boolean",
146
+ "default": true
147
+ },
148
+ "strategy": {
149
+ "description": "标题来源",
150
+ "type": "string",
151
+ "enum": ["first-h1", "single-h1", "filename", "none"],
152
+ "enumDescriptions": {
153
+ "first-h1": "开头的一级标题",
154
+ "single-h1": "文档中唯一的一级标题",
155
+ "filename": "文件名",
156
+ "none": "不设置"
157
+ },
158
+ "default": "first-h1"
159
+ }
160
+ },
161
+ "required": ["enabled", "strategy"]
162
+ }
163
+ },
164
+ "required": [
165
+ "figureCaption",
166
+ "tableCaption",
167
+ "normalizeHeadings",
168
+ "numberHeadings",
169
+ "renderMermaid",
170
+ "detectTitle",
171
+ "pandoc"
172
+ ]
173
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@v1hz/md2docx",
3
+ "version": "1.0.0",
4
+ "description": "基于 pandoc 的 Markdown 转 Word (docx) 工具,自动处理标题、编号、Mermaid 图表等",
5
+ "keywords": [
6
+ "converter",
7
+ "docx",
8
+ "markdown",
9
+ "mermaid",
10
+ "pandoc",
11
+ "word"
12
+ ],
13
+ "homepage": "https://github.com/WXY-V1hZ/md2docx#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/WXY-V1hZ/md2docx/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "WXY-V1hZ",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/WXY-V1hZ/md2docx.git"
22
+ },
23
+ "bin": {
24
+ "md2docx": "src/index.ts"
25
+ },
26
+ "files": [
27
+ "src",
28
+ "config/config.json",
29
+ "config/config.schema.json",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "type": "module",
34
+ "module": "src/index.ts",
35
+ "scripts": {
36
+ "cli": "bun run src/index.ts",
37
+ "test": "bun test",
38
+ "check": "bun tsc --noEmit && bun run oxlint && bun run oxfmt"
39
+ },
40
+ "dependencies": {
41
+ "@xmldom/xmldom": "^0.9.10",
42
+ "beautiful-mermaid": "^1.1.3",
43
+ "docx": "^9.7.1",
44
+ "pizzip": "^3.2.0",
45
+ "remark-frontmatter": "^5.0.0",
46
+ "remark-gfm": "^4.0.1",
47
+ "remark-parse": "^11.0.0",
48
+ "remark-stringify": "^11.0.0",
49
+ "sharp": "^0.35.3",
50
+ "unified": "^11.0.5",
51
+ "xpath": "^0.0.34"
52
+ },
53
+ "devDependencies": {
54
+ "oxfmt": "^0.58.0",
55
+ "oxlint": "^1.73.0"
56
+ },
57
+ "engines": {
58
+ "bun": ">=1.3"
59
+ }
60
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,200 @@
1
+ import { type AppConfig } from "./config";
2
+
3
+ type SchemaValueType = "boolean" | "integer" | "number" | "string";
4
+
5
+ interface SchemaNode {
6
+ type?: SchemaValueType | "object";
7
+ description?: string;
8
+ default?: unknown;
9
+ enum?: unknown[];
10
+ minimum?: number;
11
+ properties?: Record<string, SchemaNode>;
12
+ required?: string[];
13
+ }
14
+
15
+ export interface ConfigOption {
16
+ path: string;
17
+ type: SchemaValueType;
18
+ description: string;
19
+ default?: unknown;
20
+ enum?: unknown[];
21
+ minimum?: number;
22
+ required: boolean;
23
+ }
24
+
25
+ export interface CliOptions {
26
+ help: boolean;
27
+ web: boolean;
28
+ mdPath?: string;
29
+ outputPath?: string;
30
+ configPath?: string;
31
+ overrides: Map<string, unknown>;
32
+ }
33
+
34
+ export function getConfigOptions(schema: SchemaNode): ConfigOption[] {
35
+ const options: ConfigOption[] = [];
36
+
37
+ function walk(node: SchemaNode, prefix: string): void {
38
+ if (node.type !== "object" || !node.properties) return;
39
+ for (const [name, child] of Object.entries(node.properties)) {
40
+ const path = prefix ? `${prefix}.${name}` : name;
41
+ if (child.type === "object") {
42
+ walk(child, path);
43
+ } else if (isValueType(child.type)) {
44
+ options.push({
45
+ path,
46
+ type: child.type,
47
+ description: child.description ?? "",
48
+ default: child.default,
49
+ enum: child.enum,
50
+ minimum: child.minimum,
51
+ required: node.required?.includes(name) ?? false,
52
+ });
53
+ }
54
+ }
55
+ }
56
+
57
+ walk(schema, "");
58
+ return options;
59
+ }
60
+
61
+ export function parseCliArgs(args: string[], configOptions: ConfigOption[]): CliOptions {
62
+ const optionsByName = new Map(configOptions.map((option) => [option.path, option]));
63
+ const result: CliOptions = { help: false, web: false, overrides: new Map() };
64
+ const positional: string[] = [];
65
+
66
+ for (let i = 0; i < args.length; i++) {
67
+ const arg = args[i]!;
68
+ if (arg === "-h" || arg === "--help") {
69
+ result.help = true;
70
+ continue;
71
+ }
72
+ if (arg === "--web") {
73
+ result.web = true;
74
+ continue;
75
+ }
76
+ if (arg === "-o" || arg === "--config") {
77
+ const value = args[++i];
78
+ if (value === undefined || value.startsWith("--")) {
79
+ throw new Error(`${arg} 缺少路径`);
80
+ }
81
+ if (arg === "-o") result.outputPath = value;
82
+ else result.configPath = value;
83
+ continue;
84
+ }
85
+ if (arg.startsWith("--")) {
86
+ const name = arg.slice(2);
87
+ const option = optionsByName.get(name);
88
+ if (!option) throw new Error(`未知参数:${arg}`);
89
+ const value = args[++i];
90
+ if (value === undefined) throw new Error(`${arg} 缺少值`);
91
+ result.overrides.set(name, parseConfigValue(value, option));
92
+ continue;
93
+ }
94
+ if (arg.startsWith("-") && !isNumericArgument(arg)) {
95
+ throw new Error(`未知参数:${arg}`);
96
+ }
97
+ positional.push(arg);
98
+ }
99
+
100
+ if (!result.help && !result.web) {
101
+ if (positional.length === 0) throw new Error("缺少 Markdown 文件路径");
102
+ if (positional.length > 1) throw new Error("只能指定一个 Markdown 文件路径");
103
+ result.mdPath = positional[0];
104
+ }
105
+ return result;
106
+ }
107
+
108
+ export function applyConfigOverrides(
109
+ config: AppConfig,
110
+ overrides: Map<string, unknown>,
111
+ ): AppConfig {
112
+ const merged = structuredClone(config) as unknown as Record<string, unknown>;
113
+ for (const [path, value] of overrides) {
114
+ const segments = path.split(".");
115
+ let target = merged;
116
+ for (const segment of segments.slice(0, -1)) {
117
+ const next = target[segment];
118
+ if (!isRecord(next)) throw new Error(`配置路径不存在:${path}`);
119
+ target = next;
120
+ }
121
+ target[segments.at(-1)!] = value;
122
+ }
123
+ return merged as unknown as AppConfig;
124
+ }
125
+
126
+ export function formatHelp(configOptions: ConfigOption[]): string {
127
+ const lines = [
128
+ "用法:",
129
+ " md2docx <md-path> [选项]",
130
+ "",
131
+ "选项:",
132
+ " -o <path> 输出 docx 路径",
133
+ " --config <path> 配置文件路径,默认 config/config.json",
134
+ " --web 打开默认配置的网页编辑器",
135
+ " -h, --help 显示帮助",
136
+ "",
137
+ "配置覆盖:",
138
+ ];
139
+
140
+ for (const option of configOptions) {
141
+ const constraints = [
142
+ option.default === undefined ? undefined : `默认: ${String(option.default)}`,
143
+ option.enum ? `可选: ${option.enum.join(" | ")}` : undefined,
144
+ option.minimum === undefined ? undefined : `最小: ${option.minimum}`,
145
+ ].filter(Boolean);
146
+ lines.push(` --${option.path} <${option.type}>`);
147
+ lines.push(
148
+ ` ${option.description}${constraints.length ? `(${constraints.join(";")})` : ""}`,
149
+ );
150
+ }
151
+
152
+ return lines.join("\n");
153
+ }
154
+
155
+ function parseConfigValue(value: string, option: ConfigOption): unknown {
156
+ let parsed: unknown;
157
+ switch (option.type) {
158
+ case "boolean":
159
+ if (value !== "true" && value !== "false") {
160
+ throw new Error(`--${option.path} 需要 boolean(true 或 false),收到:${value}`);
161
+ }
162
+ parsed = value === "true";
163
+ break;
164
+ case "integer":
165
+ if (!/^-?\d+$/.test(value)) {
166
+ throw new Error(`--${option.path} 需要整数,收到:${value}`);
167
+ }
168
+ parsed = Number(value);
169
+ break;
170
+ case "number":
171
+ parsed = Number(value);
172
+ if (!Number.isFinite(parsed)) {
173
+ throw new Error(`--${option.path} 需要数字,收到:${value}`);
174
+ }
175
+ break;
176
+ case "string":
177
+ parsed = value;
178
+ break;
179
+ }
180
+
181
+ if (option.enum && !option.enum.includes(parsed)) {
182
+ throw new Error(`--${option.path} 必须是以下值之一:${option.enum.join(", ")}`);
183
+ }
184
+ if (typeof parsed === "number" && option.minimum !== undefined && parsed < option.minimum) {
185
+ throw new Error(`--${option.path} 不能小于 ${option.minimum}`);
186
+ }
187
+ return parsed;
188
+ }
189
+
190
+ function isValueType(type: SchemaNode["type"]): type is SchemaValueType {
191
+ return type === "boolean" || type === "integer" || type === "number" || type === "string";
192
+ }
193
+
194
+ function isRecord(value: unknown): value is Record<string, unknown> {
195
+ return typeof value === "object" && value !== null && !Array.isArray(value);
196
+ }
197
+
198
+ function isNumericArgument(value: string): boolean {
199
+ return /^-\d/.test(value);
200
+ }
package/src/config.ts ADDED
@@ -0,0 +1,52 @@
1
+ export interface CaptionStyle {
2
+ enabled: boolean;
3
+ format: string;
4
+ separator: string;
5
+ }
6
+
7
+ export interface MermaidConfig {
8
+ enabled: boolean;
9
+ theme: string;
10
+ density: number;
11
+ }
12
+
13
+ export interface BooleanConfig {
14
+ enabled: boolean;
15
+ }
16
+
17
+ export interface HeadingNumberingConfig {
18
+ enabled: boolean;
19
+ detectExisting: boolean;
20
+ existingPattern?: string;
21
+ useBuiltinRules?: boolean;
22
+ }
23
+
24
+ export type TitleExtractStrategy = "first-h1" | "single-h1" | "filename" | "none";
25
+
26
+ export interface TitleConfig {
27
+ enabled: boolean;
28
+ strategy: TitleExtractStrategy;
29
+ }
30
+
31
+ export interface PandocConfig {
32
+ enabled: boolean;
33
+ outputName: string;
34
+ }
35
+
36
+ export interface AppConfig {
37
+ figureCaption: CaptionStyle;
38
+ tableCaption: CaptionStyle;
39
+ normalizeHeadings: BooleanConfig;
40
+ numberHeadings: HeadingNumberingConfig;
41
+ renderMermaid: MermaidConfig;
42
+ detectTitle: TitleConfig;
43
+ pandoc: PandocConfig;
44
+ }
45
+
46
+ export async function loadConfig(path: string): Promise<AppConfig> {
47
+ const exists = await Bun.file(path).exists();
48
+ if (!exists) throw new Error(`找不到配置文件:${path}`);
49
+ const raw = JSON.parse(await Bun.file(path).text()) as Record<string, unknown>;
50
+ delete raw.$schema;
51
+ return raw as unknown as AppConfig;
52
+ }