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/__init__.py +12 -0
- mathtext2doc/__main__.py +5 -0
- mathtext2doc/cli.py +249 -0
- mathtext2doc/compiler.py +495 -0
- mathtext2doc/parser.py +809 -0
- mathtext2doc/plotter.py +775 -0
- mathtext2doc/texgen.py +228 -0
- mathtext2doc-0.1.0.dist-info/METADATA +328 -0
- mathtext2doc-0.1.0.dist-info/RECORD +13 -0
- mathtext2doc-0.1.0.dist-info/WHEEL +5 -0
- mathtext2doc-0.1.0.dist-info/entry_points.txt +2 -0
- mathtext2doc-0.1.0.dist-info/licenses/LICENSE +21 -0
- mathtext2doc-0.1.0.dist-info/top_level.txt +1 -0
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)
|