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,203 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Iterable
4
+
5
+ from .block_objects import CalloutBlock, ChartBlock, ImageBlock, MathBlock, TableBlock
6
+ from .document import Document, DocumentMeta
7
+ from .inline import CitationRef, FootnoteRef, GlossaryTerm, HardBreak, XRef
8
+ from .registry import (
9
+ AttachmentAsset,
10
+ BackgroundAsset,
11
+ BibliographyEntry,
12
+ FootnoteEntry,
13
+ GlossaryEntry,
14
+ IconAsset,
15
+ ImageAsset,
16
+ LogoAsset,
17
+ MetricDataset,
18
+ MetricValue,
19
+ PieChartDataset,
20
+ PieSlice,
21
+ TableColumn,
22
+ TableDataset,
23
+ )
24
+ from .section import Section
25
+ from .theme import ThemeConfig
26
+ from .text import (
27
+ AnnotationMarkDef,
28
+ LinkMarkDef,
29
+ ListItemStyle,
30
+ MarkDef,
31
+ Span,
32
+ TextBlock,
33
+ TextChild,
34
+ TextStyle,
35
+ )
36
+
37
+
38
+ def create_document(*, title: str | None = None, language: str | None = None, theme: ThemeConfig | dict[str, Any] | None = None, **meta_fields) -> Document:
39
+ meta = DocumentMeta(title=title, language=language, **meta_fields)
40
+ theme_value = theme if isinstance(theme, ThemeConfig) else ThemeConfig(**(theme or {}))
41
+ return Document(meta=meta, theme=theme_value)
42
+
43
+
44
+ document = create_document
45
+
46
+
47
+ def section(*, id: str, level: int, title: str, numbering: str = "auto", anchor: str | None = None) -> Section:
48
+ return Section(id=id, level=level, title=title, numbering=numbering, anchor=anchor)
49
+
50
+
51
+ def span(text: str, *, marks: list[str] | None = None) -> Span:
52
+ return Span(text=text, marks=marks or [])
53
+
54
+
55
+ def marked(text: str, *marks: str) -> Span:
56
+ return Span(text=text, marks=list(marks))
57
+
58
+
59
+ def strong(text: str) -> Span:
60
+ return Span(text=text, marks=["strong"])
61
+
62
+
63
+ def em(text: str) -> Span:
64
+ return Span(text=text, marks=["em"])
65
+
66
+
67
+ def underline(text: str) -> Span:
68
+ return Span(text=text, marks=["underline"])
69
+
70
+
71
+ def code_span(text: str) -> Span:
72
+ return Span(text=text, marks=["code"])
73
+
74
+
75
+ def link_def(*, key: str, href: str, title: str | None = None, open_in_new_tab: bool | None = None, rel: str | None = None) -> LinkMarkDef:
76
+ return LinkMarkDef(_key=key, href=href, title=title, openInNewTab=open_in_new_tab, rel=rel)
77
+
78
+
79
+ def annotation_def(*, key: str, type: str, data: dict[str, Any] | None = None) -> AnnotationMarkDef:
80
+ return AnnotationMarkDef(_key=key, _type=type, data=data or {})
81
+
82
+
83
+ def paragraph(*parts: str | TextChild, style: TextStyle = "normal", mark_defs: list[MarkDef] | None = None, list_item: ListItemStyle | None = None, level: int | None = None) -> TextBlock:
84
+ return TextBlock.from_parts(*parts, style=style, mark_defs=mark_defs, list_item=list_item, level=level)
85
+
86
+
87
+ def bullet_item(*parts: str | TextChild, level: int = 1, mark_defs: list[MarkDef] | None = None) -> TextBlock:
88
+ return TextBlock.list_block(*parts, list_item="bullet", level=level, mark_defs=mark_defs)
89
+
90
+
91
+ def number_item(*parts: str | TextChild, level: int = 1, mark_defs: list[MarkDef] | None = None) -> TextBlock:
92
+ return TextBlock.list_block(*parts, list_item="number", level=level, mark_defs=mark_defs)
93
+
94
+
95
+ def blocks_from_items(items: Iterable[str | tuple[str | TextChild, ...] | list[str | TextChild]], *, list_item: ListItemStyle, level: int = 1, mark_defs: list[MarkDef] | None = None) -> list[TextBlock]:
96
+ output: list[TextBlock] = []
97
+ for item in items:
98
+ parts = (item,) if isinstance(item, str) else tuple(item)
99
+ output.append(TextBlock.list_block(*parts, list_item=list_item, level=level, mark_defs=mark_defs))
100
+ return output
101
+
102
+
103
+ def hard_break() -> HardBreak:
104
+ return HardBreak()
105
+
106
+
107
+ def xref(*, target_type: str, target_id: str) -> XRef:
108
+ return XRef(targetType=target_type, targetId=target_id)
109
+
110
+
111
+ def citation_ref(*ref_ids: str, mode: str = "parenthetical") -> CitationRef:
112
+ return CitationRef(refIds=list(ref_ids), mode=mode)
113
+
114
+
115
+ def footnote_ref(ref_id: str) -> FootnoteRef:
116
+ return FootnoteRef(refId=ref_id)
117
+
118
+
119
+ def glossary_term(term_id: str) -> GlossaryTerm:
120
+ return GlossaryTerm(termId=term_id)
121
+
122
+
123
+ def image_block(*, id: str, image_ref: str, anchor: str | None = None) -> ImageBlock:
124
+ return ImageBlock(id=id, anchor=anchor, imageRef=image_ref)
125
+
126
+
127
+ def chart_block(*, id: str, chart_ref: str, anchor: str | None = None) -> ChartBlock:
128
+ return ChartBlock(id=id, anchor=anchor, chartRef=chart_ref)
129
+
130
+
131
+ def table_block(*, id: str, table_ref: str, anchor: str | None = None) -> TableBlock:
132
+ return TableBlock(id=id, anchor=anchor, tableRef=table_ref)
133
+
134
+
135
+ def math_block(*, id: str, latex: str, anchor: str | None = None) -> MathBlock:
136
+ return MathBlock(id=id, anchor=anchor, latex=latex)
137
+
138
+
139
+ def callout(*, id: str, blocks: list[TextBlock] | None = None, anchor: str | None = None) -> CalloutBlock:
140
+ return CalloutBlock(id=id, anchor=anchor, blocks=blocks or [])
141
+
142
+
143
+ def image_asset(*, id: str, src: str, alt: str | None = None, label: str | None = None, anchor: str | None = None, meta: dict[str, Any] | None = None, **extra) -> ImageAsset:
144
+ return ImageAsset(id=id, src=src, alt=alt, label=label, anchor=anchor, meta=meta or {}, **extra)
145
+
146
+
147
+ def logo_asset(*, id: str, src: str, alt: str | None = None, label: str | None = None, anchor: str | None = None, meta: dict[str, Any] | None = None, **extra) -> LogoAsset:
148
+ return LogoAsset(id=id, src=src, alt=alt, label=label, anchor=anchor, meta=meta or {}, **extra)
149
+
150
+
151
+ def background_asset(*, id: str, src: str, label: str | None = None, anchor: str | None = None, meta: dict[str, Any] | None = None, **extra) -> BackgroundAsset:
152
+ return BackgroundAsset(id=id, src=src, label=label, anchor=anchor, meta=meta or {}, **extra)
153
+
154
+
155
+ def icon_asset(*, id: str, src: str, alt: str | None = None, label: str | None = None, anchor: str | None = None, meta: dict[str, Any] | None = None, **extra) -> IconAsset:
156
+ return IconAsset(id=id, src=src, alt=alt, label=label, anchor=anchor, meta=meta or {}, **extra)
157
+
158
+
159
+ def attachment_asset(*, id: str, src: str, file_name: str | None = None, description: str | None = None, label: str | None = None, anchor: str | None = None, meta: dict[str, Any] | None = None, **extra) -> AttachmentAsset:
160
+ return AttachmentAsset(id=id, src=src, fileName=file_name, description=description, label=label, anchor=anchor, meta=meta or {}, **extra)
161
+
162
+
163
+ def table_column(*, key: str, header: str) -> TableColumn:
164
+ return TableColumn(key=key, header=header)
165
+
166
+
167
+ def table_dataset(*, id: str, columns: list[TableColumn], rows: list[dict], label: str | None = None, anchor: str | None = None, meta: dict[str, Any] | None = None, **extra) -> TableDataset:
168
+ return TableDataset(id=id, columns=columns, rows=rows, label=label, anchor=anchor, meta=meta or {}, **extra)
169
+
170
+
171
+ def metric_value(*, key: str, value: str | int | float | bool | None, label: str | None = None, unit: str | None = None) -> MetricValue:
172
+ return MetricValue(key=key, value=value, label=label, unit=unit)
173
+
174
+
175
+ def metric_dataset(*, id: str, values: list[MetricValue], label: str | None = None, anchor: str | None = None, meta: dict[str, Any] | None = None, **extra) -> MetricDataset:
176
+ return MetricDataset(id=id, values=values, label=label, anchor=anchor, meta=meta or {}, **extra)
177
+
178
+
179
+ def pie_slice(*, key: str, value: int | float, en: str | None = None, zh: str | None = None, description_en: str | None = None, description_zh: str | None = None) -> PieSlice:
180
+ return PieSlice(key=key, label={k: v for k, v in {"en": en, "zh": zh}.items() if v}, value=value, description={k: v for k, v in {"en": description_en, "zh": description_zh}.items() if v})
181
+
182
+
183
+ def pie_chart_dataset(*, id: str, slices: list[PieSlice], label: str | None = None, anchor: str | None = None, meta: dict[str, Any] | None = None, value_unit: str | None = None) -> PieChartDataset:
184
+ return PieChartDataset(id=id, slices=slices, label=label, anchor=anchor, meta=meta or {}, valueUnit=value_unit)
185
+
186
+
187
+ def pie_chart_from_parallel_arrays(*, id: str, area_en: list[str], area_zh: list[str] | None, value: list[int | float], description_en: list[str] | None = None, description_zh: list[str] | None = None, label: str | None = None, anchor: str | None = None, meta: dict[str, Any] | None = None, value_unit: str | None = None, sort_desc: bool = True) -> PieChartDataset:
188
+ return PieChartDataset.from_parallel_arrays(id=id, area_en=area_en, area_zh=area_zh, value=value, description_en=description_en, description_zh=description_zh, label=label, anchor=anchor, meta=meta, valueUnit=value_unit, sort_desc=sort_desc)
189
+
190
+
191
+ def bibliography_entry(*, id: str, title: str | None = None, authors: list[str] | None = None, year: int | None = None, type: str = "misc", text: str | None = None, label: str | None = None, anchor: str | None = None, meta: dict[str, Any] | None = None, **extra) -> BibliographyEntry:
192
+ final_title = title or text
193
+ if not final_title:
194
+ raise ValueError("`bibliography_entry` requires either `title` or legacy `text`.")
195
+ return BibliographyEntry(id=id, title=final_title, authors=authors or [], year=year, type=type, text=text, label=label, anchor=anchor, meta=meta or {}, **extra)
196
+
197
+
198
+ def footnote_entry(*, id: str, blocks: list[TextBlock], label: str | None = None, anchor: str | None = None, meta: dict[str, Any] | None = None) -> FootnoteEntry:
199
+ return FootnoteEntry(id=id, blocks=blocks, label=label, anchor=anchor, meta=meta or {})
200
+
201
+
202
+ def glossary_entry(*, id: str, term: str, definition: str, aliases: list[str] | None = None, short: str | None = None, label: str | None = None, anchor: str | None = None, meta: dict[str, Any] | None = None, **extra) -> GlossaryEntry:
203
+ return GlossaryEntry(id=id, term=term, definition=definition, aliases=aliases, short=short, label=label, anchor=anchor, meta=meta or {}, **extra)
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Inline object models for OVAPortableText.
5
+ OVAPortableText 的行内对象模型。
6
+
7
+ In the protocol, inline objects can only appear inside `block.children[]`.
8
+ 根据协议,行内对象只能出现在 `block.children[]` 中。
9
+ """
10
+
11
+ from typing import Literal, TypeAlias
12
+
13
+ from pydantic import Field, field_validator
14
+
15
+ from .base import OvaBaseModel
16
+
17
+
18
+ class HardBreak(OvaBaseModel):
19
+ """
20
+ Force a line break inside the same paragraph block.
21
+ 表示同一段落内部的强制换行。
22
+
23
+ This is NOT a new paragraph.
24
+ 它不是新段落。
25
+ """
26
+
27
+ type_: Literal["hard_break"] = Field(default="hard_break", alias="_type", serialization_alias="_type")
28
+
29
+
30
+ class XRef(OvaBaseModel):
31
+ """
32
+ Cross-reference to a resolvable target, such as a section or figure.
33
+ 指向可解析目标的交叉引用,例如 section 或 figure。
34
+ """
35
+
36
+ type_: Literal["xref"] = Field(default="xref", alias="_type", serialization_alias="_type")
37
+ targetType: str
38
+ targetId: str
39
+
40
+
41
+ class CitationRef(OvaBaseModel):
42
+ """
43
+ Reference one or more bibliography entries.
44
+ 引用一个或多个 bibliography 条目。
45
+ """
46
+
47
+ type_: Literal["citation_ref"] = Field(default="citation_ref", alias="_type", serialization_alias="_type")
48
+ refIds: list[str]
49
+ mode: Literal["parenthetical", "narrative"] = "parenthetical"
50
+
51
+ @field_validator("refIds")
52
+ @classmethod
53
+ def validate_ref_ids(cls, value: list[str]) -> list[str]:
54
+ """
55
+ Require at least one bibliography target.
56
+ 至少需要一个 bibliography 目标。
57
+ """
58
+ if not value:
59
+ raise ValueError("citation_ref.refIds must contain at least one bibliography id")
60
+ return value
61
+
62
+
63
+ class FootnoteRef(OvaBaseModel):
64
+ """
65
+ Reference a footnote entry.
66
+ 引用一个脚注条目。
67
+ """
68
+
69
+ type_: Literal["footnote_ref"] = Field(default="footnote_ref", alias="_type", serialization_alias="_type")
70
+ refId: str
71
+
72
+
73
+ class GlossaryTerm(OvaBaseModel):
74
+ """
75
+ Reference a glossary entry or abbreviation entry.
76
+ 引用 glossary 中的术语或缩写条目。
77
+ """
78
+
79
+ type_: Literal["glossary_term"] = Field(default="glossary_term", alias="_type", serialization_alias="_type")
80
+ termId: str
81
+
82
+
83
+ InlineObject: TypeAlias = HardBreak | XRef | CitationRef | FootnoteRef | GlossaryTerm
84
+ """
85
+ Union type of all currently supported inline objects.
86
+ 当前已支持的全部行内对象联合类型。
87
+ """
@@ -0,0 +1,227 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Display-number helper layer for OVAPortableText.
5
+ OVAPortableText 的显示编号辅助层。
6
+
7
+ Important boundary / 重要边界:
8
+ The protocol separates:
9
+ 协议明确分离了以下三层:
10
+ - stable system ID / 稳定系统 ID
11
+ - anchor / 锚点
12
+ - display number / 面向读者的显示编号
13
+
14
+ This module only computes helper-side display numbers.
15
+ 本模块只负责计算 helper 层的显示编号。
16
+ It does NOT write display numbers back into the protocol JSON.
17
+ 它不会把显示编号直接写回协议 JSON。
18
+ """
19
+
20
+ from typing import TYPE_CHECKING, Literal
21
+
22
+ from pydantic import Field
23
+
24
+ from .base import OvaBaseModel
25
+ from .block_objects import ChartBlock, ImageBlock, MathBlock, TableBlock
26
+ from .content import ContentItem
27
+ from .section import Section, SubsectionItem
28
+
29
+ if TYPE_CHECKING:
30
+ from .document import Document
31
+
32
+ ObjectNumberingMode = Literal["global", "section"]
33
+
34
+
35
+ class NumberingConfig(OvaBaseModel):
36
+ """
37
+ Configuration for helper-side display numbering.
38
+ helper 层显示编号的配置对象。
39
+
40
+ Current default / 当前默认值:
41
+ - sections: always hierarchical by tree structure
42
+ section 始终按章节树层级推导
43
+ - figure / table / equation: global continuous numbering
44
+ figure / table / equation 默认全局连续编号
45
+
46
+ Why not freeze one universal rule in the protocol?
47
+ 为什么不把这些规则直接冻结进协议本体?
48
+ Because the protocol intentionally leaves those renderer-facing choices open.
49
+ 因为协议本身刻意没有把这些更偏渲染侧的策略写死。
50
+ """
51
+
52
+ figureMode: ObjectNumberingMode = "global"
53
+ tableMode: ObjectNumberingMode = "global"
54
+ equationMode: ObjectNumberingMode = "global"
55
+
56
+
57
+ class NumberedTarget(OvaBaseModel):
58
+ """
59
+ One computed display-number entry.
60
+ 一条计算得到的显示编号记录。
61
+ """
62
+
63
+ id: str
64
+ category: Literal["section", "figure", "table", "equation"]
65
+ displayNumber: str | None = None
66
+ anchor: str | None = None
67
+ path: str
68
+
69
+
70
+ class DocumentNumbering(OvaBaseModel):
71
+ """
72
+ Computed numbering snapshot for one whole document.
73
+ 整份文档的一次编号快照。
74
+
75
+ This object is intentionally read-only in spirit.
76
+ 这个对象在设计意图上是“只读快照”。
77
+ You compute it from a document, then query it.
78
+ 它由 document 计算出来,然后供调用方查询。
79
+ """
80
+
81
+ config: NumberingConfig = Field(default_factory=NumberingConfig)
82
+ itemsById: dict[str, NumberedTarget] = Field(default_factory=dict)
83
+
84
+ @classmethod
85
+ def from_document(
86
+ cls,
87
+ document: Document,
88
+ config: NumberingConfig | None = None,
89
+ ) -> "DocumentNumbering":
90
+ """
91
+ Compute a numbering snapshot from the given document.
92
+ 从给定 document 计算一份编号快照。
93
+ """
94
+ config = config or NumberingConfig()
95
+ items: dict[str, NumberedTarget] = {}
96
+ global_counts = {"figure": 0, "table": 0, "equation": 0}
97
+
98
+ def allocate_object_number(
99
+ *,
100
+ category: Literal["figure", "table", "equation"],
101
+ section_path_numbers: list[int],
102
+ local_counts: dict[str, int],
103
+ ) -> str:
104
+ mode: ObjectNumberingMode
105
+ if category == "figure":
106
+ mode = config.figureMode
107
+ elif category == "table":
108
+ mode = config.tableMode
109
+ else:
110
+ mode = config.equationMode
111
+
112
+ if mode == "section":
113
+ local_counts[category] += 1
114
+ prefix = ".".join(str(x) for x in section_path_numbers)
115
+ return f"{prefix}-{local_counts[category]}"
116
+
117
+ global_counts[category] += 1
118
+ return str(global_counts[category])
119
+
120
+ def walk_section(section: Section, *, path: str, structural_numbers: list[int]) -> None:
121
+ section_display_number = ".".join(str(x) for x in structural_numbers) if section.numbering == "auto" else None
122
+ items[section.id] = NumberedTarget(
123
+ id=section.id,
124
+ category="section",
125
+ displayNumber=section_display_number,
126
+ anchor=section.anchor,
127
+ path=path,
128
+ )
129
+
130
+ local_counts = {"figure": 0, "table": 0, "equation": 0}
131
+
132
+ for body_index, item in enumerate(section.body):
133
+ item_path = f"{path}.body[{body_index}]"
134
+ if isinstance(item, ContentItem):
135
+ for block_index, block in enumerate(item.blocks):
136
+ block_path = f"{item_path}.blocks[{block_index}]"
137
+ if isinstance(block, (ImageBlock, ChartBlock)):
138
+ items[block.id] = NumberedTarget(
139
+ id=block.id,
140
+ category="figure",
141
+ displayNumber=allocate_object_number(
142
+ category="figure",
143
+ section_path_numbers=structural_numbers,
144
+ local_counts=local_counts,
145
+ ),
146
+ anchor=block.anchor,
147
+ path=block_path,
148
+ )
149
+ elif isinstance(block, TableBlock):
150
+ items[block.id] = NumberedTarget(
151
+ id=block.id,
152
+ category="table",
153
+ displayNumber=allocate_object_number(
154
+ category="table",
155
+ section_path_numbers=structural_numbers,
156
+ local_counts=local_counts,
157
+ ),
158
+ anchor=block.anchor,
159
+ path=block_path,
160
+ )
161
+ elif isinstance(block, MathBlock):
162
+ items[block.id] = NumberedTarget(
163
+ id=block.id,
164
+ category="equation",
165
+ displayNumber=allocate_object_number(
166
+ category="equation",
167
+ section_path_numbers=structural_numbers,
168
+ local_counts=local_counts,
169
+ ),
170
+ anchor=block.anchor,
171
+ path=block_path,
172
+ )
173
+ elif isinstance(item, SubsectionItem):
174
+ sibling_index = _next_child_index(section=item.section, parent=section)
175
+ # The actual structural path is already known from array order below.
176
+ # 真正的结构路径会由下面的顺序遍历统一给出。
177
+ walk_section(
178
+ item.section,
179
+ path=f"{item_path}.section",
180
+ structural_numbers=structural_numbers + [sibling_index],
181
+ )
182
+
183
+ def walk_section_list(sections: list[Section], *, parent_numbers: list[int], parent_path: str) -> None:
184
+ for index, section in enumerate(sections, start=1):
185
+ walk_section(
186
+ section,
187
+ path=f"{parent_path}[{index - 1}]",
188
+ structural_numbers=parent_numbers + [index],
189
+ )
190
+
191
+ def _next_child_index(section: Section, parent: Section) -> int:
192
+ """
193
+ Compute the direct-child index of `section` under `parent`.
194
+ 计算 `section` 在 `parent` 下的“直接子章节序号”。
195
+
196
+ Why do this by scanning the parent body?
197
+ 为什么通过扫描 parent.body 来做?
198
+ Because `body` can interleave content items and subsection items.
199
+ 因为 `body` 里可以交错出现 content item 与 subsection item。
200
+ We only count direct subsection items.
201
+ 我们这里只统计直接 `subsection` 项。
202
+ """
203
+ count = 0
204
+ for item in parent.body:
205
+ if isinstance(item, SubsectionItem):
206
+ count += 1
207
+ if item.section is section:
208
+ return count
209
+ return count or 1
210
+
211
+ walk_section_list(document.sections, parent_numbers=[], parent_path="sections")
212
+ return cls(config=config, itemsById=items)
213
+
214
+ def get(self, id_value: str) -> NumberedTarget | None:
215
+ """
216
+ Return the numbering record of one target by ID.
217
+ 通过 ID 返回某个目标的编号记录。
218
+ """
219
+ return self.itemsById.get(id_value)
220
+
221
+ def get_display_number(self, id_value: str) -> str | None:
222
+ """
223
+ Return only the display number string of one target.
224
+ 仅返回某个目标的显示编号字符串。
225
+ """
226
+ item = self.get(id_value)
227
+ return item.displayNumber if item else None
File without changes