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,555 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Registry-entry models for OVAPortableText.
5
+ OVAPortableText 的 registry 条目模型。
6
+
7
+ This module contains typed implementations of the top-level registries.
8
+ 本模块提供顶层 registry 的强类型实现。
9
+
10
+ Current implementation focus / 当前实现重点:
11
+ - assets.images / 图片资源
12
+ - assets.logos / 品牌 logo 资源
13
+ - assets.backgrounds / 背景资源
14
+ - assets.icons / 图标资源
15
+ - assets.attachments / 附件资源
16
+ - datasets.tables / 表格数据
17
+ - datasets.charts (pie only for now) / 图表数据(当前仅正式支持 pie)
18
+ - datasets.metrics / 指标数据占位
19
+ - bibliography / footnotes / glossary
20
+
21
+ Important note / 重要说明:
22
+ The protocol intentionally leaves some registries less detailed in v1.
23
+ 协议在 v1 中有意没有把部分 registry 细字段彻底写死。
24
+ So we provide small typed cores with extension-friendly behaviour.
25
+ 因此这里采用“最小强类型核心 + 允许扩展”的方式。
26
+ """
27
+
28
+ from typing import Any, Literal
29
+
30
+ from pydantic import ConfigDict, Field, field_validator, model_validator
31
+
32
+ from .base import OvaBaseModel
33
+ from .text import TextBlock
34
+
35
+
36
+ class RegistryEntryBase(OvaBaseModel):
37
+ """
38
+ Common base for registry entries.
39
+ registry 条目的公共基类。
40
+
41
+ The protocol repeatedly uses a common entry pattern:
42
+ 协议中多次复用了一类公共条目模式:
43
+ - `id`: stable unique identifier / 稳定唯一标识
44
+ - `anchor`: render-time anchor / 渲染时锚点
45
+ - `label`: human-friendly label / 人类可读标签
46
+ - `meta`: extension bag / 扩展元数据
47
+ """
48
+
49
+ id: str
50
+ anchor: str | None = None
51
+ label: str | None = None
52
+ meta: dict[str, Any] = Field(default_factory=dict)
53
+
54
+ @model_validator(mode="after")
55
+ def set_default_anchor(self) -> "RegistryEntryBase":
56
+ """
57
+ Use `id` as fallback anchor.
58
+ 当未提供 anchor 时,使用 `id` 作为默认锚点。
59
+ """
60
+ if self.anchor is None:
61
+ self.anchor = self.id
62
+ return self
63
+
64
+
65
+ class StaticAssetBase(RegistryEntryBase):
66
+ """
67
+ Common base for lightweight static assets.
68
+ 轻量静态资源条目的公共基类。
69
+
70
+ Why introduce this layer now?
71
+ 为什么现在引入这一层?
72
+ Because the protocol has already fixed the top-level buckets:
73
+ 因为协议已经冻结了顶层桶结构:
74
+ - images
75
+ - logos
76
+ - backgrounds
77
+ - icons
78
+ - attachments
79
+
80
+ Even though their detailed fields are not all frozen yet,
81
+ it is still useful to keep a shared strongly typed minimum shape.
82
+ 即便它们的详细字段还没有全部冻结,
83
+ 共享一个最小强类型形态仍然很有价值。
84
+ """
85
+
86
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
87
+
88
+ src: str
89
+ mimeType: str | None = None
90
+ checksum: str | None = None
91
+ source: str | None = None
92
+ copyright: str | None = None
93
+ language: str | None = None
94
+
95
+
96
+ class ImageAsset(StaticAssetBase):
97
+ """
98
+ Entry in `assets.images`.
99
+ `assets.images` 中的图片资源条目。
100
+
101
+ Notes / 说明:
102
+ - `src` is the actual image location.
103
+ `src` 是图片资源实际位置。
104
+ - `alt` is not the caption.
105
+ `alt` 不是图片题注。
106
+ - caption should still prefer adjacent `figure_caption` text blocks.
107
+ 图片题注仍然优先由相邻 `figure_caption` 文本块承载。
108
+ """
109
+
110
+ alt: str | None = None
111
+ width: int | None = None
112
+ height: int | None = None
113
+
114
+
115
+ class LogoAsset(StaticAssetBase):
116
+ """
117
+ Entry in `assets.logos`.
118
+ `assets.logos` 中的品牌 logo 条目。
119
+
120
+ The protocol only fixes the existence of the bucket in v1.
121
+ 协议在 v1 中主要冻结了这个 bucket 的存在,而不是全部细字段。
122
+ So we keep the model deliberately small.
123
+ 因此这里刻意保持字段较少。
124
+ """
125
+
126
+ alt: str | None = None
127
+ width: int | None = None
128
+ height: int | None = None
129
+ variant: str | None = None
130
+
131
+
132
+ class BackgroundAsset(StaticAssetBase):
133
+ """
134
+ Entry in `assets.backgrounds`.
135
+ `assets.backgrounds` 中的背景资源条目。
136
+ """
137
+
138
+ width: int | None = None
139
+ height: int | None = None
140
+ usage: str | None = None
141
+
142
+
143
+ class IconAsset(StaticAssetBase):
144
+ """
145
+ Entry in `assets.icons`.
146
+ `assets.icons` 中的图标资源条目。
147
+ """
148
+
149
+ alt: str | None = None
150
+ family: str | None = None
151
+ size: int | None = None
152
+
153
+
154
+ class AttachmentAsset(StaticAssetBase):
155
+ """
156
+ Entry in `assets.attachments`.
157
+ `assets.attachments` 中的附件资源条目。
158
+
159
+ Attachments are not part of the main body flow,
160
+ but they may still need stable IDs and anchors for future linking.
161
+ 附件不属于正文主内容流,
162
+ 但未来仍可能需要稳定 ID 与 anchor 来承接链接或下载入口。
163
+ """
164
+
165
+ fileName: str | None = None
166
+ description: str | None = None
167
+ sizeBytes: int | None = None
168
+
169
+
170
+ class TableColumn(OvaBaseModel):
171
+ """
172
+ Column definition for `datasets.tables[].columns[]`.
173
+ `datasets.tables[].columns[]` 的列定义对象。
174
+ """
175
+
176
+ key: str
177
+ header: str
178
+
179
+
180
+ class TableDataset(RegistryEntryBase):
181
+ """
182
+ Entry in `datasets.tables`.
183
+ `datasets.tables` 中的表格数据条目。
184
+
185
+ Structure / 结构:
186
+ - `columns[]` defines order and headers
187
+ `columns[]` 定义列顺序和列标题
188
+ - `rows[]` is an object-array, each row keyed by `columns[].key`
189
+ `rows[]` 是对象数组,每行使用 `columns[].key` 作为键
190
+ """
191
+
192
+ columns: list[TableColumn] = Field(default_factory=list)
193
+ rows: list[dict[str, str | int | float | bool | None]] = Field(default_factory=list)
194
+ columnGroups: list[dict[str, Any]] | None = None
195
+ footerRows: list[dict[str, Any]] | None = None
196
+ notes: list[str] | None = None
197
+ defaultAlign: str | None = None
198
+ cellFormatRules: list[dict[str, Any]] | None = None
199
+ rowOrder: list[str] | None = None
200
+ source: str | None = None
201
+
202
+ @field_validator("columns")
203
+ @classmethod
204
+ def validate_columns(cls, value: list[TableColumn]) -> list[TableColumn]:
205
+ """
206
+ Require unique `columns[].key` values.
207
+ 要求 `columns[].key` 在同一张表内唯一。
208
+ """
209
+ seen: set[str] = set()
210
+ for col in value:
211
+ if col.key in seen:
212
+ raise ValueError(f"Duplicate table column key: {col.key}")
213
+ seen.add(col.key)
214
+ return value
215
+
216
+ @model_validator(mode="after")
217
+ def validate_rows_against_columns(self) -> "TableDataset":
218
+ """
219
+ Ensure all row keys can be found in `columns[].key`.
220
+ 确保每一行中出现的键都能在 `columns[].key` 中找到。
221
+ """
222
+ allowed_keys = {col.key for col in self.columns}
223
+ for row_index, row in enumerate(self.rows):
224
+ extra_keys = [key for key in row.keys() if key not in allowed_keys]
225
+ if extra_keys:
226
+ raise ValueError(
227
+ f"Row {row_index} contains keys not declared in columns: {extra_keys}"
228
+ )
229
+ return self
230
+
231
+
232
+ class PieSlice(OvaBaseModel):
233
+ """
234
+ One slice in a pie chart dataset.
235
+ pie chart 数据条目中的一个扇区对象。
236
+
237
+ The protocol recommends object-array slices rather than parallel arrays.
238
+ 协议推荐使用对象数组 slices,而不是并行数组。
239
+ """
240
+
241
+ key: str
242
+ label: dict[str, str] = Field(default_factory=dict)
243
+ value: int | float
244
+ description: dict[str, str] = Field(default_factory=dict)
245
+
246
+
247
+ class PieChartDataset(RegistryEntryBase):
248
+ """
249
+ Pie chart dataset entry in `datasets.charts`.
250
+ `datasets.charts` 中的 pie chart 数据条目。
251
+
252
+ Current boundary / 当前边界:
253
+ only `chartType="pie"` is formally implemented right now.
254
+ 当前仅正式实现 `chartType="pie"`。
255
+ """
256
+
257
+ chartType: Literal["pie"] = "pie"
258
+ valueUnit: str | None = None
259
+ slices: list[PieSlice] = Field(default_factory=list)
260
+
261
+ @field_validator("slices")
262
+ @classmethod
263
+ def validate_unique_slice_keys(cls, value: list[PieSlice]) -> list[PieSlice]:
264
+ """
265
+ Require unique slice keys within the chart.
266
+ 要求同一张饼图内的扇区 key 唯一。
267
+ """
268
+ seen: set[str] = set()
269
+ for item in value:
270
+ if item.key in seen:
271
+ raise ValueError(f"Duplicate pie slice key: {item.key}")
272
+ seen.add(item.key)
273
+ return value
274
+
275
+ @classmethod
276
+ def from_parallel_arrays(
277
+ cls,
278
+ *,
279
+ id: str,
280
+ area_en: list[str],
281
+ area_zh: list[str] | None,
282
+ value: list[int | float],
283
+ description_en: list[str] | None = None,
284
+ description_zh: list[str] | None = None,
285
+ label: str | None = None,
286
+ anchor: str | None = None,
287
+ meta: dict[str, Any] | None = None,
288
+ valueUnit: str | None = None,
289
+ sort_desc: bool = True,
290
+ ) -> "PieChartDataset":
291
+ """
292
+ Compatibility helper for older parallel-array pie-chart input.
293
+ 兼容旧的并行数组饼图输入风格。
294
+
295
+ The formal protocol prefers normalized `slices[]` objects.
296
+ 正式协议更推荐归一化后的 `slices[]` 对象数组。
297
+ This helper accepts an ergonomic legacy input shape and converts it.
298
+ 这个 helper 接收更顺手的旧输入形态,并自动转换。
299
+ """
300
+ area_zh = area_zh or [""] * len(area_en)
301
+ description_en = description_en or [""] * len(area_en)
302
+ description_zh = description_zh or [""] * len(area_en)
303
+
304
+ lengths = {len(area_en), len(area_zh), len(value), len(description_en), len(description_zh)}
305
+ if len(lengths) != 1:
306
+ raise ValueError("All pie-chart parallel arrays must have the same length")
307
+
308
+ rows = []
309
+ for idx, (en, zh, val, den, dzh) in enumerate(
310
+ zip(area_en, area_zh, value, description_en, description_zh, strict=True)
311
+ ):
312
+ key = cls._slugify_key(en or zh or f"slice-{idx + 1}")
313
+ item = PieSlice(
314
+ key=key,
315
+ label={k: v for k, v in {"en": en, "zh": zh}.items() if v},
316
+ value=val,
317
+ description={k: v for k, v in {"en": den, "zh": dzh}.items() if v},
318
+ )
319
+ rows.append(item)
320
+
321
+ if sort_desc:
322
+ rows.sort(key=lambda item: item.value, reverse=True)
323
+
324
+ return cls(
325
+ id=id,
326
+ anchor=anchor,
327
+ label=label,
328
+ meta=meta or {},
329
+ chartType="pie",
330
+ valueUnit=valueUnit,
331
+ slices=rows,
332
+ )
333
+
334
+ @staticmethod
335
+ def _slugify_key(text: str) -> str:
336
+ """
337
+ Create a stable-ish key from human text.
338
+ 根据可读文本生成相对稳定的 key。
339
+ """
340
+ text = text.strip().lower()
341
+ output = []
342
+ prev_dash = False
343
+ for ch in text:
344
+ if ch.isalnum():
345
+ output.append(ch)
346
+ prev_dash = False
347
+ else:
348
+ if not prev_dash:
349
+ output.append("-")
350
+ prev_dash = True
351
+ result = "".join(output).strip("-")
352
+ return result or "slice"
353
+
354
+
355
+ class MetricValue(OvaBaseModel):
356
+ """
357
+ One metric item in a metric dataset.
358
+ metric dataset 中的单个指标对象。
359
+
360
+ The protocol currently only fixes the existence of `datasets.metrics`.
361
+ 协议当前主要冻结了 `datasets.metrics` 的存在,而不是完整细字段。
362
+ So this model intentionally stays small but typed.
363
+ 因此这个模型刻意保持小而强类型。
364
+ """
365
+
366
+ key: str
367
+ label: str | None = None
368
+ value: str | int | float | bool | None = None
369
+ unit: str | None = None
370
+
371
+
372
+ class MetricDataset(RegistryEntryBase):
373
+ """
374
+ Entry in `datasets.metrics`.
375
+ `datasets.metrics` 中的指标数据条目。
376
+ """
377
+
378
+ values: list[MetricValue] = Field(default_factory=list)
379
+ source: str | None = None
380
+
381
+ @field_validator("values")
382
+ @classmethod
383
+ def validate_unique_metric_keys(cls, value: list[MetricValue]) -> list[MetricValue]:
384
+ """
385
+ Require unique metric keys within one metric dataset.
386
+ 要求同一个 metric dataset 内部 key 唯一。
387
+ """
388
+ seen: set[str] = set()
389
+ for item in value:
390
+ if item.key in seen:
391
+ raise ValueError(f"Duplicate metric key: {item.key}")
392
+ seen.add(item.key)
393
+ return value
394
+
395
+
396
+ class BibliographyEntry(RegistryEntryBase):
397
+ """
398
+ Bibliography entry.
399
+ 参考文献条目。
400
+
401
+ Protocol alignment / 与协议对齐:
402
+ the protocol's minimum semantic shape is roughly:
403
+ 协议推荐的最小语义结构大致为:
404
+ - `type`
405
+ - `title`
406
+ - `authors`
407
+ - `year`
408
+
409
+ Backward-compatible note / 向后兼容说明:
410
+ older internal examples sometimes used a single `text` field.
411
+ 较早的内部示例有时只使用一个 `text` 字段。
412
+ We still accept that optional field as a convenience fallback.
413
+ 这里仍保留这个可选字段作为兼容性回退。
414
+ """
415
+
416
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
417
+
418
+ type: str = "misc"
419
+ title: str
420
+ authors: list[str] = Field(default_factory=list)
421
+ year: int | None = None
422
+ text: str | None = None
423
+ journal: str | None = None
424
+ publisher: str | None = None
425
+ volume: str | None = None
426
+ issue: str | None = None
427
+ pages: str | None = None
428
+ doi: str | None = None
429
+ url: str | None = None
430
+ accessedAt: str | None = None
431
+ edition: str | None = None
432
+ institution: str | None = None
433
+ language: str | None = None
434
+
435
+
436
+ class FootnoteEntry(RegistryEntryBase):
437
+ """
438
+ Footnote entry.
439
+ 脚注条目。
440
+
441
+ The protocol allows `footnotes[].blocks` to reuse the text-layer rules.
442
+ 协议允许 `footnotes[].blocks` 复用文本层规则。
443
+ """
444
+
445
+ blocks: list[TextBlock] = Field(default_factory=list)
446
+
447
+
448
+ class GlossaryEntry(RegistryEntryBase):
449
+ """
450
+ Glossary / term entry.
451
+ 术语表条目。
452
+
453
+ Minimum structure / 最小结构:
454
+ - `term`
455
+ - `definition`
456
+ - optional `aliases`
457
+ 可选 `aliases`
458
+
459
+ Backward-compatible note / 向后兼容说明:
460
+ since some earlier local examples occasionally use `short`,
461
+ 较早的本地示例偶尔会用 `short`,
462
+ we keep it as an optional compatibility field.
463
+ 因此这里保留为可选兼容字段。
464
+ """
465
+
466
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
467
+
468
+ term: str
469
+ definition: str
470
+ aliases: list[str] | None = None
471
+ short: str | None = None
472
+
473
+ @field_validator("aliases")
474
+ @classmethod
475
+ def validate_aliases(cls, value: list[str] | None) -> list[str] | None:
476
+ """
477
+ If aliases exist, they must be a list.
478
+ 如果 aliases 存在,则必须是一个列表。
479
+ """
480
+ return value
481
+
482
+
483
+ class AssetsRegistry(OvaBaseModel):
484
+ """
485
+ Top-level assets registry.
486
+ 顶层 assets registry。
487
+
488
+ Protocol alignment / 与协议对齐:
489
+ the v1 protocol already freezes these buckets:
490
+ v1 协议已经冻结了这些 bucket:
491
+ - images
492
+ - logos
493
+ - backgrounds
494
+ - icons
495
+ - attachments
496
+
497
+ This implementation now gives all of them lightweight typed models.
498
+ 这一版实现已经为它们全部提供了轻量强类型模型。
499
+ """
500
+
501
+ images: list[ImageAsset] = Field(default_factory=list)
502
+ logos: list[LogoAsset] = Field(default_factory=list)
503
+ backgrounds: list[BackgroundAsset] = Field(default_factory=list)
504
+ icons: list[IconAsset] = Field(default_factory=list)
505
+ attachments: list[AttachmentAsset] = Field(default_factory=list)
506
+
507
+ def append_image(self, asset: ImageAsset) -> "AssetsRegistry":
508
+ self.images.append(asset)
509
+ return self
510
+
511
+ def append_logo(self, asset: LogoAsset) -> "AssetsRegistry":
512
+ self.logos.append(asset)
513
+ return self
514
+
515
+ def append_background(self, asset: BackgroundAsset) -> "AssetsRegistry":
516
+ self.backgrounds.append(asset)
517
+ return self
518
+
519
+ def append_icon(self, asset: IconAsset) -> "AssetsRegistry":
520
+ self.icons.append(asset)
521
+ return self
522
+
523
+ def append_attachment(self, asset: AttachmentAsset) -> "AssetsRegistry":
524
+ self.attachments.append(asset)
525
+ return self
526
+
527
+
528
+ class DatasetsRegistry(OvaBaseModel):
529
+ """
530
+ Top-level datasets registry.
531
+ 顶层 datasets registry。
532
+
533
+ Protocol alignment / 与协议对齐:
534
+ the v1 protocol already freezes these buckets:
535
+ v1 协议已经冻结了这些 bucket:
536
+ - charts
537
+ - tables
538
+ - metrics
539
+ """
540
+
541
+ charts: list[PieChartDataset] = Field(default_factory=list)
542
+ tables: list[TableDataset] = Field(default_factory=list)
543
+ metrics: list[MetricDataset] = Field(default_factory=list)
544
+
545
+ def append_chart(self, chart: PieChartDataset) -> "DatasetsRegistry":
546
+ self.charts.append(chart)
547
+ return self
548
+
549
+ def append_table(self, table: TableDataset) -> "DatasetsRegistry":
550
+ self.tables.append(table)
551
+ return self
552
+
553
+ def append_metric(self, metric: MetricDataset) -> "DatasetsRegistry":
554
+ self.metrics.append(metric)
555
+ return self