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,303 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Top-level document models for OVAPortableText.
5
+ OVAPortableText 的顶层文档模型。
6
+
7
+ This module intentionally keeps the document object as the primary user entry.
8
+ 本模块刻意让 document 对象成为用户的主入口。
9
+
10
+ Why design it this way?
11
+ 为什么这样设计?
12
+ Because the target usage style of this package is close to common document libraries:
13
+ 因为本包希望尽量接近常见文档库的使用方式:
14
+ - create one top-level document object / 先创建一个顶层 document 对象
15
+ - keep appending sections and resources / 再不断 append section 与资源
16
+ - finally validate, resolve, number, and export / 最后统一校验、解析、编号、导出
17
+
18
+ This file therefore contains:
19
+ 因此本文件同时承载:
20
+ 1. top-level document state / 顶层文档状态
21
+ 2. typed-but-extensible metadata / 强类型但可扩展的元信息模型
22
+ 3. convenience methods for append-style authoring / 便于 append 风格写作的快捷方法
23
+ """
24
+
25
+ from typing import Any
26
+
27
+ from pydantic import ConfigDict, Field
28
+
29
+ from .base import OvaBaseModel
30
+ from .numbering import DocumentNumbering, NumberingConfig
31
+ from .registry import (
32
+ AssetsRegistry,
33
+ AttachmentAsset,
34
+ BackgroundAsset,
35
+ BibliographyEntry,
36
+ DatasetsRegistry,
37
+ FootnoteEntry,
38
+ GlossaryEntry,
39
+ IconAsset,
40
+ ImageAsset,
41
+ LogoAsset,
42
+ MetricDataset,
43
+ PieChartDataset,
44
+ TableDataset,
45
+ )
46
+ from .section import Section
47
+ from .theme import ThemeConfig
48
+
49
+
50
+ class DocumentMeta(OvaBaseModel):
51
+ """
52
+ Top-level document metadata.
53
+ 文档顶层元数据。
54
+
55
+ Why keep this model "typed but extensible"?
56
+ 为什么把它设计成“强类型但可扩展”?
57
+ Because the protocol explicitly lists common metadata directions,
58
+ but also states that the full detailed field table is not frozen in v1.0.
59
+ 因为协议已经明确列出了常见元信息方向,
60
+ 但同时也说明 v1.0 尚未冻结完整字段表。
61
+
62
+ Therefore / 因此:
63
+ - common fields get typed access / 常见字段提供强类型访问
64
+ - unknown extra fields are still allowed / 仍允许附加未知扩展字段
65
+ """
66
+
67
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
68
+
69
+ title: str | None = None
70
+ subtitle: str | None = None
71
+ language: str | None = None
72
+ author: str | None = None
73
+ date: str | None = None
74
+ reportNumber: str | None = None
75
+ documentType: str | None = None
76
+ confidentiality: str | None = None
77
+ generatedBy: str | None = None
78
+ generatedAt: str | None = None
79
+ clientId: str | None = None
80
+ projectId: str | None = None
81
+
82
+ # Protocol-mentioned likely extension directions.
83
+ # 协议中明确提到、后续大概率会继续使用的扩展方向。
84
+ reportType: str | None = None
85
+ clientName: str | None = None
86
+ locale: str | None = None
87
+ source: str | None = None
88
+
89
+
90
+ class Document(OvaBaseModel):
91
+ """
92
+ Top-level report document.
93
+ 顶层报告文档对象。
94
+
95
+ Important protocol alignment / 关键协议对齐点:
96
+ - `schemaVersion` defaults to `report.v1`
97
+ `schemaVersion` 默认值为 `report.v1`
98
+ - `theme` is preserved as a placeholder, but now has a lightweight typed model
99
+ `theme` 仍然是占位层,但现在有一个轻量强类型模型
100
+ - top-level registries should always exist, even when empty
101
+ 顶层 registry 即使为空也应存在
102
+ """
103
+
104
+ schemaVersion: str = "report.v1"
105
+ meta: DocumentMeta = Field(default_factory=DocumentMeta)
106
+ theme: ThemeConfig = Field(default_factory=ThemeConfig)
107
+ assets: AssetsRegistry = Field(default_factory=AssetsRegistry)
108
+ datasets: DatasetsRegistry = Field(default_factory=DatasetsRegistry)
109
+ bibliography: list[BibliographyEntry] = Field(default_factory=list)
110
+ footnotes: list[FootnoteEntry] = Field(default_factory=list)
111
+ glossary: list[GlossaryEntry] = Field(default_factory=list)
112
+ sections: list[Section] = Field(default_factory=list)
113
+
114
+ def append_section(self, section: Section) -> "Document":
115
+ """
116
+ Append one top-level section.
117
+ 追加一个顶层 section。
118
+ """
119
+ self.sections.append(section)
120
+ return self
121
+
122
+ def append_sections(self, *sections: Section) -> "Document":
123
+ """
124
+ Append multiple top-level sections in one call.
125
+ 一次追加多个顶层 section。
126
+ """
127
+ self.sections.extend(sections)
128
+ return self
129
+
130
+ def new_section(
131
+ self,
132
+ *,
133
+ id: str,
134
+ level: int,
135
+ title: str,
136
+ numbering: str = "auto",
137
+ anchor: str | None = None,
138
+ append: bool = True,
139
+ ) -> Section:
140
+ """
141
+ Create a new top-level section and optionally append it immediately.
142
+ 创建一个新的顶层 section,并可选择立刻 append 到 document。
143
+
144
+ Why return the section object?
145
+ 为什么返回 section 对象?
146
+ So callers can immediately continue writing content like:
147
+ 这样调用者可以立刻继续写内容,例如:
148
+
149
+ sec = doc.new_section(...)
150
+ sec.append_paragraph(...)
151
+ """
152
+ section = Section(id=id, level=level, title=title, numbering=numbering, anchor=anchor)
153
+ if append:
154
+ self.append_section(section)
155
+ return section
156
+
157
+ # ------------------------------------------------------------------
158
+ # Asset registry helpers / 资源 registry helper
159
+ # ------------------------------------------------------------------
160
+ def add_image_asset(self, asset: ImageAsset) -> "Document":
161
+ """
162
+ Append an `assets.images` entry.
163
+ 追加一个 `assets.images` 条目。
164
+ """
165
+ self.assets.append_image(asset)
166
+ return self
167
+
168
+ def add_logo_asset(self, asset: LogoAsset) -> "Document":
169
+ """
170
+ Append an `assets.logos` entry.
171
+ 追加一个 `assets.logos` 条目。
172
+ """
173
+ self.assets.append_logo(asset)
174
+ return self
175
+
176
+ def add_background_asset(self, asset: BackgroundAsset) -> "Document":
177
+ """
178
+ Append an `assets.backgrounds` entry.
179
+ 追加一个 `assets.backgrounds` 条目。
180
+ """
181
+ self.assets.append_background(asset)
182
+ return self
183
+
184
+ def add_icon_asset(self, asset: IconAsset) -> "Document":
185
+ """
186
+ Append an `assets.icons` entry.
187
+ 追加一个 `assets.icons` 条目。
188
+ """
189
+ self.assets.append_icon(asset)
190
+ return self
191
+
192
+ def add_attachment_asset(self, asset: AttachmentAsset) -> "Document":
193
+ """
194
+ Append an `assets.attachments` entry.
195
+ 追加一个 `assets.attachments` 条目。
196
+ """
197
+ self.assets.append_attachment(asset)
198
+ return self
199
+
200
+ # ------------------------------------------------------------------
201
+ # Dataset registry helpers / 数据 registry helper
202
+ # ------------------------------------------------------------------
203
+ def add_table_dataset(self, table: TableDataset) -> "Document":
204
+ """
205
+ Append a `datasets.tables` entry.
206
+ 追加一个 `datasets.tables` 条目。
207
+ """
208
+ self.datasets.append_table(table)
209
+ return self
210
+
211
+ def add_chart_dataset(self, chart: PieChartDataset) -> "Document":
212
+ """
213
+ Append a `datasets.charts` entry.
214
+ 追加一个 `datasets.charts` 条目。
215
+ """
216
+ self.datasets.append_chart(chart)
217
+ return self
218
+
219
+ def add_metric_dataset(self, metric: MetricDataset) -> "Document":
220
+ """
221
+ Append a `datasets.metrics` entry.
222
+ 追加一个 `datasets.metrics` 条目。
223
+ """
224
+ self.datasets.append_metric(metric)
225
+ return self
226
+
227
+ # ------------------------------------------------------------------
228
+ # Academic / auxiliary registry helpers / 学术与辅助 registry helper
229
+ # ------------------------------------------------------------------
230
+ def add_bibliography_entry(self, entry: BibliographyEntry) -> "Document":
231
+ """
232
+ Append one bibliography entry.
233
+ 追加一个 bibliography 条目。
234
+ """
235
+ self.bibliography.append(entry)
236
+ return self
237
+
238
+ def add_footnote(self, entry: FootnoteEntry) -> "Document":
239
+ """
240
+ Append one footnote entry.
241
+ 追加一个 footnote 条目。
242
+ """
243
+ self.footnotes.append(entry)
244
+ return self
245
+
246
+ def add_glossary_entry(self, entry: GlossaryEntry) -> "Document":
247
+ """
248
+ Append one glossary entry.
249
+ 追加一个 glossary 条目。
250
+ """
251
+ self.glossary.append(entry)
252
+ return self
253
+
254
+ def build_resolver(self):
255
+ """
256
+ Build a global resolver / index for the current document.
257
+ 为当前文档构建全局 resolver / 索引器。
258
+ """
259
+ from .resolver import DocumentResolver
260
+ return DocumentResolver.from_document(self)
261
+
262
+ def build_numbering(self, config: NumberingConfig | None = None) -> DocumentNumbering:
263
+ """
264
+ Build logical numbering hints for sections / figures / tables / equations.
265
+ 为 sections / figures / tables / equations 构建逻辑编号辅助。
266
+
267
+ Important boundary / 重要边界:
268
+ this method only computes logical display-number hints.
269
+ 这个方法只计算逻辑层 display number 辅助。
270
+ It does NOT decide final renderer wording such as "Figure 3" vs "Fig. 3".
271
+ 它不会决定最终渲染层文本,例如到底显示为 “Figure 3” 还是 “Fig. 3”。
272
+ """
273
+ return DocumentNumbering.from_document(self, config=config)
274
+
275
+ def validate(self):
276
+ """
277
+ Validate the whole document and return a structured report.
278
+ 校验整份文档并返回结构化报告。
279
+ """
280
+ from .validator import validate_document
281
+ return validate_document(self)
282
+
283
+ def assert_valid(self) -> "Document":
284
+ """
285
+ Validate the document and raise if invalid.
286
+ 校验文档;若无效则抛错。
287
+ """
288
+ from .validator import assert_valid_document
289
+ return assert_valid_document(self)
290
+
291
+ @classmethod
292
+ def from_meta(
293
+ cls,
294
+ *,
295
+ theme: ThemeConfig | dict[str, Any] | None = None,
296
+ **meta_fields: Any,
297
+ ) -> "Document":
298
+ """
299
+ Alternate constructor that builds a document from metadata fields directly.
300
+ 一个备用构造器:直接从 metadata 字段创建 document。
301
+ """
302
+ theme_value = theme if isinstance(theme, ThemeConfig) else ThemeConfig(**(theme or {}))
303
+ return cls(meta=DocumentMeta(**meta_fields), theme=theme_value)
@@ -0,0 +1,234 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Validation-related exception and report models for OVAPortableText.
5
+ OVAPortableText 的校验异常与报告模型。
6
+
7
+ This file is intentionally part of the public API.
8
+ 本文件刻意作为公开 API 的一部分。
9
+
10
+ Why?
11
+ 为什么?
12
+ Because validation is not only an internal implementation detail.
13
+ 因为校验并不只是内部实现细节。
14
+ For this package, validation is one of the core user-facing workflows:
15
+ 对本包而言,校验本身就是核心对外工作流之一:
16
+ - build a document / 构建文档
17
+ - inspect a structured validation report / 查看结构化校验报告
18
+ - optionally fail fast / 必要时快速抛错中止
19
+
20
+ Step 8 extends the report so issues carry more maintenance-friendly context,
21
+ which is especially useful when the user later debugs large report documents.
22
+ 第 8 步进一步扩展了报告结构,让 issue 带有更多便于维护的上下文;
23
+ 当后续调试大型报告文档时,这会非常有帮助。
24
+ """
25
+
26
+ from collections import Counter
27
+ from typing import Literal
28
+
29
+ from pydantic import Field
30
+
31
+ from .base import OvaBaseModel
32
+
33
+
34
+ class ValidationIssue(OvaBaseModel):
35
+ """
36
+ One validation issue found in the document.
37
+ 文档中发现的一条校验问题。
38
+
39
+ Besides the basic code / message / path fields,
40
+ Step 8 also carries optional context that helps users quickly locate the
41
+ problematic semantic object.
42
+ 除了基础的 code / message / path 字段外,
43
+ 第 8 步还补充了若干可选上下文字段,便于用户快速定位出问题的语义对象。
44
+
45
+ Common examples / 常见示例:
46
+ - which section the issue belongs to / 属于哪个 section
47
+ - which object id or anchor is involved / 涉及哪个对象 id 或 anchor
48
+ - what kind of object it is / 它属于哪种对象类型
49
+ - what to try next / 建议下一步怎么修
50
+ """
51
+
52
+ code: str
53
+ message: str
54
+ path: str | None = None
55
+ severity: Literal["error", "warning"] = "error"
56
+
57
+ # Maintenance-oriented context fields.
58
+ # 面向维护与调试的上下文字段。
59
+ location: str | None = None
60
+ contextType: str | None = None
61
+ contextId: str | None = None
62
+ contextAnchor: str | None = None
63
+ sectionId: str | None = None
64
+ sectionTitle: str | None = None
65
+ suggestion: str | None = None
66
+
67
+ def to_text(self) -> str:
68
+ """
69
+ Render one issue as a compact human-readable line.
70
+ 将单条 issue 渲染为便于人工阅读的简洁文本。
71
+ """
72
+ pieces: list[str] = [f"[{self.severity.upper()}] [{self.code}] {self.message}"]
73
+ if self.path:
74
+ pieces.append(f"path={self.path}")
75
+ if self.sectionId:
76
+ section_text = self.sectionId
77
+ if self.sectionTitle:
78
+ section_text += f" ({self.sectionTitle})"
79
+ pieces.append(f"section={section_text}")
80
+ if self.contextType:
81
+ pieces.append(f"contextType={self.contextType}")
82
+ if self.contextId:
83
+ pieces.append(f"contextId={self.contextId}")
84
+ if self.contextAnchor:
85
+ pieces.append(f"contextAnchor={self.contextAnchor}")
86
+ if self.suggestion:
87
+ pieces.append(f"suggestion={self.suggestion}")
88
+ return " | ".join(pieces)
89
+
90
+
91
+ class ValidationReport(OvaBaseModel):
92
+ """
93
+ Structured validation report for an entire document.
94
+ 整份文档的结构化校验报告。
95
+
96
+ Design intent / 设计意图:
97
+ - keep the raw issue list machine-friendly / 保持 issue 列表机器可读
98
+ - but also offer direct summary helpers / 同时提供直接可用的摘要 helper
99
+ - so the report can be used in tests, logs, CLI output, or notebooks
100
+ 从而既可用于测试,也可用于日志、CLI 输出或 notebook 调试
101
+ """
102
+
103
+ isValid: bool = True
104
+ issues: list[ValidationIssue] = Field(default_factory=list)
105
+
106
+ def add_issue(
107
+ self,
108
+ *,
109
+ code: str,
110
+ message: str,
111
+ path: str | None = None,
112
+ severity: Literal["error", "warning"] = "error",
113
+ location: str | None = None,
114
+ contextType: str | None = None,
115
+ contextId: str | None = None,
116
+ contextAnchor: str | None = None,
117
+ sectionId: str | None = None,
118
+ sectionTitle: str | None = None,
119
+ suggestion: str | None = None,
120
+ ) -> "ValidationReport":
121
+ """
122
+ Append one issue into the report.
123
+ 向报告中追加一条问题记录。
124
+
125
+ `location` defaults to `path` when omitted.
126
+ 若未显式提供 `location`,则默认回退到 `path`。
127
+ """
128
+ self.issues.append(
129
+ ValidationIssue(
130
+ code=code,
131
+ message=message,
132
+ path=path,
133
+ severity=severity,
134
+ location=location or path,
135
+ contextType=contextType,
136
+ contextId=contextId,
137
+ contextAnchor=contextAnchor,
138
+ sectionId=sectionId,
139
+ sectionTitle=sectionTitle,
140
+ suggestion=suggestion,
141
+ )
142
+ )
143
+ if severity == "error":
144
+ self.isValid = False
145
+ return self
146
+
147
+ @property
148
+ def is_valid(self) -> bool:
149
+ """
150
+ Python-friendly alias for `isValid`.
151
+ 提供一个更符合 Python 命名习惯的 `isValid` 别名。
152
+
153
+ Why keep both names?
154
+ 为什么同时保留两个名字?
155
+ - `isValid` matches the JSON-facing protocol field shape.
156
+ `isValid` 对齐 JSON / 协议输出字段风格。
157
+ - `is_valid` feels more natural when the report is used as a Python object.
158
+ 当把报告当作 Python 对象使用时,`is_valid` 更顺手。
159
+ """
160
+ return self.isValid
161
+
162
+ @property
163
+ def error_count(self) -> int:
164
+ """
165
+ Number of issues with severity = error.
166
+ 严重级别为 error 的问题数量。
167
+ """
168
+ return sum(1 for issue in self.issues if issue.severity == "error")
169
+
170
+ @property
171
+ def warning_count(self) -> int:
172
+ """
173
+ Number of issues with severity = warning.
174
+ 严重级别为 warning 的问题数量。
175
+ """
176
+ return sum(1 for issue in self.issues if issue.severity == "warning")
177
+
178
+ def codes(self) -> list[str]:
179
+ """
180
+ Return issue codes in order.
181
+ 按原顺序返回全部 issue code。
182
+ """
183
+ return [issue.code for issue in self.issues]
184
+
185
+ def counts_by_code(self) -> dict[str, int]:
186
+ """
187
+ Return a frequency mapping grouped by code.
188
+ 返回按 code 聚合的出现次数映射。
189
+ """
190
+ return dict(Counter(self.codes()))
191
+
192
+ def to_text(self, *, include_warnings: bool = True) -> str:
193
+ """
194
+ Render the whole report into a readable multi-line string.
195
+ 将整份报告渲染为便于阅读的多行文本。
196
+ """
197
+ lines = [
198
+ "OVAPortableText validation report:",
199
+ f"isValid={self.isValid}",
200
+ f"errors={self.error_count}",
201
+ f"warnings={self.warning_count}",
202
+ ]
203
+ for idx, issue in enumerate(self.issues, start=1):
204
+ if issue.severity == "warning" and not include_warnings:
205
+ continue
206
+ lines.append(f"{idx}. {issue.to_text()}")
207
+ return "\n".join(lines)
208
+
209
+ def raise_for_errors(self) -> "ValidationReport":
210
+ """
211
+ Raise `DocumentValidationError` if the report is invalid.
212
+ 若报告包含错误,则抛出 `DocumentValidationError`。
213
+ """
214
+ if not self.isValid:
215
+ raise DocumentValidationError(self)
216
+ return self
217
+
218
+
219
+ class DocumentValidationError(ValueError):
220
+ """
221
+ Exception raised when document validation fails.
222
+ 当文档校验失败时抛出的异常。
223
+ """
224
+
225
+ def __init__(self, report_or_issues: ValidationReport | list[ValidationIssue]):
226
+ if isinstance(report_or_issues, ValidationReport):
227
+ self.report = report_or_issues
228
+ self.issues = report_or_issues.issues
229
+ message = report_or_issues.to_text(include_warnings=True)
230
+ else:
231
+ self.issues = report_or_issues
232
+ self.report = ValidationReport(isValid=False, issues=report_or_issues)
233
+ message = self.report.to_text(include_warnings=True)
234
+ super().__init__(message)