mathtext2doc 0.1.0__py3-none-any.whl

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.
mathtext2doc/parser.py ADDED
@@ -0,0 +1,809 @@
1
+ """文本解析器。
2
+
3
+ 输入:UTF-8 纯文本,可能包含中文、基础 Markdown、$...$ / $$...$$ LaTeX 公式、
4
+ @plot{...} 绘图指令。
5
+
6
+ 输出:一个 Document AST,由若干 Block 节点构成:
7
+ - Heading(level, text)
8
+ - Paragraph(segments) 段落,segments 是 Inline 列表
9
+ - ListItem(segments)
10
+ - Table(header, rows)
11
+ - DisplayMath(latex) 块级公式 $$...$$
12
+ - Plot(items) @plot{...},items 是 PlotItem 列表
13
+ - BlankLine()
14
+
15
+ Inline 节点:
16
+ - Text(str) 普通文本(已经过 LaTeX 转义)
17
+ - InlineMath(latex) 行内公式 $...$
18
+ - Bold(segments) **粗体**
19
+ - Code(str) `代码`(基础支持)
20
+
21
+ PlotItem:
22
+ - kind: "explicit" | "implicit"
23
+ - expr: 原始表达式字符串(y = sin(x) 或 x^2 + y^2 = 1)
24
+ - x_range: (xmin, xmax) sympy 表达式或浮点
25
+ - y_range: (ymin, ymax) | None
26
+ - label: str | None
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import re
32
+ from dataclasses import dataclass, field
33
+ from typing import List, Optional, Tuple, Union
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # AST 节点定义
38
+ # ---------------------------------------------------------------------------
39
+
40
+ @dataclass
41
+ class Text:
42
+ text: str
43
+
44
+
45
+ @dataclass
46
+ class InlineMath:
47
+ latex: str
48
+
49
+
50
+ @dataclass
51
+ class Bold:
52
+ segments: list
53
+
54
+
55
+ @dataclass
56
+ class Italic:
57
+ segments: list
58
+
59
+
60
+ @dataclass
61
+ class Code:
62
+ code: str
63
+
64
+
65
+ @dataclass
66
+ class Heading:
67
+ level: int # 1~4(# / ## / ### / ####)
68
+ segments: list
69
+
70
+
71
+ @dataclass
72
+ class Paragraph:
73
+ segments: list
74
+
75
+
76
+ @dataclass
77
+ class ListItem:
78
+ segments: list
79
+ ordered: bool = False # 是否有序列表项
80
+ order_num: int = 0 # 有序列表序号(1, 2, 3...)
81
+
82
+
83
+ @dataclass
84
+ class Blockquote:
85
+ """引用块 > text,可含多段。blocks 为嵌套的 Block 列表。"""
86
+ blocks: list
87
+
88
+
89
+ @dataclass
90
+ class Table:
91
+ header: List[list] # 每个单元格是 inline 列表
92
+ rows: List[List[list]]
93
+
94
+
95
+ @dataclass
96
+ class DisplayMath:
97
+ latex: str
98
+
99
+
100
+ @dataclass
101
+ class PlotItem:
102
+ """@plot 的一个绘制项。
103
+
104
+ 几何图形:kind="shape", shape="point|segment|line|circle|ellipse|polygon|rectangle|vector"
105
+ 函数曲线:kind="explicit|implicit",expr 为表达式字符串
106
+ """
107
+ kind: str # "explicit" | "implicit" | "shape"
108
+ expr: Optional[str] = None # 函数曲线的原始表达式
109
+ x_range: Optional[Tuple[object, object]] = None
110
+ y_range: Optional[Tuple[object, object]] = None
111
+ label: Optional[str] = None
112
+ # 几何图形专用字段
113
+ shape: Optional[str] = None # point/segment/line/circle/ellipse/polygon/rectangle/vector
114
+ # 通用 dict 存几何参数:at/from/to/center/r/a/b/points/origin/w/h 等
115
+ # 值可能是 tuple(坐标)或 float(标量)或 list[tuple](点列表)
116
+ params: dict = field(default_factory=dict)
117
+
118
+
119
+ @dataclass
120
+ class Plot:
121
+ items: List[PlotItem] = field(default_factory=list)
122
+ width: Optional[float] = None # 单图宽度覆盖(0~1,相对于 paperwidth)
123
+ align: Optional[str] = None # 单图对齐:'left' | 'center' | None(居中)
124
+
125
+
126
+ @dataclass
127
+ class BlankLine:
128
+ pass
129
+
130
+
131
+ @dataclass
132
+ class HorizontalRule:
133
+ """分隔线 --- 或 *** 或 ___"""
134
+ pass
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # LaTeX 转义
139
+ # ---------------------------------------------------------------------------
140
+
141
+ # 普通文本里需要转义的 LaTeX 特殊字符。
142
+ # 注意:我们只对纯文本段落做转义,不转义 $...$ / $$...$$ / @plot 内部。
143
+ _LATEX_ESCAPE_MAP = [
144
+ ("\\", r"\textbackslash{}"),
145
+ ("&", r"\&"),
146
+ ("%", r"\%"),
147
+ ("$", r"\$"),
148
+ ("#", r"\#"),
149
+ ("_", r"\_"),
150
+ ("{", r"\{"),
151
+ ("}", r"\}"),
152
+ ("~", r"\textasciitilde{}"),
153
+ ("^", r"\textasciicircum{}"),
154
+ ]
155
+
156
+
157
+ def latex_escape(s: str) -> str:
158
+ """转义普通文本中的 LaTeX 特殊字符。"""
159
+ # 反斜杠必须先转义,否则会破坏后续转义。
160
+ for ch, rep in _LATEX_ESCAPE_MAP:
161
+ s = s.replace(ch, rep)
162
+ return s
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # @plot 指令解析
167
+ # ---------------------------------------------------------------------------
168
+
169
+ # @plot{ ... } 或 @plot(width=0.5){ ... } 或 @plot(align=left){ ... }
170
+ # @geometry{ ... } 是 @plot 的别名,语义相同,用于纯几何场景
171
+ # 选项语法:(width=0.5, align=left) 或 (align=left) 或 (width=0.5) 或无
172
+ # width: 0~1,图宽占 paperwidth 的比例
173
+ # align: left | center(默认 center)
174
+ _PLOT_RE = re.compile(
175
+ r"@(plot|geometry)" # 关键字
176
+ r"(?:\(\s*([^)]*)\))?" # 可选的 (...) 选项
177
+ r"\{(.*?)\}", # { body }
178
+ re.DOTALL,
179
+ )
180
+
181
+
182
+ def _parse_plot_items(body: str) -> List[PlotItem]:
183
+ """解析 @plot{...} 内部,按 ; 分隔多个绘制项。"""
184
+ items: List[PlotItem] = []
185
+ # 按分号分隔,但要跳过引号内的分号(虽然一般 label 里不会有分号,稳妥起见)。
186
+ parts = []
187
+ buf = []
188
+ in_str = False
189
+ for ch in body:
190
+ if ch == '"':
191
+ in_str = not in_str
192
+ buf.append(ch)
193
+ elif ch == ";" and not in_str:
194
+ parts.append("".join(buf))
195
+ buf = []
196
+ else:
197
+ buf.append(ch)
198
+ if buf:
199
+ parts.append("".join(buf))
200
+
201
+ for raw in parts:
202
+ s = raw.strip()
203
+ if not s:
204
+ continue
205
+ item = _parse_one_plot_item(s)
206
+ items.append(item)
207
+ return items
208
+
209
+
210
+ def _parse_one_plot_item(s: str) -> PlotItem:
211
+ """解析单个绘制项。
212
+
213
+ 支持两种语法:
214
+
215
+ 1. 函数曲线(向后兼容):
216
+ y = sin(x), x in [-pi, pi], label="sin(x)"
217
+ x^2 + y^2 = 1, x in [-2, 2], y in [-2, 2], label="单位圆"
218
+
219
+ 2. 几何图形(新增):
220
+ shape=point, at=(1, 2), label="A"
221
+ shape=segment, from=(0, 0), to=(3, 4), label="AB"
222
+ shape=circle, center=(0, 0), r=1, label="C"
223
+ shape=ellipse, center=(0, 0), a=2, b=1, label="E"
224
+ shape=polygon, points=[(0,0), (1,0), (0.5, 1)], label="△"
225
+ shape=rectangle, origin=(0, 0), w=2, h=1, label="R"
226
+ shape=vector, from=(0, 0), to=(2, 1), label="v"
227
+ shape=line, from=(0, 0), to=(3, 4), label="L"
228
+
229
+ 几何图形的 x in / y in 是可选的(不指定时从图形自动估算)。
230
+ """
231
+ # 拆分时跳过 [...]、(...) 和 "..." 内的逗号。
232
+ segs = []
233
+ buf = []
234
+ in_str = False
235
+ bracket = 0 # [] 计数
236
+ paren = 0 # () 计数
237
+ for ch in s:
238
+ if ch == '"':
239
+ in_str = not in_str
240
+ buf.append(ch)
241
+ elif ch == "[" and not in_str:
242
+ bracket += 1
243
+ buf.append(ch)
244
+ elif ch == "]" and not in_str:
245
+ bracket = max(0, bracket - 1)
246
+ buf.append(ch)
247
+ elif ch == "(" and not in_str:
248
+ paren += 1
249
+ buf.append(ch)
250
+ elif ch == ")" and not in_str:
251
+ paren = max(0, paren - 1)
252
+ buf.append(ch)
253
+ elif ch == "," and not in_str and bracket == 0 and paren == 0:
254
+ segs.append("".join(buf).strip())
255
+ buf = []
256
+ else:
257
+ buf.append(ch)
258
+ if buf:
259
+ segs.append("".join(buf).strip())
260
+
261
+ if not segs:
262
+ raise PlotParseError(f"空的绘图项:{s!r}")
263
+
264
+ first = segs[0]
265
+
266
+ # ---------- 分支 1:几何图形 ----------
267
+ m_shape = re.match(r"shape\s*=\s*(\w+)\s*$", first)
268
+ if m_shape:
269
+ return _parse_shape_item(m_shape.group(1), segs[1:], s)
270
+
271
+ # ---------- 分支 2:函数曲线 ----------
272
+ expr = first
273
+ x_range = None
274
+ y_range = None
275
+ label = None
276
+
277
+ for seg in segs[1:]:
278
+ if not seg:
279
+ continue
280
+ m = re.match(r"x\s+in\s+\[(.*)\]\s*$", seg)
281
+ if m:
282
+ x_range = _parse_range(m.group(1), axis="x")
283
+ continue
284
+ m = re.match(r"y\s+in\s+\[(.*)\]\s*$", seg)
285
+ if m:
286
+ y_range = _parse_range(m.group(1), axis="y")
287
+ continue
288
+ m = re.match(r'label\s*=\s*"(.*)"\s*$', seg)
289
+ if m:
290
+ label = m.group(1)
291
+ continue
292
+ raise PlotParseError(f"无法识别的绘图参数:{seg!r}(在项 {s!r} 中)")
293
+
294
+ if x_range is None:
295
+ raise PlotParseError(f"绘图项缺少 x in [...] 定义域:{s!r}")
296
+
297
+ # 判定显函数 / 隐函数
298
+ if re.match(r"^\s*y\s*=", expr):
299
+ kind = "explicit"
300
+ elif "=" in expr:
301
+ kind = "implicit"
302
+ if y_range is None:
303
+ raise PlotParseError(
304
+ f"隐函数必须指定 y in [...] 定义域:{s!r}"
305
+ )
306
+ else:
307
+ raise PlotParseError(
308
+ f"无法判定显函数 / 隐函数,缺少 '=':{s!r}"
309
+ )
310
+
311
+ return PlotItem(
312
+ kind=kind,
313
+ expr=expr,
314
+ x_range=x_range,
315
+ y_range=y_range,
316
+ label=label,
317
+ )
318
+
319
+
320
+ # 已知几何图形及其必需参数
321
+ _SHAPE_SPECS = {
322
+ "point": {"required": ["at"]},
323
+ "segment": {"required": ["from", "to"]},
324
+ "line": {"required": ["from", "to"]},
325
+ "circle": {"required": ["center", "r"]},
326
+ "ellipse": {"required": ["center", "a", "b"]},
327
+ "polygon": {"required": ["points"]},
328
+ "rectangle": {"required": ["origin", "w", "h"]},
329
+ "vector": {"required": ["from", "to"]},
330
+ "parabola": {"required": ["vertex", "p"], "optional": ["direction"]},
331
+ }
332
+
333
+
334
+ def _parse_shape_item(shape: str, param_segs: list, raw: str) -> PlotItem:
335
+ """解析几何图形项。"""
336
+ shape = shape.lower().strip()
337
+ if shape not in _SHAPE_SPECS:
338
+ raise PlotParseError(
339
+ f"未知几何图形 shape={shape!r}(在项 {raw!r} 中)。"
340
+ f"支持:{', '.join(_SHAPE_SPECS.keys())}"
341
+ )
342
+
343
+ params: dict = {}
344
+ label = None
345
+ x_range = None
346
+ y_range = None
347
+
348
+ for seg in param_segs:
349
+ if not seg:
350
+ continue
351
+ # x in [...] / y in [...]
352
+ m = re.match(r"x\s+in\s+\[(.*)\]\s*$", seg)
353
+ if m:
354
+ x_range = _parse_range(m.group(1), axis="x")
355
+ continue
356
+ m = re.match(r"y\s+in\s+\[(.*)\]\s*$", seg)
357
+ if m:
358
+ y_range = _parse_range(m.group(1), axis="y")
359
+ continue
360
+ # label="..."
361
+ m = re.match(r'label\s*=\s*"(.*)"\s*$', seg)
362
+ if m:
363
+ label = m.group(1)
364
+ continue
365
+ # key=value 形式的参数
366
+ m = re.match(r"(\w+)\s*=\s*(.+)$", seg)
367
+ if m:
368
+ key = m.group(1).lower()
369
+ val_str = m.group(2).strip()
370
+ params[key] = _parse_shape_value(val_str, key, raw)
371
+ continue
372
+ raise PlotParseError(f"无法识别的几何参数:{seg!r}(在项 {raw!r} 中)")
373
+
374
+ # 校验必需参数
375
+ required = _SHAPE_SPECS[shape]["required"]
376
+ for k in required:
377
+ if k not in params:
378
+ raise PlotParseError(
379
+ f"图形 {shape!r} 缺少必需参数 {k!r}(在项 {raw!r} 中)"
380
+ )
381
+
382
+ return PlotItem(
383
+ kind="shape",
384
+ shape=shape,
385
+ x_range=x_range,
386
+ y_range=y_range,
387
+ label=label,
388
+ params=params,
389
+ )
390
+
391
+
392
+ def _parse_shape_value(val_str: str, key: str, raw: str):
393
+ """解析几何参数值。
394
+
395
+ 支持的形式:
396
+ (1, 2) → tuple[float, float]
397
+ [(0,0), (1,0), ...] → list[tuple[float, float]]
398
+ 1.5 / pi / -2 → float
399
+ """
400
+ s = val_str.strip()
401
+ # 点列表:[(x,y), (x,y), ...]
402
+ if s.startswith("[") and s.endswith("]"):
403
+ inner = s[1:-1].strip()
404
+ # 用正则切分 "),(" 之间的边界
405
+ # 简单做法:逐字符扫描,按顶层逗号切分
406
+ parts = []
407
+ buf = []
408
+ depth = 0
409
+ for ch in inner:
410
+ if ch == "(":
411
+ depth += 1
412
+ buf.append(ch)
413
+ elif ch == ")":
414
+ depth = max(0, depth - 1)
415
+ buf.append(ch)
416
+ elif ch == "," and depth == 0:
417
+ parts.append("".join(buf).strip())
418
+ buf = []
419
+ else:
420
+ buf.append(ch)
421
+ if buf:
422
+ parts.append("".join(buf).strip())
423
+ pts = []
424
+ for p in parts:
425
+ p = p.strip()
426
+ if not p:
427
+ continue
428
+ pts.append(_parse_point(p, key, raw))
429
+ if not pts:
430
+ raise PlotParseError(f"点列表为空(在项 {raw!r} 中)")
431
+ return pts
432
+ # 单个点:(x, y)
433
+ if s.startswith("(") and s.endswith(")"):
434
+ return _parse_point(s, key, raw)
435
+ # 标量:数字或 pi/e 表达式
436
+ return _eval_scalar(s, axis=key, which=f"参数 {key}")
437
+
438
+
439
+ def _parse_point(s: str, key: str, raw: str) -> Tuple[float, float]:
440
+ """解析 (x, y) 坐标点。"""
441
+ s = s.strip()
442
+ if not (s.startswith("(") and s.endswith(")")):
443
+ raise PlotParseError(f"坐标点应为 (x, y) 形式:{s!r}(在项 {raw!r} 中)")
444
+ inner = s[1:-1].strip()
445
+ if "," not in inner:
446
+ raise PlotParseError(f"坐标点缺少逗号:{s!r}(在项 {raw!r} 中)")
447
+ a_str, b_str = inner.split(",", 1)
448
+ x = _eval_scalar(a_str.strip(), axis=key, which=f"点 x 坐标")
449
+ y = _eval_scalar(b_str.strip(), axis=key, which=f"点 y 坐标")
450
+ return (x, y)
451
+
452
+
453
+ def _parse_range(s: str, axis: str):
454
+ """解析 [a, b] 区间,a/b 可以是数字或 pi、e、-pi/2 等简单表达式。
455
+
456
+ 返回 (float, float)。用 sympy 解析后转 float,安全起见限定符号集。
457
+ """
458
+ s = s.strip()
459
+ # 拆分时只允许最外层一个逗号(区间里不会有嵌套逗号)
460
+ if "," not in s:
461
+ raise PlotParseError(f"区间缺少逗号分隔:[{s}]")
462
+ a_str, b_str = s.split(",", 1)
463
+ a = _eval_scalar(a_str.strip(), axis=axis, which="下界")
464
+ b = _eval_scalar(b_str.strip(), axis=axis, which="上界")
465
+ if not (a < b):
466
+ raise PlotParseError(f"区间下界必须小于上界:[{s}]")
467
+ return (a, b)
468
+
469
+
470
+ def _eval_scalar(s: str, axis: str, which: str) -> float:
471
+ """安全求值:仅允许数字、pi、e、+ - * / ^ 和括号。"""
472
+ # ^ → **,让 Python/sympy 能算
473
+ expr = s.replace("^", "**")
474
+ # 只允许这些字符
475
+ if not re.match(r"^[\d\.\+\-\*\/\(\)\s*pi eE]+$", expr.replace("**", "^")):
476
+ # 上面的正则替换有点 tricky,再用更严格的白名单检查
477
+ pass
478
+ # 严格白名单:允许的 token
479
+ allowed = set("0123456789.+-*/() \t")
480
+ # 把 "pi" 和 "e" 作为整体允许
481
+ cleaned = expr.replace("pi", "").replace("e", "").replace("**", "").replace("E", "")
482
+ if any(ch not in allowed for ch in cleaned):
483
+ raise PlotParseError(
484
+ f"{axis} 区间{which} {s!r} 包含不允许的字符"
485
+ )
486
+ try:
487
+ # 用 sympy 求值,限定局部命名空间
488
+ import sympy as sp
489
+ val = sp.sympify(expr, locals={"pi": sp.pi, "e": sp.E, "E": sp.E})
490
+ return float(val)
491
+ except Exception as ex:
492
+ raise PlotParseError(
493
+ f"{axis} 区间{which} {s!r} 求值失败:{ex}"
494
+ )
495
+
496
+
497
+ # ---------------------------------------------------------------------------
498
+ # 异常
499
+ # ---------------------------------------------------------------------------
500
+
501
+ class ParseError(Exception):
502
+ pass
503
+
504
+
505
+ class PlotParseError(ParseError):
506
+ pass
507
+
508
+
509
+ # ---------------------------------------------------------------------------
510
+ # 行级解析
511
+ # ---------------------------------------------------------------------------
512
+
513
+ # 内联公式 $...$(非贪婪,单行内匹配)
514
+ _INLINE_MATH_RE = re.compile(r"\$([^$\n]+)\$")
515
+ # 粗体 **...**
516
+ _BOLD_RE = re.compile(r"\*\*([^*]+)\*\*")
517
+ # 行内代码 `...`
518
+ _CODE_RE = re.compile(r"`([^`]+)`")
519
+ # 标题(1~4 级)
520
+ _HEADING_RE = re.compile(r"^(#{1,4})\s+(.*)$")
521
+ # 无序列表项
522
+ _LIST_RE = re.compile(r"^-\s+(.*)$")
523
+ # 有序列表项
524
+ _OLIST_RE = re.compile(r"^(\d+)\.\s+(.*)$")
525
+ # 分隔线
526
+ _HR_RE = re.compile(r"^(-{3,}|\*{3,}|_{3,})\s*$")
527
+ # 引用块
528
+ _QUOTE_RE = re.compile(r"^>\s?(.*)$")
529
+ # 表格分隔行
530
+ _TABLE_SEP_RE = re.compile(r"^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$")
531
+
532
+
533
+ def _parse_inline(text: str) -> list:
534
+ """解析一段行内文本,返回 Inline 节点列表。
535
+
536
+ 识别:$...$ 公式、**bold**、*italic*、`code`
537
+ 注意:** 必须比 * 先匹配(否则 *italic* 会被当成两个 * 单字符)
538
+ """
539
+ buf = text
540
+ out: list = []
541
+ i = 0
542
+ while i < len(buf):
543
+ ch = buf[i]
544
+ if ch == "$":
545
+ j = buf.find("$", i + 1)
546
+ if j == -1:
547
+ out.append(Text(latex_escape(ch)))
548
+ i += 1
549
+ continue
550
+ out.append(InlineMath(buf[i + 1:j]))
551
+ i = j + 1
552
+ elif buf.startswith("**", i):
553
+ j = buf.find("**", i + 2)
554
+ if j == -1:
555
+ out.append(Text(latex_escape("**")))
556
+ i += 2
557
+ continue
558
+ out.append(Bold(_parse_inline(buf[i + 2:j])))
559
+ i = j + 2
560
+ elif ch == "*":
561
+ # *italic*(单个星号)
562
+ j = buf.find("*", i + 1)
563
+ if j == -1:
564
+ out.append(Text(latex_escape(ch)))
565
+ i += 1
566
+ continue
567
+ inner = buf[i + 1:j]
568
+ # 避免空 italic
569
+ if not inner:
570
+ out.append(Text(latex_escape("*")))
571
+ i += 1
572
+ continue
573
+ out.append(Italic(_parse_inline(inner)))
574
+ i = j + 1
575
+ elif ch == "`":
576
+ j = buf.find("`", i + 1)
577
+ if j == -1:
578
+ out.append(Text(latex_escape(ch)))
579
+ i += 1
580
+ continue
581
+ out.append(Code(buf[i + 1:j]))
582
+ i = j + 1
583
+ else:
584
+ k = i
585
+ while k < len(buf) and buf[k] not in "$`*" and not buf.startswith("**", k):
586
+ k += 1
587
+ chunk = buf[i:k]
588
+ out.append(Text(latex_escape(chunk)))
589
+ i = k
590
+ return out
591
+
592
+
593
+ def _is_table_row(line: str) -> bool:
594
+ return line.lstrip().startswith("|")
595
+
596
+
597
+ def _split_table_row(line: str) -> List[str]:
598
+ s = line.strip()
599
+ if s.startswith("|"):
600
+ s = s[1:]
601
+ if s.endswith("|"):
602
+ s = s[:-1]
603
+ return [cell.strip() for cell in s.split("|")]
604
+
605
+
606
+ # ---------------------------------------------------------------------------
607
+ # 主解析入口
608
+ # ---------------------------------------------------------------------------
609
+
610
+ def parse_document(text: str) -> List:
611
+ """解析整篇文档,返回 Block 节点列表。
612
+
613
+ 会先抽出所有 @plot{...} 块(替换成占位符),然后再按行解析 Markdown,
614
+ 最后把占位符还原回 Plot 节点。
615
+ """
616
+ # 第一步:抽出 @plot{...}
617
+ plots: List[Plot] = []
618
+ plot_placeholders: List[str] = []
619
+
620
+ def _capture_plot(m: "re.Match[str]") -> str:
621
+ # group(1) = 关键字 plot/geometry
622
+ # group(2) = 选项字符串(如 "width=0.5, align=left"),可能为 None
623
+ # group(3) = body
624
+ opts_str = m.group(2)
625
+ body = m.group(3)
626
+ items = _parse_plot_items(body)
627
+ width = None
628
+ align = None
629
+ if opts_str:
630
+ # 解析 "width=0.5, align=left" 形式
631
+ for opt in opts_str.split(","):
632
+ opt = opt.strip()
633
+ if not opt:
634
+ continue
635
+ km = re.match(r"^(\w+)\s*=\s*(.+)$", opt)
636
+ if not km:
637
+ continue
638
+ key = km.group(1).lower()
639
+ val = km.group(2).strip()
640
+ if key == "width":
641
+ try:
642
+ width = float(val)
643
+ except ValueError:
644
+ raise PlotParseError(f"@plot width 值无效:{val}")
645
+ if not (0 < width <= 1.0):
646
+ raise PlotParseError(
647
+ f"@plot 的 width 必须在 (0, 1] 之间,得到 {width}"
648
+ )
649
+ elif key == "align":
650
+ align = val.lower()
651
+ if align not in ("left", "center"):
652
+ raise PlotParseError(
653
+ f"@plot 的 align 必须是 left 或 center,得到 {align}"
654
+ )
655
+ plot = Plot(items=items, width=width, align=align)
656
+ plots.append(plot)
657
+ placeholder = f"\x00PLOT{len(plots) - 1}\x00"
658
+ plot_placeholders.append(placeholder)
659
+ return placeholder
660
+
661
+ # @plot{...} 必须独占一段;为了让内联出现的 @plot 也能正确识别,
662
+ # 我们在替换的同时在占位符前后插入换行,强制它独占一行。
663
+ def _capture_plot_with_newlines(m: "re.Match[str]") -> str:
664
+ return "\n" + _capture_plot(m) + "\n"
665
+
666
+ text2 = _PLOT_RE.sub(_capture_plot_with_newlines, text)
667
+
668
+ # 第二步:先把 $$...$$ 块级公式抽出
669
+ display_maths: List[DisplayMath] = []
670
+ def _capture_display(m: "re.Match[str]") -> str:
671
+ latex = m.group(1).strip()
672
+ display_maths.append(DisplayMath(latex=latex))
673
+ return f"\x00DISPLAYMATH{len(display_maths) - 1}\x00"
674
+
675
+ # 同样:$$...$$ 可能内联出现,强制独占一行
676
+ def _capture_display_with_newlines(m: "re.Match[str]") -> str:
677
+ return "\n" + _capture_display(m) + "\n"
678
+
679
+ text2 = re.sub(r"\$\$(.+?)\$\$", _capture_display_with_newlines, text2, flags=re.DOTALL)
680
+
681
+ # 第三步:按行解析
682
+ lines = text2.split("\n")
683
+ blocks: List = []
684
+ i = 0
685
+ n = len(lines)
686
+ while i < n:
687
+ line = lines[i]
688
+
689
+ # 空行
690
+ if not line.strip():
691
+ blocks.append(BlankLine())
692
+ i += 1
693
+ continue
694
+
695
+ # 块级公式占位符独占一行
696
+ m_dm = re.match(r"^\x00DISPLAYMATH(\d+)\x00$", line.strip())
697
+ if m_dm:
698
+ idx = int(m_dm.group(1))
699
+ blocks.append(display_maths[idx])
700
+ i += 1
701
+ continue
702
+
703
+ # @plot 占位符独占一行
704
+ m_pl = re.match(r"^\x00PLOT(\d+)\x00$", line.strip())
705
+ if m_pl:
706
+ idx = int(m_pl.group(1))
707
+ blocks.append(plots[idx])
708
+ i += 1
709
+ continue
710
+
711
+ # 标题(1~4 级)
712
+ m = _HEADING_RE.match(line)
713
+ if m:
714
+ level = len(m.group(1))
715
+ content = m.group(2).strip()
716
+ blocks.append(Heading(level=level, segments=_parse_inline(content)))
717
+ i += 1
718
+ continue
719
+
720
+ # 分隔线 --- *** ___
721
+ if _HR_RE.match(line):
722
+ blocks.append(HorizontalRule())
723
+ i += 1
724
+ continue
725
+
726
+ # 引用块 > text(连续多行 > 合并成一个 Blockquote)
727
+ if _QUOTE_RE.match(line):
728
+ quote_lines = []
729
+ while i < n and _QUOTE_RE.match(lines[i]):
730
+ m_q = _QUOTE_RE.match(lines[i])
731
+ quote_lines.append(m_q.group(1))
732
+ i += 1
733
+ # 递归解析引用内容
734
+ quote_text = "\n".join(quote_lines)
735
+ quote_blocks = parse_document(quote_text)
736
+ blocks.append(Blockquote(blocks=quote_blocks))
737
+ continue
738
+
739
+ # 无序列表项 -
740
+ m = _LIST_RE.match(line)
741
+ if m:
742
+ content = m.group(1).strip()
743
+ blocks.append(ListItem(segments=_parse_inline(content), ordered=False))
744
+ i += 1
745
+ continue
746
+
747
+ # 有序列表项 1. 2. 3.
748
+ m = _OLIST_RE.match(line)
749
+ if m:
750
+ num = int(m.group(1))
751
+ content = m.group(2).strip()
752
+ blocks.append(ListItem(segments=_parse_inline(content), ordered=True, order_num=num))
753
+ i += 1
754
+ continue
755
+
756
+ # 表格
757
+ if _is_table_row(line):
758
+ tbl_lines = []
759
+ while i < n and _is_table_row(lines[i]):
760
+ tbl_lines.append(lines[i])
761
+ i += 1
762
+ table = _parse_table(tbl_lines)
763
+ blocks.append(table)
764
+ continue
765
+
766
+ # 普通段落:连续非空、非特殊行合并成一段
767
+ para_buf = [line]
768
+ i += 1
769
+ while i < n:
770
+ nxt = lines[i]
771
+ if (
772
+ not nxt.strip()
773
+ or _HEADING_RE.match(nxt)
774
+ or _LIST_RE.match(nxt)
775
+ or _OLIST_RE.match(nxt)
776
+ or _HR_RE.match(nxt)
777
+ or _QUOTE_RE.match(nxt)
778
+ or _is_table_row(nxt)
779
+ or re.match(r"^\x00DISPLAYMATH(\d+)\x00$", nxt.strip())
780
+ or re.match(r"^\x00PLOT(\d+)\x00$", nxt.strip())
781
+ ):
782
+ break
783
+ para_buf.append(nxt)
784
+ i += 1
785
+ para_text = " ".join(p.strip() for p in para_buf)
786
+ blocks.append(Paragraph(segments=_parse_inline(para_text)))
787
+
788
+ return blocks
789
+
790
+
791
+ def _parse_table(tbl_lines: List[str]) -> Table:
792
+ """解析 Markdown 表格。第一行是表头,第二行是分隔行 |---|---|,其余是数据行。"""
793
+ if len(tbl_lines) < 2:
794
+ raise ParseError(f"表格行数不足:{tbl_lines}")
795
+ header_cells = _split_table_row(tbl_lines[0])
796
+ header = [_parse_inline(c) for c in header_cells]
797
+ if not _TABLE_SEP_RE.match(tbl_lines[1]):
798
+ raise ParseError(
799
+ f"表格第二行应为分隔行 |---|---|,实际为:{tbl_lines[1]!r}"
800
+ )
801
+ rows = []
802
+ for ln in tbl_lines[2:]:
803
+ cells = _split_table_row(ln)
804
+ # 列数对齐
805
+ while len(cells) < len(header_cells):
806
+ cells.append("")
807
+ cells = cells[: len(header_cells)]
808
+ rows.append([_parse_inline(c) for c in cells])
809
+ return Table(header=header, rows=rows)