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.
@@ -0,0 +1,511 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Document validation for OVAPortableText.
5
+ OVAPortableText 的文档校验器。
6
+
7
+ This validator intentionally stays close to the protocol's v1 guidance:
8
+ 本校验器刻意贴近协议 v1 的建议:
9
+ - schemaVersion must exist / `schemaVersion` 必填
10
+ - sections/body structure must be valid / `sections` 与 `body` 结构必须合法
11
+ - top-level registries should exist / 顶层 registry 应存在
12
+ - section nesting levels should be self-consistent / section 层级应自洽
13
+ - all references should resolve / 所有引用应能解析
14
+ - formal heading styles should not appear inside content.blocks
15
+ `content.blocks` 中不应出现正式标题样式
16
+
17
+ Step 8 improves the *quality* of validation output:
18
+ 第 8 步重点提升的是校验输出质量:
19
+ - richer issue context / 更丰富的 issue 上下文
20
+ - better maintenance hints / 更明确的维护提示
21
+ - readable report summaries / 更易读的报告摘要
22
+ """
23
+
24
+ from .block_objects import CalloutBlock, ChartBlock, ImageBlock, TableBlock
25
+ from .content import ALLOWED_TEXT_STYLES, ContentItem, Span, TextBlock
26
+ from .document import Document
27
+ from .exceptions import ValidationReport
28
+ from .inline import CitationRef, FootnoteRef, GlossaryTerm, XRef
29
+ from .registry import BibliographyEntry, FootnoteEntry, GlossaryEntry
30
+ from .resolver import DocumentResolver, ResolvedTarget
31
+ from .section import Section, SubsectionItem
32
+
33
+ ALLOWED_DECORATOR_MARKS = {"strong", "em", "underline", "code"}
34
+
35
+
36
+ def validate_document(document: Document) -> ValidationReport:
37
+ """
38
+ Validate the whole document and return a structured report.
39
+ 校验整份文档,并返回结构化报告。
40
+ """
41
+ report = ValidationReport()
42
+
43
+ if not document.schemaVersion:
44
+ report.add_issue(
45
+ code="missing_schema_version",
46
+ message="`schemaVersion` is required.",
47
+ path="schemaVersion",
48
+ contextType="document",
49
+ suggestion="Set `schemaVersion` to `report.v1` unless you intentionally target another protocol version.",
50
+ )
51
+
52
+ if not isinstance(document.sections, list):
53
+ report.add_issue(
54
+ code="invalid_sections",
55
+ message="`sections` must be a list.",
56
+ path="sections",
57
+ contextType="document",
58
+ suggestion="Top-level `sections` should always be present, even when empty.",
59
+ )
60
+ return report
61
+
62
+ for index, section in enumerate(document.sections):
63
+ _validate_section(section, path=f"sections[{index}]", parent_level=None, report=report)
64
+
65
+ _validate_bibliography(document.bibliography, report=report)
66
+ _validate_footnotes(document.footnotes, report=report)
67
+ _validate_glossary(document.glossary, report=report)
68
+
69
+ resolver = document.build_resolver()
70
+
71
+ for duplicate_id, targets in resolver.duplicates.items():
72
+ locations = ", ".join(target.location for target in targets)
73
+ first = targets[0]
74
+ report.add_issue(
75
+ code="duplicate_id",
76
+ message=f"Duplicate global id detected: {duplicate_id!r}. Locations: {locations}",
77
+ contextType="global_id",
78
+ contextId=duplicate_id,
79
+ sectionId=first.sectionId,
80
+ sectionTitle=first.sectionTitle,
81
+ suggestion="Keep each globally resolvable object's `id` unique across the whole document.",
82
+ )
83
+
84
+ for anchor, targets in resolver.duplicateAnchors.items():
85
+ locations = ", ".join(target.location for target in targets)
86
+ first = targets[0]
87
+ report.add_issue(
88
+ code="duplicate_anchor",
89
+ message=f"Duplicate anchor detected: {anchor!r}. Locations: {locations}",
90
+ severity="warning",
91
+ contextType="anchor",
92
+ contextAnchor=anchor,
93
+ sectionId=first.sectionId,
94
+ sectionTitle=first.sectionTitle,
95
+ suggestion="Prefer globally unique anchors if the renderer will support page jumps or bookmarks.",
96
+ )
97
+
98
+ for index, section in enumerate(document.sections):
99
+ _validate_section_references(section, path=f"sections[{index}]", resolver=resolver, report=report)
100
+
101
+ return report
102
+
103
+
104
+ def assert_valid_document(document: Document) -> Document:
105
+ """
106
+ Validate and raise on errors.
107
+ 校验文档;若存在 error 则抛错。
108
+ """
109
+ validate_document(document).raise_for_errors()
110
+ return document
111
+
112
+
113
+ def _ctx(
114
+ *,
115
+ section: Section | None = None,
116
+ context_type: str | None = None,
117
+ context_id: str | None = None,
118
+ context_anchor: str | None = None,
119
+ suggestion: str | None = None,
120
+ location: str | None = None,
121
+ ) -> dict[str, str | None]:
122
+ """
123
+ Build common context kwargs for `ValidationReport.add_issue()`.
124
+ 为 `ValidationReport.add_issue()` 构建通用上下文参数。
125
+
126
+ This helper keeps the validator code readable while still attaching
127
+ rich issue context.
128
+ 这个 helper 的意义是:
129
+ 既让校验器代码保持可读,又能给 issue 挂上较丰富的上下文信息。
130
+ """
131
+ return {
132
+ "location": location,
133
+ "contextType": context_type,
134
+ "contextId": context_id,
135
+ "contextAnchor": context_anchor,
136
+ "sectionId": section.id if section else None,
137
+ "sectionTitle": section.title if section else None,
138
+ "suggestion": suggestion,
139
+ }
140
+
141
+
142
+ def _ctx_from_target(target: ResolvedTarget, *, suggestion: str | None = None) -> dict[str, str | None]:
143
+ """
144
+ Build context kwargs from a resolved target.
145
+ 根据 resolver target 构建上下文参数。
146
+ """
147
+ return {
148
+ "location": target.location,
149
+ "contextType": target.targetType,
150
+ "contextId": target.id,
151
+ "contextAnchor": target.anchor,
152
+ "sectionId": target.sectionId,
153
+ "sectionTitle": target.sectionTitle,
154
+ "suggestion": suggestion,
155
+ }
156
+
157
+
158
+ def _validate_section(section: Section, *, path: str, parent_level: int | None, report: ValidationReport) -> None:
159
+ if not isinstance(section.body, list):
160
+ report.add_issue(
161
+ code="invalid_section_body",
162
+ message="Section `body` must be a list.",
163
+ path=f"{path}.body",
164
+ **_ctx(section=section, context_type="section", context_id=section.id, context_anchor=section.anchor, location=f"{path}.body", suggestion="Set `body` to an array of `content` / `subsection` items."),
165
+ )
166
+ return
167
+
168
+ if parent_level is None:
169
+ if section.level < 1:
170
+ report.add_issue(
171
+ code="invalid_top_section_level",
172
+ message=f"Top-level section level should be >= 1. Got {section.level}.",
173
+ path=f"{path}.level",
174
+ **_ctx(section=section, context_type="section", context_id=section.id, context_anchor=section.anchor, location=f"{path}.level", suggestion="Top-level sections are normally level 1."),
175
+ )
176
+ elif section.level != parent_level + 1:
177
+ report.add_issue(
178
+ code="invalid_section_level",
179
+ message=f"Child section level should be parent level + 1. Got parent={parent_level}, child={section.level}.",
180
+ path=f"{path}.level",
181
+ **_ctx(section=section, context_type="section", context_id=section.id, context_anchor=section.anchor, location=f"{path}.level", suggestion="Keep formal subsections strictly aligned with the section tree, instead of using heading-like text styles."),
182
+ )
183
+
184
+ for body_index, item in enumerate(section.body):
185
+ item_path = f"{path}.body[{body_index}]"
186
+ if item.itemType not in {"content", "subsection"}:
187
+ report.add_issue(
188
+ code="invalid_body_item_type",
189
+ message=f"Unsupported body itemType: {item.itemType!r}",
190
+ path=f"{item_path}.itemType",
191
+ **_ctx(section=section, context_type="body_item", location=f"{item_path}.itemType", suggestion="Use `itemType = \"content\"` or `itemType = \"subsection\"`."),
192
+ )
193
+
194
+ if isinstance(item, ContentItem):
195
+ if not isinstance(item.blocks, list):
196
+ report.add_issue(
197
+ code="invalid_content_blocks",
198
+ message="`content.blocks` must be a list.",
199
+ path=f"{item_path}.blocks",
200
+ **_ctx(section=section, context_type="content", location=f"{item_path}.blocks", suggestion="Wrap consecutive content blocks in an array."),
201
+ )
202
+ continue
203
+ for block_index, block in enumerate(item.blocks):
204
+ block_path = f"{item_path}.blocks[{block_index}]"
205
+ if isinstance(block, TextBlock):
206
+ _validate_text_block_structure(block, path=block_path, section=section, report=report)
207
+ elif isinstance(item, SubsectionItem):
208
+ _validate_section(item.section, path=f"{item_path}.section", parent_level=section.level, report=report)
209
+
210
+
211
+ def _validate_text_block_structure(block: TextBlock, *, path: str, section: Section | None, report: ValidationReport) -> None:
212
+ if block.style not in ALLOWED_TEXT_STYLES:
213
+ report.add_issue(
214
+ code="invalid_text_style",
215
+ message=f"Unsupported text block style: {block.style!r}",
216
+ path=f"{path}.style",
217
+ **_ctx(section=section, context_type="text_block", location=f"{path}.style", suggestion="Use one of the protocol-approved text styles only."),
218
+ )
219
+
220
+ if block.style in {"h1", "h2", "h3", "h4"}:
221
+ report.add_issue(
222
+ code="forbidden_heading_style",
223
+ message="Formal heading styles h1/h2/h3/h4 must not appear in `content.blocks`.",
224
+ path=f"{path}.style",
225
+ **_ctx(section=section, context_type="text_block", location=f"{path}.style", suggestion="Use formal `Section` / `subsection` nodes for document structure, and reserve text styles like `subheading` for in-body visual grouping."),
226
+ )
227
+
228
+ if block.listItem is None and block.level is not None:
229
+ report.add_issue(
230
+ code="invalid_list_level_without_list_item",
231
+ message="`level` must not appear without `listItem`.",
232
+ path=f"{path}.level",
233
+ **_ctx(section=section, context_type="text_block", location=f"{path}.level", suggestion="Set both `listItem` and `level`, or remove `level`."),
234
+ )
235
+
236
+ if block.level is not None and block.level < 1:
237
+ report.add_issue(
238
+ code="invalid_list_level",
239
+ message="Text block list level must be >= 1.",
240
+ path=f"{path}.level",
241
+ **_ctx(section=section, context_type="text_block", location=f"{path}.level", suggestion="List nesting is 1-based in this package."),
242
+ )
243
+
244
+ mark_def_keys: set[str] = set()
245
+ for mark_def_index, mark_def in enumerate(block.markDefs):
246
+ if mark_def.key in mark_def_keys:
247
+ report.add_issue(
248
+ code="duplicate_mark_def_key",
249
+ message=f"Duplicate markDef key in the same block: {mark_def.key!r}",
250
+ path=f"{path}.markDefs[{mark_def_index}]._key",
251
+ **_ctx(section=section, context_type="text_block", location=f"{path}.markDefs[{mark_def_index}]", suggestion="Keep each markDef key unique within one text block."),
252
+ )
253
+ mark_def_keys.add(mark_def.key)
254
+
255
+ for child_index, child in enumerate(block.children):
256
+ if not isinstance(child, Span):
257
+ continue
258
+ for mark_index, mark in enumerate(child.marks):
259
+ if mark not in ALLOWED_DECORATOR_MARKS and mark not in mark_def_keys:
260
+ report.add_issue(
261
+ code="unresolved_mark_reference",
262
+ message=f"Span mark {mark!r} is neither a known decorator mark nor a key present in `markDefs[]`.",
263
+ path=f"{path}.children[{child_index}].marks[{mark_index}]",
264
+ **_ctx(section=section, context_type="span", location=f"{path}.children[{child_index}]", suggestion="Either use a built-in decorator mark or define the key in `markDefs[]` of the same block."),
265
+ )
266
+
267
+
268
+ def _validate_bibliography(entries: list[BibliographyEntry], *, report: ValidationReport) -> None:
269
+ for index, entry in enumerate(entries):
270
+ path = f"bibliography[{index}]"
271
+ if not entry.type:
272
+ report.add_issue(
273
+ code="invalid_bibliography_type",
274
+ message="`type` is required.",
275
+ path=f"{path}.type",
276
+ contextType="bibliography_item",
277
+ contextId=entry.id,
278
+ contextAnchor=entry.anchor,
279
+ location=f"{path}.type",
280
+ suggestion="Set a normalized bibliography type such as `article`, `book`, or `misc`.",
281
+ )
282
+ if not entry.title:
283
+ report.add_issue(
284
+ code="invalid_bibliography_title",
285
+ message="`title` is required.",
286
+ path=f"{path}.title",
287
+ contextType="bibliography_item",
288
+ contextId=entry.id,
289
+ contextAnchor=entry.anchor,
290
+ location=f"{path}.title",
291
+ suggestion="Provide a stable human-readable title for bibliography entries.",
292
+ )
293
+ if not isinstance(entry.authors, list):
294
+ report.add_issue(
295
+ code="invalid_bibliography_authors",
296
+ message="`authors` must be a list.",
297
+ path=f"{path}.authors",
298
+ contextType="bibliography_item",
299
+ contextId=entry.id,
300
+ contextAnchor=entry.anchor,
301
+ location=f"{path}.authors",
302
+ suggestion="Use a string array, even if there is only one author.",
303
+ )
304
+ elif len(entry.authors) == 0:
305
+ report.add_issue(
306
+ code="empty_bibliography_authors",
307
+ message="`authors` should not be empty according to the current protocol guidance.",
308
+ path=f"{path}.authors",
309
+ severity="warning",
310
+ contextType="bibliography_item",
311
+ contextId=entry.id,
312
+ contextAnchor=entry.anchor,
313
+ location=f"{path}.authors",
314
+ suggestion="Prefer a non-empty author list for better citation quality.",
315
+ )
316
+ if entry.year is None:
317
+ report.add_issue(
318
+ code="missing_bibliography_year",
319
+ message="`year` is recommended for bibliography entries.",
320
+ path=f"{path}.year",
321
+ severity="warning",
322
+ contextType="bibliography_item",
323
+ contextId=entry.id,
324
+ contextAnchor=entry.anchor,
325
+ location=f"{path}.year",
326
+ suggestion="Add `year` if known; it helps later rendering and citation styling.",
327
+ )
328
+
329
+
330
+ def _validate_footnotes(entries: list[FootnoteEntry], *, report: ValidationReport) -> None:
331
+ for index, entry in enumerate(entries):
332
+ path = f"footnotes[{index}]"
333
+ if not isinstance(entry.blocks, list):
334
+ report.add_issue(
335
+ code="invalid_footnote_blocks",
336
+ message="`blocks` must be a list.",
337
+ path=f"{path}.blocks",
338
+ contextType="footnote",
339
+ contextId=entry.id,
340
+ contextAnchor=entry.anchor,
341
+ location=f"{path}.blocks",
342
+ suggestion="Store footnote content as a Portable Text block array.",
343
+ )
344
+ continue
345
+ if len(entry.blocks) == 0:
346
+ report.add_issue(
347
+ code="empty_footnote_blocks",
348
+ message="`footnotes[].blocks` should not be empty.",
349
+ path=f"{path}.blocks",
350
+ contextType="footnote",
351
+ contextId=entry.id,
352
+ contextAnchor=entry.anchor,
353
+ location=f"{path}.blocks",
354
+ suggestion="Provide at least one text block for each footnote entry.",
355
+ )
356
+ continue
357
+ for block_index, block in enumerate(entry.blocks):
358
+ _validate_text_block_structure(block, path=f"{path}.blocks[{block_index}]", section=None, report=report)
359
+
360
+
361
+ def _validate_glossary(entries: list[GlossaryEntry], *, report: ValidationReport) -> None:
362
+ for index, entry in enumerate(entries):
363
+ path = f"glossary[{index}]"
364
+ if not entry.term:
365
+ report.add_issue(
366
+ code="invalid_glossary_term",
367
+ message="`term` is required.",
368
+ path=f"{path}.term",
369
+ contextType="glossary_term",
370
+ contextId=entry.id,
371
+ contextAnchor=entry.anchor,
372
+ location=f"{path}.term",
373
+ suggestion="Glossary entries should have a stable visible term string.",
374
+ )
375
+ if not entry.definition:
376
+ report.add_issue(
377
+ code="invalid_glossary_definition",
378
+ message="`definition` is required.",
379
+ path=f"{path}.definition",
380
+ contextType="glossary_term",
381
+ contextId=entry.id,
382
+ contextAnchor=entry.anchor,
383
+ location=f"{path}.definition",
384
+ suggestion="Provide a non-empty definition for glossary links to resolve meaningfully.",
385
+ )
386
+ if entry.aliases is not None:
387
+ if not isinstance(entry.aliases, list):
388
+ report.add_issue(
389
+ code="invalid_glossary_aliases",
390
+ message="`aliases` must be a list.",
391
+ path=f"{path}.aliases",
392
+ contextType="glossary_term",
393
+ contextId=entry.id,
394
+ contextAnchor=entry.anchor,
395
+ location=f"{path}.aliases",
396
+ suggestion="Use a string array for aliases.",
397
+ )
398
+ elif len(set(entry.aliases)) != len(entry.aliases):
399
+ report.add_issue(
400
+ code="duplicate_glossary_alias",
401
+ message="Duplicate alias strings found in one glossary entry.",
402
+ path=f"{path}.aliases",
403
+ severity="warning",
404
+ contextType="glossary_term",
405
+ contextId=entry.id,
406
+ contextAnchor=entry.anchor,
407
+ location=f"{path}.aliases",
408
+ suggestion="Remove repeated alias strings inside the same glossary entry.",
409
+ )
410
+
411
+
412
+ def _validate_section_references(section: Section, *, path: str, resolver: DocumentResolver, report: ValidationReport) -> None:
413
+ for body_index, item in enumerate(section.body):
414
+ item_path = f"{path}.body[{body_index}]"
415
+ if isinstance(item, ContentItem):
416
+ for block_index, block in enumerate(item.blocks):
417
+ block_path = f"{item_path}.blocks[{block_index}]"
418
+ if isinstance(block, TextBlock):
419
+ _validate_text_block_inline_references(block, path=block_path, section=section, resolver=resolver, report=report)
420
+ elif isinstance(block, ImageBlock):
421
+ if resolver.resolve_xref(target_type="image_asset", target_id=block.imageRef) is None:
422
+ report.add_issue(
423
+ code="unresolved_image_ref",
424
+ message=f"`imageRef` cannot be resolved: {block.imageRef!r}",
425
+ path=f"{block_path}.imageRef",
426
+ **_ctx(section=section, context_type="image", context_id=block.id, context_anchor=block.anchor, location=f"{block_path}.imageRef", suggestion="Add the corresponding entry to `assets.images`, or fix the referenced id."),
427
+ )
428
+ elif isinstance(block, ChartBlock):
429
+ if resolver.resolve_xref(target_type="chart_dataset", target_id=block.chartRef) is None:
430
+ report.add_issue(
431
+ code="unresolved_chart_ref",
432
+ message=f"`chartRef` cannot be resolved: {block.chartRef!r}",
433
+ path=f"{block_path}.chartRef",
434
+ **_ctx(section=section, context_type="chart", context_id=block.id, context_anchor=block.anchor, location=f"{block_path}.chartRef", suggestion="Add the corresponding entry to `datasets.charts`, or fix the referenced id."),
435
+ )
436
+ elif isinstance(block, TableBlock):
437
+ if resolver.resolve_xref(target_type="table_dataset", target_id=block.tableRef) is None:
438
+ report.add_issue(
439
+ code="unresolved_table_ref",
440
+ message=f"`tableRef` cannot be resolved: {block.tableRef!r}",
441
+ path=f"{block_path}.tableRef",
442
+ **_ctx(section=section, context_type="table", context_id=block.id, context_anchor=block.anchor, location=f"{block_path}.tableRef", suggestion="Add the corresponding entry to `datasets.tables`, or fix the referenced id."),
443
+ )
444
+ elif isinstance(block, CalloutBlock):
445
+ for sub_index, sub_block in enumerate(block.blocks):
446
+ _validate_text_block_structure(sub_block, path=f"{block_path}.blocks[{sub_index}]", section=section, report=report)
447
+ _validate_text_block_inline_references(sub_block, path=f"{block_path}.blocks[{sub_index}]", section=section, resolver=resolver, report=report)
448
+ elif isinstance(item, SubsectionItem):
449
+ _validate_section_references(item.section, path=f"{item_path}.section", resolver=resolver, report=report)
450
+
451
+
452
+ def _validate_text_block_inline_references(block: TextBlock, *, path: str, section: Section, resolver: DocumentResolver, report: ValidationReport) -> None:
453
+ for child_index, child in enumerate(block.children):
454
+ child_path = f"{path}.children[{child_index}]"
455
+ if isinstance(child, XRef):
456
+ if not resolver.is_supported_target_type(child.targetType):
457
+ report.add_issue(
458
+ code="unsupported_xref_target_type",
459
+ message=f"Unsupported `xref.targetType`: {child.targetType!r}. Supported values include: {sorted(resolver.supported_target_types())}",
460
+ path=f"{child_path}.targetType",
461
+ **_ctx(section=section, context_type="xref", location=f"{child_path}.targetType", suggestion="Use one of the resolver-supported semantic types, such as `section`, `figure`, `table`, `equation`, `footnote`, or `bibliography`."),
462
+ )
463
+ continue
464
+ target = resolver.resolve_xref(target_type=child.targetType, target_id=child.targetId)
465
+ if target is None:
466
+ report.add_issue(
467
+ code="unresolved_xref",
468
+ message=f"`xref` target cannot be resolved: targetType={child.targetType!r}, targetId={child.targetId!r}",
469
+ path=child_path,
470
+ **_ctx(section=section, context_type="xref", location=child_path, suggestion="Check both `targetType` and `targetId`, and confirm that the target object exists exactly once in the document."),
471
+ )
472
+ elif isinstance(child, CitationRef):
473
+ if len(child.refIds) == 0:
474
+ report.add_issue(
475
+ code="empty_citation_ref",
476
+ message="`citation_ref.refIds` must not be empty.",
477
+ path=f"{child_path}.refIds",
478
+ **_ctx(section=section, context_type="citation_ref", location=f"{child_path}.refIds", suggestion="Provide at least one bibliography id."),
479
+ )
480
+ continue
481
+ for ref_index, ref_id in enumerate(child.refIds):
482
+ target = resolver.resolve_xref(target_type="bibliography_item", target_id=ref_id)
483
+ if target is None:
484
+ report.add_issue(
485
+ code="unresolved_citation_ref",
486
+ message=f"`citation_ref` target cannot be resolved: {ref_id!r}",
487
+ path=f"{child_path}.refIds[{ref_index}]",
488
+ **_ctx(section=section, context_type="citation_ref", location=f"{child_path}.refIds[{ref_index}]", suggestion="Create a matching bibliography entry under the top-level `bibliography` registry."),
489
+ )
490
+ else:
491
+ # Optional future hook: successful resolution context is now available if needed.
492
+ # 这里保留成功解析的 target 上下文,方便未来继续扩展校验逻辑。
493
+ _ = _ctx_from_target(target)
494
+ elif isinstance(child, FootnoteRef):
495
+ target = resolver.resolve_xref(target_type="footnote", target_id=child.refId)
496
+ if target is None:
497
+ report.add_issue(
498
+ code="unresolved_footnote_ref",
499
+ message=f"`footnote_ref` target cannot be resolved: {child.refId!r}",
500
+ path=f"{child_path}.refId",
501
+ **_ctx(section=section, context_type="footnote_ref", location=f"{child_path}.refId", suggestion="Create a matching top-level footnote entry."),
502
+ )
503
+ elif isinstance(child, GlossaryTerm):
504
+ target = resolver.resolve_xref(target_type="glossary_term", target_id=child.termId)
505
+ if target is None:
506
+ report.add_issue(
507
+ code="unresolved_glossary_term",
508
+ message=f"`glossary_term` target cannot be resolved: {child.termId!r}",
509
+ path=f"{child_path}.termId",
510
+ **_ctx(section=section, context_type="glossary_term_ref", location=f"{child_path}.termId", suggestion="Create a matching top-level glossary entry."),
511
+ )
@@ -0,0 +1,9 @@
1
+ """
2
+ Package version information for OVAPortableText.
3
+ OVAPortableText 的包版本信息。
4
+
5
+ This module provides a single source of truth for the runtime package version.
6
+ 本模块提供运行时版本号的单一事实来源。
7
+ """
8
+
9
+ __version__ = "0.1.1"