@dongfangw/study-kit 0.1.2 → 0.2.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.
Files changed (50) hide show
  1. package/README.md +47 -3
  2. package/ext/adoc-convert/index.mjs +62 -62
  3. package/lib/client.js +1858 -160
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.d.ts.map +1 -1
  6. package/lib/index.js +32 -16
  7. package/lib/index.js.map +1 -1
  8. package/lib/libPath.d.ts +28 -0
  9. package/lib/libPath.d.ts.map +1 -0
  10. package/lib/libPath.js +36 -0
  11. package/lib/libPath.js.map +1 -0
  12. package/lib/pdf.d.ts +31 -0
  13. package/lib/pdf.d.ts.map +1 -0
  14. package/lib/pdf.js +174 -0
  15. package/lib/pdf.js.map +1 -0
  16. package/lib/render.d.ts.map +1 -1
  17. package/lib/render.js +58 -1
  18. package/lib/render.js.map +1 -1
  19. package/lib/slidev.d.ts.map +1 -1
  20. package/lib/slidev.js +50 -13
  21. package/lib/slidev.js.map +1 -1
  22. package/lib/slidevEnv.d.ts.map +1 -1
  23. package/lib/slidevEnv.js +30 -1
  24. package/lib/slidevEnv.js.map +1 -1
  25. package/lib/tools.d.ts.map +1 -1
  26. package/lib/tools.js +8 -0
  27. package/lib/tools.js.map +1 -1
  28. package/lib/types/client/LibraryWorkspace.d.ts.map +1 -1
  29. package/lib/types/client/PdfViewer.d.ts +15 -0
  30. package/lib/types/client/PdfViewer.d.ts.map +1 -0
  31. package/lib/types/client/annotations.d.ts +26 -2
  32. package/lib/types/client/annotations.d.ts.map +1 -1
  33. package/lib/types/client/docEnhance.d.ts.map +1 -1
  34. package/lib/types/client/docKey.d.ts +8 -0
  35. package/lib/types/client/docKey.d.ts.map +1 -1
  36. package/lib/types/client/pdfAnno.d.ts +44 -0
  37. package/lib/types/client/pdfAnno.d.ts.map +1 -0
  38. package/lib/types/client/pdfPos.d.ts +27 -0
  39. package/lib/types/client/pdfPos.d.ts.map +1 -0
  40. package/lib/types/client/pdfjs.d.ts +87 -0
  41. package/lib/types/client/pdfjs.d.ts.map +1 -0
  42. package/lib/types/client/styles.module.css.gen.d.ts.map +1 -1
  43. package/package.json +140 -138
  44. package/skills/aero-concept-qa/SKILL.md +59 -59
  45. package/skills/aero-learning-path/SKILL.md +55 -55
  46. package/skills/aero-study-guide/SKILL.md +47 -47
  47. package/skills/aero-think/SKILL.md +59 -59
  48. package/skills/make-mindmap/SKILL.md +237 -0
  49. package/skills/make-mindmap/example//347/244/272/344/276/213.mmd +45 -0
  50. package/skills/make-mindmap/render.mjs +855 -0
@@ -0,0 +1,855 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 零依赖思维导图渲染器:缩进大纲 → SVG。
4
+ *
5
+ * 为什么自带渲染器(而不是挂 PlantUML / mermaid-cli / Kroki):
6
+ * AI 学伴的阅读器用**裸 @asciidoctor/core** 逐页渲染(study-kit 的 ext/adoc-convert),
7
+ * 没有装 asciidoctor-diagram / kroki 扩展。所以 `[plantuml]`、`[mermaid]` 这些图块
8
+ * 在阅读器里会退化成一段等宽纯文本——正是要消灭的那种"文本路线图"。
9
+ * 而阅读器会把 `image::` 解析成 <img src="/aero-kb/asset?...">,asset 路由又把 .svg
10
+ * 当 image/svg+xml 伺服。于是路线是:**先离线渲染成 SVG 落盘,再用 image:: 引用**。
11
+ * 这条路不需要 Java、不需要 Chrome、不需要联网,打印时还是矢量。
12
+ *
13
+ * 输入语法 = Mermaid mindmap 的缩进子集(可以直接把 mermaid 的 mindmap 粘过来):
14
+ *
15
+ * --- ← 可选 YAML 前置配置(见 SKILL.md 的配置项总表)
16
+ * title: 思维导图插图
17
+ * layout: right
18
+ * theme: paper
19
+ * ---
20
+ * mindmap ← 可选关键字
21
+ * root((思维导图插图))
22
+ * 写大纲
23
+ * 缩进即层级
24
+ * 渲染成 SVG
25
+ * 零依赖,纯 Node
26
+ *
27
+ * 节点形状:id((圆)) / id(圆角) / id[方框] / id{{六边形}} / 裸文本(用默认形状)
28
+ * 其中 id 可省略;`(( ))` 之类也可以只写形状不带 id。
29
+ * 样式类: 行尾或下一行写 `:::强调` 引用配置里的 classes;`- ` / `* ` 项目符号会被忽略。
30
+ *
31
+ * 用法:
32
+ * node render.mjs <源文件> [选项]
33
+ * --out <路径> 输出 SVG(缺省与源文件同名 .svg)
34
+ * --theme <名> paper|aero|ink|warm|forest|slate|contrast
35
+ * --layout <名> right|left|both|tree
36
+ * --connector <名> curve|elbow|straight|arc
37
+ * --density <名> compact|normal|roomy
38
+ * --title <文本> 覆盖标题
39
+ * --transparent 无底色(深色阅读主题下更协调)
40
+ * --scale <系数> 整体字号系数
41
+ * --json 只打印统计(节点数 / 画布尺寸),不写文件
42
+ * -h, --help
43
+ */
44
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
45
+ import path from 'node:path'
46
+
47
+ // ════════════════════════════════════════════════════════════════════════════
48
+ // 0. 极简 YAML(只够读前置配置:标量 / 行内 [a,b] {a: 1} / 块级列表与映射 / 注释)
49
+ // 不引第三方依赖:技能要能在任何机器上直接跑;解析不了就报清楚的行号。
50
+ // ════════════════════════════════════════════════════════════════════════════
51
+ function stripComment(line) {
52
+ let q = null
53
+ for (let i = 0; i < line.length; i += 1) {
54
+ const ch = line[i]
55
+ if (q !== null) { if (ch === q) q = null; continue }
56
+ if (ch === '"' || ch === "'") { q = ch; continue }
57
+ if (ch === '#' && (i === 0 || /\s/u.test(line[i - 1]))) return line.slice(0, i)
58
+ }
59
+ return line
60
+ }
61
+
62
+ function splitTop(text, sep) {
63
+ const out = []
64
+ let depth = 0
65
+ let q = null
66
+ let cur = ''
67
+ for (const ch of text) {
68
+ if (q !== null) { cur += ch; if (ch === q) q = null; continue }
69
+ if (ch === '"' || ch === "'") { q = ch; cur += ch; continue }
70
+ if (ch === '[' || ch === '{' || ch === '(') depth += 1
71
+ if (ch === ']' || ch === '}' || ch === ')') depth -= 1
72
+ if (ch === sep && depth === 0) { out.push(cur); cur = ''; continue }
73
+ cur += ch
74
+ }
75
+ out.push(cur)
76
+ return out.map((s) => s.trim()).filter((s) => s !== '')
77
+ }
78
+
79
+ const unquote = (s) => {
80
+ const t = String(s).trim()
81
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) return t.slice(1, -1)
82
+ return t
83
+ }
84
+
85
+ function parseScalar(raw) {
86
+ const s = String(raw).trim()
87
+ if (s === '' || s === '~' || s === 'null') return null
88
+ if (s === 'true') return true
89
+ if (s === 'false') return false
90
+ if (/^-?\d+(\.\d+)?$/u.test(s)) return Number(s)
91
+ if (s.startsWith('[') && s.endsWith(']')) return splitTop(s.slice(1, -1), ',').map(parseScalar)
92
+ if (s.startsWith('{') && s.endsWith('}')) {
93
+ const obj = {}
94
+ for (const part of splitTop(s.slice(1, -1), ',')) {
95
+ const idx = part.indexOf(':')
96
+ if (idx < 0) continue
97
+ obj[unquote(part.slice(0, idx))] = parseScalar(part.slice(idx + 1))
98
+ }
99
+ return obj
100
+ }
101
+ if (s.startsWith('"') && s.endsWith('"') && s.length > 1) return s.slice(1, -1).replace(/\\"/gu, '"').replace(/\\n/gu, '\n')
102
+ if (s.startsWith("'") && s.endsWith("'") && s.length > 1) return s.slice(1, -1)
103
+ return s
104
+ }
105
+
106
+ function keyColon(body) {
107
+ let q = null
108
+ let depth = 0
109
+ for (let i = 0; i < body.length; i += 1) {
110
+ const ch = body[i]
111
+ if (q !== null) { if (ch === q) q = null; continue }
112
+ if (ch === '"' || ch === "'") { q = ch; continue }
113
+ if (ch === '[' || ch === '{') depth += 1
114
+ if (ch === ']' || ch === '}') depth -= 1
115
+ if (ch === ':' && depth === 0 && (i === body.length - 1 || /\s/u.test(body[i + 1]))) return i
116
+ }
117
+ return null
118
+ }
119
+
120
+ function parseMiniYaml(text) {
121
+ const rows = text.split(/\r?\n/u)
122
+ .map((raw) => stripComment(raw).replace(/\s+$/u, ''))
123
+ .filter((raw) => raw.trim() !== '')
124
+ .map((raw) => ({ indent: raw.length - raw.trimStart().length, body: raw.trim() }))
125
+ let at = 0
126
+
127
+ function parseBlock(indent) {
128
+ if (at >= rows.length) return null
129
+ const isList = rows[at].body === '-' || rows[at].body.startsWith('- ')
130
+ if (isList) {
131
+ const arr = []
132
+ while (at < rows.length && rows[at].indent === indent && (rows[at].body === '-' || rows[at].body.startsWith('- '))) {
133
+ const body = rows[at].body === '-' ? '' : rows[at].body.slice(2).trim()
134
+ at += 1
135
+ if (body === '') {
136
+ arr.push(at < rows.length && rows[at].indent > indent ? parseBlock(rows[at].indent) : null)
137
+ continue
138
+ }
139
+ if (body.startsWith('{')) { arr.push(parseScalar(body)); continue }
140
+ const colon = keyColon(body)
141
+ if (colon === null) { arr.push(parseScalar(body)); continue }
142
+ const obj = {}
143
+ obj[unquote(body.slice(0, colon))] = parseScalar(body.slice(colon + 1))
144
+ while (at < rows.length && rows[at].indent > indent && !rows[at].body.startsWith('- ')) {
145
+ const b = rows[at].body
146
+ const c = keyColon(b)
147
+ at += 1
148
+ if (c === null) continue
149
+ const k = unquote(b.slice(0, c))
150
+ const rest = b.slice(c + 1).trim()
151
+ obj[k] = rest === '' && at < rows.length && rows[at].indent > indent ? parseBlock(rows[at].indent) : parseScalar(rest)
152
+ }
153
+ arr.push(obj)
154
+ }
155
+ return arr
156
+ }
157
+ const obj = {}
158
+ while (at < rows.length && rows[at].indent === indent) {
159
+ const body = rows[at].body
160
+ const colon = keyColon(body)
161
+ if (colon === null) { at += 1; continue }
162
+ const key = unquote(body.slice(0, colon))
163
+ const rest = body.slice(colon + 1).trim()
164
+ at += 1
165
+ obj[key] = rest === '' ? (at < rows.length && rows[at].indent > indent ? parseBlock(rows[at].indent) : null) : parseScalar(rest)
166
+ }
167
+ return obj
168
+ }
169
+ if (rows.length === 0) return {}
170
+ return parseBlock(rows[0].indent) ?? {}
171
+ }
172
+
173
+ // ════════════════════════════════════════════════════════════════════════════
174
+ // 1. 主题 / 密度 / 枚举
175
+ // ════════════════════════════════════════════════════════════════════════════
176
+ const THEMES = {
177
+ paper: {
178
+ bg: '#ffffff', ink: '#1f2328', muted: '#6b7280', rule: '#d8dde5',
179
+ palette: ['#3b6ea5', '#b2623c', '#3f7f5f', '#7f5aa8', '#9a7b2f', '#3d7b8a'],
180
+ root: '#25405f',
181
+ },
182
+ aero: {
183
+ bg: '#fbfdff', ink: '#16233a', muted: '#5f7086', rule: '#d3dfec',
184
+ palette: ['#1f5fa8', '#2b8a8a', '#c2703a', '#6a4fa3', '#2f7d4f', '#a8434f'],
185
+ root: '#1b3a63',
186
+ },
187
+ ink: {
188
+ bg: '#ffffff', ink: '#111111', muted: '#5a5a5a', rule: '#cfcfcf',
189
+ palette: ['#3a3a3a', '#6a6a6a', '#262626', '#4f4f4f', '#7a7a7a', '#1a1a1a'],
190
+ root: '#111111',
191
+ },
192
+ warm: {
193
+ bg: '#fdf8f0', ink: '#3a3126', muted: '#8d7f68', rule: '#e4d8c2',
194
+ palette: ['#a8672f', '#8a6a2b', '#5f7d42', '#a84f52', '#3f6f89', '#7a5a9a'],
195
+ root: '#7a4a1e',
196
+ },
197
+ forest: {
198
+ bg: '#f5f9f3', ink: '#22301d', muted: '#77866e', rule: '#d5e2cc',
199
+ palette: ['#2f7d4f', '#4f8a3a', '#8a7d2f', '#2f7d7d', '#6a4fa3', '#a85a3a'],
200
+ root: '#1f4f33',
201
+ },
202
+ slate: {
203
+ bg: '#22262e', ink: '#e6ebf2', muted: '#93a1b3', rule: '#39424f',
204
+ palette: ['#7aa2f7', '#7dcfff', '#e0af68', '#bb9af7', '#9ece6a', '#f7768e'],
205
+ root: '#3d59a1',
206
+ },
207
+ contrast: {
208
+ bg: '#ffffff', ink: '#000000', muted: '#333333', rule: '#000000',
209
+ palette: ['#000000', '#8b0000', '#003f8b', '#00502a', '#5a2d82', '#8a5a00'],
210
+ root: '#000000',
211
+ },
212
+ }
213
+
214
+ const DENSITY = {
215
+ compact: { level: 40, sibling: 4, branch: 13, padX: 9, padY: 4 },
216
+ normal: { level: 66, sibling: 7, branch: 26, padX: 13, padY: 6.5 },
217
+ roomy: { level: 88, sibling: 13, branch: 40, padX: 17, padY: 9.5 },
218
+ }
219
+
220
+ const CONNECTORS = new Set(['curve', 'elbow', 'straight', 'arc'])
221
+ const LAYOUTS = new Set(['right', 'left', 'both', 'tree'])
222
+ const SHAPES = new Set(['rect', 'rounded', 'pill', 'hex', 'circle', 'underline', 'none'])
223
+
224
+ // ════════════════════════════════════════════════════════════════════════════
225
+ // 2. 颜色工具
226
+ // ════════════════════════════════════════════════════════════════════════════
227
+ function toRgb(color) {
228
+ if (typeof color !== 'string') return null
229
+ const s = color.trim()
230
+ let m = /^#([0-9a-f]{3})$/iu.exec(s)
231
+ if (m) return [0, 1, 2].map((i) => parseInt(m[1][i] + m[1][i], 16))
232
+ m = /^#([0-9a-f]{6})$/iu.exec(s)
233
+ if (m) return [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16))
234
+ m = /^rgba?\(([^)]+)\)$/iu.exec(s)
235
+ if (m) {
236
+ const p = m[1].split(',').map((v) => parseFloat(v.trim()))
237
+ if (p.length >= 3) return p.slice(0, 3)
238
+ }
239
+ return null
240
+ }
241
+ const toHex = (rgb) => `#${rgb.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('')}`
242
+ function mix(a, b, t, fallback = '#888888') {
243
+ const x = toRgb(a) ?? toRgb(fallback)
244
+ const y = toRgb(b) ?? toRgb(fallback)
245
+ if (x === null || y === null) return typeof a === 'string' && a !== 'none' ? a : fallback
246
+ return toHex(x.map((v, i) => v + (y[i] - v) * t))
247
+ }
248
+ const isTransparent = (bg) => bg === 'transparent' || bg === 'none'
249
+ function readableOn(color) {
250
+ const rgb = toRgb(color)
251
+ if (rgb === null) return '#ffffff'
252
+ const [r, g, b] = rgb.map((v) => {
253
+ const s = v / 255
254
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
255
+ })
256
+ const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b
257
+ return lum > 0.45 ? '#111318' : '#ffffff'
258
+ }
259
+
260
+ // ════════════════════════════════════════════════════════════════════════════
261
+ // 3. 源文件解析
262
+ // ════════════════════════════════════════════════════════════════════════════
263
+ const CIRCLED = [...'①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳']
264
+
265
+ function splitFrontMatter(text) {
266
+ const t = text.replace(/^\uFEFF/u, '')
267
+ const m = /^\s*---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(\r?\n|$)/u.exec(t)
268
+ if (m === null) return { config: {}, body: t }
269
+ return { config: parseMiniYaml(m[1]), body: t.slice(m[0].length) }
270
+ }
271
+
272
+ function parseSource(body) {
273
+ const lines = body.split(/\r?\n/u)
274
+ const flat = []
275
+ let order = 0
276
+ for (const raw of lines) {
277
+ const line = raw.replace(/\t/gu, ' ').replace(/\s+$/u, '')
278
+ if (line.trim() === '') continue
279
+ if (/^\s*%%/u.test(line)) continue
280
+ if (/^\s*(mindmap|outline)\s*$/iu.test(line)) continue
281
+ let text = line.trim()
282
+ text = text.replace(/^[-*+]\s+/u, '')
283
+ if (text === '') continue
284
+ flat.push({ raw: text, indent: line.length - line.trimStart().length, order: order++ })
285
+ }
286
+ if (flat.length === 0) throw new Error('源文件里没有任何节点行')
287
+
288
+ const stack = []
289
+ const roots = []
290
+ for (const n of flat) {
291
+ n.children = []
292
+ while (stack.length > 0 && stack[stack.length - 1].indent >= n.indent) stack.pop()
293
+ if (stack.length === 0) roots.push(n)
294
+ else stack[stack.length - 1].children.push(n)
295
+ stack.push(n)
296
+ }
297
+ if (roots.length === 1) return { root: roots[0], multi: false }
298
+ const synthetic = { raw: '', indent: -1, order: -1, children: roots, synthetic: true }
299
+ return { root: synthetic, multi: true }
300
+ }
301
+
302
+ // `id((文本))` / `((文本))` / `id[文本]` / 裸文本
303
+ const SHAPE_RULES = [
304
+ { re: /^(?:[\w-]*)(?=\()\s*\(\(([\s\S]*)\)\)$/u, shape: 'circle' },
305
+ { re: /^(?:[\w-]*)\{\{([\s\S]*)\}\}$/u, shape: 'hex' },
306
+ { re: /^(?:[\w-]*)\[([\s\S]*)\]$/u, shape: 'rect' },
307
+ { re: /^(?:[\w-]*)(?=\()\s*\(([\s\S]*)\)$/u, shape: 'rounded' },
308
+ ]
309
+
310
+ function decorate(node, depth, top) {
311
+ let text = node.raw
312
+ let classes = []
313
+ const cls = /\s*:::([\w\-\s]+)$/u.exec(text)
314
+ if (cls !== null) {
315
+ classes = cls[1].trim().split(/\s+/u)
316
+ text = text.slice(0, cls.index).trim()
317
+ }
318
+ let shape = null
319
+ for (const r of SHAPE_RULES) {
320
+ const m = r.re.exec(text)
321
+ if (m !== null) {
322
+ // 只在括号内确有内容时才认(避免把 "详见 (a)" 这类当形状)
323
+ text = m[1].trim()
324
+ shape = r.shape
325
+ break
326
+ }
327
+ }
328
+ node.text = text.replace(/<br\s*\/?>/giu, '\n')
329
+ node.shape = shape
330
+ node.classes = classes
331
+ node.depth = depth
332
+ node.top = depth <= 1 ? node : top
333
+ node.index = node.index ?? 0
334
+ node.children.forEach((child, i) => { child.index = i; decorate(child, depth + 1, node.top) })
335
+ return node
336
+ }
337
+
338
+ // ════════════════════════════════════════════════════════════════════════════
339
+ // 4. 文字测量(无 canvas:CJK / 拉丁 / 标点分别估算,确定性优先)
340
+ // ════════════════════════════════════════════════════════════════════════════
341
+ const CJK = /[\u2E80-\u9FFF\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/u
342
+ function charWidth(ch) {
343
+ if (CJK.test(ch)) return 1.0
344
+ if (ch === ' ') return 0.32
345
+ // ①–⑳ 这类圈号在全角 em 上排,按 1.05em 算,否则右边缘会被字形顶出框
346
+ if (/[\u2460-\u24FF\u2776-\u2793]/u.test(ch)) return 1.05
347
+ // 希腊字母(α ε Λ β)在中文正文里按 0.66em 估,太窄会让公式行溢出
348
+ if (/[\u0370-\u03FF]/u.test(ch)) return 0.66
349
+ if (/[A-Z0-9]/u.test(ch)) return 0.62
350
+ if (/[a-z]/u.test(ch)) return 0.55
351
+ if (/[.,:;'"`|!()[\]{}]/u.test(ch)) return 0.34
352
+ if (/[-–—_/\\+*=<>~^%$#@&·×∈≤≥≈→←↓↑]/u.test(ch)) return 0.56
353
+ if ((ch.codePointAt(0) ?? 0) > 0xffff) return 1.0
354
+ return 0.58
355
+ }
356
+ const textWidth = (s, size) => {
357
+ let w = 0
358
+ for (const ch of s) w += charWidth(ch)
359
+ return w * size
360
+ }
361
+ function wrapText(text, maxWidth, size) {
362
+ const out = []
363
+ for (const para of String(text).split('\n')) {
364
+ if (maxWidth <= 0 || textWidth(para, size) <= maxWidth) { out.push(para); continue }
365
+ const tokens = []
366
+ let buf = ''
367
+ for (const ch of para) {
368
+ if (CJK.test(ch) || ch === ' ') {
369
+ if (buf !== '') { tokens.push(buf); buf = '' }
370
+ tokens.push(ch)
371
+ } else buf += ch
372
+ }
373
+ if (buf !== '') tokens.push(buf)
374
+ let line = ''
375
+ for (const tk of tokens) {
376
+ if (line !== '' && textWidth(line + tk, size) > maxWidth) {
377
+ out.push(line.trimEnd())
378
+ line = tk === ' ' ? '' : tk
379
+ } else line += tk
380
+ }
381
+ out.push(line.trimEnd())
382
+ }
383
+ return out.length === 0 ? [''] : out
384
+ }
385
+
386
+ // ════════════════════════════════════════════════════════════════════════════
387
+ // 5. 样式
388
+ // ════════════════════════════════════════════════════════════════════════════
389
+ function buildStyles(cfg) {
390
+ const theme = THEMES[cfg.theme] ?? THEMES.paper
391
+ const density = DENSITY[cfg.density] ?? DENSITY.normal
392
+ const fs = Number(cfg.font?.size ?? 15) * Number(cfg.font?.scale ?? 1) * Number(cfg.scale ?? 1)
393
+ const gap = {
394
+ level: Number(cfg.gap?.level ?? density.level),
395
+ sibling: Number(cfg.gap?.sibling ?? density.sibling),
396
+ branch: Number(cfg.gap?.branch ?? density.branch),
397
+ }
398
+ const pad = {
399
+ x: Number(cfg.padding?.x ?? density.padX),
400
+ y: Number(cfg.padding?.y ?? density.padY),
401
+ }
402
+ const bg = isTransparent(cfg.background) ? 'transparent' : (cfg.background ?? theme.bg)
403
+ const paper = isTransparent(bg) ? '#ffffff' : bg
404
+ const palette = Array.isArray(cfg.palette) && cfg.palette.length > 0 ? cfg.palette : theme.palette
405
+ const defaultShape = SHAPES.has(cfg.shape) ? cfg.shape : 'rounded'
406
+ const base = [
407
+ { size: fs * 1.42, weight: 700, shape: 'pill', fill: theme.root, color: readableOn(theme.root), stroke: 'none', sw: 0, radius: 999 },
408
+ { size: fs * 1.1, weight: 650, shape: 'pill', fill: 'auto', color: 'auto', stroke: 'auto', sw: 1.4, radius: 999 },
409
+ { size: fs * 1.0, weight: 500, shape: defaultShape, fill: 'none', color: 'auto', stroke: 'none', sw: 0, radius: Number(cfg.radius ?? 9) },
410
+ { size: fs * 0.93, weight: 400, shape: 'none', fill: 'none', color: theme.muted, stroke: 'none', sw: 0, radius: Number(cfg.radius ?? 9) },
411
+ ]
412
+ const declared = Array.isArray(cfg.levels) ? cfg.levels : []
413
+ const levels = base.map((b, i) => ({ ...b, ...(declared[i] ?? {}) }))
414
+ const classes = cfg.classes && typeof cfg.classes === 'object' ? cfg.classes : {}
415
+ return { theme, paper, fs, gap, pad, bg, palette, levels, classes, defaultShape }
416
+ }
417
+
418
+ function branchColorOf(node, st) {
419
+ const owner = node.top === node ? node : node.top
420
+ return st.palette[(owner?.index ?? 0) % st.palette.length]
421
+ }
422
+
423
+ function resolveStyle(node, st) {
424
+ const depth = node.depth
425
+ const branch = branchColorOf(node, st)
426
+ const lt = st.levels[Math.min(depth, st.levels.length - 1)]
427
+ const style = {
428
+ size: Number(lt.size),
429
+ weight: Number(lt.weight),
430
+ shape: lt.shape === 'auto' || lt.shape === undefined ? st.defaultShape : lt.shape,
431
+ fill: lt.fill,
432
+ stroke: lt.stroke,
433
+ sw: Number(lt.sw ?? 0),
434
+ color: lt.color,
435
+ radius: Number(lt.radius ?? 9),
436
+ italic: lt.italic === true,
437
+ padX: lt.padX === undefined ? undefined : Number(lt.padX),
438
+ padY: lt.padY === undefined ? undefined : Number(lt.padY),
439
+ maxWidth: lt.maxWidth === undefined ? undefined : Number(lt.maxWidth),
440
+ }
441
+ if (style.fill === 'auto' || style.fill == null || style.fill === '') {
442
+ style.fill = depth === 0 ? st.theme.root : mix(branch, st.paper, 0.87)
443
+ }
444
+ if (style.stroke === 'auto') style.stroke = branch
445
+ if (style.color === 'auto' || style.color == null) {
446
+ style.color = depth === 0 ? readableOn(style.fill) : mix(branch, st.theme.ink, 0.42)
447
+ }
448
+ if (typeof style.fill !== 'string') style.fill = 'none'
449
+ if (typeof style.stroke !== 'string') style.stroke = 'none'
450
+ for (const name of node.classes ?? []) {
451
+ const c = st.classes[name]
452
+ if (!c) continue
453
+ for (const [k, v] of Object.entries(c)) {
454
+ if (k === 'size' || k === 'sw' || k === 'radius') style[k] = Number(v)
455
+ else if (k === 'italic') style[k] = v === true || v === 'true'
456
+ else style[k] = v
457
+ }
458
+ }
459
+ if (node.shape !== null) style.shape = node.shape
460
+ if (style.shape === 'circle') style.radius = 999
461
+ style.branch = branch
462
+ return style
463
+ }
464
+
465
+ // ════════════════════════════════════════════════════════════════════════════
466
+ // 6. 度量 + 布局
467
+ // ════════════════════════════════════════════════════════════════════════════
468
+ function measure(root, cfg, st) {
469
+ const maxW = Number(cfg.maxNodeWidth ?? 240)
470
+ const rootMaxW = Number(cfg.rootMaxWidth ?? maxW * 2)
471
+ const lh = Number(cfg.font?.lineHeight ?? 1.32)
472
+ const walk = (node) => {
473
+ node.style = resolveStyle(node, st)
474
+ const padX = node.style.padX ?? st.pad.x
475
+ const padY = node.style.padY ?? st.pad.y
476
+ const budget = node.depth === 0 ? Math.max(rootMaxW, maxW) : (node.style.maxWidth ?? maxW)
477
+ const prefixText = node.numberPrefix ? `${node.numberPrefix} ` : ''
478
+ node.lines = wrapText(prefixText + node.text, Math.max(40, budget - padX * 2), node.style.size)
479
+ const wMax = Math.max(...node.lines.map((l) => textWidth(l, node.style.size)), 8)
480
+ node.tw = wMax
481
+ node.th = node.lines.length * node.style.size * lh
482
+ node.w = wMax + padX * 2
483
+ node.h = node.th + padY * 2
484
+ if (node.style.shape === 'none' || node.style.shape === 'underline') {
485
+ node.w = wMax + padX * 0.5
486
+ node.h = node.th + 2
487
+ } else if (node.style.shape === 'pill' || node.style.shape === 'circle') {
488
+ // 胶囊/圆的端头是半圆,水平内边距必须至少 h/2 才不会让字顶出弧线
489
+ node.w = Math.max(node.w, wMax + node.h * 1.02)
490
+ }
491
+ node.children.forEach(walk)
492
+ }
493
+ const number = (node, prefix) => {
494
+ node.numberPrefix = ''
495
+ if (cfg.numbering === 'circled' && node.depth === 1) node.numberPrefix = CIRCLED[node.index] ?? `${node.index + 1}`
496
+ if (cfg.numbering === 'arabic') node.numberPrefix = prefix === '' ? `${node.index + 1}` : `${prefix}.${node.index + 1}`
497
+ if (cfg.numbering === 'path' && node.depth >= 1) node.numberPrefix = `${prefix}${node.index + 1}`
498
+ node.children.forEach((c) => number(c, node.depth === 0 ? '' : (node.numberPrefix || `${node.index + 1}`)))
499
+ }
500
+ number(root, '')
501
+ walk(root)
502
+ return { lh }
503
+ }
504
+
505
+ const collect = (node, acc = []) => {
506
+ acc.push(node)
507
+ for (const c of node.children) collect(c, acc)
508
+ return acc
509
+ }
510
+
511
+ /**
512
+ * 横向布局(right / left / both)。
513
+ * 主轴:depth 方向(x);副轴:兄弟方向(y)。
514
+ * 主轴用「父节点外侧边 + 层间距 + 自身半宽」累加,这样父宽度不影响子层对齐,
515
+ * 但父→子的连线不会穿进自己的框。
516
+ */
517
+ function layoutHorizontal(root, st, mode) {
518
+ const kids = root.children
519
+ let groups
520
+ if (mode === 'both') {
521
+ const half = Math.ceil(kids.length / 2)
522
+ groups = [
523
+ { side: 'right', nodes: kids.slice(0, half) },
524
+ { side: 'left', nodes: kids.slice(half) },
525
+ ]
526
+ for (const g of groups) for (const n of g.nodes) markSide(n, g.side)
527
+ } else {
528
+ const side = mode === 'left' ? 'left' : 'right'
529
+ groups = [{ side, nodes: kids }]
530
+ for (const n of kids) markSide(n, side)
531
+ }
532
+ root.side = mode === 'left' ? 'left' : 'right'
533
+
534
+ const maxAtDepth = {}
535
+ for (const n of collect(root)) maxAtDepth[n.depth] = Math.max(maxAtDepth[n.depth] ?? 0, n.w)
536
+
537
+ for (const g of groups) {
538
+ if (g.nodes.length === 0) continue
539
+ const ext = (n) => {
540
+ const kids2 = n.children
541
+ const total = kids2.reduce((s, k) => s + ext(k), 0) + Math.max(0, kids2.length - 1) * st.gap.sibling
542
+ n.kidsTotal = total
543
+ n.ext = Math.max(n.h, total)
544
+ return n.ext
545
+ }
546
+ const groupTotal = g.nodes.reduce((s, k) => s + ext(k), 0) + Math.max(0, g.nodes.length - 1) * st.gap.branch
547
+ let cursor = -groupTotal / 2
548
+ const placeCross = (n, start) => {
549
+ n.cy = start + n.ext / 2
550
+ let c = start + (n.ext - n.kidsTotal) / 2
551
+ for (const k of n.children) { placeCross(k, c); c += k.ext + st.gap.sibling }
552
+ }
553
+ for (const k of g.nodes) { placeCross(k, cursor); cursor += k.ext + st.gap.branch }
554
+
555
+ const walkMain = (node, parent) => {
556
+ const dir = g.side === 'left' ? -1 : 1
557
+ if (parent === root) {
558
+ node.cx = dir * (root.w / 2 + st.gap.level + node.w / 2)
559
+ } else {
560
+ node.cx = parent.cx + dir * (parent.w / 2 + st.gap.level + node.w / 2)
561
+ }
562
+ node.children.forEach((c) => walkMain(c, node))
563
+ }
564
+ for (const k of g.nodes) walkMain(k, root)
565
+ }
566
+ root.cx = 0
567
+ root.cy = 0
568
+ // 上下居中:把整幅按 y 归零
569
+ const ys = collect(root).map((n) => n.cy)
570
+ const mid = (Math.min(...ys) + Math.max(...ys)) / 2
571
+ for (const n of collect(root)) n.cy -= mid
572
+ return { vertical: false }
573
+ }
574
+
575
+ function markSide(node, side) {
576
+ node.side = side
577
+ node.children.forEach((c) => markSide(c, side))
578
+ }
579
+
580
+ /** 纵向布局(tree / org):主轴是 y,副轴是 x。文字仍然横排。 */
581
+ function layoutVertical(root, st) {
582
+ for (const n of collect(root)) n.side = 'right'
583
+ const ext = (n) => {
584
+ const total = n.children.reduce((s, k) => s + ext(k), 0) + Math.max(0, n.children.length - 1) * st.gap.sibling
585
+ n.kidsTotal = total
586
+ n.ext = Math.max(n.w, total)
587
+ return n.ext
588
+ }
589
+ const total = ext(root)
590
+ const placeCross = (n, start) => {
591
+ n.cx = start + n.ext / 2
592
+ let c = start + (n.ext - n.kidsTotal) / 2
593
+ for (const k of n.children) { placeCross(k, c); c += k.ext + st.gap.sibling }
594
+ }
595
+ placeCross(root, -total / 2)
596
+ const walkMain = (node, parent) => {
597
+ node.cy = parent === null ? 0 : parent.cy + parent.h / 2 + st.gap.level + node.h / 2
598
+ node.children.forEach((c) => walkMain(c, node))
599
+ }
600
+ walkMain(root, null)
601
+ const xs = collect(root).map((n) => n.cx)
602
+ const mid = (Math.min(...xs) + Math.max(...xs)) / 2
603
+ for (const n of collect(root)) n.cx -= mid
604
+ return { vertical: true }
605
+ }
606
+
607
+ // ════════════════════════════════════════════════════════════════════════════
608
+ // 7. SVG
609
+ // ════════════════════════════════════════════════════════════════════════════
610
+ const esc = (s) => String(s).replace(/&/gu, '&amp;').replace(/</gu, '&lt;').replace(/>/gu, '&gt;').replace(/"/gu, '&quot;')
611
+ const n2 = (v) => Math.round(v * 100) / 100
612
+
613
+ function nodeAttrs(n) {
614
+ const x = n2(n.cx - n.w / 2)
615
+ const y = n2(n.cy - n.h / 2)
616
+ return { x, y, w: n2(n.w), h: n2(n.h) }
617
+ }
618
+
619
+ function shapeMarkup(n) {
620
+ const s = n.style
621
+ const { x, y, w, h } = nodeAttrs(n)
622
+ if (s.shape === 'none' || s.shape === 'underline') {
623
+ if (s.shape !== 'underline') return ''
624
+ const yy = n2(n.cy + n.h / 2)
625
+ return `<line x1="${n2(n.cx - n.w / 2)}" y1="${yy}" x2="${n2(n.cx + n.w / 2)}" y2="${yy}" stroke="${s.branch}" stroke-width="1.1" opacity="0.5" stroke-linecap="round"/>`
626
+ }
627
+ const strokeAttr = s.sw > 0 && s.stroke !== 'none' ? ` stroke="${s.stroke}" stroke-width="${n2(s.sw)}"` : ' stroke="none"'
628
+ const fillAttr = ` fill="${s.fill}"`
629
+ let d
630
+ if (s.shape === 'circle' || s.shape === 'pill') {
631
+ const rx = w / 2
632
+ const ry = h / 2
633
+ d = `M${n2(x)},${n2(y + ry)} a${n2(rx)},${n2(ry)} 0 1 0 ${n2(w)},0 a${n2(rx)},${n2(ry)} 0 1 0 ${n2(-w)},0 Z`
634
+ } else if (s.shape === 'hex') {
635
+ const inset = Math.min(h * 0.45, w * 0.16)
636
+ d = `M${n2(x + inset)},${n2(y)} H${n2(x + w - inset)} L${n2(x + w)},${n2(y + h / 2)} L${n2(x + w - inset)},${n2(y + h)} H${n2(x + inset)} L${n2(x)},${n2(y + h / 2)} Z`
637
+ } else {
638
+ const r = Math.max(0, Math.min(s.shape === 'rect' ? 0 : s.radius, w / 2, h / 2))
639
+ if (r < 0.6) d = `M${n2(x)},${n2(y)} h${n2(w)} v${n2(h)} h${n2(-w)} Z`
640
+ else d = `M${n2(x + r)},${n2(y)} h${n2(w - 2 * r)} a${n2(r)},${n2(r)} 0 0 1 ${n2(r)},${n2(r)} v${n2(h - 2 * r)} a${n2(r)},${n2(r)} 0 0 1 ${n2(-r)},${n2(r)} h${n2(-(w - 2 * r))} a${n2(r)},${n2(r)} 0 0 1 ${n2(-r)},${n2(-r)} v${n2(-(h - 2 * r))} a${n2(r)},${n2(r)} 0 0 1 ${n2(r)},${n2(-r)} Z`
641
+ }
642
+ return `<path d="${d}"${fillAttr}${strokeAttr}/>`
643
+ }
644
+
645
+ function edgePath(pa, ch, connector, vertical) {
646
+ if (vertical) {
647
+ const sy = pa.cy + pa.h / 2
648
+ const ey = ch.cy - ch.h / 2
649
+ const sx = pa.cx
650
+ const ex = ch.cx
651
+ const dy = ey - sy
652
+ if (connector === 'straight') return `M${n2(sx)},${n2(sy)} L${n2(ex)},${n2(ey)}`
653
+ if (connector === 'elbow') return `M${n2(sx)},${n2(sy)} V${n2(sy + dy / 2)} H${n2(ex)} V${n2(ey)}`
654
+ const c = Math.max(8, Math.abs(dy) * 0.45)
655
+ return `M${n2(sx)},${n2(sy)} C${n2(sx)},${n2(sy + c)} ${n2(ex)},${n2(ey - c)} ${n2(ex)},${n2(ey)}`
656
+ }
657
+ const dir = ch.side === 'left' ? -1 : 1
658
+ const sx = pa.cx + (dir > 0 ? pa.w / 2 : -pa.w / 2)
659
+ const ex = ch.cx - (dir > 0 ? ch.w / 2 : -ch.w / 2)
660
+ const sy = pa.cy
661
+ const ey = ch.cy
662
+ const dx = ex - sx
663
+ if (connector === 'straight') return `M${n2(sx)},${n2(sy)} L${n2(ex)},${n2(ey)}`
664
+ if (connector === 'elbow') return `M${n2(sx)},${n2(sy)} H${n2(sx + dx / 2)} V${n2(ey)} H${n2(ex)}`
665
+ if (connector === 'arc') {
666
+ const r = Math.max(Math.abs(dx) * 0.9, Math.abs(ey - sy) * 1.1, 10)
667
+ return `M${n2(sx)},${n2(sy)} A${n2(r)},${n2(r)} 0 0 ${dir > 0 ? 1 : 0} ${n2(ex)},${n2(ey)}`
668
+ }
669
+ const c = Math.max(10, Math.abs(dx) * 0.42)
670
+ return `M${n2(sx)},${n2(sy)} C${n2(sx + dir * c)},${n2(sy)} ${n2(ex - dir * c)},${n2(ey)} ${n2(ex)},${n2(ey)}`
671
+ }
672
+
673
+ function render(root, cfg, st, geo) {
674
+ const all = collect(root)
675
+ const connector = CONNECTORS.has(cfg.connector) ? cfg.connector : 'curve'
676
+ const family = String(cfg.font?.family ?? "'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', -apple-system, 'Segoe UI', sans-serif")
677
+ const lh = Number(cfg.font?.lineHeight ?? 1.32)
678
+ const pad = Number(cfg.canvasPad ?? 26)
679
+
680
+ let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity
681
+ for (const n of all) {
682
+ minX = Math.min(minX, n.cx - n.w / 2)
683
+ maxX = Math.max(maxX, n.cx + n.w / 2)
684
+ minY = Math.min(minY, n.cy - n.h / 2)
685
+ maxY = Math.max(maxY, n.cy + n.h / 2)
686
+ }
687
+ const contentW = maxX - minX
688
+ const contentH = maxY - minY
689
+
690
+ // 页眉页脚在**绝对坐标**里画,避免与 group 平移叠算
691
+ const titleSize = st.fs * 1.5
692
+ const subSize = st.fs * 0.95
693
+ const hasTitle = typeof cfg.title === 'string' && cfg.title !== ''
694
+ const hasSub = typeof cfg.subtitle === 'string' && cfg.subtitle !== ''
695
+ const hasFoot = typeof cfg.footnote === 'string' && cfg.footnote !== ''
696
+ let header = 0
697
+ if (hasTitle) header += titleSize * 1.05
698
+ if (hasSub) header += subSize * 1.5
699
+ if (hasTitle || hasSub) header += pad * 0.5
700
+ const footH = hasFoot ? st.fs * 2.1 + pad * 0.4 : 0
701
+
702
+ const W = Math.max(contentW + pad * 2, hasTitle ? textWidth(cfg.title, titleSize) * 0.55 + pad * 2 : 0)
703
+ const H = contentH + pad * 2 + header + footH
704
+ const offsetX = (W - contentW) / 2 - minX
705
+ const offsetY = pad + header - minY
706
+
707
+ const out = []
708
+ out.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${n2(W)}" height="${n2(H)}" viewBox="0 0 ${n2(W)} ${n2(H)}" role="img" aria-label="${esc(cfg.title ?? (root.text || 'mindmap'))}">`)
709
+ out.push(`<title>${esc(cfg.title ?? (root.text || 'mindmap'))}</title>`)
710
+ if (cfg.shadow === true) {
711
+ out.push('<defs><filter id="mm-shadow" x="-25%" y="-25%" width="150%" height="150%"><feDropShadow dx="0" dy="1.4" stdDeviation="1.8" flood-color="#0f1926" flood-opacity="0.15"/></filter></defs>')
712
+ }
713
+ if (st.bg !== 'transparent') out.push(`<rect width="${n2(W)}" height="${n2(H)}" fill="${st.bg}"/>`)
714
+ if (cfg.border === true) out.push(`<rect x="0.75" y="0.75" width="${n2(W - 1.5)}" height="${n2(H - 1.5)}" rx="10" fill="none" stroke="${st.theme.rule}" stroke-width="1.5"/>`)
715
+
716
+ if (hasTitle) {
717
+ const ty = pad * 0.9 + titleSize * 0.82
718
+ out.push(`<text x="${n2(W / 2)}" y="${n2(ty)}" text-anchor="middle" font-family="${esc(family)}" font-size="${n2(titleSize)}" font-weight="700" fill="${st.theme.ink}">${esc(cfg.title)}</text>`)
719
+ if (hasSub) {
720
+ out.push(`<text x="${n2(W / 2)}" y="${n2(ty + subSize * 1.55)}" text-anchor="middle" font-family="${esc(family)}" font-size="${n2(subSize)}" fill="${st.theme.muted}">${esc(cfg.subtitle)}</text>`)
721
+ }
722
+ }
723
+
724
+ out.push(`<g transform="translate(${n2(offsetX)},${n2(offsetY)})" font-family="${esc(family)}">`)
725
+
726
+ const drawEdge = (n) => {
727
+ for (const c of n.children) {
728
+ const ew = Array.isArray(cfg.edgeWidth)
729
+ ? Number(cfg.edgeWidth[Math.min(c.depth - 1, cfg.edgeWidth.length - 1)])
730
+ : (c.depth === 1 ? 3 : c.depth === 2 ? 2 : 1.35)
731
+ const color = c.depth === 1 ? c.style.branch : mix(c.style.branch, st.paper, c.depth === 2 ? 0.16 : 0.46)
732
+ out.push(`<path d="${edgePath(n, c, connector, geo.vertical)}" fill="none" stroke="${color}" stroke-width="${n2(ew)}" stroke-linecap="round" opacity="${c.depth >= 3 ? 0.85 : 1}"/>`)
733
+ drawEdge(c)
734
+ }
735
+ }
736
+ drawEdge(root)
737
+
738
+ for (const n of all) {
739
+ const s = n.style
740
+ if (s.fill !== 'none' || s.sw > 0 || s.shape === 'underline') {
741
+ const mark = shapeMarkup(n)
742
+ if (mark !== '') out.push(mark)
743
+ }
744
+ const blockH = n.lines.length * s.size * lh
745
+ let baseline = n.cy - blockH / 2 + s.size * (0.5 * lh + 0.34)
746
+ const weight = Number.isFinite(s.weight) ? s.weight : 400
747
+ const italic = s.italic ? ' font-style="italic"' : ''
748
+ const letter = s.letterSpacing !== undefined ? ` letter-spacing="${s.letterSpacing}"` : ''
749
+ for (const line of n.lines) {
750
+ out.push(`<text x="${n2(n.cx)}" y="${n2(baseline)}" text-anchor="middle" font-size="${n2(s.size)}" font-weight="${weight}"${italic}${letter} fill="${s.color}">${esc(line)}</text>`)
751
+ baseline += s.size * lh
752
+ }
753
+ }
754
+ out.push('</g>')
755
+
756
+ if (hasFoot) {
757
+ out.push(`<text x="${n2(W / 2)}" y="${n2(H - pad * 0.6)}" text-anchor="middle" font-family="${esc(family)}" font-size="${n2(st.fs * 0.82)}" fill="${st.theme.muted}">${esc(cfg.footnote)}</text>`)
758
+ }
759
+ out.push('</svg>')
760
+ return { svg: out.join('\n'), width: W, height: H }
761
+ }
762
+
763
+ // ════════════════════════════════════════════════════════════════════════════
764
+ // 8. CLI
765
+ // ════════════════════════════════════════════════════════════════════════════
766
+ const HELP = `思维导图渲染器(零依赖,缩进大纲 → SVG)
767
+
768
+ node render.mjs <源文件.mmd|.txt> [选项]
769
+
770
+ 选项
771
+ --out <路径> 输出 SVG(缺省与源文件同名 .svg)
772
+ --theme <名> paper | aero | ink | warm | forest | slate | contrast
773
+ --layout <名> right | left | both | tree
774
+ --connector <名> curve | elbow | straight | arc
775
+ --density <名> compact | normal | roomy
776
+ --title <文本> 画布标题(覆盖前置配置)
777
+ --transparent 无底色(跟随阅读器主题)
778
+ --scale <系数> 整体字号系数(1 = 原样)
779
+ --json 只打印统计,不写文件
780
+ -h, --help 本帮助
781
+
782
+ 前置配置(YAML,夹在 --- 之间)见同级 SKILL.md 的「配置项总表」。`
783
+
784
+ function parseArgs(argv) {
785
+ const opts = { file: null }
786
+ for (let i = 0; i < argv.length; i += 1) {
787
+ const a = argv[i]
788
+ const next = () => { i += 1; return argv[i] }
789
+ switch (a) {
790
+ case '-h': case '--help': opts.help = true; break
791
+ case '--out': opts.out = next(); break
792
+ case '--theme': opts.theme = next(); break
793
+ case '--layout': opts.layout = next(); break
794
+ case '--connector': opts.connector = next(); break
795
+ case '--density': opts.density = next(); break
796
+ case '--title': opts.title = next(); break
797
+ case '--scale': opts.scale = Number(next()); break
798
+ case '--transparent': opts.transparent = true; break
799
+ case '--json': opts.json = true; break
800
+ default:
801
+ if (a.startsWith('--')) throw new Error(`未知选项:${a}`)
802
+ if (opts.file === null) opts.file = a
803
+ else throw new Error(`多余参数:${a}`)
804
+ }
805
+ }
806
+ return opts
807
+ }
808
+
809
+ function main() {
810
+ let opts
811
+ try { opts = parseArgs(process.argv.slice(2)) } catch (e) { console.error(String(e.message ?? e)); process.exitCode = 2; return }
812
+ if (opts.help === true) { console.log(HELP); return }
813
+ if (opts.file === null) { console.error(HELP); process.exitCode = 2; return }
814
+
815
+ const abs = path.resolve(process.cwd(), opts.file)
816
+ const { config, body } = splitFrontMatter(readFileSync(abs, 'utf8'))
817
+ const cfg = { ...config }
818
+ for (const k of ['theme', 'layout', 'connector', 'density', 'title', 'scale']) {
819
+ if (opts[k] !== undefined) cfg[k] = opts[k]
820
+ }
821
+ if (opts.transparent === true) cfg.background = 'transparent'
822
+ if (cfg.theme !== undefined && THEMES[cfg.theme] === undefined) throw new Error(`未知 theme:${cfg.theme}(可选 ${Object.keys(THEMES).join(' / ')})`)
823
+ if (cfg.layout !== undefined && !LAYOUTS.has(cfg.layout)) throw new Error(`未知 layout:${cfg.layout}(可选 ${[...LAYOUTS].join(' / ')})`)
824
+ if (cfg.connector !== undefined && !CONNECTORS.has(cfg.connector)) throw new Error(`未知 connector:${cfg.connector}(可选 ${[...CONNECTORS].join(' / ')})`)
825
+
826
+ const st = buildStyles(cfg)
827
+ const parsed = parseSource(body)
828
+ const tree = decorate(parsed.root, 0, null)
829
+ measure(tree, cfg, st)
830
+ const mode = cfg.layout ?? 'right'
831
+ const geo = mode === 'tree' ? layoutVertical(tree, st) : layoutHorizontal(tree, st, mode)
832
+ const { svg, width, height } = render(tree, cfg, st, geo)
833
+ const all = collect(tree)
834
+ const stats = {
835
+ nodes: all.length,
836
+ depth: Math.max(...all.map((n) => n.depth)) + 1,
837
+ width: Math.round(width),
838
+ height: Math.round(height),
839
+ theme: st.theme === THEMES[cfg.theme] ? (cfg.theme ?? 'paper') : (cfg.theme ?? 'paper'),
840
+ layout: mode,
841
+ connector: cfg.connector ?? 'curve',
842
+ }
843
+ if (opts.json === true) { console.log(JSON.stringify(stats, null, 2)); return }
844
+ const out = path.resolve(process.cwd(), opts.out ?? `${abs.replace(/\.[^./\\]+$/u, '')}.svg`)
845
+ mkdirSync(path.dirname(out), { recursive: true })
846
+ writeFileSync(out, svg, 'utf8')
847
+ console.log(`${path.basename(out)} ${stats.width}×${stats.height} ${stats.nodes} 节点 / ${stats.depth} 层 theme=${stats.theme} layout=${mode} connector=${stats.connector}`)
848
+ }
849
+
850
+ try {
851
+ main()
852
+ } catch (error) {
853
+ console.error(`渲染失败:${error instanceof Error ? error.message : String(error)}`)
854
+ process.exitCode = 1
855
+ }