@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 +21 -0
- package/README.md +115 -0
- package/config/config.json +34 -0
- package/config/config.schema.json +173 -0
- package/package.json +60 -0
- package/src/cli.ts +200 -0
- package/src/config.ts +52 -0
- package/src/index.ts +63 -0
- package/src/paths.ts +32 -0
- package/src/preprocess/caption.ts +107 -0
- package/src/preprocess/index.ts +49 -0
- package/src/preprocess/mermaid.ts +146 -0
- package/src/preprocess/title.ts +144 -0
- package/src/style/extract.ts +381 -0
- package/src/web/app.css +298 -0
- package/src/web/app.js +181 -0
- package/src/web/index.html +27 -0
- package/src/web.ts +148 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { mkdirSync, 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 { CONFIG_PATH, CONFIG_SCHEMA_PATH, formattedMdPath, preprocessDir } from "./paths";
|
|
10
|
+
import { preprocess } from "./preprocess/index";
|
|
11
|
+
import { startWebEditor } from "./web";
|
|
12
|
+
|
|
13
|
+
export async function run(args: string[]): Promise<number> {
|
|
14
|
+
try {
|
|
15
|
+
const schema = JSON.parse(await Bun.file(CONFIG_SCHEMA_PATH).text());
|
|
16
|
+
const configOptions = getConfigOptions(schema);
|
|
17
|
+
const cli = parseCliArgs(args, configOptions);
|
|
18
|
+
|
|
19
|
+
if (cli.help) {
|
|
20
|
+
console.log(formatHelp(configOptions));
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
if (cli.web) {
|
|
24
|
+
await startWebEditor();
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const mdPath = cli.mdPath!;
|
|
29
|
+
if (!(await Bun.file(mdPath).exists())) throw new Error(`找不到 Markdown 文件:${mdPath}`);
|
|
30
|
+
|
|
31
|
+
const config = await loadConfig(cli.configPath ?? CONFIG_PATH);
|
|
32
|
+
const cfg = applyConfigOverrides(config, cli.overrides);
|
|
33
|
+
const baseName = parse(mdPath).name;
|
|
34
|
+
const outDir = preprocessDir(baseName);
|
|
35
|
+
mkdirSync(outDir, { recursive: true });
|
|
36
|
+
|
|
37
|
+
const formattedMd = await preprocess(mdPath, cfg, outDir);
|
|
38
|
+
const mdOutput = formattedMdPath(baseName);
|
|
39
|
+
writeFileSync(mdOutput, formattedMd, "utf-8");
|
|
40
|
+
|
|
41
|
+
if (cfg.pandoc.enabled) {
|
|
42
|
+
const configuredName = cfg.pandoc.outputName.replaceAll("{file_name}", baseName);
|
|
43
|
+
const docxOutput = resolve(cli.outputPath ?? configuredName);
|
|
44
|
+
mkdirSync(dirname(docxOutput), { recursive: true });
|
|
45
|
+
const result = await $`pandoc ${mdOutput} -o ${docxOutput}`.nothrow();
|
|
46
|
+
if (result.exitCode !== 0) {
|
|
47
|
+
console.error(`pandoc 转换失败 (exit code ${result.exitCode}):`, result.stderr.toString());
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
console.log(`已生成:${docxOutput}`);
|
|
51
|
+
}
|
|
52
|
+
return 0;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
55
|
+
console.error(`错误:${message}`);
|
|
56
|
+
console.error("使用 --help 查看帮助");
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (import.meta.main) {
|
|
62
|
+
process.exitCode = await run(Bun.argv.slice(2));
|
|
63
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// ── 路径常量 ───────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
/** 配置文件路径 */
|
|
4
|
+
export const CONFIG_PATH = "config/config.json";
|
|
5
|
+
|
|
6
|
+
/** 配置 schema 路径 */
|
|
7
|
+
export const CONFIG_SCHEMA_PATH = "config/config.schema.json";
|
|
8
|
+
|
|
9
|
+
/** 临时文件根目录 */
|
|
10
|
+
export const TMP_DIR = "tmp";
|
|
11
|
+
|
|
12
|
+
/** 预处理模块的输出目录(相对于项目根目录) */
|
|
13
|
+
export function preprocessDir(baseName: string): string {
|
|
14
|
+
return `${TMP_DIR}/preprocess/${baseName}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 预处理后的 Markdown 文件路径 */
|
|
18
|
+
export function formattedMdPath(baseName: string): string {
|
|
19
|
+
return `${preprocessDir(baseName)}/${baseName}_formatted.md`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 样式模块目录 */
|
|
23
|
+
export const STYLE_DIR = `${TMP_DIR}/style`;
|
|
24
|
+
|
|
25
|
+
/** 参考 docx 文件路径(样式提取的输入) */
|
|
26
|
+
export const STYLE_REF_DOCX = `${STYLE_DIR}/ref.docx`;
|
|
27
|
+
|
|
28
|
+
/** 样式 JSON 文件路径(提取结果的输出 / 模板生成的输入) */
|
|
29
|
+
export const STYLE_JSON = `${STYLE_DIR}/style.json`;
|
|
30
|
+
|
|
31
|
+
/** 生成的模板 docx 文件路径 */
|
|
32
|
+
export const STYLE_TEMPLATE_DOCX = `${STYLE_DIR}/template.docx`;
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { type Root } from "mdast";
|
|
2
|
+
import { writeFileSync, mkdirSync, existsSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { renderMermaidSVG, THEMES } from "beautiful-mermaid";
|
|
5
|
+
import sharp from "sharp";
|
|
6
|
+
|
|
7
|
+
export async function renderMermaid(root: Root, outDir: string, theme: string, density: number) {
|
|
8
|
+
if (!existsSync(outDir)) {
|
|
9
|
+
mkdirSync(outDir, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const mermaidTheme = THEMES[theme as keyof typeof THEMES] ?? THEMES["tokyo-night-light"];
|
|
13
|
+
|
|
14
|
+
let counter = 0;
|
|
15
|
+
for (let i = 0; i < root.children.length; i++) {
|
|
16
|
+
const child = root.children[i]!;
|
|
17
|
+
if (child?.type !== "code" || child.lang !== "mermaid") continue;
|
|
18
|
+
|
|
19
|
+
counter++;
|
|
20
|
+
const pngFile = join(outDir, `mermaid_${counter}.png`);
|
|
21
|
+
const title = getMermaidTitle(child.meta);
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
let svg = renderMermaidSVG(child.value, mermaidTheme);
|
|
25
|
+
svg = resolveCSSVars(svg);
|
|
26
|
+
const png = await sharp(Buffer.from(svg), {
|
|
27
|
+
density,
|
|
28
|
+
})
|
|
29
|
+
.png()
|
|
30
|
+
.toBuffer();
|
|
31
|
+
writeFileSync(pngFile, png);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error(`mermaid 渲染失败 (#${counter}):`, err);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
root.children[i] = {
|
|
38
|
+
type: "paragraph",
|
|
39
|
+
children: [
|
|
40
|
+
{
|
|
41
|
+
type: "image",
|
|
42
|
+
url: pngFile,
|
|
43
|
+
title: title ?? undefined,
|
|
44
|
+
alt: title ?? "",
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
} as unknown as never;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 从 mermaid code 的 meta 中提取 title="..." */
|
|
52
|
+
function getMermaidTitle(meta: string | null | undefined): string | null {
|
|
53
|
+
if (!meta) return null;
|
|
54
|
+
const match = meta.match(/title\s*=\s*["']([^"']+)["']/);
|
|
55
|
+
return match ? match[1]! : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface MixColors {
|
|
59
|
+
c1: string;
|
|
60
|
+
c2: string;
|
|
61
|
+
pct: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 将 SVG 中的 CSS 自定义属性(var())和 color-mix() 内联为
|
|
66
|
+
* sharp/librsvg 能理解的具体十六进制颜色值。
|
|
67
|
+
*/
|
|
68
|
+
function resolveCSSVars(svg: string): string {
|
|
69
|
+
const vars: Record<string, string> = {};
|
|
70
|
+
|
|
71
|
+
// 1. 从 <svg style="--bg:#...;--fg:#..."> 提取基础变量
|
|
72
|
+
const styleAttr = svg.match(/<svg[^>]*style="([^"]+)"/)?.[1];
|
|
73
|
+
if (!styleAttr) return svg;
|
|
74
|
+
styleAttr.replace(/--([\w-]+)\s*:\s*([^;]+)/g, (_, n, v) => {
|
|
75
|
+
vars[n] = v.trim();
|
|
76
|
+
return "";
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// 2. 从 <style> 块提取派生变量(svg { --_text: var(--fg); ... })
|
|
80
|
+
const styleBlock = svg.match(/<style>([\s\S]*?)<\/style>/)?.[1];
|
|
81
|
+
if (styleBlock) {
|
|
82
|
+
const svgRules = styleBlock.match(/svg\s*\{([^}]+)\}/)?.[1];
|
|
83
|
+
if (svgRules) {
|
|
84
|
+
svgRules.replace(/--([\w-]+)\s*:\s*([^;]+)/g, (_, n, v) => {
|
|
85
|
+
vars[n] = v.trim();
|
|
86
|
+
return "";
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 3. 解析所有 var() 引用和 color-mix()
|
|
92
|
+
let changed = true;
|
|
93
|
+
while (changed) {
|
|
94
|
+
changed = false;
|
|
95
|
+
for (const key of Object.keys(vars)) {
|
|
96
|
+
const newVal = vars[key]!.replace(/var\(--([\w-]+)\)/g, (_, name) => {
|
|
97
|
+
const resolved = vars[name];
|
|
98
|
+
if (resolved) {
|
|
99
|
+
changed = true;
|
|
100
|
+
return resolved;
|
|
101
|
+
}
|
|
102
|
+
return `var(--${name})`;
|
|
103
|
+
});
|
|
104
|
+
vars[key] = newVal;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 4. 解析 color-mix(in srgb, <color> X%, <color>)
|
|
109
|
+
function resolveColorMix(expr: string): string {
|
|
110
|
+
const colors = getMixColors(expr);
|
|
111
|
+
if (!colors) return expr;
|
|
112
|
+
return blendColors(colors.c1, colors.c2, colors.pct);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const key of Object.keys(vars)) {
|
|
116
|
+
vars[key] = vars[key]!.replace(/color-mix\([^)]+\)/g, (m) => resolveColorMix(m));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 5. 全局替换所有剩余的 var() 为具体值
|
|
120
|
+
return svg.replace(/var\(--([\w-]+)\)/g, (_, name) => {
|
|
121
|
+
return vars[name] ?? `var(--${name})`;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 解析 color-mix(in srgb, <color> <pct>%, <color>) */
|
|
126
|
+
function getMixColors(expr: string): MixColors | null {
|
|
127
|
+
const match = expr.match(
|
|
128
|
+
/color-mix\(\s*in\s+srgb\s*,\s*(#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3})\s+(\d+(?:\.\d+)?)%\s*,\s*(#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3})\s*\)/,
|
|
129
|
+
);
|
|
130
|
+
if (!match) return null;
|
|
131
|
+
return { c1: match[1]!, c2: match[3]!, pct: parseFloat(match[2]!) / 100 };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 混合两个十六进制颜色,pct 是 c1 的权重 */
|
|
135
|
+
function blendColors(c1: string, c2: string, pct: number): string {
|
|
136
|
+
const r1 = Number.parseInt(c1.slice(1, 3), 16);
|
|
137
|
+
const g1 = Number.parseInt(c1.slice(3, 5), 16);
|
|
138
|
+
const b1 = Number.parseInt(c1.slice(5, 7), 16);
|
|
139
|
+
const r2 = Number.parseInt(c2.slice(1, 3), 16);
|
|
140
|
+
const g2 = Number.parseInt(c2.slice(3, 5), 16);
|
|
141
|
+
const b2 = Number.parseInt(c2.slice(5, 7), 16);
|
|
142
|
+
const r = Math.round(r1 * pct + r2 * (1 - pct));
|
|
143
|
+
const g = Math.round(g1 * pct + g2 * (1 - pct));
|
|
144
|
+
const b = Math.round(b1 * pct + b2 * (1 - pct));
|
|
145
|
+
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
|
146
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { type Heading, type Root, type Text, type Yaml } from "mdast";
|
|
2
|
+
import { type HeadingNumberingConfig, type TitleConfig } from "../config";
|
|
3
|
+
|
|
4
|
+
export function addTitle(fileName: string, root: Root, headings: Heading[], config: TitleConfig) {
|
|
5
|
+
const { enabled, strategy } = config;
|
|
6
|
+
if (!enabled || strategy === "none") return;
|
|
7
|
+
|
|
8
|
+
const hasFrontmatterTitle = root.children.some(
|
|
9
|
+
(c): c is Yaml => c.type === "yaml" && /^title:/m.test(c.value),
|
|
10
|
+
);
|
|
11
|
+
if (hasFrontmatterTitle) return;
|
|
12
|
+
|
|
13
|
+
if (strategy === "filename") {
|
|
14
|
+
const fallbackTitle = fileName.replace(/\.\w+$/, "");
|
|
15
|
+
root.children.unshift({ type: "yaml", value: `title: ${fallbackTitle}` });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const depth1 = headings.filter((n) => n.depth === 1);
|
|
20
|
+
const firstDepth1 = depth1[0];
|
|
21
|
+
|
|
22
|
+
if (strategy === "single-h1") {
|
|
23
|
+
if (firstDepth1 != null && depth1.length === 1) {
|
|
24
|
+
const titleText = firstDepth1.children
|
|
25
|
+
.filter((c): c is Text => c.type === "text")
|
|
26
|
+
.map((c) => c.value)
|
|
27
|
+
.join("");
|
|
28
|
+
|
|
29
|
+
const idx = root.children.indexOf(firstDepth1);
|
|
30
|
+
if (idx !== -1) root.children.splice(idx, 1);
|
|
31
|
+
headings.splice(headings.indexOf(firstDepth1), 1);
|
|
32
|
+
|
|
33
|
+
root.children.unshift({ type: "yaml", value: `title: ${titleText}` });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// strategy === "first-h1"
|
|
39
|
+
if (firstDepth1 != null && depth1.length === 1 && firstDepth1 === headings[0]) {
|
|
40
|
+
const titleText = firstDepth1.children
|
|
41
|
+
.filter((c): c is Text => c.type === "text")
|
|
42
|
+
.map((c) => c.value)
|
|
43
|
+
.join("");
|
|
44
|
+
|
|
45
|
+
const idx = root.children.indexOf(firstDepth1);
|
|
46
|
+
if (idx !== -1) root.children.splice(idx, 1);
|
|
47
|
+
headings.splice(headings.indexOf(firstDepth1), 1);
|
|
48
|
+
|
|
49
|
+
root.children.unshift({ type: "yaml", value: `title: ${titleText}` });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const fallbackTitle = fileName.replace(/\.\w+$/, "");
|
|
54
|
+
root.children.unshift({ type: "yaml", value: `title: ${fallbackTitle}` });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeHeadings(nodes: Heading[]) {
|
|
58
|
+
if (nodes.length === 0) return;
|
|
59
|
+
|
|
60
|
+
let minDepth = 6;
|
|
61
|
+
for (const node of nodes) {
|
|
62
|
+
minDepth = Math.min(minDepth, node.depth);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const offset = minDepth - 1;
|
|
66
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
67
|
+
nodes[i]!.depth -= offset;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (let i = 1; i < nodes.length; i++) {
|
|
71
|
+
if (nodes[i]!.depth > nodes[i - 1]!.depth + 1) {
|
|
72
|
+
nodes[i]!.depth = (nodes[i - 1]!.depth + 1) as 1 | 2 | 3 | 4 | 5 | 6;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function numberHeadings(nodes: Heading[], config: HeadingNumberingConfig) {
|
|
78
|
+
if (nodes.length === 0) return;
|
|
79
|
+
const { detectExisting, existingPattern } = config;
|
|
80
|
+
const counter: number[] = [0, 0, 0, 0, 0, 0];
|
|
81
|
+
for (let i = 0; i <= nodes[0]!.depth - 2; ++i) {
|
|
82
|
+
counter[i] = 1;
|
|
83
|
+
}
|
|
84
|
+
for (const node of nodes) {
|
|
85
|
+
const d = node.depth;
|
|
86
|
+
counter[d - 1]!++;
|
|
87
|
+
counter.fill(0, d);
|
|
88
|
+
const prefix = buildPrefix(counter);
|
|
89
|
+
const first = node.children[0];
|
|
90
|
+
if (first?.type === "text") {
|
|
91
|
+
const text = detectExisting
|
|
92
|
+
? stripHeadingNum(first.value, existingPattern, config?.useBuiltinRules)
|
|
93
|
+
: first.value;
|
|
94
|
+
first.value = text ? `${prefix} ${text}` : prefix;
|
|
95
|
+
} else {
|
|
96
|
+
node.children.unshift({
|
|
97
|
+
type: "text",
|
|
98
|
+
value: prefix,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildPrefix(counter: number[]): string {
|
|
105
|
+
const end = counter.findLastIndex((x) => x !== 0);
|
|
106
|
+
return counter.slice(0, end + 1).join(".");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 匹配形如 "1.2.3"、"1.2.3." 的数字序号前缀,末尾可选空格。
|
|
111
|
+
* 注意:只匹配至少两段或带结尾点的,避免与公共版本号冲突。
|
|
112
|
+
*/
|
|
113
|
+
const RE_DOT_NUM = /^\d+(\.\d+)+\.?\s*/;
|
|
114
|
+
|
|
115
|
+
/** 匹配形如 "(一)"、"(一)" 的中文括号序号前缀 */
|
|
116
|
+
const RE_CN_PAREN = /^[((][一二三四五六七八九十百千]+[))]\s*/;
|
|
117
|
+
|
|
118
|
+
/** 匹配形如 "一、"、"一." 的中文数字单级序号前缀 */
|
|
119
|
+
const RE_CN_NUM = /^[一二三四五六七八九十百千]+[、.]\s*/;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 匹配形如 "一、二、三" 或 "一、二、三、" 的中文顿号多级序号前缀。
|
|
123
|
+
* 注意此正则须在 RE_CN_NUM 之后测试,以避免单级被优先匹配。
|
|
124
|
+
*/
|
|
125
|
+
const RE_CN_DUN = /^[一二三四五六七八九十百千]+(?:、[一二三四五六七八九十百千]+)+、?\s*/;
|
|
126
|
+
|
|
127
|
+
/** 去掉 heading 文本中已有的编号前缀 */
|
|
128
|
+
function stripHeadingNum(text: string, customPattern?: string, useBuiltinRules?: boolean): string {
|
|
129
|
+
let result = text;
|
|
130
|
+
if (customPattern) {
|
|
131
|
+
result = result.replace(new RegExp(`^${customPattern}`), "");
|
|
132
|
+
}
|
|
133
|
+
if (useBuiltinRules !== false) {
|
|
134
|
+
result = result.replace(
|
|
135
|
+
new RegExp(
|
|
136
|
+
`^(?:${[RE_CN_DUN.source, RE_CN_PAREN.source, RE_CN_NUM.source, RE_DOT_NUM.source].join(
|
|
137
|
+
"|",
|
|
138
|
+
)})`,
|
|
139
|
+
),
|
|
140
|
+
"",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|