@v1hz/md2docx 1.6.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@v1hz/md2docx",
3
- "version": "1.6.0",
3
+ "version": "2.1.0",
4
4
  "description": "基于 pandoc 的 Markdown 转 Word 工具,自动格式化 Markdown,支持自定义样式,AI 友好,用户友好。",
5
5
  "keywords": [
6
6
  "converter",
@@ -21,43 +21,48 @@
21
21
  "url": "git+https://github.com/WXY-V1hZ/md2docx.git"
22
22
  },
23
23
  "bin": {
24
- "md2docx": "src/index.ts"
24
+ "md2docx": "dist/index.js"
25
25
  },
26
26
  "files": [
27
- "src",
28
- "config/config.json",
29
- "config/config.schema.json",
30
- "config/style.json",
27
+ "dist",
28
+ "config",
31
29
  "README.md",
32
30
  "LICENSE"
33
31
  ],
34
32
  "type": "module",
35
- "module": "src/index.ts",
33
+ "module": "dist/index.js",
36
34
  "scripts": {
37
35
  "cli": "bun run src/index.ts",
36
+ "clean:dist": "bun -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"",
37
+ "build": "bun run clean:dist && bun build src/index.ts --target=node --outdir dist --entry-naming index.js --asset-naming [name].[ext]",
38
+ "build:exe": "bun run clean:dist && bun build src/index.ts --compile --outfile dist/md2docx.exe",
39
+ "prepack": "bun run build",
38
40
  "test": "bun test",
39
41
  "check": "bun tsc --noEmit && bun run oxlint && bun run oxfmt"
40
42
  },
41
43
  "dependencies": {
44
+ "@resvg/resvg-wasm": "2.6.2",
42
45
  "@xmldom/xmldom": "^0.9.10",
43
46
  "beautiful-mermaid": "^1.1.3",
47
+ "commander": "^15.0.0",
44
48
  "docx": "^9.7.1",
45
49
  "pizzip": "^3.2.0",
46
50
  "remark-frontmatter": "^5.0.0",
47
51
  "remark-gfm": "^4.0.1",
48
52
  "remark-parse": "^11.0.0",
49
53
  "remark-stringify": "^11.0.0",
50
- "sharp": "^0.35.3",
51
54
  "unified": "^11.0.5",
52
55
  "xpath": "^0.0.34"
53
56
  },
54
57
  "devDependencies": {
55
58
  "@types/bun": "^1.3.14",
59
+ "@types/node": "^24.0.0",
56
60
  "oxfmt": "^0.58.0",
57
61
  "oxlint": "^1.73.0",
62
+ "sharp": "^0.35.3",
58
63
  "typescript": "^7.0.2"
59
64
  },
60
65
  "engines": {
61
- "bun": ">=1.3"
66
+ "node": ">=22.12.0"
62
67
  }
63
68
  }
package/src/cli.ts DELETED
@@ -1,210 +0,0 @@
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
- version: boolean;
28
- web: boolean;
29
- mdPath?: string;
30
- outputPath?: string;
31
- configPath?: string;
32
- overrides: Map<string, unknown>;
33
- }
34
-
35
- export function getConfigOptions(schema: SchemaNode): ConfigOption[] {
36
- const options: ConfigOption[] = [];
37
-
38
- function walk(node: SchemaNode, prefix: string): void {
39
- if (node.type !== "object" || !node.properties) return;
40
- for (const [name, child] of Object.entries(node.properties)) {
41
- const path = prefix ? `${prefix}.${name}` : name;
42
- if (child.type === "object") {
43
- walk(child, path);
44
- } else if (isValueType(child.type)) {
45
- options.push({
46
- path,
47
- type: child.type,
48
- description: child.description ?? "",
49
- default: child.default,
50
- enum: child.enum,
51
- minimum: child.minimum,
52
- required: node.required?.includes(name) ?? false,
53
- });
54
- }
55
- }
56
- }
57
-
58
- walk(schema, "");
59
- return options;
60
- }
61
-
62
- export function parseCliArgs(args: string[], configOptions: ConfigOption[]): CliOptions {
63
- const optionsByName = new Map(configOptions.map((option) => [option.path, option]));
64
- const result: CliOptions = { help: false, version: false, web: false, overrides: new Map() };
65
- const positional: string[] = [];
66
-
67
- for (let i = 0; i < args.length; i++) {
68
- const arg = args[i]!;
69
- if (arg === "-h" || arg === "--help") {
70
- result.help = true;
71
- continue;
72
- }
73
- if (arg === "-v" || arg === "--version") {
74
- result.version = true;
75
- continue;
76
- }
77
- if (arg === "--web") {
78
- result.web = true;
79
- continue;
80
- }
81
- if (arg === "-o" || arg === "--config") {
82
- const value = args[++i];
83
- if (value === undefined || value.startsWith("--")) {
84
- throw new Error(`${arg} 缺少路径`);
85
- }
86
- if (arg === "-o") result.outputPath = value;
87
- else result.configPath = value;
88
- continue;
89
- }
90
- if (arg.startsWith("--")) {
91
- const name = arg.slice(2);
92
- const option = optionsByName.get(name);
93
- if (!option) throw new Error(`未知参数:${arg}`);
94
- const value = args[++i];
95
- if (value === undefined) throw new Error(`${arg} 缺少值`);
96
- result.overrides.set(name, parseConfigValue(value, option));
97
- continue;
98
- }
99
- if (arg.startsWith("-") && !isNumericArgument(arg)) {
100
- throw new Error(`未知参数:${arg}`);
101
- }
102
- positional.push(arg);
103
- }
104
-
105
- if (!result.help && !result.version && !result.web) {
106
- if (positional.length === 0) throw new Error("缺少 Markdown 文件路径");
107
- if (positional.length > 1) throw new Error("只能指定一个 Markdown 文件路径");
108
- result.mdPath = positional[0];
109
- }
110
- return result;
111
- }
112
-
113
- export function applyConfigOverrides(
114
- config: AppConfig,
115
- overrides: Map<string, unknown>,
116
- ): AppConfig {
117
- const merged = structuredClone(config) as unknown as Record<string, unknown>;
118
- for (const [path, value] of overrides) {
119
- const segments = path.split(".");
120
- let target = merged;
121
- for (const segment of segments.slice(0, -1)) {
122
- const next = target[segment];
123
- if (!isRecord(next)) throw new Error(`配置路径不存在:${path}`);
124
- target = next;
125
- }
126
- target[segments.at(-1)!] = value;
127
- }
128
- return merged as unknown as AppConfig;
129
- }
130
-
131
- export function formatHelp(configOptions: ConfigOption[]): string {
132
- const lines = [
133
- "用法:",
134
- " md2docx <md-path> [选项]",
135
- " md2docx clean",
136
- "",
137
- "选项:",
138
- " -o <path> 输出 docx 路径",
139
- " --config <path> 配置文件路径,默认 config/config.json",
140
- " --web 打开默认配置的网页编辑器",
141
- " -v, --version 显示版本号",
142
- " -h, --help 显示帮助",
143
- "",
144
- "子命令:",
145
- " clean 清除 tmp/ 缓存",
146
- "",
147
- "配置覆盖:",
148
- ];
149
-
150
- for (const option of configOptions) {
151
- const constraints = [
152
- option.default === undefined ? undefined : `默认: ${String(option.default)}`,
153
- option.enum ? `可选: ${option.enum.join(" | ")}` : undefined,
154
- option.minimum === undefined ? undefined : `最小: ${option.minimum}`,
155
- ].filter(Boolean);
156
- lines.push(` --${option.path} <${option.type}>`);
157
- lines.push(
158
- ` ${option.description}${constraints.length ? `(${constraints.join(";")})` : ""}`,
159
- );
160
- }
161
-
162
- return lines.join("\n");
163
- }
164
-
165
- function parseConfigValue(value: string, option: ConfigOption): unknown {
166
- let parsed: unknown;
167
- switch (option.type) {
168
- case "boolean":
169
- if (value !== "true" && value !== "false") {
170
- throw new Error(`--${option.path} 需要 boolean(true 或 false),收到:${value}`);
171
- }
172
- parsed = value === "true";
173
- break;
174
- case "integer":
175
- if (!/^-?\d+$/.test(value)) {
176
- throw new Error(`--${option.path} 需要整数,收到:${value}`);
177
- }
178
- parsed = Number(value);
179
- break;
180
- case "number":
181
- parsed = Number(value);
182
- if (!Number.isFinite(parsed)) {
183
- throw new Error(`--${option.path} 需要数字,收到:${value}`);
184
- }
185
- break;
186
- case "string":
187
- parsed = value;
188
- break;
189
- }
190
-
191
- if (option.enum && !option.enum.includes(parsed)) {
192
- throw new Error(`--${option.path} 必须是以下值之一:${option.enum.join(", ")}`);
193
- }
194
- if (typeof parsed === "number" && option.minimum !== undefined && parsed < option.minimum) {
195
- throw new Error(`--${option.path} 不能小于 ${option.minimum}`);
196
- }
197
- return parsed;
198
- }
199
-
200
- function isValueType(type: SchemaNode["type"]): type is SchemaValueType {
201
- return type === "boolean" || type === "integer" || type === "number" || type === "string";
202
- }
203
-
204
- function isRecord(value: unknown): value is Record<string, unknown> {
205
- return typeof value === "object" && value !== null && !Array.isArray(value);
206
- }
207
-
208
- function isNumericArgument(value: string): boolean {
209
- return /^-\d/.test(value);
210
- }
package/src/config.ts DELETED
@@ -1,52 +0,0 @@
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
- }
package/src/index.ts DELETED
@@ -1,99 +0,0 @@
1
- #!/usr/bin/env bun
2
-
3
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs";
4
- import { dirname, parse, resolve } from "path";
5
- import { $ } from "bun";
6
-
7
- import { applyConfigOverrides, formatHelp, getConfigOptions, parseCliArgs } from "./cli";
8
- import { loadConfig } from "./config";
9
- import {
10
- CONFIG_PATH,
11
- CONFIG_SCHEMA_PATH,
12
- PKG_DIR,
13
- STYLE_CONFIG,
14
- TMP_DIR,
15
- formattedMdPath,
16
- preprocessDir,
17
- } from "./paths";
18
- import { preprocess } from "./preprocess/index";
19
- import { startWebEditor } from "./web";
20
- import { ensureTemplateDocx } from "./style/generate";
21
-
22
- async function cleanCache(): Promise<number> {
23
- const resolved = resolve(TMP_DIR);
24
- if (existsSync(resolved)) {
25
- rmSync(resolved, { recursive: true, force: true });
26
- console.log(`已清除缓存:${resolved}`);
27
- } else {
28
- console.log("缓存目录不存在,无需清理。");
29
- }
30
- return 0;
31
- }
32
-
33
- export async function run(args: string[]): Promise<number> {
34
- try {
35
- // 检查子命令
36
- const subcommand = args[0];
37
- if (subcommand === "clean") {
38
- return await cleanCache();
39
- }
40
- const schema = JSON.parse(await Bun.file(CONFIG_SCHEMA_PATH).text());
41
- const configOptions = getConfigOptions(schema);
42
- const cli = parseCliArgs(args, configOptions);
43
-
44
- if (cli.version) {
45
- const { version } = JSON.parse(await Bun.file(resolve(PKG_DIR, "package.json")).text()) as {
46
- version: string;
47
- };
48
- console.log(`@v1hz/md2docx v${version}`);
49
- return 0;
50
- }
51
- if (cli.help) {
52
- console.log(formatHelp(configOptions));
53
- return 0;
54
- }
55
- if (cli.web) {
56
- await startWebEditor();
57
- return 0;
58
- }
59
-
60
- const mdPath = resolve(cli.mdPath!);
61
- if (!existsSync(mdPath)) throw new Error(`找不到 Markdown 文件:${mdPath}`);
62
-
63
- const config = await loadConfig(cli.configPath ?? CONFIG_PATH);
64
- const cfg = applyConfigOverrides(config, cli.overrides);
65
- const baseName = parse(mdPath).name;
66
- const outDir = preprocessDir(baseName);
67
- mkdirSync(outDir, { recursive: true });
68
-
69
- const formattedMd = await preprocess(mdPath, cfg, outDir);
70
- const mdOutput = formattedMdPath(baseName);
71
- writeFileSync(mdOutput, formattedMd, "utf-8");
72
-
73
- if (cfg.pandoc.enabled) {
74
- const templatePath = await ensureTemplateDocx(STYLE_CONFIG);
75
- const configuredName = cfg.pandoc.outputName.replaceAll("{file_name}", baseName);
76
- const docxOutput = resolve(cli.outputPath ?? configuredName);
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}` : "";
80
- const result =
81
- await $`pandoc ${mdOutput} -o ${docxOutput} --reference-doc=${templatePath} ${luaFlag}`.nothrow();
82
- if (result.exitCode !== 0) {
83
- console.error(`pandoc 转换失败 (exit code ${result.exitCode}):`, result.stderr.toString());
84
- return 1;
85
- }
86
- console.log(`已生成:${docxOutput}`);
87
- }
88
- return 0;
89
- } catch (error) {
90
- const message = error instanceof Error ? error.message : String(error);
91
- console.error(`错误:${message}`);
92
- console.error("使用 --help 查看帮助");
93
- return 1;
94
- }
95
- }
96
-
97
- if (import.meta.main) {
98
- process.exitCode = await run(Bun.argv.slice(2));
99
- }
package/src/paths.ts DELETED
@@ -1,34 +0,0 @@
1
- import { resolve } from "path";
2
-
3
- const PKG_DIR = resolve(import.meta.dir, "..");
4
-
5
- /** 包根目录 */
6
- export { PKG_DIR };
7
-
8
- /** 默认配置文件路径(相对于包安装目录) */
9
- export const CONFIG_PATH = resolve(PKG_DIR, "config/config.json");
10
-
11
- /** 默认配置 schema 路径(相对于包安装目录) */
12
- export const CONFIG_SCHEMA_PATH = resolve(PKG_DIR, "config/config.schema.json");
13
-
14
- /** 临时文件根目录(相对于当前工作目录) */
15
- export const TMP_DIR = "tmp";
16
-
17
- /** 预处理模块的输出目录(相对于当前工作目录) */
18
- export function preprocessDir(baseName: string): string {
19
- return `${TMP_DIR}/preprocess/${baseName}`;
20
- }
21
-
22
- /** 预处理后的 Markdown 文件路径 */
23
- export function formattedMdPath(baseName: string): string {
24
- return `${preprocessDir(baseName)}/${baseName}_formatted.md`;
25
- }
26
-
27
- /** 样式模块目录 */
28
- export const STYLE_DIR = `${TMP_DIR}/style`;
29
-
30
- /** 样式配置文件(相对于包安装目录),用户维护的样式定义 */
31
- export const STYLE_CONFIG = resolve(PKG_DIR, "config/style.json");
32
-
33
- /** 生成的模板 docx 文件路径 */
34
- export const STYLE_TEMPLATE_DOCX = `${STYLE_DIR}/style.docx`;
@@ -1,107 +0,0 @@
1
- import { type Image, type Paragraph, type Root, type Text } from "mdast";
2
- import { type AppConfig } from "../config";
3
-
4
- export function numberTables(root: Root, config: AppConfig) {
5
- const {
6
- tableCaption: { format, separator },
7
- } = config;
8
- let counter = 0;
9
- const inserts: { at: number; text: string }[] = [];
10
- const used = new Set<number>();
11
- for (let i = 0; i < root.children.length; i++) {
12
- const child = root.children[i]!;
13
- if (child?.type !== "table") continue;
14
-
15
- counter++;
16
- const label = format.replace("{n}", String(counter));
17
-
18
- const prevIdx = i - 1;
19
- const prev = prevIdx >= 0 ? root.children[prevIdx] : undefined;
20
- if (prev && isCaption(prev) && !used.has(prevIdx)) {
21
- used.add(prevIdx);
22
- const existing = stripTableNum(captionText(prev), format);
23
- const first = prev.children[0] as Text;
24
- first.value = `Table: ${label}${existing ? `${separator}${existing}` : ""}`;
25
- continue;
26
- }
27
-
28
- const nextIdx = i + 1;
29
- const next = nextIdx < root.children.length ? root.children[nextIdx] : undefined;
30
- if (next && isCaption(next) && !used.has(nextIdx)) {
31
- used.add(nextIdx);
32
- const existing = stripTableNum(captionText(next), format);
33
- const first = next.children[0] as Text;
34
- first.value = `Table: ${label}${existing ? `${separator}${existing}` : ""}`;
35
- continue;
36
- }
37
-
38
- inserts.push({ at: i + 1, text: `Table: ${label}` });
39
- }
40
-
41
- for (let j = inserts.length - 1; j >= 0; j--) {
42
- const { at, text } = inserts[j]!;
43
- root.children.splice(at, 0, {
44
- type: "paragraph",
45
- children: [{ type: "text", value: text }],
46
- } as Paragraph);
47
- }
48
- }
49
-
50
- export function numberPictures(root: Root, config: AppConfig) {
51
- const {
52
- figureCaption: { format, separator },
53
- } = config;
54
- let counter = 0;
55
- for (let i = 0; i < root.children.length; i++) {
56
- const child = root.children[i]!;
57
- if (
58
- child?.type === "paragraph" &&
59
- child.children.length === 1 &&
60
- child.children[0]?.type === "image"
61
- ) {
62
- counter++;
63
- const img = child.children[0] as Image;
64
- const cleaned = stripPictureNum(img.title ?? img.alt ?? "", format);
65
- const label = cleaned || fileNameFromUrl(img.url) || "";
66
- const prefix = format.replace("{n}", String(counter));
67
- img.alt = `${prefix}${label ? `${separator}${label}` : ""}`;
68
- }
69
- }
70
- }
71
-
72
- /** 从 URL/路径中提取不带扩展名的文件名 */
73
- function fileNameFromUrl(url: string): string | null {
74
- const match = url.match(/\/([^/]+?)(?:\.[^/.]+)?$/);
75
- return match ? match[1]! : null;
76
- }
77
-
78
- function isCaption(node: unknown): node is Paragraph {
79
- return (
80
- node != null &&
81
- typeof node === "object" &&
82
- (node as { type?: string }).type === "paragraph" &&
83
- (node as { children?: { type?: string; value?: string }[] }).children?.length === 1 &&
84
- (node as { children: { type?: string; value?: string }[] }).children[0]?.type === "text" &&
85
- (
86
- (node as { children: { type?: string; value?: string }[] }).children[0]?.value ?? ""
87
- ).startsWith("Table: ")
88
- );
89
- }
90
-
91
- function captionText(node: Paragraph): string {
92
- const first = node.children[0];
93
- if (first?.type === "text") {
94
- return first.value.replace(/^Table: /, "");
95
- }
96
- return "";
97
- }
98
-
99
- function stripTableNum(text: string, format: string): string {
100
- const escaped = format.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&").replace("{n}", "\\d+");
101
- return text.replace(new RegExp(`^${escaped}`), "");
102
- }
103
-
104
- function stripPictureNum(text: string, format: string): string {
105
- const escaped = format.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&").replace("{n}", "\\d+");
106
- return text.replace(new RegExp(`^${escaped}`), "");
107
- }
@@ -1,49 +0,0 @@
1
- import { type Heading } from "mdast";
2
- import { unified } from "unified";
3
- import remarkParse from "remark-parse";
4
- import remarkStringify from "remark-stringify";
5
- import remarkGfm from "remark-gfm";
6
- import remarkFrontmatter from "remark-frontmatter";
7
- import { visit } from "unist-util-visit";
8
-
9
- import { type AppConfig } from "../config";
10
- import { addTitle, normalizeHeadings, numberHeadings } from "./title";
11
- import { numberTables, numberPictures } from "./caption";
12
- import { renderMermaid } from "./mermaid";
13
-
14
- export async function preprocess(mdPath: string, cfg: AppConfig, outDir: string): Promise<string> {
15
- const md = await Bun.file(mdPath).text();
16
-
17
- const processor = unified()
18
- .use(remarkParse)
19
- .use(remarkFrontmatter, ["yaml"])
20
- .use(remarkGfm)
21
- .use(remarkStringify, { resourceLink: true, bullet: "-" });
22
- const ast = processor.parse(md);
23
-
24
- const headings: Heading[] = [];
25
- visit(ast, "heading", (heading: Heading) => {
26
- headings.push(heading);
27
- });
28
-
29
- addTitle(mdPath, ast, headings, cfg.detectTitle);
30
-
31
- if (cfg.normalizeHeadings.enabled || cfg.numberHeadings.enabled) {
32
- normalizeHeadings(headings);
33
- }
34
- if (cfg.numberHeadings.enabled) {
35
- numberHeadings(headings, cfg.numberHeadings);
36
- }
37
-
38
- if (cfg.tableCaption.enabled) {
39
- numberTables(ast, cfg);
40
- }
41
- if (cfg.renderMermaid.enabled) {
42
- await renderMermaid(ast, outDir, cfg.renderMermaid.theme, cfg.renderMermaid.density);
43
- }
44
- if (cfg.figureCaption.enabled) {
45
- numberPictures(ast, cfg);
46
- }
47
-
48
- return processor.stringify(ast);
49
- }