OVAPortableText 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.
- ova_portable_text/__init__.py +93 -0
- ova_portable_text/base.py +165 -0
- ova_portable_text/block_objects.py +139 -0
- ova_portable_text/content.py +74 -0
- ova_portable_text/document.py +303 -0
- ova_portable_text/exceptions.py +234 -0
- ova_portable_text/helpers.py +203 -0
- ova_portable_text/inline.py +87 -0
- ova_portable_text/numbering.py +227 -0
- ova_portable_text/py.typed +0 -0
- ova_portable_text/registry.py +555 -0
- ova_portable_text/resolver.py +340 -0
- ova_portable_text/section.py +412 -0
- ova_portable_text/text.py +426 -0
- ova_portable_text/theme.py +46 -0
- ova_portable_text/validator.py +511 -0
- ova_portable_text/version.py +9 -0
- ovaportabletext-0.1.0.dist-info/METADATA +241 -0
- ovaportabletext-0.1.0.dist-info/RECORD +21 -0
- ovaportabletext-0.1.0.dist-info/WHEEL +4 -0
- ovaportabletext-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Document resolver / index builder for OVAPortableText.
|
|
5
|
+
OVAPortableText 的文档解析索引器。
|
|
6
|
+
|
|
7
|
+
The protocol requires that future reference-like abilities can resolve targets
|
|
8
|
+
consistently across the whole document.
|
|
9
|
+
协议要求未来凡是类似“引用 / 跳转 / 目录”的能力,都能在整份文档范围内稳定解析目标。
|
|
10
|
+
|
|
11
|
+
Step 8 adds richer target metadata and lightweight summary helpers, so the
|
|
12
|
+
resolver is useful not only for validation, but also for debugging and test logs.
|
|
13
|
+
第 8 步补充了更丰富的 target 元数据和轻量摘要 helper,
|
|
14
|
+
使 resolver 不只适合校验,也更适合调试和测试日志输出。
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from collections import Counter, defaultdict
|
|
18
|
+
|
|
19
|
+
from pydantic import Field
|
|
20
|
+
|
|
21
|
+
from .base import OvaBaseModel
|
|
22
|
+
from .block_objects import CalloutBlock, ChartBlock, ImageBlock, MathBlock, TableBlock
|
|
23
|
+
from .content import ContentItem
|
|
24
|
+
from .document import Document
|
|
25
|
+
from .section import Section, SubsectionItem
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ResolvedTarget(OvaBaseModel):
|
|
29
|
+
"""
|
|
30
|
+
One globally resolvable target.
|
|
31
|
+
一个全局可解析目标。
|
|
32
|
+
|
|
33
|
+
Extra fields are intentionally included to make downstream validation output
|
|
34
|
+
more actionable.
|
|
35
|
+
这里刻意加入了一些额外字段,使后续校验输出更具可操作性。
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
id: str
|
|
39
|
+
targetType: str
|
|
40
|
+
anchor: str | None = None
|
|
41
|
+
location: str
|
|
42
|
+
sourceLayer: str
|
|
43
|
+
sectionId: str | None = None
|
|
44
|
+
sectionTitle: str | None = None
|
|
45
|
+
|
|
46
|
+
def to_text(self) -> str:
|
|
47
|
+
"""
|
|
48
|
+
Render the target into one compact debug line.
|
|
49
|
+
将 target 渲染成一行紧凑的调试文本。
|
|
50
|
+
"""
|
|
51
|
+
parts = [f"{self.targetType}:{self.id}"]
|
|
52
|
+
if self.anchor:
|
|
53
|
+
parts.append(f"anchor={self.anchor}")
|
|
54
|
+
if self.sectionId:
|
|
55
|
+
if self.sectionTitle:
|
|
56
|
+
parts.append(f"section={self.sectionId} ({self.sectionTitle})")
|
|
57
|
+
else:
|
|
58
|
+
parts.append(f"section={self.sectionId}")
|
|
59
|
+
parts.append(f"location={self.location}")
|
|
60
|
+
return " | ".join(parts)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class DocumentResolver(OvaBaseModel):
|
|
64
|
+
"""
|
|
65
|
+
Global resolver / index for a document.
|
|
66
|
+
文档的全局解析器 / 索引器。
|
|
67
|
+
|
|
68
|
+
Main responsibilities / 主要职责:
|
|
69
|
+
1. collect all resolvable targets / 收集所有可解析目标
|
|
70
|
+
2. detect duplicate IDs / 发现重复 ID
|
|
71
|
+
3. detect duplicate anchors / 发现重复 anchor
|
|
72
|
+
4. resolve `xref` target types through a consistent alias mapping
|
|
73
|
+
通过统一别名映射解析 `xref.targetType`
|
|
74
|
+
5. provide quick summaries for debugging / 提供便于调试的摘要
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
targetsById: dict[str, ResolvedTarget] = Field(default_factory=dict)
|
|
78
|
+
targetsByType: dict[str, dict[str, ResolvedTarget]] = Field(default_factory=dict)
|
|
79
|
+
targetsByAnchor: dict[str, ResolvedTarget] = Field(default_factory=dict)
|
|
80
|
+
duplicates: dict[str, list[ResolvedTarget]] = Field(default_factory=dict)
|
|
81
|
+
duplicateAnchors: dict[str, list[ResolvedTarget]] = Field(default_factory=dict)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_document(cls, document: Document) -> "DocumentResolver":
|
|
85
|
+
"""
|
|
86
|
+
Build a resolver index from the whole document.
|
|
87
|
+
从整份文档构建 resolver 索引。
|
|
88
|
+
"""
|
|
89
|
+
bucket: dict[str, list[ResolvedTarget]] = defaultdict(list)
|
|
90
|
+
anchor_bucket: dict[str, list[ResolvedTarget]] = defaultdict(list)
|
|
91
|
+
type_bucket: dict[str, dict[str, ResolvedTarget]] = defaultdict(dict)
|
|
92
|
+
|
|
93
|
+
def add_target(
|
|
94
|
+
*,
|
|
95
|
+
id: str,
|
|
96
|
+
target_type: str,
|
|
97
|
+
anchor: str | None,
|
|
98
|
+
location: str,
|
|
99
|
+
source_layer: str,
|
|
100
|
+
section_id: str | None = None,
|
|
101
|
+
section_title: str | None = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
target = ResolvedTarget(
|
|
104
|
+
id=id,
|
|
105
|
+
targetType=target_type,
|
|
106
|
+
anchor=anchor,
|
|
107
|
+
location=location,
|
|
108
|
+
sourceLayer=source_layer,
|
|
109
|
+
sectionId=section_id,
|
|
110
|
+
sectionTitle=section_title,
|
|
111
|
+
)
|
|
112
|
+
bucket[id].append(target)
|
|
113
|
+
if anchor:
|
|
114
|
+
anchor_bucket[anchor].append(target)
|
|
115
|
+
|
|
116
|
+
def add_type_alias(*, alias_type: str, target: ResolvedTarget) -> None:
|
|
117
|
+
type_bucket[alias_type][target.id] = ResolvedTarget(
|
|
118
|
+
id=target.id,
|
|
119
|
+
targetType=alias_type,
|
|
120
|
+
anchor=target.anchor,
|
|
121
|
+
location=target.location,
|
|
122
|
+
sourceLayer=target.sourceLayer,
|
|
123
|
+
sectionId=target.sectionId,
|
|
124
|
+
sectionTitle=target.sectionTitle,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def walk_section(section: Section, path: str) -> None:
|
|
128
|
+
add_target(
|
|
129
|
+
id=section.id,
|
|
130
|
+
target_type="section",
|
|
131
|
+
anchor=section.anchor,
|
|
132
|
+
location=path,
|
|
133
|
+
source_layer="body",
|
|
134
|
+
section_id=section.id,
|
|
135
|
+
section_title=section.title,
|
|
136
|
+
)
|
|
137
|
+
for body_index, item in enumerate(section.body):
|
|
138
|
+
item_path = f"{path}.body[{body_index}]"
|
|
139
|
+
if isinstance(item, ContentItem):
|
|
140
|
+
for block_index, block in enumerate(item.blocks):
|
|
141
|
+
block_path = f"{item_path}.blocks[{block_index}]"
|
|
142
|
+
common = dict(
|
|
143
|
+
location=block_path,
|
|
144
|
+
source_layer="body",
|
|
145
|
+
section_id=section.id,
|
|
146
|
+
section_title=section.title,
|
|
147
|
+
)
|
|
148
|
+
if isinstance(block, ImageBlock):
|
|
149
|
+
add_target(id=block.id, target_type="image", anchor=block.anchor, **common)
|
|
150
|
+
elif isinstance(block, ChartBlock):
|
|
151
|
+
add_target(id=block.id, target_type="chart", anchor=block.anchor, **common)
|
|
152
|
+
elif isinstance(block, TableBlock):
|
|
153
|
+
add_target(id=block.id, target_type="table", anchor=block.anchor, **common)
|
|
154
|
+
elif isinstance(block, MathBlock):
|
|
155
|
+
add_target(id=block.id, target_type="math_block", anchor=block.anchor, **common)
|
|
156
|
+
elif isinstance(block, CalloutBlock):
|
|
157
|
+
add_target(id=block.id, target_type="callout", anchor=block.anchor, **common)
|
|
158
|
+
elif isinstance(item, SubsectionItem):
|
|
159
|
+
walk_section(item.section, f"{item_path}.section")
|
|
160
|
+
|
|
161
|
+
for index, section in enumerate(document.sections):
|
|
162
|
+
walk_section(section, f"sections[{index}]")
|
|
163
|
+
|
|
164
|
+
for index, asset in enumerate(document.assets.images):
|
|
165
|
+
add_target(id=asset.id, target_type="image_asset", anchor=asset.anchor, location=f"assets.images[{index}]", source_layer="assets")
|
|
166
|
+
for index, asset in enumerate(document.assets.logos):
|
|
167
|
+
add_target(id=asset.id, target_type="logo_asset", anchor=asset.anchor, location=f"assets.logos[{index}]", source_layer="assets")
|
|
168
|
+
for index, asset in enumerate(document.assets.backgrounds):
|
|
169
|
+
add_target(id=asset.id, target_type="background_asset", anchor=asset.anchor, location=f"assets.backgrounds[{index}]", source_layer="assets")
|
|
170
|
+
for index, asset in enumerate(document.assets.icons):
|
|
171
|
+
add_target(id=asset.id, target_type="icon_asset", anchor=asset.anchor, location=f"assets.icons[{index}]", source_layer="assets")
|
|
172
|
+
for index, asset in enumerate(document.assets.attachments):
|
|
173
|
+
add_target(id=asset.id, target_type="attachment_asset", anchor=asset.anchor, location=f"assets.attachments[{index}]", source_layer="assets")
|
|
174
|
+
for index, chart in enumerate(document.datasets.charts):
|
|
175
|
+
add_target(id=chart.id, target_type="chart_dataset", anchor=chart.anchor, location=f"datasets.charts[{index}]", source_layer="datasets")
|
|
176
|
+
for index, table in enumerate(document.datasets.tables):
|
|
177
|
+
add_target(id=table.id, target_type="table_dataset", anchor=table.anchor, location=f"datasets.tables[{index}]", source_layer="datasets")
|
|
178
|
+
for index, metric in enumerate(document.datasets.metrics):
|
|
179
|
+
add_target(id=metric.id, target_type="metric_dataset", anchor=metric.anchor, location=f"datasets.metrics[{index}]", source_layer="datasets")
|
|
180
|
+
for index, item in enumerate(document.bibliography):
|
|
181
|
+
add_target(id=item.id, target_type="bibliography_item", anchor=item.anchor, location=f"bibliography[{index}]", source_layer="registry")
|
|
182
|
+
for index, item in enumerate(document.footnotes):
|
|
183
|
+
add_target(id=item.id, target_type="footnote", anchor=item.anchor, location=f"footnotes[{index}]", source_layer="registry")
|
|
184
|
+
for index, item in enumerate(document.glossary):
|
|
185
|
+
add_target(id=item.id, target_type="glossary_term", anchor=item.anchor, location=f"glossary[{index}]", source_layer="registry")
|
|
186
|
+
|
|
187
|
+
targets_by_id: dict[str, ResolvedTarget] = {}
|
|
188
|
+
duplicates: dict[str, list[ResolvedTarget]] = {}
|
|
189
|
+
for id_value, items in bucket.items():
|
|
190
|
+
if len(items) == 1:
|
|
191
|
+
targets_by_id[id_value] = items[0]
|
|
192
|
+
else:
|
|
193
|
+
duplicates[id_value] = items
|
|
194
|
+
|
|
195
|
+
targets_by_anchor: dict[str, ResolvedTarget] = {}
|
|
196
|
+
duplicate_anchors: dict[str, list[ResolvedTarget]] = {}
|
|
197
|
+
for anchor_value, items in anchor_bucket.items():
|
|
198
|
+
if len(items) == 1:
|
|
199
|
+
targets_by_anchor[anchor_value] = items[0]
|
|
200
|
+
else:
|
|
201
|
+
duplicate_anchors[anchor_value] = items
|
|
202
|
+
|
|
203
|
+
for target in targets_by_id.values():
|
|
204
|
+
canonical = cls._canonical_target_type(target.targetType)
|
|
205
|
+
type_bucket[canonical][target.id] = ResolvedTarget(
|
|
206
|
+
id=target.id,
|
|
207
|
+
targetType=canonical,
|
|
208
|
+
anchor=target.anchor,
|
|
209
|
+
location=target.location,
|
|
210
|
+
sourceLayer=target.sourceLayer,
|
|
211
|
+
sectionId=target.sectionId,
|
|
212
|
+
sectionTitle=target.sectionTitle,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if canonical in {"image", "chart"}:
|
|
216
|
+
add_type_alias(alias_type="figure", target=target)
|
|
217
|
+
if canonical == "math_block":
|
|
218
|
+
add_type_alias(alias_type="equation", target=target)
|
|
219
|
+
|
|
220
|
+
return cls(
|
|
221
|
+
targetsById=targets_by_id,
|
|
222
|
+
targetsByType={k: v for k, v in type_bucket.items()},
|
|
223
|
+
targetsByAnchor=targets_by_anchor,
|
|
224
|
+
duplicates=duplicates,
|
|
225
|
+
duplicateAnchors=duplicate_anchors,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def get(self, id_value: str) -> ResolvedTarget | None:
|
|
229
|
+
"""
|
|
230
|
+
Resolve by global unique ID only.
|
|
231
|
+
仅按全局唯一 ID 解析目标。
|
|
232
|
+
"""
|
|
233
|
+
return self.targetsById.get(id_value)
|
|
234
|
+
|
|
235
|
+
def get_by_anchor(self, anchor_value: str) -> ResolvedTarget | None:
|
|
236
|
+
"""
|
|
237
|
+
Resolve by globally unique anchor.
|
|
238
|
+
按全局唯一 anchor 解析目标。
|
|
239
|
+
|
|
240
|
+
If the anchor is duplicated, this returns `None` and callers should inspect
|
|
241
|
+
`duplicateAnchors` instead.
|
|
242
|
+
如果 anchor 存在重复,这里会返回 `None`,调用方应查看 `duplicateAnchors`。
|
|
243
|
+
"""
|
|
244
|
+
return self.targetsByAnchor.get(anchor_value)
|
|
245
|
+
|
|
246
|
+
def resolve_xref(self, *, target_type: str, target_id: str) -> ResolvedTarget | None:
|
|
247
|
+
"""
|
|
248
|
+
Resolve an xref target by type alias and id.
|
|
249
|
+
按 target type 别名 + id 解析 xref 目标。
|
|
250
|
+
"""
|
|
251
|
+
canonical = self._canonical_target_type(target_type)
|
|
252
|
+
return self.targetsByType.get(canonical, {}).get(target_id)
|
|
253
|
+
|
|
254
|
+
def counts_by_type(self) -> dict[str, int]:
|
|
255
|
+
"""
|
|
256
|
+
Count unique resolved targets grouped by canonical type.
|
|
257
|
+
按 canonical type 统计唯一已解析目标数量。
|
|
258
|
+
"""
|
|
259
|
+
return dict(sorted((key, len(value)) for key, value in self.targetsByType.items()))
|
|
260
|
+
|
|
261
|
+
def counts_by_layer(self) -> dict[str, int]:
|
|
262
|
+
"""
|
|
263
|
+
Count targets grouped by source layer, such as body / assets / datasets.
|
|
264
|
+
按 source layer 统计目标数量,例如 body / assets / datasets。
|
|
265
|
+
"""
|
|
266
|
+
counter = Counter(target.sourceLayer for target in self.targetsById.values())
|
|
267
|
+
return dict(sorted(counter.items()))
|
|
268
|
+
|
|
269
|
+
def debug_summary(self) -> str:
|
|
270
|
+
"""
|
|
271
|
+
Build a compact multi-line resolver summary.
|
|
272
|
+
生成适合调试输出的 resolver 多行摘要。
|
|
273
|
+
"""
|
|
274
|
+
lines = [
|
|
275
|
+
"OVAPortableText resolver summary:",
|
|
276
|
+
f"unique_targets={len(self.targetsById)}",
|
|
277
|
+
f"duplicate_ids={len(self.duplicates)}",
|
|
278
|
+
f"duplicate_anchors={len(self.duplicateAnchors)}",
|
|
279
|
+
f"type_counts={self.counts_by_type()}",
|
|
280
|
+
f"layer_counts={self.counts_by_layer()}",
|
|
281
|
+
]
|
|
282
|
+
return "\n".join(lines)
|
|
283
|
+
|
|
284
|
+
@classmethod
|
|
285
|
+
def is_supported_target_type(cls, target_type: str) -> bool:
|
|
286
|
+
"""
|
|
287
|
+
Check whether a target type is known to this resolver.
|
|
288
|
+
检查某个 target type 是否为当前 resolver 已知类型。
|
|
289
|
+
"""
|
|
290
|
+
normalized = target_type.strip().lower().replace("-", "_")
|
|
291
|
+
return normalized in cls._target_aliases()
|
|
292
|
+
|
|
293
|
+
@classmethod
|
|
294
|
+
def supported_target_types(cls) -> set[str]:
|
|
295
|
+
"""
|
|
296
|
+
Return all accepted external target type names.
|
|
297
|
+
返回当前接受的全部外部 target type 名称。
|
|
298
|
+
"""
|
|
299
|
+
return set(cls._target_aliases().keys())
|
|
300
|
+
|
|
301
|
+
@classmethod
|
|
302
|
+
def _canonical_target_type(cls, target_type: str) -> str:
|
|
303
|
+
normalized = target_type.strip().lower().replace("-", "_")
|
|
304
|
+
return cls._target_aliases().get(normalized, normalized)
|
|
305
|
+
|
|
306
|
+
@staticmethod
|
|
307
|
+
def _target_aliases() -> dict[str, str]:
|
|
308
|
+
"""
|
|
309
|
+
Central alias mapping used by the resolver.
|
|
310
|
+
resolver 使用的统一 target type 别名表。
|
|
311
|
+
"""
|
|
312
|
+
return {
|
|
313
|
+
"section": "section",
|
|
314
|
+
"figure": "figure",
|
|
315
|
+
"image": "image",
|
|
316
|
+
"chart": "chart",
|
|
317
|
+
"table": "table",
|
|
318
|
+
"equation": "equation",
|
|
319
|
+
"math": "math_block",
|
|
320
|
+
"math_block": "math_block",
|
|
321
|
+
"callout": "callout",
|
|
322
|
+
"bibliography": "bibliography_item",
|
|
323
|
+
"bibliography_item": "bibliography_item",
|
|
324
|
+
"reference": "bibliography_item",
|
|
325
|
+
"citation": "bibliography_item",
|
|
326
|
+
"footnote": "footnote",
|
|
327
|
+
"glossary": "glossary_term",
|
|
328
|
+
"glossary_term": "glossary_term",
|
|
329
|
+
"term": "glossary_term",
|
|
330
|
+
"image_asset": "image_asset",
|
|
331
|
+
"logo_asset": "logo_asset",
|
|
332
|
+
"background_asset": "background_asset",
|
|
333
|
+
"icon_asset": "icon_asset",
|
|
334
|
+
"attachment": "attachment_asset",
|
|
335
|
+
"attachment_asset": "attachment_asset",
|
|
336
|
+
"chart_dataset": "chart_dataset",
|
|
337
|
+
"table_dataset": "table_dataset",
|
|
338
|
+
"metric": "metric_dataset",
|
|
339
|
+
"metric_dataset": "metric_dataset",
|
|
340
|
+
}
|