From 9849a9832512f7bb9fc449c1f0e676d7fc66730f Mon Sep 17 00:00:00 2001 From: Maxanox Date: Tue, 13 Oct 2026 10:25:53 +0200 Subject: [PATCH 01/78] test: park the modules later 0.5 plans port Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS --- tests/conftest.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 39a6bb9..57acdfa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,3 +33,21 @@ def full_coverage_cnd_path() -> Path: @pytest.fixture def unpaginated_cnd_path() -> Path: return FIXTURES_DIR / "unpaginated.cnd" + + +# Test modules not yet ported to format 0.5.0 (docs/proposals/0013). The +# 0.5 series lands plan by plan on one integration branch; each plan +# removes the entries it ports, and the list must be empty before that +# branch merges into main. +collect_ignore = [ + "test_builder.py", + "test_cli.py", + "test_converters.py", + "test_declaration.py", + "test_full_coverage.py", + "test_markdown_structure.py", + "test_node_display_visitor.py", + "test_reconcile.py", + "test_render.py", + "test_visitor.py", +] From 03b231a538848ff4308abca88fa6163130055a6c Mon Sep 17 00:00:00 2001 From: Maxanox Date: Tue, 13 Oct 2026 10:28:26 +0200 Subject: [PATCH 02/78] feat(core): RichText, Mark and Numbering value objects (proposal 0013) Move the forward edge classes and RawSource into cnd.core.text, add Edge, Mark, RichText and Numbering, and regenerate the schema for the reworded NodeRef description. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS --- schema/cnd.schema.json | 2 +- src/cnd/core/nodes.py | 59 +----------------- src/cnd/core/text.py | 133 +++++++++++++++++++++++++++++++++++++++++ tests/factories.py | 8 +++ tests/test_text.py | 66 ++++++++++++++++++++ 5 files changed, 210 insertions(+), 58 deletions(-) create mode 100644 src/cnd/core/text.py create mode 100644 tests/factories.py create mode 100644 tests/test_text.py diff --git a/schema/cnd.schema.json b/schema/cnd.schema.json index 58666bb..a948dfd 100644 --- a/schema/cnd.schema.json +++ b/schema/cnd.schema.json @@ -1186,7 +1186,7 @@ "type": "object" }, "NodeRef": { - "description": "Forward cross-reference edge to another node.\n\nAn edge is ``{label, text_span?}``: it names its target by label and\nresolves through the CND's label index (docs/adr/0017, superseding the\n``{id, label}`` form of docs/adr/0002). ``text_span`` marks where the\nreference marker sits in the containing node's rendered text, as a\n``[start, end)`` pair of Unicode code-point offsets (docs/adr/0013).", + "description": "Forward cross-reference edge to another node.\n\nAn edge is ``{label, text_span?}``: it names its target by label and\nresolves through the CND's label index (docs/adr/0017, superseding the\n``{id, label}`` form of docs/adr/0002). ``text_span`` marks where the\nreference marker sits in the ``value`` of the ``RichText`` that holds\nthe edge, as a\n``[start, end)`` pair of Unicode code-point offsets (docs/adr/0013).", "properties": { "label": { "title": "Label", diff --git a/src/cnd/core/nodes.py b/src/cnd/core/nodes.py index d23cb8d..b7668fb 100644 --- a/src/cnd/core/nodes.py +++ b/src/cnd/core/nodes.py @@ -5,6 +5,8 @@ from pydantic import BaseModel, Field +from cnd.core.text import CiteRef, FootnoteRef, LinkRef, NodeRef, RawSource # noqa: F401 + class NodeLocation(BaseModel): """Layout facts the SDK cannot derive from the tree: the page where the @@ -25,63 +27,6 @@ class NodeLocation(BaseModel): page: int -class RawSource(BaseModel): - """The producer's verbatim source for a node, with its language. - - A bare string dropped the one thing a consumer needs to decide whether - to attempt a parse: which language the content is in. ``format`` is an - open string (``"typst"``, ``"latex"``, ``"mathml"``…), never a closed - enum — a new producer must not require a spec revision. - """ - - format: str - value: str - - -class NodeRef(BaseModel): - """Forward cross-reference edge to another node. - - An edge is ``{label, text_span?}``: it names its target by label and - resolves through the CND's label index (docs/adr/0017, superseding the - ``{id, label}`` form of docs/adr/0002). ``text_span`` marks where the - reference marker sits in the containing node's rendered text, as a - ``[start, end)`` pair of Unicode code-point offsets (docs/adr/0013). - """ - - label: str - text_span: list[int] | None = None - - -class CiteRef(BaseModel): - """Forward citation edge; ``label`` resolves in the CND ``bibliography`` pool.""" - - label: str - text_span: list[int] | None = None - form: Literal["normal", "prose", "full", "author", "year", "none"] | None = None - supplement: str | None = None - - -class FootnoteRef(BaseModel): - """Forward footnote edge; ``label`` resolves in the CND ``footnotes`` pool.""" - - label: str - text_span: list[int] | None = None - - -class LinkRef(BaseModel): - """Forward hyperlink edge; ``href`` resolves nowhere in the CND. - - The fourth forward family and the only one not keyed by label: a - hyperlink's target is outside the document by definition, so it admits - no resolution domain and has no dangling state — a URL is a URL. The - scheme is a consumer's business (``https:``, ``doi:``, an application - scheme); the format only carries the string. - """ - - href: str - text_span: list[int] | None = None - - class NodeBase(BaseModel): """Shared fields for every cnd node.""" diff --git a/src/cnd/core/text.py b/src/cnd/core/text.py new file mode 100644 index 0000000..36ca298 --- /dev/null +++ b/src/cnd/core/text.py @@ -0,0 +1,133 @@ +"""Rich text: prose with standoff marks and its own link families. + +Every slot that holds prose is a ``RichText`` (docs/proposals/0013, +principle 1). Every ``[start, end)`` span — a mark or an edge's +``text_span`` — indexes the ``value`` of the ``RichText`` that holds it, +in Unicode code points (docs/adr/0013). Verbatim content (code, math +source, ``raw``) and metadata stay plain strings. +""" + +from typing import Literal, Union + +from pydantic import BaseModel, Field + + +class RawSource(BaseModel): + """The producer's verbatim source for a node, with its language. + + A bare string dropped the one thing a consumer needs to decide whether + to attempt a parse: which language the content is in. ``format`` is an + open string (``"typst"``, ``"latex"``, ``"mathml"``…), never a closed + enum — a new producer must not require a spec revision. + """ + + format: str + value: str + + +class NodeRef(BaseModel): + """Forward cross-reference edge to another node. + + An edge is ``{label, text_span?}``: it names its target by label and + resolves through the CND's label index (docs/adr/0017, superseding the + ``{id, label}`` form of docs/adr/0002). ``text_span`` marks where the + reference marker sits in the ``value`` of the ``RichText`` that holds + the edge, as a + ``[start, end)`` pair of Unicode code-point offsets (docs/adr/0013). + """ + + label: str + text_span: list[int] | None = None + + +class CiteRef(BaseModel): + """Forward citation edge; ``label`` resolves in the CND ``bibliography`` pool.""" + + label: str + text_span: list[int] | None = None + form: Literal["normal", "prose", "full", "author", "year", "none"] | None = None + supplement: str | None = None + + +class FootnoteRef(BaseModel): + """Forward footnote edge; ``label`` resolves in the CND ``footnotes`` pool.""" + + label: str + text_span: list[int] | None = None + + +class LinkRef(BaseModel): + """Forward hyperlink edge; ``href`` resolves nowhere in the CND. + + The fourth forward family and the only one not keyed by label: a + hyperlink's target is outside the document by definition, so it admits + no resolution domain and has no dangling state — a URL is a URL. The + scheme is a consumer's business (``https:``, ``doi:``, an application + scheme); the format only carries the string. + """ + + href: str + text_span: list[int] | None = None + + +Edge = Union[NodeRef, CiteRef, FootnoteRef, LinkRef] +"""Any forward edge. Three are label-keyed; ``LinkRef`` is href-keyed.""" + +KNOWN_MARK_KINDS = frozenset( + {"strong", "emph", "code", "strike", "sup", "sub", "underline", "highlight", "math"} +) +"""The mark kinds the spec defines. ``Mark.kind`` is an open vocabulary: +any other string is valid, and a consumer that does not know it renders +the covered text unchanged.""" + +ATOMIC_MARK_KINDS = frozenset({"code", "math"}) +"""Verbatim marks: they contain no other mark and no edge span may reach +inside them partially (docs/proposals/0013, Marks rule 4).""" + + +class Mark(BaseModel): + """A standoff span of inline formatting inside a ``RichText.value``. + + ``raw`` is the source of the covered text, used by ``math`` (the + readable linear form lives in ``value``, the formula here). + """ + + start: int + end: int + kind: str + raw: RawSource | None = None + + +class RichText(BaseModel): + """Prose: a text, its language, its marks and its forward edges. + + An edge belongs to the text in which its marker opens; its owning + node is the nearest node holding the text. Every list defaults to + empty and may be omitted, so ``{"value": "…"}`` is a complete text. + ``lang`` null inherits from the enclosing text, then the node chain, + then ``doc.lang``. + """ + + value: str + lang: str | None = None + marks: list[Mark] = Field(default_factory=list) + refs: list[NodeRef] = Field(default_factory=list) + cites: list[CiteRef] = Field(default_factory=list) + footnotes: list[FootnoteRef] = Field(default_factory=list) + links: list[LinkRef] = Field(default_factory=list) + + def edges(self) -> list[Edge]: + """All forward edges of this text: refs, cites, footnotes, links.""" + return [*self.refs, *self.cites, *self.footnotes, *self.links] + + +class Numbering(BaseModel): + """A resolved counter as displayed: ``value`` ("3", "2.1.1", "(1)") + and the counter word in the document's language (``name``, "Figure"). + + Replaces the flat ``number`` + ``counter_label`` pair, so a word + without a number can no longer be written (docs/proposals/0013). + """ + + value: str + name: str | None = None diff --git a/tests/factories.py b/tests/factories.py new file mode 100644 index 0000000..8320945 --- /dev/null +++ b/tests/factories.py @@ -0,0 +1,8 @@ +"""Small constructors shared by the 0.5 test modules.""" + +from cnd.core.text import RichText + + +def rt(value: str, **kwargs) -> RichText: + """A RichText from a string, the shorthand every test needs.""" + return RichText(value=value, **kwargs) diff --git a/tests/test_text.py b/tests/test_text.py new file mode 100644 index 0000000..3d1d8bb --- /dev/null +++ b/tests/test_text.py @@ -0,0 +1,66 @@ +"""Rich text value objects (docs/proposals/0013).""" + +import pytest +from pydantic import ValidationError + +from cnd.core.text import ( + ATOMIC_MARK_KINDS, + KNOWN_MARK_KINDS, + CiteRef, + FootnoteRef, + LinkRef, + Mark, + NodeRef, + Numbering, + RawSource, + RichText, +) + + +def test_a_bare_value_is_a_complete_rich_text() -> None: + text = RichText.model_validate({"value": "plain"}) + assert text.marks == [] and text.refs == [] and text.links == [] + assert text.lang is None + + +def test_edges_flatten_the_four_families_in_order() -> None: + text = RichText( + value="abcdef", + refs=[NodeRef(label="r")], + cites=[CiteRef(label="c")], + footnotes=[FootnoteRef(label="f")], + links=[LinkRef(href="https://example.com")], + ) + assert [type(e).__name__ for e in text.edges()] == [ + "NodeRef", "CiteRef", "FootnoteRef", "LinkRef", + ] + + +def test_mark_kind_is_an_open_vocabulary() -> None: + mark = Mark(start=0, end=1, kind="smallcaps") + assert mark.kind == "smallcaps" + assert "smallcaps" not in KNOWN_MARK_KINDS + + +def test_known_and_atomic_mark_kinds() -> None: + assert KNOWN_MARK_KINDS == { + "strong", "emph", "code", "strike", "sup", "sub", + "underline", "highlight", "math", + } + assert ATOMIC_MARK_KINDS == {"code", "math"} + + +def test_a_math_mark_carries_its_source() -> None: + mark = Mark(start=0, end=3, kind="math", raw=RawSource(format="typst", value="x^2")) + assert mark.raw.value == "x^2" + + +def test_a_string_is_not_a_rich_text() -> None: + with pytest.raises(ValidationError): + RichText.model_validate("plain") + + +def test_numbering_requires_a_value() -> None: + assert Numbering(value="3").name is None + with pytest.raises(ValidationError): + Numbering.model_validate({"name": "Figure"}) From 93e0aad8e171d22a5e372be4d12da360899c48e4 Mon Sep 17 00:00:00 2001 From: Maxanox Date: Tue, 13 Oct 2026 10:30:31 +0200 Subject: [PATCH 03/78] =?UTF-8?q?feat(core):=200.5=20node=20model=20?= =?UTF-8?q?=E2=80=94=20RichText=20prose,=20children=20containers,=20admoni?= =?UTF-8?q?tion,=20layout=20(proposal=200013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS --- src/cnd/__init__.py | 22 +++- src/cnd/converters/html.py | 6 +- src/cnd/core/__init__.py | 17 ++- src/cnd/core/cnd.py | 24 +++- src/cnd/core/nodes.py | 217 ++++++++++++++++++++----------------- src/cnd/declaration.py | 8 +- tests/conftest.py | 6 + tests/test_models.py | 109 +++++++++++++++++++ 8 files changed, 293 insertions(+), 116 deletions(-) create mode 100644 tests/test_models.py diff --git a/src/cnd/__init__.py b/src/cnd/__init__.py index 3eea8b0..127a3e7 100644 --- a/src/cnd/__init__.py +++ b/src/cnd/__init__.py @@ -8,22 +8,27 @@ """ from cnd.core import ( + AdmonitionNode, BibEntry, + Cell, CiteRef, Cnd, CndNode, CodeNode, DocDate, DocMetadata, + Edge, FigureNode, Footnote, FootnoteRef, HeadingNode, ImageNode, + LayoutNode, LinkRef, LinkTarget, ListItem, ListNode, + Mark, MarkdownRenderer, MathNode, NodeBase, @@ -33,21 +38,24 @@ NodeTextMode, NodeTraverse, NodeTraverseContext, + Numbering, ParagraphNode, QuoteNode, RawSource, + RichText, SourceInfo, StopPredicate, - TableCell, TableNode, TermItem, TermsNode, Violation, content_hash, + edges_of, iter_nodes, node_hash, pool_entry_hash, position_totals, + texts_of, validate, ) from cnd.converters import ( @@ -59,8 +67,10 @@ from cnd.visitors import BaseVisitor, VisitTarget __all__ = [ + "AdmonitionNode", "BaseVisitor", "BibEntry", + "Cell", "CiteRef", "Cnd", "CndConverter", @@ -69,16 +79,19 @@ "ConversionResult", "DocDate", "DocMetadata", + "Edge", "FigureNode", "Footnote", "FootnoteRef", "HeadingNode", "HtmlConverter", "ImageNode", + "LayoutNode", "LinkRef", "LinkTarget", "ListItem", "ListNode", + "Mark", "MarkdownConverter", "MarkdownRenderer", "MathNode", @@ -89,21 +102,24 @@ "NodeTextMode", "NodeTraverse", "NodeTraverseContext", + "Numbering", "ParagraphNode", "QuoteNode", "RawSource", + "RichText", "SourceInfo", "StopPredicate", - "TableCell", "TableNode", "TermItem", "TermsNode", - "VisitTarget", "Violation", + "VisitTarget", "content_hash", + "edges_of", "iter_nodes", "node_hash", "pool_entry_hash", "position_totals", + "texts_of", "validate", ] diff --git a/src/cnd/converters/html.py b/src/cnd/converters/html.py index 88119c6..860e1a9 100644 --- a/src/cnd/converters/html.py +++ b/src/cnd/converters/html.py @@ -24,7 +24,7 @@ NodeBase, ParagraphNode, QuoteNode, - TableCell, + Cell, TableNode, TermsNode, ) @@ -85,7 +85,7 @@ def render_paragraph(self, node: ParagraphNode) -> str: return f"{escape(node.text)}

" def render_table(self, node: TableNode) -> str: - rows: dict[int, list[TableCell]] = {} + rows: dict[int, list[Cell]] = {} for cell in node.cells: rows.setdefault(cell.row, []).append(cell) body = [] @@ -152,7 +152,7 @@ def render_terms(self, node: TermsNode) -> str: return f"{items}" -def _cell_html(cell: TableCell) -> str: +def _cell_html(cell: Cell) -> str: tag = "th" if cell.is_header else "td" attrs = "" if cell.rowspan != 1: diff --git a/src/cnd/core/__init__.py b/src/cnd/core/__init__.py index 1a946dc..a41b59d 100644 --- a/src/cnd/core/__init__.py +++ b/src/cnd/core/__init__.py @@ -10,6 +10,8 @@ from cnd.core.hashing import content_hash, node_hash, pool_entry_hash from cnd.core.node_text import NodeTextMode from cnd.core.nodes import ( + AdmonitionNode, + Cell, CiteRef, CndNode, CodeNode, @@ -17,6 +19,7 @@ FootnoteRef, HeadingNode, ImageNode, + LayoutNode, LinkRef, ListItem, ListNode, @@ -30,33 +33,40 @@ QuoteNode, RawSource, StopPredicate, - TableCell, TableNode, TermItem, TermsNode, + edges_of, iter_nodes, position_totals, + texts_of, ) +from cnd.core.text import Edge, Mark, Numbering, RichText from cnd.core.render import MarkdownRenderer, NodeRenderer from cnd.core.validate import Violation, validate __all__ = [ + "AdmonitionNode", "BibEntry", + "Cell", "CiteRef", "Cnd", "CndNode", "CodeNode", "DocDate", "DocMetadata", + "Edge", "FigureNode", "Footnote", "FootnoteRef", "HeadingNode", "ImageNode", + "LayoutNode", "LinkRef", "LinkTarget", "ListItem", "ListNode", + "Mark", "MarkdownRenderer", "MathNode", "NodeBase", @@ -66,20 +76,23 @@ "NodeTextMode", "NodeTraverse", "NodeTraverseContext", + "Numbering", "ParagraphNode", "QuoteNode", "RawSource", + "RichText", "SourceInfo", "StopPredicate", - "TableCell", "TableNode", "TermItem", "TermsNode", "Violation", "content_hash", + "edges_of", "iter_nodes", "node_hash", "pool_entry_hash", "position_totals", + "texts_of", "validate", ] diff --git a/src/cnd/core/cnd.py b/src/cnd/core/cnd.py index 9fe132c..bbc3e03 100644 --- a/src/cnd/core/cnd.py +++ b/src/cnd/core/cnd.py @@ -9,12 +9,14 @@ CndNode, NodeTraverse, StopPredicate, + edges_of, iter_nodes, position_totals, ) +from cnd.core.text import RichText -CND_VERSION = "0.4.0" +CND_VERSION = "0.5.0" """The format version this SDK builds and targets (spec/cnd-spec.md). Stamped into ``Cnd.cnd_version`` by the builder (docs/adr/0019). One @@ -38,6 +40,11 @@ class DocMetadata(BaseModel): was built from is ``Cnd.source``, deliberately separate: the same work can be built from a Typst source and later re-imported from another format (docs/proposals/0008). + + ``status`` and ``version`` are the values the author declares on the + document, unrelated to ``cnd_version`` and to any build history. ``extra`` + holds any other title-page metadata; its keys never repeat a field name + (validation, docs/proposals/0013). """ title: str @@ -46,6 +53,9 @@ class DocMetadata(BaseModel): keywords: list[str] = Field(default_factory=list) description: str | None = None lang: str | None = None + status: str | None = None + version: str | None = None + extra: dict[str, str] = Field(default_factory=dict) class SourceInfo(BaseModel): @@ -82,7 +92,7 @@ class BibEntry(BaseModel): id: UUID label: str - formatted: str | None = None + formatted: RichText | None = None type: str | None = None authors: list[str] = Field(default_factory=list) title: str | None = None @@ -94,11 +104,13 @@ class BibEntry(BaseModel): class Footnote(BaseModel): - """Footnote pool entry — target of ``footnotes`` edges. Flat text.""" + """Footnote pool entry — target of ``footnotes`` edges. Its content is + block nodes: real nodes (global id/label uniqueness, location rule) + kept out of the body walk (docs/proposals/0013).""" id: UUID label: str - text: str + children: list[CndNode] = Field(default_factory=list) LinkTarget = Union[CndNode, BibEntry, Footnote] @@ -190,7 +202,9 @@ def incoming(self, label: str) -> list[CndNode]: seen: dict[str, set[UUID]] = {} for visit in self.iter(): node = visit.node - for link in (*node.refs, *node.cites, *node.footnotes): + for link in edges_of(node): + if not hasattr(link, "label"): + continue # hyperlinks are href-keyed and never incoming sources = seen.setdefault(link.label, set()) if node.id in sources: continue diff --git a/src/cnd/core/nodes.py b/src/cnd/core/nodes.py index b7668fb..41bdcac 100644 --- a/src/cnd/core/nodes.py +++ b/src/cnd/core/nodes.py @@ -5,7 +5,16 @@ from pydantic import BaseModel, Field -from cnd.core.text import CiteRef, FootnoteRef, LinkRef, NodeRef, RawSource # noqa: F401 +from cnd.core.text import ( # noqa: F401 (re-exported for existing imports) + CiteRef, + Edge, + FootnoteRef, + LinkRef, + NodeRef, + Numbering, + RawSource, + RichText, +) class NodeLocation(BaseModel): @@ -27,118 +36,71 @@ class NodeLocation(BaseModel): page: int +KNOWN_ADMONITION_KINDS = frozenset({"note", "tip", "important", "warning", "caution"}) +"""Known ``admonition.kind`` values (open vocabulary; fallback: a generic +callout headed by the title, or by the kind).""" + +KNOWN_TABLE_KINDS = frozenset({"data", "content"}) +"""Known ``table.kind`` values (open vocabulary; ``None`` = unstated).""" + + class NodeBase(BaseModel): - """Shared fields for every cnd node.""" + """Shared fields for every node. The link families live in the node's + texts (``RichText``), not here (docs/proposals/0013).""" id: UUID label: str | None = None - refs: list[NodeRef] = Field(default_factory=list) - cites: list[CiteRef] = Field(default_factory=list) - footnotes: list[FootnoteRef] = Field(default_factory=list) - links: list[LinkRef] = Field(default_factory=list) state_metadata: dict[str, Any] = Field(default_factory=dict) location: NodeLocation | None = None class HeadingNode(NodeBase): - """Section heading with nested section content in ``children``. - - ``number`` — shared in shape with ``MathNode`` and ``FigureNode`` — is - the counter value **as resolved and displayed** (``"2.1.1"``, ``"(1)"``, - ``"3"``), never the pattern that produced it: a consumer cannot replay a - counter engine, so only the resolved value is usable. - - ``counter_label`` is the word displayed in front of it (``"Figure"``, - ``"Tabelle"``), kept in its own field rather than fused into ``number`` - (docs/proposals/0008, 0010). Fusing them was the defect; carrying the - word is not. It is not always derivable — for a custom ``kind`` the - author supplies it and nothing else in the CND encodes it. - """ + """Section heading; the section's content is in ``children``.""" type: Literal["heading"] level: int - number: str | None = None - counter_label: str | None = None - text: str + numbering: Numbering | None = None + text: RichText heading_path: list[str] children: list["CndNode"] = Field(default_factory=list) class ParagraphNode(NodeBase): - """Plain text paragraph.""" + """A paragraph: the leaf that carries prose.""" type: Literal["paragraph"] - text: str - lang: str | None = None - - -class TableCell(BaseModel): - """Single cell inside a table node.""" - - row: int - col: int - rowspan: int = 1 - colspan: int = 1 - is_header: bool = False - text: str - - -class TableNode(NodeBase): - """Structured table with typed cells. - - ``kind`` distinguishes a semantic ``table`` from a layout ``grid``: both - share the same cell model, but a grid carries no tabular semantics. - - A table has no caption of its own — a captioned/numbered table is a - ``TableNode`` wrapped in a ``FigureNode``, which carries the caption. - """ - - type: Literal["table"] - kind: Literal["table", "grid"] = "table" - # Optional producer-supplied hint used by mode="auto" rendering (see - # cnd.core.render.MarkdownRenderer): "content" for a table whose cells - # read fine inlined as text (a short comparison table, a parameter - # list), "data" for one that doesn't (a numeric measurement grid). - # Unset (the common case today) is treated as "data". - content_kind: Literal["data", "content"] | None = None - cells: list[TableCell] = Field(default_factory=list) - raw: RawSource | None = None + text: RichText class QuoteNode(NodeBase): - """Block quotation with optional attribution (author or source).""" + """Block quotation. An inline quotation stays text in its paragraph.""" type: Literal["quote"] - text: str - attribution: str | None = None - block: bool = True - lang: str | None = None + attribution: RichText | None = None + children: list["CndNode"] = Field(default_factory=list) class CodeNode(NodeBase): - """Raw code block, preserved verbatim with its language tag.""" + """Code block, verbatim. Inline code is a ``code`` mark. ``title`` is + the label an author gives a listing, typically a file name.""" type: Literal["code"] text: str lang: str | None = None - block: bool = True + title: str | None = None class MathNode(NodeBase): - """Block-level equation.""" + """Block equation. Inline math is a ``math`` mark.""" type: Literal["math"] text: str raw: RawSource | None = None - number: str | None = None - counter_label: str | None = None - block: bool = True + numbering: Numbering | None = None class ImageNode(NodeBase): - """Leaf image content. A bare image outside any figure is an ``ImageNode`` - with no wrapper; a captioned image is an ``ImageNode`` inside a - ``FigureNode``.""" + """Leaf image content.""" type: Literal["image"] path: str | None = None @@ -146,77 +108,134 @@ class ImageNode(NodeBase): class FigureNode(NodeBase): - """Captioned/numbered float wrapper — never a content carrier. - - The wrapped content (image, table, code, …) lives in ``children`` and - keeps its own node type and ``location``. ``kind`` is the counter/label - selector of the figure ("image", "table", or an author-custom kind like - "atom") — an open string, never a content discriminator. Nested figures - (subfigures) are allowed. An unconvertible body yields ``children=[]`` - with ``raw`` filled. - """ + """Captioned/numbered wrapper; content keeps its own node type in + ``children``. ``kind`` is the figure's category (open vocabulary), + useful above all for author-defined categories ``children`` cannot + reveal. ``description`` is the text equivalent of the visual. An + unconvertible body (a drawing) yields ``children=[]`` with ``raw``.""" type: Literal["figure"] kind: str | None = None - caption: str | None = None - number: str | None = None - counter_label: str | None = None + caption: RichText | None = None + description: RichText | None = None + numbering: Numbering | None = None children: list["CndNode"] = Field(default_factory=list) raw: RawSource | None = None +class AdmonitionNode(NodeBase): + """A callout ("Note", "Warning"). ``kind`` is an open vocabulary; the + known values are ``KNOWN_ADMONITION_KINDS``.""" + + type: Literal["admonition"] + kind: str + title: RichText | None = None + children: list["CndNode"] = Field(default_factory=list) + + class ListItem(BaseModel): - """Single item of a bullet or numbered list.""" + """One list entry. Its content is block nodes; a nested list is a + ``list`` node with its own ``ordered``. ``checked`` is ``None`` for an + item that is not a task-list checkbox.""" - text: str number: int | None = None - children: list["ListItem"] = Field(default_factory=list) + checked: bool | None = None + children: list["CndNode"] = Field(default_factory=list) class ListNode(NodeBase): - """Bullet (`ordered=False`) or numbered (`ordered=True`) list.""" + """Bullet (``ordered=False``) or numbered (``ordered=True``) list.""" type: Literal["list"] ordered: bool = False - tight: bool = True items: list[ListItem] = Field(default_factory=list) class TermItem(BaseModel): - """Single term/description pair of a definition list.""" + """One term and its definition (block nodes in ``children``).""" - term: str - description: str + term: RichText + children: list["CndNode"] = Field(default_factory=list) class TermsNode(NodeBase): - """Definition list (Typst ``/ term: description`` items).""" + """Definition list.""" type: Literal["terms"] - tight: bool = True items: list[TermItem] = Field(default_factory=list) +class Cell(BaseModel): + """One cell of a ``table`` or a ``layout``; content is block nodes.""" + + row: int + col: int + rowspan: int = 1 + colspan: int = 1 + is_header: bool = False + children: list["CndNode"] = Field(default_factory=list) + + +class TableNode(NodeBase): + """A table whose rows and columns carry meaning. ``kind`` states what + it is: ``"data"`` (a dataset of records), ``"content"`` (cells to + read), ``None`` (unstated). Open vocabulary; never guessed by a + producer. A captioned table is wrapped in a ``FigureNode``.""" + + type: Literal["table"] + kind: str | None = None + cells: list[Cell] = Field(default_factory=list) + + +class LayoutNode(NodeBase): + """A grid used for arrangement only; position carries no relation.""" + + type: Literal["layout"] + cells: list[Cell] = Field(default_factory=list) + + CndNode = Annotated[ Union[ HeadingNode, ParagraphNode, - TableNode, QuoteNode, CodeNode, MathNode, - FigureNode, ImageNode, + FigureNode, + AdmonitionNode, ListNode, TermsNode, + TableNode, + LayoutNode, ], Field(discriminator="type"), ] -ListItem.model_rebuild() +for _model in (HeadingNode, QuoteNode, FigureNode, AdmonitionNode, ListItem, TermItem, Cell): + _model.model_rebuild() + + +def texts_of(node: "CndNode") -> list[RichText]: + """The node's own texts, in document order; never a nested node's.""" + if isinstance(node, (HeadingNode, ParagraphNode)): + return [node.text] + if isinstance(node, QuoteNode): + return [node.attribution] if node.attribution is not None else [] + if isinstance(node, FigureNode): + return [t for t in (node.caption, node.description) if t is not None] + if isinstance(node, AdmonitionNode): + return [node.title] if node.title is not None else [] + if isinstance(node, TermsNode): + return [item.term for item in node.items] + return [] + + +def edges_of(node: "CndNode") -> list[Edge]: + """Every forward edge of the node's own texts, flattened.""" + return [edge for text in texts_of(node) for edge in text.edges()] + -HeadingNode.model_rebuild() # For recursive reference to children -FigureNode.model_rebuild() # For recursive reference to children @dataclass(frozen=True) diff --git a/src/cnd/declaration.py b/src/cnd/declaration.py index 048f159..171fa85 100644 --- a/src/cnd/declaration.py +++ b/src/cnd/declaration.py @@ -53,7 +53,7 @@ FootnoteRef, NodeRef, RawSource, - TableCell, + Cell, TermItem, ) @@ -81,7 +81,7 @@ class _Strict(BaseModel): **This strictness is partial by construction.** ``extra="forbid"`` is per model and does not reach the value objects reused from the CND - (``TableCell``, the link edges, ``DocMetadata``): those keep the CND's + (``Cell``, the link edges, ``DocMetadata``): those keep the CND's lenient config, since forbidding on them would change CND parsing. A stray key on a node or at the top level is caught; one *inside* a table cell or a link edge is not. The open extension bags @@ -135,7 +135,7 @@ class DeclTableNode(DeclNodeBase): type: Literal["table"] kind: Literal["table", "grid"] = "table" content_kind: Literal["data", "content"] | None = None - cells: list[TableCell] = Field(default_factory=list) + cells: list[Cell] = Field(default_factory=list) raw: RawSource | None = None @@ -315,6 +315,6 @@ class Declaration(_Strict): "NodeRef", "RawSource", "SourceInfo", - "TableCell", + "Cell", "TermItem", ] diff --git a/tests/conftest.py b/tests/conftest.py index 57acdfa..51bce3e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -50,4 +50,10 @@ def unpaginated_cnd_path() -> Path: "test_reconcile.py", "test_render.py", "test_visitor.py", + # ported by plan 1, Tasks 4-7 + "test_cnd.py", + "test_hashing.py", + "test_schema.py", + "test_traverse.py", + "test_validate.py", ] diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..9020454 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,109 @@ +"""The 0.5 node model (docs/proposals/0013).""" + +from uuid import uuid4 + +import pytest +from pydantic import TypeAdapter, ValidationError + +from cnd.core.cnd import CND_VERSION, BibEntry, DocMetadata, Footnote +from cnd.core.nodes import ( + KNOWN_ADMONITION_KINDS, + KNOWN_TABLE_KINDS, + AdmonitionNode, + Cell, + CndNode, + FigureNode, + LayoutNode, + ListItem, + ListNode, + ParagraphNode, + QuoteNode, + TableNode, + TermItem, + TermsNode, + edges_of, + texts_of, +) +from cnd.core.text import NodeRef +from factories import rt + +NODE = TypeAdapter(CndNode) + + +def _para(value: str = "p", **kw) -> ParagraphNode: + return ParagraphNode(id=uuid4(), type="paragraph", text=rt(value, **kw)) + + +def test_the_format_version_is_0_5_0() -> None: + assert CND_VERSION == "0.5.0" + + +def test_a_paragraph_text_must_be_rich_text() -> None: + with pytest.raises(ValidationError): + NODE.validate_python({"id": str(uuid4()), "type": "paragraph", "text": "plain"}) + + +def test_link_families_left_the_node_base() -> None: + assert "refs" not in ParagraphNode.model_fields + assert "refs" in type(rt("x")).model_fields + + +def test_new_node_types_parse() -> None: + for payload in ( + {"type": "admonition", "kind": "warning", "children": []}, + {"type": "layout", "cells": []}, + ): + node = NODE.validate_python({"id": str(uuid4()), **payload}) + assert node.type == payload["type"] + + +def test_containers_hold_nodes_in_children() -> None: + item = ListItem(checked=True, children=[_para("a")]) + cell = Cell(row=0, col=0, children=[_para("b")]) + term = TermItem(term=rt("T"), children=[_para("c")]) + assert item.children[0].text.value == "a" + assert cell.children[0].text.value == "b" + assert term.children[0].text.value == "c" + + +def test_a_nested_list_carries_its_own_kind() -> None: + inner = ListNode(id=uuid4(), type="list", ordered=False, items=[ListItem(children=[_para("x")])]) + outer = ListNode(id=uuid4(), type="list", ordered=True, items=[ListItem(children=[_para("y"), inner])]) + assert outer.ordered and not outer.items[0].children[1].ordered + + +def test_removed_fields_are_gone() -> None: + assert "tight" not in ListNode.model_fields and "tight" not in TermsNode.model_fields + assert "content_kind" not in TableNode.model_fields and "raw" not in TableNode.model_fields + assert "block" not in QuoteNode.model_fields and "text" not in QuoteNode.model_fields + assert "number" not in FigureNode.model_fields and "counter_label" not in FigureNode.model_fields + + +def test_open_vocabularies_accept_free_values() -> None: + assert AdmonitionNode(id=uuid4(), type="admonition", kind="example").kind == "example" + assert TableNode(id=uuid4(), type="table", kind="schedule").kind == "schedule" + assert KNOWN_ADMONITION_KINDS == {"note", "tip", "important", "warning", "caution"} + assert KNOWN_TABLE_KINDS == {"data", "content"} + + +def test_texts_and_edges_of_a_figure() -> None: + fig = FigureNode( + id=uuid4(), type="figure", + caption=rt("See it", refs=[NodeRef(label="a")]), + description=rt("Bars", refs=[NodeRef(label="b")]), + ) + assert [t.value for t in texts_of(fig)] == ["See it", "Bars"] + assert [e.label for e in edges_of(fig)] == ["a", "b"] + + +def test_texts_of_terms_are_the_terms_only() -> None: + terms = TermsNode(id=uuid4(), type="terms", items=[TermItem(term=rt("T"), children=[_para("d")])]) + assert [t.value for t in texts_of(terms)] == ["T"] + + +def test_document_metadata_and_pools() -> None: + doc = DocMetadata(title="T", status="Approved", version="1.2", extra={"client": "ACME"}) + assert doc.extra["client"] == "ACME" + entry = BibEntry(id=uuid4(), label="k", formatted=rt("Knuth")) + note = Footnote(id=uuid4(), label="fn", children=[_para("n")]) + assert entry.formatted.value == "Knuth" and note.children[0].text.value == "n" From 90212cfa264b649b72a7f0b0905cd083e3fa1282 Mon Sep 17 00:00:00 2001 From: Maxanox Date: Tue, 13 Oct 2026 10:32:42 +0200 Subject: [PATCH 04/78] test(fixtures): rewrite fixtures to 0.5, add elements.cnd, regenerate the schema Migrate the six top-level fixtures to 0.5.0 (heading refs into text, grid table to layout, nested list item to nested list node), add fixtures/elements.cnd, regenerate schema/cnd.schema.json, and port test_cnd.py and test_schema.py out of collect_ignore. Two tests added: elements fixture coverage and incoming() reading edges from texts. No tests deleted; no assertion needed loosening. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS --- fixtures/comprehensive.cnd | 842 ++++++++++++++++------ fixtures/elements.cnd | 409 +++++++++++ fixtures/full_coverage.cnd | 819 +++++++++++++++------- fixtures/minimal.cnd | 22 +- fixtures/rich_content.cnd | 191 +++-- fixtures/structured.cnd | 145 ++-- fixtures/unpaginated.cnd | 53 +- schema/cnd.schema.json | 1357 +++++++++++++++++++++--------------- tests/conftest.py | 7 +- tests/test_cnd.py | 47 +- 10 files changed, 2709 insertions(+), 1183 deletions(-) create mode 100644 fixtures/elements.cnd diff --git a/fixtures/comprehensive.cnd b/fixtures/comprehensive.cnd index bf21e53..5ad3954 100644 --- a/fixtures/comprehensive.cnd +++ b/fixtures/comprehensive.cnd @@ -1,5 +1,5 @@ { - "cnd_version": "0.3.0", + "cnd_version": "0.5.0", "built_at": "2026-06-06T12:00:00Z", "source": { "type": "typst", @@ -30,230 +30,356 @@ { "id": "00000000-0000-4000-b000-000000000001", "type": "paragraph", - "text": "Résumé exécutif placé avant le premier titre. Ce paragraphe doit produire un chunk isolé avec un heading_path vide.", - "lang": "fr", "location": { "page": 1 + }, + "text": { + "value": "Résumé exécutif placé avant le premier titre. Ce paragraphe doit produire un chunk isolé avec un heading_path vide.", + "lang": "fr" } }, { "id": "00000000-0000-4000-b000-000000000002", "type": "heading", - "level": 1, - "text": "Architecture générale", - "heading_path": [ - "1 Architecture générale" - ], "label": "ch-architecture", "location": { "page": 1 }, + "level": 1, + "numbering": { + "value": "1" + }, + "text": { + "value": "Architecture générale" + }, + "heading_path": [ + "1 Architecture générale" + ], "children": [ { "id": "00000000-0000-4000-b000-000000000003", "type": "paragraph", - "text": "Le système DCS repose sur trois couches fonctionnelles : acquisition, traitement et supervision.", - "lang": "fr", "state_metadata": { "revision": "4.2", "status": "approved" }, "location": { "page": 1 + }, + "text": { + "value": "Le système DCS repose sur trois couches fonctionnelles : acquisition, traitement et supervision.", + "lang": "fr" } }, { "id": "00000000-0000-4000-b000-000000000004", "type": "heading", + "label": "sec-acquisition", + "location": { + "page": 2 + }, "level": 2, - "text": "Couche d'acquisition", + "numbering": { + "value": "1.1" + }, + "text": { + "value": "Couche d'acquisition", + "refs": [ + { + "label": "tab-plages-mesure" + } + ] + }, "heading_path": [ "1 Architecture générale", "1.1 Couche d'acquisition" ], - "label": "sec-acquisition", - "location": { - "page": 2 - }, "children": [ { "id": "00000000-0000-4000-b000-000000000005", "type": "paragraph", - "text": "Les capteurs analogiques et numériques sont regroupés par bus de terrain. Voir le tableau des plages de mesure.", - "lang": "fr", "location": { "page": 2 }, - "refs": [ - { - "label": "tab-plages-mesure" - } - ] + "text": { + "value": "Les capteurs analogiques et numériques sont regroupés par bus de terrain. Voir le tableau des plages de mesure.", + "lang": "fr", + "refs": [ + { + "label": "tab-plages-mesure" + } + ] + } }, { "id": "00000000-0000-4000-b000-000000000006", "type": "paragraph", - "text": "La fréquence d'échantillonnage nominale est de 10 Hz pour les boucles rapides.", - "lang": "fr", "location": { "page": 2 + }, + "text": { + "value": "La fréquence d'échantillonnage nominale est de 10 Hz pour les boucles rapides.", + "lang": "fr" } }, { "id": "00000000-0000-4000-c000-000000000001", "type": "figure", - "kind": "table", - "caption": "Plages de mesure des capteurs de terrain.", "location": { "page": 2 }, + "kind": "table", + "caption": { + "value": "Plages de mesure des capteurs de terrain." + }, + "numbering": { + "value": "1", + "name": "Table" + }, "children": [ { "id": "00000000-0000-4000-b000-000000000007", "type": "table", - "content_kind": "content", "label": "tab-plages-mesure", "location": { "page": 2 }, + "kind": "content", "cells": [ { "row": 0, "col": 0, - "rowspan": 1, "colspan": 2, "is_header": true, - "text": "Capteur" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000005", + "type": "paragraph", + "text": { + "value": "Capteur" + }, + "location": { + "page": 2 + } + } + ] }, { "row": 0, "col": 2, - "rowspan": 1, - "colspan": 1, "is_header": true, - "text": "Plage" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000006", + "type": "paragraph", + "text": { + "value": "Plage" + }, + "location": { + "page": 2 + } + } + ] }, { "row": 1, "col": 0, "rowspan": 2, - "colspan": 1, - "is_header": false, - "text": "Pression" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000007", + "type": "paragraph", + "text": { + "value": "Pression" + }, + "location": { + "page": 2 + } + } + ] }, { "row": 1, "col": 1, - "rowspan": 1, - "colspan": 1, - "is_header": false, - "text": "PT-101" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000008", + "type": "paragraph", + "text": { + "value": "PT-101" + }, + "location": { + "page": 2 + } + } + ] }, { "row": 1, "col": 2, - "rowspan": 1, - "colspan": 1, - "is_header": false, - "text": "0–16 bar" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000009", + "type": "paragraph", + "text": { + "value": "0–16 bar" + }, + "location": { + "page": 2 + } + } + ] }, { "row": 2, "col": 1, - "rowspan": 1, - "colspan": 1, - "is_header": false, - "text": "PT-102" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000010", + "type": "paragraph", + "text": { + "value": "PT-102" + }, + "location": { + "page": 2 + } + } + ] }, { "row": 2, "col": 2, - "rowspan": 1, - "colspan": 1, - "is_header": false, - "text": "0–25 bar" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000011", + "type": "paragraph", + "text": { + "value": "0–25 bar" + }, + "location": { + "page": 2 + } + } + ] }, { "row": 3, "col": 0, - "rowspan": 1, - "colspan": 1, - "is_header": false, - "text": "Débit" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000012", + "type": "paragraph", + "text": { + "value": "Débit" + }, + "location": { + "page": 2 + } + } + ] }, { "row": 3, "col": 1, - "rowspan": 1, - "colspan": 1, - "is_header": false, - "text": "FT-201" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000013", + "type": "paragraph", + "text": { + "value": "FT-201" + }, + "location": { + "page": 2 + } + } + ] }, { "row": 3, "col": 2, - "rowspan": 1, - "colspan": 1, - "is_header": false, - "text": "0–500 L/min" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000014", + "type": "paragraph", + "text": { + "value": "0–500 L/min" + }, + "location": { + "page": 2 + } + } + ] } - ], - "raw": { - "format": "typst", - "value": "table(columns: (auto, auto, auto), ..)" - } + ] } ], - "number": "1", - "counter_label": "Table" - } - ], - "refs": [ - { - "label": "tab-plages-mesure" + "raw": { + "format": "typst", + "value": "table(columns: (auto, auto, auto), ..)" + } } - ], - "number": "1.1" + ] }, { "id": "00000000-0000-4000-b000-000000000008", "type": "heading", + "label": "sec-traitement", + "location": { + "page": 3 + }, "level": 2, - "text": "Couche de traitement", + "numbering": { + "value": "1.2" + }, + "text": { + "value": "Couche de traitement", + "refs": [ + { + "label": "tab-plages-mesure" + }, + { + "label": "tab-recap-signaux" + } + ] + }, "heading_path": [ "1 Architecture générale", "1.2 Couche de traitement" ], - "label": "sec-traitement", - "location": { - "page": 3 - }, "children": [ { "id": "00000000-0000-4000-b000-000000000009", "type": "paragraph", - "text": "Les algorithmes de régulation s'exécutent sur le contrôleur redondant. Les plages capteurs (Table 1) et le récapitulatif annexe (Table 4) doivent rester cohérents.", - "lang": "fr", "location": { "page": 3 }, - "refs": [ - { - "label": "tab-plages-mesure" - }, - { - "label": "tab-recap-signaux" - } - ] + "text": { + "value": "Les algorithmes de régulation s'exécutent sur le contrôleur redondant. Les plages capteurs (Table 1) et le récapitulatif annexe (Table 4) doivent rester cohérents.", + "lang": "fr", + "refs": [ + { + "label": "tab-plages-mesure" + }, + { + "label": "tab-recap-signaux" + } + ] + } }, { "id": "00000000-0000-4000-c000-000000000002", "type": "figure", - "kind": "table", - "caption": "Boucles de régulation PID actives.", "location": { "page": 3 }, + "kind": "table", + "caption": { + "value": "Boucles de régulation PID actives." + }, + "numbering": { + "value": "2", + "name": "Table" + }, "children": [ { "id": "00000000-0000-4000-b000-000000000010", @@ -267,148 +393,259 @@ "row": 0, "col": 0, "is_header": true, - "text": "Boucle" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000015", + "type": "paragraph", + "text": { + "value": "Boucle" + }, + "location": { + "page": 3 + } + } + ] }, { "row": 0, "col": 1, "is_header": true, - "text": "Mode" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000016", + "type": "paragraph", + "text": { + "value": "Mode" + }, + "location": { + "page": 3 + } + } + ] }, { "row": 0, "col": 2, "is_header": true, - "text": "Kp" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000017", + "type": "paragraph", + "text": { + "value": "Kp" + }, + "location": { + "page": 3 + } + } + ] }, { "row": 1, "col": 0, - "is_header": false, - "text": "LC-101" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000018", + "type": "paragraph", + "text": { + "value": "LC-101" + }, + "location": { + "page": 3 + } + } + ] }, { "row": 1, "col": 1, - "is_header": false, - "text": "AUTO" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000019", + "type": "paragraph", + "text": { + "value": "AUTO" + }, + "location": { + "page": 3 + } + } + ] }, { "row": 1, "col": 2, - "is_header": false, - "text": "1.25" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000020", + "type": "paragraph", + "text": { + "value": "1.25" + }, + "location": { + "page": 3 + } + } + ] }, { "row": 2, "col": 0, - "is_header": false, - "text": "FC-202" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000021", + "type": "paragraph", + "text": { + "value": "FC-202" + }, + "location": { + "page": 3 + } + } + ] }, { "row": 2, "col": 1, - "is_header": false, - "text": "CASCADE" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000022", + "type": "paragraph", + "text": { + "value": "CASCADE" + }, + "location": { + "page": 3 + } + } + ] }, { "row": 2, "col": 2, - "is_header": false, - "text": "0.80" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000023", + "type": "paragraph", + "text": { + "value": "0.80" + }, + "location": { + "page": 3 + } + } + ] } ] } - ], - "number": "2", - "counter_label": "Table" - } - ], - "refs": [ - { - "label": "tab-plages-mesure" - }, - { - "label": "tab-recap-signaux" + ] } - ], - "number": "1.2" + ] } - ], - "number": "1" + ] }, { "id": "00000000-0000-4000-b000-000000000011", "type": "heading", - "level": 1, - "text": "Exploitation", - "heading_path": [ - "2 Exploitation" - ], "label": "ch-exploitation", "location": { "page": 4 }, + "level": 1, + "numbering": { + "value": "2" + }, + "text": { + "value": "Exploitation" + }, + "heading_path": [ + "2 Exploitation" + ], "children": [ { "id": "00000000-0000-4000-b000-000000000012", "type": "heading", + "location": { + "page": 4 + }, "level": 2, - "text": "Surveillance opérateur", + "numbering": { + "value": "2.1" + }, + "text": { + "value": "Surveillance opérateur" + }, "heading_path": [ "2 Exploitation", "2.1 Surveillance opérateur" ], - "location": { - "page": 4 - }, "children": [ { "id": "00000000-0000-4000-b000-000000000013", "type": "heading", + "label": "sec-alarmes", + "location": { + "page": 4 + }, "level": 3, - "text": "Gestion des alarmes", + "numbering": { + "value": "2.1.1" + }, + "text": { + "value": "Gestion des alarmes", + "refs": [ + { + "label": "tab-delais-alarmes" + } + ] + }, "heading_path": [ "2 Exploitation", "2.1 Surveillance opérateur", "2.1.1 Gestion des alarmes" ], - "label": "sec-alarmes", - "location": { - "page": 4 - }, "children": [ { "id": "00000000-0000-4000-b000-000000000014", "type": "paragraph", - "text": "Les alarmes sont classées en quatre niveaux de criticité : info, avertissement, alarme, critique.", - "lang": "fr", "location": { "page": 4 + }, + "text": { + "value": "Les alarmes sont classées en quatre niveaux de criticité : info, avertissement, alarme, critique.", + "lang": "fr" } }, { "id": "00000000-0000-4000-b000-000000000015", "type": "paragraph", - "text": "Le délai d'acquittement maximal est défini par la politique site. Consulter la matrice des délais.", - "lang": "fr", "location": { "page": 5 }, - "refs": [ - { - "label": "tab-delais-alarmes" - } - ] + "text": { + "value": "Le délai d'acquittement maximal est défini par la politique site. Consulter la matrice des délais.", + "lang": "fr", + "refs": [ + { + "label": "tab-delais-alarmes" + } + ] + } }, { "id": "00000000-0000-4000-c000-000000000003", "type": "figure", - "kind": "table", - "caption": "Délais d'acquittement par niveau de criticité.", "location": { "page": 5 }, + "kind": "table", + "caption": { + "value": "Délais d'acquittement par niveau de criticité." + }, + "numbering": { + "value": "3", + "name": "Table" + }, "children": [ { "id": "00000000-0000-4000-b000-000000000016", @@ -423,117 +660,206 @@ "col": 0, "colspan": 2, "is_header": true, - "text": "Niveau" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000024", + "type": "paragraph", + "text": { + "value": "Niveau" + }, + "location": { + "page": 5 + } + } + ] }, { "row": 0, "col": 2, "is_header": true, - "text": "Délai max" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000025", + "type": "paragraph", + "text": { + "value": "Délai max" + }, + "location": { + "page": 5 + } + } + ] }, { "row": 1, "col": 0, - "is_header": false, - "text": "Info" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000026", + "type": "paragraph", + "text": { + "value": "Info" + }, + "location": { + "page": 5 + } + } + ] }, { "row": 1, "col": 1, - "is_header": false, - "text": "—" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000027", + "type": "paragraph", + "text": { + "value": "—" + }, + "location": { + "page": 5 + } + } + ] }, { "row": 1, "col": 2, - "is_header": false, - "text": "24 h" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000028", + "type": "paragraph", + "text": { + "value": "24 h" + }, + "location": { + "page": 5 + } + } + ] }, { "row": 2, "col": 0, - "is_header": false, - "text": "Critique" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000029", + "type": "paragraph", + "text": { + "value": "Critique" + }, + "location": { + "page": 5 + } + } + ] }, { "row": 2, "col": 1, - "is_header": false, - "text": "P1" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000030", + "type": "paragraph", + "text": { + "value": "P1" + }, + "location": { + "page": 5 + } + } + ] }, { "row": 2, "col": 2, - "is_header": false, - "text": "5 min" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000031", + "type": "paragraph", + "text": { + "value": "5 min" + }, + "location": { + "page": 5 + } + } + ] } ] } - ], - "number": "3", - "counter_label": "Table" - } - ], - "refs": [ - { - "label": "tab-delais-alarmes" + ] } - ], - "number": "2.1.1" + ] } - ], - "number": "2.1" + ] }, { "id": "00000000-0000-4000-b000-000000000017", "type": "heading", + "label": "sec-maintenance", + "location": { + "page": 5 + }, "level": 2, - "text": "Maintenance préventive", + "numbering": { + "value": "2.2" + }, + "text": { + "value": "Maintenance préventive" + }, "heading_path": [ "2 Exploitation", "2.2 Maintenance préventive" ], - "label": "sec-maintenance", - "location": { - "page": 5 - }, "children": [ { "id": "00000000-0000-4000-b000-000000000018", "type": "paragraph", - "text": "Les interventions planifiées suivent le calendrier OEM. Aucune table n'est associée à cette section.", - "lang": "fr", "location": { "page": 5 + }, + "text": { + "value": "Les interventions planifiées suivent le calendrier OEM. Aucune table n'est associée à cette section.", + "lang": "fr" } } - ], - "number": "2.2" + ] } - ], - "number": "2" + ] }, { "id": "00000000-0000-4000-b000-000000000019", "type": "heading", - "level": 1, - "text": "Annexes", - "heading_path": [ - "3 Annexes" - ], "label": "ch-annexes", "location": { "page": 6 }, + "level": 1, + "numbering": { + "value": "3" + }, + "text": { + "value": "Annexes" + }, + "heading_path": [ + "3 Annexes" + ], "children": [ { "id": "00000000-0000-4000-c000-000000000004", "type": "figure", - "kind": "table", - "caption": "Récapitulatif des signaux instrumentés.", "location": { "page": 6 }, + "kind": "table", + "caption": { + "value": "Récapitulatif des signaux instrumentés." + }, + "numbering": { + "value": "4", + "name": "Table" + }, "children": [ { "id": "00000000-0000-4000-b000-000000000020", @@ -547,64 +873,154 @@ "row": 0, "col": 0, "is_header": true, - "text": "Tag" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000032", + "type": "paragraph", + "text": { + "value": "Tag" + }, + "location": { + "page": 6 + } + } + ] }, { "row": 0, "col": 1, "is_header": true, - "text": "Unité" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000033", + "type": "paragraph", + "text": { + "value": "Unité" + }, + "location": { + "page": 6 + } + } + ] }, { "row": 0, "col": 2, "is_header": true, - "text": "Bus" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000034", + "type": "paragraph", + "text": { + "value": "Bus" + }, + "location": { + "page": 6 + } + } + ] }, { "row": 1, "col": 0, - "is_header": false, - "text": "PT-101" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000035", + "type": "paragraph", + "text": { + "value": "PT-101" + }, + "location": { + "page": 6 + } + } + ] }, { "row": 1, "col": 1, - "is_header": false, - "text": "bar" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000036", + "type": "paragraph", + "text": { + "value": "bar" + }, + "location": { + "page": 6 + } + } + ] }, { "row": 1, "col": 2, - "is_header": false, - "text": "Profibus" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000037", + "type": "paragraph", + "text": { + "value": "Profibus" + }, + "location": { + "page": 6 + } + } + ] }, { "row": 2, "col": 0, - "is_header": false, - "text": "FT-201" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000038", + "type": "paragraph", + "text": { + "value": "FT-201" + }, + "location": { + "page": 6 + } + } + ] }, { "row": 2, "col": 1, - "is_header": false, - "text": "L/min" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000039", + "type": "paragraph", + "text": { + "value": "L/min" + }, + "location": { + "page": 6 + } + } + ] }, { "row": 2, "col": 2, - "is_header": false, - "text": "Modbus" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000040", + "type": "paragraph", + "text": { + "value": "Modbus" + }, + "location": { + "page": 6 + } + } + ] } ] } - ], - "number": "4", - "counter_label": "Table" + ] } - ], - "number": "3" + ] } ], "bibliography": [], diff --git a/fixtures/elements.cnd b/fixtures/elements.cnd new file mode 100644 index 0000000..100c6df --- /dev/null +++ b/fixtures/elements.cnd @@ -0,0 +1,409 @@ +{ + "id": "00000000-0000-4000-d000-000000000000", + "cnd_version": "0.5.0", + "built_at": "2026-10-13T00:00:00Z", + "doc": { + "title": "Elements of format 0.5", + "lang": "en", + "status": "Approved", + "version": "1.2", + "extra": { + "client": "ACME", + "reference": "PRJ-042" + } + }, + "nodes": [ + { + "id": "00000000-0000-4000-d000-000000000002", + "type": "heading", + "level": 1, + "numbering": { + "value": "1", + "name": "Section" + }, + "text": { + "value": "Elements of format 0.5" + }, + "heading_path": [ + "Elements of format 0.5" + ], + "children": [ + { + "id": "00000000-0000-4000-d000-000000000001", + "type": "paragraph", + "text": { + "value": "Pressure rose 103 times, see Theorem 1 and the note, after Knuth: E = mc^2 holds.", + "marks": [ + { + "start": 9, + "end": 13, + "kind": "strong" + }, + { + "start": 16, + "end": 17, + "kind": "sup" + }, + { + "start": 66, + "end": 74, + "kind": "math", + "raw": { + "format": "typst", + "value": "E = m c^2" + } + } + ], + "refs": [ + { + "label": "thm-main", + "text_span": [ + 29, + 38 + ] + } + ], + "cites": [ + { + "label": "knuth1984", + "text_span": [ + 59, + 64 + ], + "form": "prose" + } + ], + "footnotes": [ + { + "label": "fn-detail", + "text_span": [ + 43, + 51 + ] + } + ] + } + }, + { + "id": "00000000-0000-4000-d000-000000000003", + "type": "admonition", + "kind": "warning", + "title": { + "value": "Careful" + }, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000004", + "type": "paragraph", + "text": { + "value": "Never edit a CND in place." + } + } + ] + }, + { + "id": "00000000-0000-4000-d000-000000000005", + "type": "list", + "ordered": true, + "items": [ + { + "number": 1, + "checked": true, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000006", + "type": "paragraph", + "text": { + "value": "Compile the source" + } + }, + { + "id": "00000000-0000-4000-d000-000000000007", + "type": "list", + "ordered": false, + "items": [ + { + "children": [ + { + "id": "00000000-0000-4000-d000-000000000008", + "type": "paragraph", + "text": { + "value": "with fonts" + } + } + ] + } + ] + } + ] + }, + { + "number": 2, + "checked": false, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000009", + "type": "paragraph", + "text": { + "value": "Validate the output" + } + } + ] + } + ] + }, + { + "id": "00000000-0000-4000-d000-000000000010", + "type": "terms", + "items": [ + { + "term": { + "value": "Mark", + "marks": [ + { + "start": 0, + "end": 4, + "kind": "emph" + } + ] + }, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000011", + "type": "paragraph", + "text": { + "value": "A standoff span with a kind." + } + }, + { + "id": "00000000-0000-4000-d000-000000000012", + "type": "paragraph", + "text": { + "value": "Marks of different kinds may overlap." + } + } + ] + } + ] + }, + { + "id": "00000000-0000-4000-d000-000000000013", + "type": "table", + "kind": "data", + "cells": [ + { + "row": 0, + "col": 0, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000014", + "type": "paragraph", + "text": { + "value": "Station" + } + } + ], + "is_header": true + }, + { + "row": 0, + "col": 1, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000015", + "type": "paragraph", + "text": { + "value": "hPa" + } + } + ], + "is_header": true + }, + { + "row": 1, + "col": 0, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000016", + "type": "paragraph", + "text": { + "value": "Brest" + } + } + ] + }, + { + "row": 1, + "col": 1, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000017", + "type": "paragraph", + "text": { + "value": "1013" + } + } + ] + } + ] + }, + { + "id": "00000000-0000-4000-d000-000000000018", + "type": "layout", + "cells": [ + { + "row": 0, + "col": 0, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000019", + "type": "image", + "path": "a.png", + "alt": "Before" + } + ] + }, + { + "row": 0, + "col": 1, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000020", + "type": "image", + "path": "b.png", + "alt": "After" + } + ] + } + ] + }, + { + "id": "00000000-0000-4000-d000-000000000021", + "type": "figure", + "label": "thm-main", + "kind": "theorem", + "caption": { + "value": "Mass–energy equivalence" + }, + "description": { + "value": "States that energy equals mass times the speed of light squared." + }, + "numbering": { + "value": "1", + "name": "Theorem" + }, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000022", + "type": "paragraph", + "text": { + "value": "Energy and mass are equivalent." + } + } + ] + }, + { + "id": "00000000-0000-4000-d000-000000000023", + "type": "quote", + "attribution": { + "value": "A. Einstein", + "marks": [ + { + "start": 0, + "end": 11, + "kind": "emph" + } + ] + }, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000024", + "type": "paragraph", + "text": { + "value": "First paragraph of the quote." + } + }, + { + "id": "00000000-0000-4000-d000-000000000025", + "type": "paragraph", + "text": { + "value": "Second paragraph." + } + } + ] + }, + { + "id": "00000000-0000-4000-d000-000000000026", + "type": "code", + "text": "print('hi')\n", + "lang": "python", + "title": "main.py" + }, + { + "id": "00000000-0000-4000-d000-000000000027", + "type": "math", + "text": "a^2 + b^2 = c^2", + "raw": { + "format": "typst", + "value": "a^2 + b^2 = c^2" + }, + "numbering": { + "value": "(1)" + } + } + ] + } + ], + "bibliography": [ + { + "id": "00000000-0000-4000-d000-000000000031", + "label": "knuth1984", + "formatted": { + "value": "Knuth, D. The TeXbook. Addison-Wesley, 1984.", + "marks": [ + { + "start": 10, + "end": 21, + "kind": "emph" + } + ] + }, + "type": "book", + "authors": [ + "Donald Knuth" + ], + "title": "The TeXbook", + "year": 1984 + } + ], + "footnotes": [ + { + "id": "00000000-0000-4000-d000-000000000028", + "label": "fn-detail", + "children": [ + { + "id": "00000000-0000-4000-d000-000000000029", + "type": "paragraph", + "text": { + "value": "Details live here." + } + }, + { + "id": "00000000-0000-4000-d000-000000000030", + "type": "paragraph", + "label": "fn-second", + "text": { + "value": "A second paragraph, with code.", + "marks": [ + { + "start": 25, + "end": 29, + "kind": "code" + } + ] + } + } + ] + } + ] +} diff --git a/fixtures/full_coverage.cnd b/fixtures/full_coverage.cnd index 49eb43f..887cd3e 100644 --- a/fixtures/full_coverage.cnd +++ b/fixtures/full_coverage.cnd @@ -1,5 +1,5 @@ { - "cnd_version": "0.3.0", + "cnd_version": "0.5.0", "built_at": "2026-07-15T08:30:00Z", "source": { "type": "typst", @@ -30,11 +30,6 @@ { "id": "00000000-0000-4000-e000-000000000001", "type": "heading", - "level": 1, - "text": "Vue d'ensemble", - "heading_path": [ - "1 Vue d'ensemble" - ], "label": "ch-overview", "state_metadata": { "revision": "1.3", @@ -43,533 +38,803 @@ "location": { "page": 1 }, + "level": 1, + "numbering": { + "value": "1" + }, + "text": { + "value": "Vue d'ensemble" + }, + "heading_path": [ + "1 Vue d'ensemble" + ], "children": [ { "id": "00000000-0000-4000-e000-000000000002", "type": "paragraph", - "text": "Les mesures nominales sont récapitulées dans la table des mesures ; la structure atomique est illustrée plus bas.", - "lang": "fr", "label": "para-intro", "state_metadata": { "reviewed_by": "M. Durand" }, - "refs": [ - { - "label": "tab-mesures", - "text_span": [ - 48, - 65 - ] - }, - { - "label": "fig-atom" - } - ], - "cites": [ - { - "label": "durand2025", - "text_span": [ - 4, - 11 - ], - "form": "normal", - "supplement": "p. 42" - }, - { - "label": "durand2025", - "text_span": [ - 68, - 77 - ], - "form": "prose" - } - ], - "footnotes": [ - { - "label": "fn-unit", - "text_span": [ - 12, - 21 - ] - } - ], "location": { "page": 1 + }, + "text": { + "value": "Les mesures nominales sont récapitulées dans la table des mesures ; la structure atomique est illustrée plus bas.", + "lang": "fr", + "refs": [ + { + "label": "tab-mesures", + "text_span": [ + 48, + 65 + ] + }, + { + "label": "fig-atom" + } + ], + "cites": [ + { + "label": "durand2025", + "text_span": [ + 4, + 11 + ], + "form": "normal", + "supplement": "p. 42" + }, + { + "label": "durand2025", + "text_span": [ + 68, + 77 + ], + "form": "prose" + } + ], + "footnotes": [ + { + "label": "fn-unit", + "text_span": [ + 12, + 21 + ] + } + ] } }, { "id": "00000000-0000-4000-e000-000000000003", "type": "list", + "location": { + "page": 1 + }, "ordered": true, - "tight": true, "items": [ { - "text": "Étalonner les capteurs", "number": 1, "children": [ { - "text": "Vérifier le zéro", - "number": 1 + "id": "00000000-0000-4000-c000-000000000001", + "type": "paragraph", + "text": { + "value": "Étalonner les capteurs" + }, + "location": { + "page": 1 + } }, { - "text": "Vérifier la pleine échelle", - "number": 2 + "id": "00000000-0000-4000-c000-000000000002", + "type": "list", + "ordered": true, + "location": { + "page": 1 + }, + "items": [ + { + "number": 1, + "children": [ + { + "id": "00000000-0000-4000-c000-000000000003", + "type": "paragraph", + "text": { + "value": "Vérifier le zéro" + }, + "location": { + "page": 1 + } + } + ] + }, + { + "number": 2, + "children": [ + { + "id": "00000000-0000-4000-c000-000000000004", + "type": "paragraph", + "text": { + "value": "Vérifier la pleine échelle" + }, + "location": { + "page": 1 + } + } + ] + } + ] } ] }, { - "text": "Lancer l'acquisition", - "number": 2 + "number": 2, + "children": [ + { + "id": "00000000-0000-4000-c000-000000000005", + "type": "paragraph", + "text": { + "value": "Lancer l'acquisition" + }, + "location": { + "page": 1 + } + } + ] } - ], - "location": { - "page": 1 - } + ] }, { "id": "00000000-0000-4000-e000-000000000004", "type": "list", + "location": { + "page": 1 + }, "ordered": false, - "tight": false, "items": [ { - "text": "Alimentation 24 V redondée" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000006", + "type": "paragraph", + "text": { + "value": "Alimentation 24 V redondée" + }, + "location": { + "page": 1 + } + } + ] }, { - "text": "Bus de terrain isolé galvaniquement" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000007", + "type": "paragraph", + "text": { + "value": "Bus de terrain isolé galvaniquement" + }, + "location": { + "page": 1 + } + } + ] } - ], - "location": { - "page": 1 - } + ] }, { "id": "00000000-0000-4000-e000-000000000005", "type": "terms", - "tight": true, + "location": { + "page": 2 + }, "items": [ { - "term": "Banc", - "description": "Ensemble mécanique portant les capteurs sous test." + "term": { + "value": "Banc" + }, + "children": [ + { + "id": "00000000-0000-4000-c000-000000000008", + "type": "paragraph", + "text": { + "value": "Ensemble mécanique portant les capteurs sous test." + }, + "location": { + "page": 2 + } + } + ] }, { - "term": "Cycle", - "description": "Séquence complète de montée et descente en charge." + "term": { + "value": "Cycle" + }, + "children": [ + { + "id": "00000000-0000-4000-c000-000000000009", + "type": "paragraph", + "text": { + "value": "Séquence complète de montée et descente en charge." + }, + "location": { + "page": 2 + } + } + ] }, { - "term": "Dérive", - "description": "Écart de mesure observé entre deux étalonnages." + "term": { + "value": "Dérive" + }, + "children": [ + { + "id": "00000000-0000-4000-c000-000000000010", + "type": "paragraph", + "text": { + "value": "Écart de mesure observé entre deux étalonnages." + }, + "location": { + "page": 2 + } + } + ] } - ], - "location": { - "page": 2 - } + ] }, { "id": "00000000-0000-4000-e000-000000000006", "type": "quote", - "text": "Mesurer, c'est comparer à une référence.", - "attribution": "Manuel de métrologie interne", - "block": true, - "lang": "fr", "location": { "page": 2 - } + }, + "attribution": { + "value": "Manuel de métrologie interne" + }, + "children": [ + { + "id": "00000000-0000-4000-c000-000000000011", + "type": "paragraph", + "text": { + "value": "Mesurer, c'est comparer à une référence.", + "lang": "fr" + }, + "location": { + "page": 2 + } + } + ] }, { "id": "00000000-0000-4000-e000-000000000007", "type": "code", - "text": "def moyenne(valeurs):\n return sum(valeurs) / len(valeurs)", - "lang": "python", - "block": true, "location": { "page": 2 - } + }, + "text": "def moyenne(valeurs):\n return sum(valeurs) / len(valeurs)", + "lang": "python" }, { "id": "00000000-0000-4000-e000-000000000008", "type": "math", - "text": "e(t) = m(t) - r(t)", - "block": true, "label": "eq-erreur", "location": { "page": 2 }, - "number": "(1)", + "text": "e(t) = m(t) - r(t)", "raw": { "format": "typst", "value": "$ e(t) = m(t) - r(t) $" + }, + "numbering": { + "value": "(1)" } }, { "id": "00000000-0000-4000-e000-000000000030", "type": "heading", + "label": "sec-figures", + "location": { + "page": 3 + }, "level": 2, - "text": "Résultats et figures", + "numbering": { + "value": "1.1" + }, + "text": { + "value": "Résultats et figures", + "refs": [ + { + "label": "fig-schema" + } + ] + }, "heading_path": [ "1 Vue d'ensemble", "1.1 Résultats et figures" ], - "label": "sec-figures", - "refs": [ - { - "label": "fig-schema" - } - ], - "location": { - "page": 3 - }, "children": [ { "id": "00000000-0000-4000-e000-000000000010", "type": "figure", - "kind": "table", "label": "tab-mesures", - "caption": "Mesures nominales par capteur.", "location": { "page": 3 }, + "kind": "table", + "caption": { + "value": "Mesures nominales par capteur." + }, + "numbering": { + "value": "1", + "name": "Table" + }, "children": [ { "id": "00000000-0000-4000-e000-000000000011", "type": "table", - "kind": "table", - "content_kind": "content", + "location": { + "page": 3 + }, + "kind": "content", "cells": [ { "row": 0, "col": 0, "is_header": true, - "text": "Capteur" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000012", + "type": "paragraph", + "text": { + "value": "Capteur" + }, + "location": { + "page": 3 + } + } + ] }, { "row": 0, "col": 1, "is_header": true, - "text": "Nominal" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000013", + "type": "paragraph", + "text": { + "value": "Nominal" + }, + "location": { + "page": 3 + } + } + ] }, { "row": 1, "col": 0, - "text": "PT-01" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000014", + "type": "paragraph", + "text": { + "value": "PT-01" + }, + "location": { + "page": 3 + } + } + ] }, { "row": 1, "col": 1, - "text": "10 bar" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000015", + "type": "paragraph", + "text": { + "value": "10 bar" + }, + "location": { + "page": 3 + } + } + ] }, { "row": 2, "col": 0, - "text": "FT-02" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000016", + "type": "paragraph", + "text": { + "value": "FT-02" + }, + "location": { + "page": 3 + } + } + ] }, { "row": 2, "col": 1, - "text": "250 L/min" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000017", + "type": "paragraph", + "text": { + "value": "250 L/min" + }, + "location": { + "page": 3 + } + } + ] } - ], - "location": { - "page": 3 - }, - "raw": { - "format": "typst", - "value": "table(columns: 2, ..)" - } + ] } ], - "number": "1", - "counter_label": "Table" + "raw": { + "format": "typst", + "value": "table(columns: 2, ..)" + } }, { "id": "00000000-0000-4000-e000-000000000012", - "type": "table", - "kind": "grid", - "content_kind": "data", + "type": "layout", "label": "grid-layout", + "location": { + "page": 3 + }, "cells": [ { "row": 0, "col": 0, "rowspan": 2, - "colspan": 1, "is_header": true, - "text": "Zone" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000018", + "type": "paragraph", + "text": { + "value": "Zone" + }, + "location": { + "page": 3 + } + } + ] }, { "row": 0, "col": 1, - "rowspan": 1, "colspan": 2, "is_header": true, - "text": "Relevés bruts" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000019", + "type": "paragraph", + "text": { + "value": "Relevés bruts" + }, + "location": { + "page": 3 + } + } + ] }, { "row": 1, "col": 1, - "text": "12.4" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000020", + "type": "paragraph", + "text": { + "value": "12.4" + }, + "location": { + "page": 3 + } + } + ] }, { "row": 1, "col": 2, - "text": "12.6" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000021", + "type": "paragraph", + "text": { + "value": "12.6" + }, + "location": { + "page": 3 + } + } + ] } - ], - "location": { - "page": 3 - }, - "raw": { - "format": "typst", - "value": "grid(columns: 3, ..)" - } + ] }, { "id": "00000000-0000-4000-e000-000000000013", "type": "figure", - "kind": "image", "label": "fig-schema", - "caption": "Schéma du banc de mesure.", "location": { "page": 4 }, + "kind": "image", + "caption": { + "value": "Schéma du banc de mesure." + }, + "numbering": { + "value": "1", + "name": "Figure" + }, "children": [ { "id": "00000000-0000-4000-e000-000000000014", "type": "image", - "path": "figures/banc.png", - "alt": "Vue d'ensemble du banc de mesure", "location": { "page": 4 - } + }, + "path": "figures/banc.png", + "alt": "Vue d'ensemble du banc de mesure" } - ], - "number": "1", - "counter_label": "Figure" + ] }, { "id": "00000000-0000-4000-e000-000000000015", "type": "figure", - "kind": "listing", "label": "lst-config", - "caption": "Configuration de l'acquisition.", "location": { "page": 4 }, + "kind": "listing", + "caption": { + "value": "Configuration de l'acquisition." + }, + "numbering": { + "value": "1", + "name": "Listing" + }, "children": [ { "id": "00000000-0000-4000-e000-000000000016", "type": "code", - "text": "{\n \"frequence_hz\": 10,\n \"duree_s\": 300\n}", - "lang": "json", - "block": true, "location": { "page": 4 - } + }, + "text": "{\n \"frequence_hz\": 10,\n \"duree_s\": 300\n}", + "lang": "json" } - ], - "number": "1", - "counter_label": "Listing" + ] }, { "id": "00000000-0000-4000-e000-000000000017", "type": "figure", - "kind": "image", "label": "fig-compare", - "caption": "Comparaison avant/après étalonnage.", "location": { "page": 5 }, + "kind": "image", + "caption": { + "value": "Comparaison avant/après étalonnage." + }, + "numbering": { + "value": "2", + "name": "Figure" + }, "children": [ { "id": "00000000-0000-4000-e000-000000000018", "type": "figure", - "kind": "image", - "caption": "(a) Avant étalonnage.", "location": { "page": 5 }, + "kind": "image", + "caption": { + "value": "(a) Avant étalonnage." + }, "children": [ { "id": "00000000-0000-4000-e000-000000000019", "type": "image", - "path": "figures/avant.png", - "alt": "Courbe avant étalonnage", "location": { "page": 5 - } + }, + "path": "figures/avant.png", + "alt": "Courbe avant étalonnage" } ] }, { "id": "00000000-0000-4000-e000-000000000021", "type": "figure", - "kind": "image", - "caption": "(b) Après étalonnage.", "location": { "page": 5 }, + "kind": "image", + "caption": { + "value": "(b) Après étalonnage." + }, "children": [ { "id": "00000000-0000-4000-e000-000000000022", "type": "image", - "path": "figures/apres.png", - "alt": "Courbe après étalonnage", "location": { "page": 5 - } + }, + "path": "figures/apres.png", + "alt": "Courbe après étalonnage" } ] } - ], - "number": "2", - "counter_label": "Figure" + ] }, { "id": "00000000-0000-4000-e000-000000000023", "type": "figure", - "kind": "atom", "label": "fig-atom", - "caption": "Structure atomique du réseau mesuré.", "location": { "page": 6 }, + "kind": "atom", + "caption": { + "value": "Structure atomique du réseau mesuré." + }, + "numbering": { + "value": "1", + "name": "Atome" + }, "children": [ { "id": "00000000-0000-4000-e000-000000000024", "type": "image", - "path": "figures/reseau.svg", - "alt": "Maille cristalline", "location": { "page": 6 - } + }, + "path": "figures/reseau.svg", + "alt": "Maille cristalline" } - ], - "number": "1", - "counter_label": "Atome" + ] }, { "id": "00000000-0000-4000-e000-000000000025", "type": "figure", - "kind": "canvas", "label": "fig-canvas", - "caption": "Tracé vectoriel non convertible.", - "children": [], "location": { "page": 6 }, - "number": "3", + "kind": "canvas", + "caption": { + "value": "Tracé vectoriel non convertible." + }, + "numbering": { + "value": "3", + "name": "Figure" + }, + "children": [], "raw": { "format": "typst", "value": "#figure(canvas(length: 1cm, { plot(..) }))" - }, - "counter_label": "Figure" + } }, { "id": "00000000-0000-4000-e000-000000000026", "type": "image", - "path": "figures/logo-banc.png", - "alt": "Logo du banc, image nue hors figure", "location": { "page": 6 - } + }, + "path": "figures/logo-banc.png", + "alt": "Logo du banc, image nue hors figure" }, { "id": "00000000-0000-4000-e000-000000000027", "type": "image", - "path": null, - "alt": "Image incorporée sans chemin extrait", "location": { "page": 6 - } + }, + "alt": "Image incorporée sans chemin extrait" } - ], - "number": "1.1" + ] } - ], - "number": "1" + ] }, { "id": "00000000-0000-4000-e000-000000000040", "type": "heading", - "level": 1, - "text": "Références", - "heading_path": [ - "2 Références" - ], "label": "ch-references", "location": { "page": 7 }, + "level": 1, + "numbering": { + "value": "2" + }, + "text": { + "value": "Références" + }, + "heading_path": [ + "2 Références" + ], "children": [ { "id": "00000000-0000-4000-e000-000000000041", "type": "paragraph", - "text": "Grille brute en annexe. Les travaux antérieurs couvrent la méthode, l'auteur, l'année et une citation muette.", - "lang": "fr", - "refs": [ - { - "label": "grid-layout", - "text_span": [ - 0, - 11 - ] - } - ], - "cites": [ - { - "label": "nguyen2023", - "text_span": [ - 24, - 42 - ], - "form": "full" - }, - { - "label": "durand2025", - "text_span": [ - 58, - 67 - ], - "form": "author" - }, - { - "label": "nguyen2023", - "text_span": [ - 69, - 77 - ], - "form": "year" - }, - { - "label": "nguyen2023", - "text_span": null, - "form": "none" - } - ], - "footnotes": [ - { - "label": "fn-proto", - "text_span": [ - 7, - 12 - ] - } - ], - "links": [ - { - "href": "https://example.org/appendix/grid-raw", - "text_span": [ - 16, - 22 - ] - } - ], "location": { "page": 7 + }, + "text": { + "value": "Grille brute en annexe. Les travaux antérieurs couvrent la méthode, l'auteur, l'année et une citation muette.", + "lang": "fr", + "refs": [ + { + "label": "grid-layout", + "text_span": [ + 0, + 11 + ] + } + ], + "cites": [ + { + "label": "nguyen2023", + "text_span": [ + 24, + 42 + ], + "form": "full" + }, + { + "label": "durand2025", + "text_span": [ + 58, + 67 + ], + "form": "author" + }, + { + "label": "nguyen2023", + "text_span": [ + 69, + 77 + ], + "form": "year" + }, + { + "label": "nguyen2023", + "text_span": null, + "form": "none" + } + ], + "footnotes": [ + { + "label": "fn-proto", + "text_span": [ + 7, + 12 + ] + } + ], + "links": [ + { + "href": "https://example.org/appendix/grid-raw", + "text_span": [ + 16, + 22 + ] + } + ] } } - ], - "number": "2" + ] } ], "bibliography": [ @@ -586,7 +851,9 @@ "container": "Revue de Métrologie Appliquée", "doi": "10.1000/rma.2025.808", "url": "https://example.org/rma/808", - "formatted": "Durand, M., & Petit, C. (2025). Étalonnage automatique des chaînes de mesure. Revue de Métrologie Appliquée, 8(2), 101–118.", + "formatted": { + "value": "Durand, M., & Petit, C. (2025). Étalonnage automatique des chaînes de mesure. Revue de Métrologie Appliquée, 8(2), 101–118." + }, "fields": { "type": "article", "title": "Étalonnage automatique des chaînes de mesure", @@ -611,19 +878,43 @@ { "id": "00000000-0000-4000-e000-000000000101", "label": "nguyen2023", - "formatted": "Nguyen, T. (2023). Field bus diagnostics in practice. Munich: Feldbus Verlag." + "formatted": { + "value": "Nguyen, T. (2023). Field bus diagnostics in practice. Munich: Feldbus Verlag." + } } ], "footnotes": [ { "id": "00000000-0000-4000-e000-000000000200", "label": "fn-unit", - "text": "Toutes les valeurs sont exprimées en unités SI." + "children": [ + { + "id": "00000000-0000-4000-c000-000000000022", + "type": "paragraph", + "text": { + "value": "Toutes les valeurs sont exprimées en unités SI." + }, + "location": { + "page": 1 + } + } + ] }, { "id": "00000000-0000-4000-e000-000000000201", "label": "fn-proto", - "text": "Le prototype de la grille figure au dossier d'essais 2026-041." + "children": [ + { + "id": "00000000-0000-4000-c000-000000000023", + "type": "paragraph", + "text": { + "value": "Le prototype de la grille figure au dossier d'essais 2026-041." + }, + "location": { + "page": 7 + } + } + ] } ] } diff --git a/fixtures/minimal.cnd b/fixtures/minimal.cnd index c0186c8..4423260 100644 --- a/fixtures/minimal.cnd +++ b/fixtures/minimal.cnd @@ -1,5 +1,5 @@ { - "cnd_version": "0.3.0", + "cnd_version": "0.5.0", "built_at": "2026-05-20T10:00:00Z", "source": { "type": "typst", @@ -27,23 +27,29 @@ { "id": "550e8400-e29b-41d4-a716-446655440001", "type": "heading", + "location": { + "page": 1 + }, "level": 1, - "number": "1", - "text": "Introduction", + "numbering": { + "value": "1" + }, + "text": { + "value": "Introduction" + }, "heading_path": [ "1 Introduction" ], - "location": { - "page": 1 - }, "children": [ { "id": "550e8400-e29b-41d4-a716-446655440002", "type": "paragraph", - "text": "Premier paragraphe du document.", - "lang": "fr", "location": { "page": 1 + }, + "text": { + "value": "Premier paragraphe du document.", + "lang": "fr" } } ] diff --git a/fixtures/rich_content.cnd b/fixtures/rich_content.cnd index 7b2de2f..725b39d 100644 --- a/fixtures/rich_content.cnd +++ b/fixtures/rich_content.cnd @@ -1,5 +1,5 @@ { - "cnd_version": "0.3.0", + "cnd_version": "0.5.0", "built_at": "2026-07-01T09:00:00Z", "source": { "type": "typst", @@ -29,130 +29,168 @@ { "id": "00000000-0000-4000-d000-000000000001", "type": "heading", - "level": 1, - "text": "Interfaces", - "heading_path": [ - "1 Interfaces" - ], "label": "ch-interfaces", "location": { "page": 1 }, + "level": 1, + "numbering": { + "value": "1" + }, + "text": { + "value": "Interfaces" + }, + "heading_path": [ + "1 Interfaces" + ], "children": [ { "id": "00000000-0000-4000-d000-000000000002", "type": "paragraph", - "text": "Selon Smith et al., l'interface expose un point d'accès REST documenté dans le listing 1.", - "lang": "fr", - "refs": [ - { - "label": "lst-api", - "text_span": [ - 77, - 88 - ] - } - ], - "cites": [ - { - "label": "smith2024", - "text_span": [ - 6, - 18 - ], - "form": "prose", - "supplement": "p. 12" - } - ], - "footnotes": [ - { - "label": "fn-rest", - "text_span": [ - 57, - 61 - ] - } - ], "location": { "page": 1 + }, + "text": { + "value": "Selon Smith et al., l'interface expose un point d'accès REST documenté dans le listing 1.", + "lang": "fr", + "refs": [ + { + "label": "lst-api", + "text_span": [ + 77, + 88 + ] + } + ], + "cites": [ + { + "label": "smith2024", + "text_span": [ + 6, + 18 + ], + "form": "prose", + "supplement": "p. 12" + } + ], + "footnotes": [ + { + "label": "fn-rest", + "text_span": [ + 57, + 61 + ] + } + ] } }, { "id": "00000000-0000-4000-d000-000000000003", "type": "terms", - "tight": true, + "location": { + "page": 1 + }, "items": [ { - "term": "Endpoint", - "description": "URL stable exposée par le serveur." + "term": { + "value": "Endpoint" + }, + "children": [ + { + "id": "00000000-0000-4000-c000-000000000001", + "type": "paragraph", + "text": { + "value": "URL stable exposée par le serveur." + }, + "location": { + "page": 1 + } + } + ] }, { - "term": "Jeton", - "description": "Secret porteur transmis dans l'en-tête Authorization." + "term": { + "value": "Jeton" + }, + "children": [ + { + "id": "00000000-0000-4000-c000-000000000002", + "type": "paragraph", + "text": { + "value": "Secret porteur transmis dans l'en-tête Authorization." + }, + "location": { + "page": 1 + } + } + ] } - ], - "location": { - "page": 1 - } + ] }, { "id": "00000000-0000-4000-d000-000000000004", "type": "figure", - "kind": "listing", "label": "lst-api", - "caption": "Appel du point d'accès de santé.", "location": { "page": 2 }, + "kind": "listing", + "caption": { + "value": "Appel du point d'accès de santé." + }, + "numbering": { + "value": "1", + "name": "Listing" + }, "children": [ { "id": "00000000-0000-4000-d000-000000000005", "type": "code", - "text": "curl -s https://api.example.com/health", - "lang": "bash", - "block": true, "location": { "page": 2 - } + }, + "text": "curl -s https://api.example.com/health", + "lang": "bash" } - ], - "number": "1", - "counter_label": "Listing" + ] }, { "id": "00000000-0000-4000-d000-000000000006", "type": "image", - "path": "figures/architecture.png", - "alt": "Schéma d'architecture des interfaces", "location": { "page": 2 - } + }, + "path": "figures/architecture.png", + "alt": "Schéma d'architecture des interfaces" }, { "id": "00000000-0000-4000-d000-000000000007", "type": "figure", - "kind": "image", "label": "fig-flux", - "caption": "Flux d'authentification.", "location": { "page": 3 }, + "kind": "image", + "caption": { + "value": "Flux d'authentification." + }, + "numbering": { + "value": "1", + "name": "Figure" + }, "children": [ { "id": "00000000-0000-4000-d000-000000000008", "type": "image", - "path": "figures/auth-flow.png", - "alt": "Diagramme de séquence du flux d'authentification", "location": { "page": 3 - } + }, + "path": "figures/auth-flow.png", + "alt": "Diagramme de séquence du flux d'authentification" } - ], - "number": "1", - "counter_label": "Figure" + ] } - ], - "number": "1" + ] } ], "bibliography": [ @@ -169,7 +207,9 @@ "container": "Journal of Systems Integration", "doi": "10.1000/jsi.2024.12345", "url": "https://example.org/jsi/12345", - "formatted": "Smith, J., & Doe, A. (2024). Designing resilient REST interfaces. Journal of Systems Integration, 12(3), 45–58.", + "formatted": { + "value": "Smith, J., & Doe, A. (2024). Designing resilient REST interfaces. Journal of Systems Integration, 12(3), 45–58." + }, "fields": { "type": "article", "title": "Designing resilient REST interfaces", @@ -195,7 +235,18 @@ { "id": "00000000-0000-4000-d000-000000000200", "label": "fn-rest", - "text": "REST : Representational State Transfer." + "children": [ + { + "id": "00000000-0000-4000-c000-000000000003", + "type": "paragraph", + "text": { + "value": "REST : Representational State Transfer." + }, + "location": { + "page": 1 + } + } + ] } ] } diff --git a/fixtures/structured.cnd b/fixtures/structured.cnd index 18892d9..dd3f36a 100644 --- a/fixtures/structured.cnd +++ b/fixtures/structured.cnd @@ -1,5 +1,5 @@ { - "cnd_version": "0.3.0", + "cnd_version": "0.5.0", "built_at": "2026-05-31T14:30:00Z", "source": { "type": "typst", @@ -27,56 +27,74 @@ { "id": "00000000-0000-4000-a000-000000000001", "type": "heading", + "location": { + "page": 1 + }, "level": 1, - "text": "Description du système", + "numbering": { + "value": "1" + }, + "text": { + "value": "Description du système" + }, "heading_path": [ "1 Description du système" ], - "location": { - "page": 1 - }, "children": [ { "id": "00000000-0000-4000-a000-000000000002", "type": "heading", + "label": "sec-params", + "location": { + "page": 1 + }, "level": 2, - "text": "Paramètres nominaux", + "numbering": { + "value": "1.1" + }, + "text": { + "value": "Paramètres nominaux", + "refs": [ + { + "label": "tab-params-nominaux" + } + ] + }, "heading_path": [ "1 Description du système", "1.1 Paramètres nominaux" ], - "label": "sec-params", - "refs": [ - { - "label": "tab-params-nominaux" - } - ], - "location": { - "page": 1 - }, "children": [ { "id": "00000000-0000-4000-a000-000000000003", "type": "paragraph", - "text": "Le système est composé de quatre modules principaux.", - "lang": "fr", - "refs": [ - { - "label": "tab-params-nominaux" - } - ], "location": { "page": 1 + }, + "text": { + "value": "Le système est composé de quatre modules principaux.", + "lang": "fr", + "refs": [ + { + "label": "tab-params-nominaux" + } + ] } }, { "id": "00000000-0000-4000-a000-000000000005", "type": "figure", - "kind": "table", - "caption": "Paramètres nominaux de fonctionnement.", "location": { "page": 1 }, + "kind": "table", + "caption": { + "value": "Paramètres nominaux de fonctionnement." + }, + "numbering": { + "value": "1", + "name": "Table" + }, "children": [ { "id": "00000000-0000-4000-a000-000000000004", @@ -89,50 +107,81 @@ { "row": 0, "col": 0, - "rowspan": 1, "colspan": 2, "is_header": true, - "text": "Paramètre" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000001", + "type": "paragraph", + "text": { + "value": "Paramètre" + }, + "location": { + "page": 1 + } + } + ] }, { "row": 0, "col": 1, - "rowspan": 1, - "colspan": 1, "is_header": true, - "text": "Valeur" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000002", + "type": "paragraph", + "text": { + "value": "Valeur" + }, + "location": { + "page": 1 + } + } + ] }, { "row": 1, "col": 0, - "rowspan": 1, - "colspan": 1, - "is_header": false, - "text": "Débit nominal" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000003", + "type": "paragraph", + "text": { + "value": "Débit nominal" + }, + "location": { + "page": 1 + } + } + ] }, { "row": 1, "col": 1, - "rowspan": 1, - "colspan": 1, - "is_header": false, - "text": "120 L/min" + "children": [ + { + "id": "00000000-0000-4000-c000-000000000004", + "type": "paragraph", + "text": { + "value": "120 L/min" + }, + "location": { + "page": 1 + } + } + ] } - ], - "raw": { - "format": "typst", - "value": "table(columns: (auto, auto), ...)" - } + ] } ], - "number": "1", - "counter_label": "Table" + "raw": { + "format": "typst", + "value": "table(columns: (auto, auto), ...)" + } } - ], - "number": "1.1" + ] } - ], - "number": "1" + ] } ], "bibliography": [], diff --git a/fixtures/unpaginated.cnd b/fixtures/unpaginated.cnd index 2efde5b..35c308a 100644 --- a/fixtures/unpaginated.cnd +++ b/fixtures/unpaginated.cnd @@ -1,5 +1,5 @@ { - "cnd_version": "0.3.0", + "cnd_version": "0.5.0", "built_at": "2026-07-22T09:30:00Z", "source": { "type": "markdown", @@ -14,35 +14,44 @@ { "id": "00000000-0000-4000-f000-000000000001", "type": "heading", + "label": "sec-handshake", "level": 1, - "text": "Handshake", + "text": { + "value": "Handshake" + }, "heading_path": [ "Handshake" ], - "label": "sec-handshake", "children": [ { "id": "00000000-0000-4000-f000-000000000002", "type": "paragraph", - "text": "The client opens with a version token, as shown in the listing below.", - "refs": [ - { - "label": "lst-open", - "text_span": [50, 68] - } - ], - "footnotes": [ - { - "label": "fn-token" - } - ] + "text": { + "value": "The client opens with a version token, as shown in the listing below.", + "refs": [ + { + "label": "lst-open", + "text_span": [ + 50, + 68 + ] + } + ], + "footnotes": [ + { + "label": "fn-token" + } + ] + } }, { "id": "00000000-0000-4000-f000-000000000003", "type": "figure", - "kind": "code", - "caption": "Opening frame", "label": "lst-open", + "kind": "code", + "caption": { + "value": "Opening frame" + }, "children": [ { "id": "00000000-0000-4000-f000-000000000004", @@ -71,7 +80,15 @@ { "id": "00000000-0000-4000-f000-000000000200", "label": "fn-token", - "text": "The version token is opaque to intermediaries." + "children": [ + { + "id": "00000000-0000-4000-c000-000000000001", + "type": "paragraph", + "text": { + "value": "The version token is opaque to intermediaries." + } + } + ] } ] } diff --git a/schema/cnd.schema.json b/schema/cnd.schema.json index a948dfd..c92b9de 100644 --- a/schema/cnd.schema.json +++ b/schema/cnd.schema.json @@ -1,5 +1,131 @@ { "$defs": { + "AdmonitionNode": { + "description": "A callout (\"Note\", \"Warning\"). ``kind`` is an open vocabulary; the\nknown values are ``KNOWN_ADMONITION_KINDS``.", + "properties": { + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Label" + }, + "state_metadata": { + "additionalProperties": true, + "title": "State Metadata", + "type": "object" + }, + "location": { + "anyOf": [ + { + "$ref": "#/$defs/NodeLocation" + }, + { + "type": "null" + } + ], + "default": null + }, + "type": { + "const": "admonition", + "title": "Type", + "type": "string" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "title": { + "anyOf": [ + { + "$ref": "#/$defs/RichText" + }, + { + "type": "null" + } + ], + "default": null + }, + "children": { + "items": { + "discriminator": { + "mapping": { + "admonition": "#/$defs/AdmonitionNode", + "code": "#/$defs/CodeNode", + "figure": "#/$defs/FigureNode", + "heading": "#/$defs/HeadingNode", + "image": "#/$defs/ImageNode", + "layout": "#/$defs/LayoutNode", + "list": "#/$defs/ListNode", + "math": "#/$defs/MathNode", + "paragraph": "#/$defs/ParagraphNode", + "quote": "#/$defs/QuoteNode", + "table": "#/$defs/TableNode", + "terms": "#/$defs/TermsNode" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/HeadingNode" + }, + { + "$ref": "#/$defs/ParagraphNode" + }, + { + "$ref": "#/$defs/QuoteNode" + }, + { + "$ref": "#/$defs/CodeNode" + }, + { + "$ref": "#/$defs/MathNode" + }, + { + "$ref": "#/$defs/ImageNode" + }, + { + "$ref": "#/$defs/FigureNode" + }, + { + "$ref": "#/$defs/AdmonitionNode" + }, + { + "$ref": "#/$defs/ListNode" + }, + { + "$ref": "#/$defs/TermsNode" + }, + { + "$ref": "#/$defs/TableNode" + }, + { + "$ref": "#/$defs/LayoutNode" + } + ] + }, + "title": "Children", + "type": "array" + } + }, + "required": [ + "id", + "type", + "kind" + ], + "title": "AdmonitionNode", + "type": "object" + }, "BibEntry": { "description": "Bibliography pool entry \u2014 target of ``cites`` edges.\n\n``formatted`` is the reference string as displayed in the built\ndocument (the faithful capture). It is nullable because requiring it\npresupposes a style engine ran, which a hand author or a markdown\nproducer has no reason to have. A curated typed subset of common\nbibliographic fields is lifted alongside it; the full source entry\n(e.g. Hayagriva) is carried losslessly as structured JSON in ``fields``.\n\nAn entry must carry ``formatted``, structured fields, or both \u2014 never an\nempty, unconsumable entry. That floor is a validator rule, not a shape\nthe type system can express.", "properties": { @@ -15,14 +141,13 @@ "formatted": { "anyOf": [ { - "type": "string" + "$ref": "#/$defs/RichText" }, { "type": "null" } ], - "default": null, - "title": "Formatted" + "default": null }, "type": { "anyOf": [ @@ -116,6 +241,101 @@ "title": "BibEntry", "type": "object" }, + "Cell": { + "description": "One cell of a ``table`` or a ``layout``; content is block nodes.", + "properties": { + "row": { + "title": "Row", + "type": "integer" + }, + "col": { + "title": "Col", + "type": "integer" + }, + "rowspan": { + "default": 1, + "title": "Rowspan", + "type": "integer" + }, + "colspan": { + "default": 1, + "title": "Colspan", + "type": "integer" + }, + "is_header": { + "default": false, + "title": "Is Header", + "type": "boolean" + }, + "children": { + "items": { + "discriminator": { + "mapping": { + "admonition": "#/$defs/AdmonitionNode", + "code": "#/$defs/CodeNode", + "figure": "#/$defs/FigureNode", + "heading": "#/$defs/HeadingNode", + "image": "#/$defs/ImageNode", + "layout": "#/$defs/LayoutNode", + "list": "#/$defs/ListNode", + "math": "#/$defs/MathNode", + "paragraph": "#/$defs/ParagraphNode", + "quote": "#/$defs/QuoteNode", + "table": "#/$defs/TableNode", + "terms": "#/$defs/TermsNode" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/HeadingNode" + }, + { + "$ref": "#/$defs/ParagraphNode" + }, + { + "$ref": "#/$defs/QuoteNode" + }, + { + "$ref": "#/$defs/CodeNode" + }, + { + "$ref": "#/$defs/MathNode" + }, + { + "$ref": "#/$defs/ImageNode" + }, + { + "$ref": "#/$defs/FigureNode" + }, + { + "$ref": "#/$defs/AdmonitionNode" + }, + { + "$ref": "#/$defs/ListNode" + }, + { + "$ref": "#/$defs/TermsNode" + }, + { + "$ref": "#/$defs/TableNode" + }, + { + "$ref": "#/$defs/LayoutNode" + } + ] + }, + "title": "Children", + "type": "array" + } + }, + "required": [ + "row", + "col" + ], + "title": "Cell", + "type": "object" + }, "CiteRef": { "description": "Forward citation edge; ``label`` resolves in the CND ``bibliography`` pool.", "properties": { @@ -178,7 +398,7 @@ "type": "object" }, "CodeNode": { - "description": "Raw code block, preserved verbatim with its language tag.", + "description": "Code block, verbatim. Inline code is a ``code`` mark. ``title`` is\nthe label an author gives a listing, typically a file name.", "properties": { "id": { "format": "uuid", @@ -197,34 +417,6 @@ "default": null, "title": "Label" }, - "refs": { - "items": { - "$ref": "#/$defs/NodeRef" - }, - "title": "Refs", - "type": "array" - }, - "cites": { - "items": { - "$ref": "#/$defs/CiteRef" - }, - "title": "Cites", - "type": "array" - }, - "footnotes": { - "items": { - "$ref": "#/$defs/FootnoteRef" - }, - "title": "Footnotes", - "type": "array" - }, - "links": { - "items": { - "$ref": "#/$defs/LinkRef" - }, - "title": "Links", - "type": "array" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -262,10 +454,17 @@ "default": null, "title": "Lang" }, - "block": { - "default": true, - "title": "Block", - "type": "boolean" + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" } }, "required": [ @@ -315,7 +514,7 @@ "type": "object" }, "DocMetadata": { - "description": "Bibliographic metadata for the work the CND represents.\n\nThis identifies the *work* \u2014 title, authors, date. The input artifact it\nwas built from is ``Cnd.source``, deliberately separate: the same work\ncan be built from a Typst source and later re-imported from another\nformat (docs/proposals/0008).", + "description": "Bibliographic metadata for the work the CND represents.\n\nThis identifies the *work* \u2014 title, authors, date. The input artifact it\nwas built from is ``Cnd.source``, deliberately separate: the same work\ncan be built from a Typst source and later re-imported from another\nformat (docs/proposals/0008).\n\n``status`` and ``version`` are the values the author declares on the\ndocument, unrelated to ``cnd_version`` and to any build history. ``extra``\nholds any other title-page metadata; its keys never repeat a field name\n(validation, docs/proposals/0013).", "properties": { "title": { "title": "Title", @@ -369,17 +568,48 @@ ], "default": null, "title": "Lang" - } - }, - "required": [ - "title" - ], - "title": "DocMetadata", - "type": "object" - }, - "FigureNode": { - "description": "Captioned/numbered float wrapper \u2014 never a content carrier.\n\nThe wrapped content (image, table, code, \u2026) lives in ``children`` and\nkeeps its own node type and ``location``. ``kind`` is the counter/label\nselector of the figure (\"image\", \"table\", or an author-custom kind like\n\"atom\") \u2014 an open string, never a content discriminator. Nested figures\n(subfigures) are allowed. An unconvertible body yields ``children=[]``\nwith ``raw`` filled.", - "properties": { + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Version" + }, + "extra": { + "additionalProperties": { + "type": "string" + }, + "title": "Extra", + "type": "object" + } + }, + "required": [ + "title" + ], + "title": "DocMetadata", + "type": "object" + }, + "FigureNode": { + "description": "Captioned/numbered wrapper; content keeps its own node type in\n``children``. ``kind`` is the figure's category (open vocabulary),\nuseful above all for author-defined categories ``children`` cannot\nreveal. ``description`` is the text equivalent of the visual. An\nunconvertible body (a drawing) yields ``children=[]`` with ``raw``.", + "properties": { "id": { "format": "uuid", "title": "Id", @@ -397,34 +627,6 @@ "default": null, "title": "Label" }, - "refs": { - "items": { - "$ref": "#/$defs/NodeRef" - }, - "title": "Refs", - "type": "array" - }, - "cites": { - "items": { - "$ref": "#/$defs/CiteRef" - }, - "title": "Cites", - "type": "array" - }, - "footnotes": { - "items": { - "$ref": "#/$defs/FootnoteRef" - }, - "title": "Footnotes", - "type": "array" - }, - "links": { - "items": { - "$ref": "#/$defs/LinkRef" - }, - "title": "Links", - "type": "array" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -461,47 +663,46 @@ "caption": { "anyOf": [ { - "type": "string" + "$ref": "#/$defs/RichText" }, { "type": "null" } ], - "default": null, - "title": "Caption" + "default": null }, - "number": { + "description": { "anyOf": [ { - "type": "string" + "$ref": "#/$defs/RichText" }, { "type": "null" } ], - "default": null, - "title": "Number" + "default": null }, - "counter_label": { + "numbering": { "anyOf": [ { - "type": "string" + "$ref": "#/$defs/Numbering" }, { "type": "null" } ], - "default": null, - "title": "Counter Label" + "default": null }, "children": { "items": { "discriminator": { "mapping": { + "admonition": "#/$defs/AdmonitionNode", "code": "#/$defs/CodeNode", "figure": "#/$defs/FigureNode", "heading": "#/$defs/HeadingNode", "image": "#/$defs/ImageNode", + "layout": "#/$defs/LayoutNode", "list": "#/$defs/ListNode", "math": "#/$defs/MathNode", "paragraph": "#/$defs/ParagraphNode", @@ -518,9 +719,6 @@ { "$ref": "#/$defs/ParagraphNode" }, - { - "$ref": "#/$defs/TableNode" - }, { "$ref": "#/$defs/QuoteNode" }, @@ -530,17 +728,26 @@ { "$ref": "#/$defs/MathNode" }, + { + "$ref": "#/$defs/ImageNode" + }, { "$ref": "#/$defs/FigureNode" }, { - "$ref": "#/$defs/ImageNode" + "$ref": "#/$defs/AdmonitionNode" }, { "$ref": "#/$defs/ListNode" }, { "$ref": "#/$defs/TermsNode" + }, + { + "$ref": "#/$defs/TableNode" + }, + { + "$ref": "#/$defs/LayoutNode" } ] }, @@ -567,7 +774,7 @@ "type": "object" }, "Footnote": { - "description": "Footnote pool entry \u2014 target of ``footnotes`` edges. Flat text.", + "description": "Footnote pool entry \u2014 target of ``footnotes`` edges. Its content is\nblock nodes: real nodes (global id/label uniqueness, location rule)\nkept out of the body walk (docs/proposals/0013).", "properties": { "id": { "format": "uuid", @@ -578,15 +785,71 @@ "title": "Label", "type": "string" }, - "text": { - "title": "Text", - "type": "string" + "children": { + "items": { + "discriminator": { + "mapping": { + "admonition": "#/$defs/AdmonitionNode", + "code": "#/$defs/CodeNode", + "figure": "#/$defs/FigureNode", + "heading": "#/$defs/HeadingNode", + "image": "#/$defs/ImageNode", + "layout": "#/$defs/LayoutNode", + "list": "#/$defs/ListNode", + "math": "#/$defs/MathNode", + "paragraph": "#/$defs/ParagraphNode", + "quote": "#/$defs/QuoteNode", + "table": "#/$defs/TableNode", + "terms": "#/$defs/TermsNode" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/HeadingNode" + }, + { + "$ref": "#/$defs/ParagraphNode" + }, + { + "$ref": "#/$defs/QuoteNode" + }, + { + "$ref": "#/$defs/CodeNode" + }, + { + "$ref": "#/$defs/MathNode" + }, + { + "$ref": "#/$defs/ImageNode" + }, + { + "$ref": "#/$defs/FigureNode" + }, + { + "$ref": "#/$defs/AdmonitionNode" + }, + { + "$ref": "#/$defs/ListNode" + }, + { + "$ref": "#/$defs/TermsNode" + }, + { + "$ref": "#/$defs/TableNode" + }, + { + "$ref": "#/$defs/LayoutNode" + } + ] + }, + "title": "Children", + "type": "array" } }, "required": [ "id", - "label", - "text" + "label" ], "title": "Footnote", "type": "object" @@ -621,7 +884,7 @@ "type": "object" }, "HeadingNode": { - "description": "Section heading with nested section content in ``children``.\n\n``number`` \u2014 shared in shape with ``MathNode`` and ``FigureNode`` \u2014 is\nthe counter value **as resolved and displayed** (``\"2.1.1\"``, ``\"(1)\"``,\n``\"3\"``), never the pattern that produced it: a consumer cannot replay a\ncounter engine, so only the resolved value is usable.\n\n``counter_label`` is the word displayed in front of it (``\"Figure\"``,\n``\"Tabelle\"``), kept in its own field rather than fused into ``number``\n(docs/proposals/0008, 0010). Fusing them was the defect; carrying the\nword is not. It is not always derivable \u2014 for a custom ``kind`` the\nauthor supplies it and nothing else in the CND encodes it.", + "description": "Section heading; the section's content is in ``children``.", "properties": { "id": { "format": "uuid", @@ -640,34 +903,6 @@ "default": null, "title": "Label" }, - "refs": { - "items": { - "$ref": "#/$defs/NodeRef" - }, - "title": "Refs", - "type": "array" - }, - "cites": { - "items": { - "$ref": "#/$defs/CiteRef" - }, - "title": "Cites", - "type": "array" - }, - "footnotes": { - "items": { - "$ref": "#/$defs/FootnoteRef" - }, - "title": "Footnotes", - "type": "array" - }, - "links": { - "items": { - "$ref": "#/$defs/LinkRef" - }, - "title": "Links", - "type": "array" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -693,33 +928,19 @@ "title": "Level", "type": "integer" }, - "number": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Number" - }, - "counter_label": { + "numbering": { "anyOf": [ { - "type": "string" + "$ref": "#/$defs/Numbering" }, { "type": "null" } ], - "default": null, - "title": "Counter Label" + "default": null }, "text": { - "title": "Text", - "type": "string" + "$ref": "#/$defs/RichText" }, "heading_path": { "items": { @@ -732,10 +953,12 @@ "items": { "discriminator": { "mapping": { + "admonition": "#/$defs/AdmonitionNode", "code": "#/$defs/CodeNode", "figure": "#/$defs/FigureNode", "heading": "#/$defs/HeadingNode", "image": "#/$defs/ImageNode", + "layout": "#/$defs/LayoutNode", "list": "#/$defs/ListNode", "math": "#/$defs/MathNode", "paragraph": "#/$defs/ParagraphNode", @@ -752,9 +975,6 @@ { "$ref": "#/$defs/ParagraphNode" }, - { - "$ref": "#/$defs/TableNode" - }, { "$ref": "#/$defs/QuoteNode" }, @@ -764,17 +984,26 @@ { "$ref": "#/$defs/MathNode" }, + { + "$ref": "#/$defs/ImageNode" + }, { "$ref": "#/$defs/FigureNode" }, { - "$ref": "#/$defs/ImageNode" + "$ref": "#/$defs/AdmonitionNode" }, { "$ref": "#/$defs/ListNode" }, { "$ref": "#/$defs/TermsNode" + }, + { + "$ref": "#/$defs/TableNode" + }, + { + "$ref": "#/$defs/LayoutNode" } ] }, @@ -793,7 +1022,7 @@ "type": "object" }, "ImageNode": { - "description": "Leaf image content. A bare image outside any figure is an ``ImageNode``\nwith no wrapper; a captioned image is an ``ImageNode`` inside a\n``FigureNode``.", + "description": "Leaf image content.", "properties": { "id": { "format": "uuid", @@ -812,34 +1041,6 @@ "default": null, "title": "Label" }, - "refs": { - "items": { - "$ref": "#/$defs/NodeRef" - }, - "title": "Refs", - "type": "array" - }, - "cites": { - "items": { - "$ref": "#/$defs/CiteRef" - }, - "title": "Cites", - "type": "array" - }, - "footnotes": { - "items": { - "$ref": "#/$defs/FootnoteRef" - }, - "title": "Footnotes", - "type": "array" - }, - "links": { - "items": { - "$ref": "#/$defs/LinkRef" - }, - "title": "Links", - "type": "array" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -893,42 +1094,94 @@ "title": "ImageNode", "type": "object" }, - "LinkRef": { - "description": "Forward hyperlink edge; ``href`` resolves nowhere in the CND.\n\nThe fourth forward family and the only one not keyed by label: a\nhyperlink's target is outside the document by definition, so it admits\nno resolution domain and has no dangling state \u2014 a URL is a URL. The\nscheme is a consumer's business (``https:``, ``doi:``, an application\nscheme); the format only carries the string.", + "LayoutNode": { + "description": "A grid used for arrangement only; position carries no relation.", "properties": { - "href": { - "title": "Href", + "id": { + "format": "uuid", + "title": "Id", "type": "string" }, - "text_span": { + "label": { "anyOf": [ { - "items": { - "type": "integer" - }, - "type": "array" + "type": "string" }, { "type": "null" } ], "default": null, - "title": "Text Span" - } - }, - "required": [ - "href" - ], - "title": "LinkRef", - "type": "object" - }, - "ListItem": { - "description": "Single item of a bullet or numbered list.", + "title": "Label" + }, + "state_metadata": { + "additionalProperties": true, + "title": "State Metadata", + "type": "object" + }, + "location": { + "anyOf": [ + { + "$ref": "#/$defs/NodeLocation" + }, + { + "type": "null" + } + ], + "default": null + }, + "type": { + "const": "layout", + "title": "Type", + "type": "string" + }, + "cells": { + "items": { + "$ref": "#/$defs/Cell" + }, + "title": "Cells", + "type": "array" + } + }, + "required": [ + "id", + "type" + ], + "title": "LayoutNode", + "type": "object" + }, + "LinkRef": { + "description": "Forward hyperlink edge; ``href`` resolves nowhere in the CND.\n\nThe fourth forward family and the only one not keyed by label: a\nhyperlink's target is outside the document by definition, so it admits\nno resolution domain and has no dangling state \u2014 a URL is a URL. The\nscheme is a consumer's business (``https:``, ``doi:``, an application\nscheme); the format only carries the string.", "properties": { - "text": { - "title": "Text", + "href": { + "title": "Href", "type": "string" }, + "text_span": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text Span" + } + }, + "required": [ + "href" + ], + "title": "LinkRef", + "type": "object" + }, + "ListItem": { + "description": "One list entry. Its content is block nodes; a nested list is a\n``list`` node with its own ``ordered``. ``checked`` is ``None`` for an\nitem that is not a task-list checkbox.", + "properties": { "number": { "anyOf": [ { @@ -941,22 +1194,85 @@ "default": null, "title": "Number" }, + "checked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Checked" + }, "children": { "items": { - "$ref": "#/$defs/ListItem" + "discriminator": { + "mapping": { + "admonition": "#/$defs/AdmonitionNode", + "code": "#/$defs/CodeNode", + "figure": "#/$defs/FigureNode", + "heading": "#/$defs/HeadingNode", + "image": "#/$defs/ImageNode", + "layout": "#/$defs/LayoutNode", + "list": "#/$defs/ListNode", + "math": "#/$defs/MathNode", + "paragraph": "#/$defs/ParagraphNode", + "quote": "#/$defs/QuoteNode", + "table": "#/$defs/TableNode", + "terms": "#/$defs/TermsNode" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/HeadingNode" + }, + { + "$ref": "#/$defs/ParagraphNode" + }, + { + "$ref": "#/$defs/QuoteNode" + }, + { + "$ref": "#/$defs/CodeNode" + }, + { + "$ref": "#/$defs/MathNode" + }, + { + "$ref": "#/$defs/ImageNode" + }, + { + "$ref": "#/$defs/FigureNode" + }, + { + "$ref": "#/$defs/AdmonitionNode" + }, + { + "$ref": "#/$defs/ListNode" + }, + { + "$ref": "#/$defs/TermsNode" + }, + { + "$ref": "#/$defs/TableNode" + }, + { + "$ref": "#/$defs/LayoutNode" + } + ] }, "title": "Children", "type": "array" } }, - "required": [ - "text" - ], "title": "ListItem", "type": "object" }, "ListNode": { - "description": "Bullet (`ordered=False`) or numbered (`ordered=True`) list.", + "description": "Bullet (``ordered=False``) or numbered (``ordered=True``) list.", "properties": { "id": { "format": "uuid", @@ -975,34 +1291,6 @@ "default": null, "title": "Label" }, - "refs": { - "items": { - "$ref": "#/$defs/NodeRef" - }, - "title": "Refs", - "type": "array" - }, - "cites": { - "items": { - "$ref": "#/$defs/CiteRef" - }, - "title": "Cites", - "type": "array" - }, - "footnotes": { - "items": { - "$ref": "#/$defs/FootnoteRef" - }, - "title": "Footnotes", - "type": "array" - }, - "links": { - "items": { - "$ref": "#/$defs/LinkRef" - }, - "title": "Links", - "type": "array" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -1029,11 +1317,6 @@ "title": "Ordered", "type": "boolean" }, - "tight": { - "default": true, - "title": "Tight", - "type": "boolean" - }, "items": { "items": { "$ref": "#/$defs/ListItem" @@ -1049,8 +1332,43 @@ "title": "ListNode", "type": "object" }, + "Mark": { + "description": "A standoff span of inline formatting inside a ``RichText.value``.\n\n``raw`` is the source of the covered text, used by ``math`` (the\nreadable linear form lives in ``value``, the formula here).", + "properties": { + "start": { + "title": "Start", + "type": "integer" + }, + "end": { + "title": "End", + "type": "integer" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "raw": { + "anyOf": [ + { + "$ref": "#/$defs/RawSource" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "start", + "end", + "kind" + ], + "title": "Mark", + "type": "object" + }, "MathNode": { - "description": "Block-level equation.", + "description": "Block equation. Inline math is a ``math`` mark.", "properties": { "id": { "format": "uuid", @@ -1069,34 +1387,6 @@ "default": null, "title": "Label" }, - "refs": { - "items": { - "$ref": "#/$defs/NodeRef" - }, - "title": "Refs", - "type": "array" - }, - "cites": { - "items": { - "$ref": "#/$defs/CiteRef" - }, - "title": "Cites", - "type": "array" - }, - "footnotes": { - "items": { - "$ref": "#/$defs/FootnoteRef" - }, - "title": "Footnotes", - "type": "array" - }, - "links": { - "items": { - "$ref": "#/$defs/LinkRef" - }, - "title": "Links", - "type": "array" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -1133,34 +1423,16 @@ ], "default": null }, - "number": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Number" - }, - "counter_label": { + "numbering": { "anyOf": [ { - "type": "string" + "$ref": "#/$defs/Numbering" }, { "type": "null" } ], - "default": null, - "title": "Counter Label" - }, - "block": { - "default": true, - "title": "Block", - "type": "boolean" + "default": null } }, "required": [ @@ -1214,8 +1486,34 @@ "title": "NodeRef", "type": "object" }, + "Numbering": { + "description": "A resolved counter as displayed: ``value`` (\"3\", \"2.1.1\", \"(1)\")\nand the counter word in the document's language (``name``, \"Figure\").\n\nReplaces the flat ``number`` + ``counter_label`` pair, so a word\nwithout a number can no longer be written (docs/proposals/0013).", + "properties": { + "value": { + "title": "Value", + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + } + }, + "required": [ + "value" + ], + "title": "Numbering", + "type": "object" + }, "ParagraphNode": { - "description": "Plain text paragraph.", + "description": "A paragraph: the leaf that carries prose.", "properties": { "id": { "format": "uuid", @@ -1234,34 +1532,6 @@ "default": null, "title": "Label" }, - "refs": { - "items": { - "$ref": "#/$defs/NodeRef" - }, - "title": "Refs", - "type": "array" - }, - "cites": { - "items": { - "$ref": "#/$defs/CiteRef" - }, - "title": "Cites", - "type": "array" - }, - "footnotes": { - "items": { - "$ref": "#/$defs/FootnoteRef" - }, - "title": "Footnotes", - "type": "array" - }, - "links": { - "items": { - "$ref": "#/$defs/LinkRef" - }, - "title": "Links", - "type": "array" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -1284,10 +1554,26 @@ "type": "string" }, "text": { - "title": "Text", + "$ref": "#/$defs/RichText" + } + }, + "required": [ + "id", + "type", + "text" + ], + "title": "ParagraphNode", + "type": "object" + }, + "QuoteNode": { + "description": "Block quotation. An inline quotation stays text in its paragraph.", + "properties": { + "id": { + "format": "uuid", + "title": "Id", "type": "string" }, - "lang": { + "label": { "anyOf": [ { "type": "string" @@ -1297,26 +1583,136 @@ } ], "default": null, - "title": "Lang" + "title": "Label" + }, + "state_metadata": { + "additionalProperties": true, + "title": "State Metadata", + "type": "object" + }, + "location": { + "anyOf": [ + { + "$ref": "#/$defs/NodeLocation" + }, + { + "type": "null" + } + ], + "default": null + }, + "type": { + "const": "quote", + "title": "Type", + "type": "string" + }, + "attribution": { + "anyOf": [ + { + "$ref": "#/$defs/RichText" + }, + { + "type": "null" + } + ], + "default": null + }, + "children": { + "items": { + "discriminator": { + "mapping": { + "admonition": "#/$defs/AdmonitionNode", + "code": "#/$defs/CodeNode", + "figure": "#/$defs/FigureNode", + "heading": "#/$defs/HeadingNode", + "image": "#/$defs/ImageNode", + "layout": "#/$defs/LayoutNode", + "list": "#/$defs/ListNode", + "math": "#/$defs/MathNode", + "paragraph": "#/$defs/ParagraphNode", + "quote": "#/$defs/QuoteNode", + "table": "#/$defs/TableNode", + "terms": "#/$defs/TermsNode" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/HeadingNode" + }, + { + "$ref": "#/$defs/ParagraphNode" + }, + { + "$ref": "#/$defs/QuoteNode" + }, + { + "$ref": "#/$defs/CodeNode" + }, + { + "$ref": "#/$defs/MathNode" + }, + { + "$ref": "#/$defs/ImageNode" + }, + { + "$ref": "#/$defs/FigureNode" + }, + { + "$ref": "#/$defs/AdmonitionNode" + }, + { + "$ref": "#/$defs/ListNode" + }, + { + "$ref": "#/$defs/TermsNode" + }, + { + "$ref": "#/$defs/TableNode" + }, + { + "$ref": "#/$defs/LayoutNode" + } + ] + }, + "title": "Children", + "type": "array" } }, "required": [ "id", - "type", - "text" + "type" ], - "title": "ParagraphNode", + "title": "QuoteNode", "type": "object" }, - "QuoteNode": { - "description": "Block quotation with optional attribution (author or source).", + "RawSource": { + "description": "The producer's verbatim source for a node, with its language.\n\nA bare string dropped the one thing a consumer needs to decide whether\nto attempt a parse: which language the content is in. ``format`` is an\nopen string (``\"typst\"``, ``\"latex\"``, ``\"mathml\"``\u2026), never a closed\nenum \u2014 a new producer must not require a spec revision.", "properties": { - "id": { - "format": "uuid", - "title": "Id", + "format": { + "title": "Format", "type": "string" }, - "label": { + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "format", + "value" + ], + "title": "RawSource", + "type": "object" + }, + "RichText": { + "description": "Prose: a text, its language, its marks and its forward edges.\n\nAn edge belongs to the text in which its marker opens; its owning\nnode is the nearest node holding the text. Every list defaults to\nempty and may be omitted, so ``{\"value\": \"\u2026\"}`` is a complete text.\n``lang`` null inherits from the enclosing text, then the node chain,\nthen ``doc.lang``.", + "properties": { + "value": { + "title": "Value", + "type": "string" + }, + "lang": { "anyOf": [ { "type": "string" @@ -1326,7 +1722,14 @@ } ], "default": null, - "title": "Label" + "title": "Lang" + }, + "marks": { + "items": { + "$ref": "#/$defs/Mark" + }, + "title": "Marks", + "type": "array" }, "refs": { "items": { @@ -1355,87 +1758,12 @@ }, "title": "Links", "type": "array" - }, - "state_metadata": { - "additionalProperties": true, - "title": "State Metadata", - "type": "object" - }, - "location": { - "anyOf": [ - { - "$ref": "#/$defs/NodeLocation" - }, - { - "type": "null" - } - ], - "default": null - }, - "type": { - "const": "quote", - "title": "Type", - "type": "string" - }, - "text": { - "title": "Text", - "type": "string" - }, - "attribution": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Attribution" - }, - "block": { - "default": true, - "title": "Block", - "type": "boolean" - }, - "lang": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Lang" - } - }, - "required": [ - "id", - "type", - "text" - ], - "title": "QuoteNode", - "type": "object" - }, - "RawSource": { - "description": "The producer's verbatim source for a node, with its language.\n\nA bare string dropped the one thing a consumer needs to decide whether\nto attempt a parse: which language the content is in. ``format`` is an\nopen string (``\"typst\"``, ``\"latex\"``, ``\"mathml\"``\u2026), never a closed\nenum \u2014 a new producer must not require a spec revision.", - "properties": { - "format": { - "title": "Format", - "type": "string" - }, - "value": { - "title": "Value", - "type": "string" } }, "required": [ - "format", "value" ], - "title": "RawSource", + "title": "RichText", "type": "object" }, "SourceInfo": { @@ -1469,47 +1797,8 @@ "title": "SourceInfo", "type": "object" }, - "TableCell": { - "description": "Single cell inside a table node.", - "properties": { - "row": { - "title": "Row", - "type": "integer" - }, - "col": { - "title": "Col", - "type": "integer" - }, - "rowspan": { - "default": 1, - "title": "Rowspan", - "type": "integer" - }, - "colspan": { - "default": 1, - "title": "Colspan", - "type": "integer" - }, - "is_header": { - "default": false, - "title": "Is Header", - "type": "boolean" - }, - "text": { - "title": "Text", - "type": "string" - } - }, - "required": [ - "row", - "col", - "text" - ], - "title": "TableCell", - "type": "object" - }, "TableNode": { - "description": "Structured table with typed cells.\n\n``kind`` distinguishes a semantic ``table`` from a layout ``grid``: both\nshare the same cell model, but a grid carries no tabular semantics.\n\nA table has no caption of its own \u2014 a captioned/numbered table is a\n``TableNode`` wrapped in a ``FigureNode``, which carries the caption.", + "description": "A table whose rows and columns carry meaning. ``kind`` states what\nit is: ``\"data\"`` (a dataset of records), ``\"content\"`` (cells to\nread), ``None`` (unstated). Open vocabulary; never guessed by a\nproducer. A captioned table is wrapped in a ``FigureNode``.", "properties": { "id": { "format": "uuid", @@ -1528,34 +1817,6 @@ "default": null, "title": "Label" }, - "refs": { - "items": { - "$ref": "#/$defs/NodeRef" - }, - "title": "Refs", - "type": "array" - }, - "cites": { - "items": { - "$ref": "#/$defs/CiteRef" - }, - "title": "Cites", - "type": "array" - }, - "footnotes": { - "items": { - "$ref": "#/$defs/FootnoteRef" - }, - "title": "Footnotes", - "type": "array" - }, - "links": { - "items": { - "$ref": "#/$defs/LinkRef" - }, - "title": "Links", - "type": "array" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -1578,21 +1839,8 @@ "type": "string" }, "kind": { - "default": "table", - "enum": [ - "table", - "grid" - ], - "title": "Kind", - "type": "string" - }, - "content_kind": { "anyOf": [ { - "enum": [ - "data", - "content" - ], "type": "string" }, { @@ -1600,25 +1848,14 @@ } ], "default": null, - "title": "Content Kind" + "title": "Kind" }, "cells": { "items": { - "$ref": "#/$defs/TableCell" + "$ref": "#/$defs/Cell" }, "title": "Cells", "type": "array" - }, - "raw": { - "anyOf": [ - { - "$ref": "#/$defs/RawSource" - }, - { - "type": "null" - } - ], - "default": null } }, "required": [ @@ -1629,26 +1866,81 @@ "type": "object" }, "TermItem": { - "description": "Single term/description pair of a definition list.", + "description": "One term and its definition (block nodes in ``children``).", "properties": { "term": { - "title": "Term", - "type": "string" + "$ref": "#/$defs/RichText" }, - "description": { - "title": "Description", - "type": "string" + "children": { + "items": { + "discriminator": { + "mapping": { + "admonition": "#/$defs/AdmonitionNode", + "code": "#/$defs/CodeNode", + "figure": "#/$defs/FigureNode", + "heading": "#/$defs/HeadingNode", + "image": "#/$defs/ImageNode", + "layout": "#/$defs/LayoutNode", + "list": "#/$defs/ListNode", + "math": "#/$defs/MathNode", + "paragraph": "#/$defs/ParagraphNode", + "quote": "#/$defs/QuoteNode", + "table": "#/$defs/TableNode", + "terms": "#/$defs/TermsNode" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/HeadingNode" + }, + { + "$ref": "#/$defs/ParagraphNode" + }, + { + "$ref": "#/$defs/QuoteNode" + }, + { + "$ref": "#/$defs/CodeNode" + }, + { + "$ref": "#/$defs/MathNode" + }, + { + "$ref": "#/$defs/ImageNode" + }, + { + "$ref": "#/$defs/FigureNode" + }, + { + "$ref": "#/$defs/AdmonitionNode" + }, + { + "$ref": "#/$defs/ListNode" + }, + { + "$ref": "#/$defs/TermsNode" + }, + { + "$ref": "#/$defs/TableNode" + }, + { + "$ref": "#/$defs/LayoutNode" + } + ] + }, + "title": "Children", + "type": "array" } }, "required": [ - "term", - "description" + "term" ], "title": "TermItem", "type": "object" }, "TermsNode": { - "description": "Definition list (Typst ``/ term: description`` items).", + "description": "Definition list.", "properties": { "id": { "format": "uuid", @@ -1667,34 +1959,6 @@ "default": null, "title": "Label" }, - "refs": { - "items": { - "$ref": "#/$defs/NodeRef" - }, - "title": "Refs", - "type": "array" - }, - "cites": { - "items": { - "$ref": "#/$defs/CiteRef" - }, - "title": "Cites", - "type": "array" - }, - "footnotes": { - "items": { - "$ref": "#/$defs/FootnoteRef" - }, - "title": "Footnotes", - "type": "array" - }, - "links": { - "items": { - "$ref": "#/$defs/LinkRef" - }, - "title": "Links", - "type": "array" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -1716,11 +1980,6 @@ "title": "Type", "type": "string" }, - "tight": { - "default": true, - "title": "Tight", - "type": "boolean" - }, "items": { "items": { "$ref": "#/$defs/TermItem" @@ -1771,10 +2030,12 @@ "items": { "discriminator": { "mapping": { + "admonition": "#/$defs/AdmonitionNode", "code": "#/$defs/CodeNode", "figure": "#/$defs/FigureNode", "heading": "#/$defs/HeadingNode", "image": "#/$defs/ImageNode", + "layout": "#/$defs/LayoutNode", "list": "#/$defs/ListNode", "math": "#/$defs/MathNode", "paragraph": "#/$defs/ParagraphNode", @@ -1791,9 +2052,6 @@ { "$ref": "#/$defs/ParagraphNode" }, - { - "$ref": "#/$defs/TableNode" - }, { "$ref": "#/$defs/QuoteNode" }, @@ -1803,17 +2061,26 @@ { "$ref": "#/$defs/MathNode" }, + { + "$ref": "#/$defs/ImageNode" + }, { "$ref": "#/$defs/FigureNode" }, { - "$ref": "#/$defs/ImageNode" + "$ref": "#/$defs/AdmonitionNode" }, { "$ref": "#/$defs/ListNode" }, { "$ref": "#/$defs/TermsNode" + }, + { + "$ref": "#/$defs/TableNode" + }, + { + "$ref": "#/$defs/LayoutNode" } ] }, diff --git a/tests/conftest.py b/tests/conftest.py index 51bce3e..f3f88a0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,6 +35,11 @@ def unpaginated_cnd_path() -> Path: return FIXTURES_DIR / "unpaginated.cnd" +@pytest.fixture +def elements_cnd_path() -> Path: + return FIXTURES_DIR / "elements.cnd" + + # Test modules not yet ported to format 0.5.0 (docs/proposals/0013). The # 0.5 series lands plan by plan on one integration branch; each plan # removes the entries it ports, and the list must be empty before that @@ -51,9 +56,7 @@ def unpaginated_cnd_path() -> Path: "test_render.py", "test_visitor.py", # ported by plan 1, Tasks 4-7 - "test_cnd.py", "test_hashing.py", - "test_schema.py", "test_traverse.py", "test_validate.py", ] diff --git a/tests/test_cnd.py b/tests/test_cnd.py index f5f199b..5e426f2 100644 --- a/tests/test_cnd.py +++ b/tests/test_cnd.py @@ -1,6 +1,7 @@ from pathlib import Path from uuid import UUID +from factories import rt from ids import ( HEADING002_ID, HEADING002_LABEL, @@ -15,7 +16,8 @@ ) from cnd import BibEntry, Cnd, FigureNode, Footnote -from cnd.core.nodes import LinkRef, ParagraphNode +from cnd.core.nodes import ParagraphNode +from cnd.core.text import LinkRef def _load(path: Path) -> Cnd: @@ -97,7 +99,7 @@ def test_bibliography_entry_round_trips( [entry] = cnd.bibliography assert entry.id == RICH_BIB_ENTRY_ID assert entry.label == "smith2024" - assert entry.formatted.startswith("Smith, J., & Doe, A. (2024).") + assert entry.formatted.value.startswith("Smith, J., & Doe, A. (2024).") assert entry.year == 2024 assert entry.fields["parent"]["title"] == "Journal of Systems Integration" @@ -109,7 +111,7 @@ def test_footnote_entry_round_trips( [footnote] = cnd.footnotes assert footnote.id == RICH_FOOTNOTE_ID assert footnote.label == "fn-rest" - assert footnote.text == "REST : Representational State Transfer." + assert footnote.children[0].text.value == "REST : Representational State Transfer." def test_link_families_and_spans_parse( self, rich_content_cnd_path: Path, @@ -117,17 +119,17 @@ def test_link_families_and_spans_parse( cnd = _load(rich_content_cnd_path) para = next(v.node for v in cnd.iter() if v.node.id == RICH_PARA_ID) - [ref] = para.refs + [ref] = para.text.refs assert ref.label == RICH_FIG_CODE_LABEL assert ref.text_span == [77, 88] - [cite] = para.cites + [cite] = para.text.cites assert cite.label == RICH_BIB_ENTRY_LABEL assert cite.form == "prose" assert cite.supplement == "p. 12" assert cite.text_span == [6, 18] - [footnote_ref] = para.footnotes + [footnote_ref] = para.text.footnotes assert footnote_ref.label == RICH_FOOTNOTE_LABEL assert footnote_ref.text_span == [57, 61] @@ -137,9 +139,9 @@ def test_each_link_family_resolves_in_its_own_domain( cnd = _load(rich_content_cnd_path) para = next(v.node for v in cnd.iter() if v.node.id == RICH_PARA_ID) - assert isinstance(cnd.resolve(para.refs[0].label), FigureNode) - assert isinstance(cnd.resolve(para.cites[0].label), BibEntry) - assert isinstance(cnd.resolve(para.footnotes[0].label), Footnote) + assert isinstance(cnd.resolve(para.text.refs[0].label), FigureNode) + assert isinstance(cnd.resolve(para.text.cites[0].label), BibEntry) + assert isinstance(cnd.resolve(para.text.footnotes[0].label), Footnote) class TestPagination: @@ -200,17 +202,32 @@ def test_doc_metadata_defaults_are_omittable( class TestLinks: def test_links_is_a_fourth_forward_family_keyed_by_href(self) -> None: node = ParagraphNode( - id=UUID(int=1), type="paragraph", text="see the auth spec", - links=[LinkRef(href="https://example.com/spec", text_span=[4, 17])], + id=UUID(int=1), type="paragraph", text=rt( + "see the auth spec", + links=[LinkRef(href="https://example.com/spec", text_span=[4, 17])], + ), ) - assert node.links[0].href == "https://example.com/spec" - assert node.links[0].text_span == [4, 17] + assert node.text.links[0].href == "https://example.com/spec" + assert node.text.links[0].text_span == [4, 17] def test_links_defaults_to_empty_so_every_existing_cnd_stays_valid(self) -> None: - node = ParagraphNode(id=UUID(int=2), type="paragraph", text="x") + node = ParagraphNode(id=UUID(int=2), type="paragraph", text=rt("x")) - assert node.links == [] + assert node.text.links == [] def test_link_ref_admits_any_scheme_and_needs_no_span(self) -> None: assert LinkRef(href="doi:10.1000/182").text_span is None + + +def test_elements_fixture_parses_and_carries_every_new_element(elements_cnd_path) -> None: + cnd = Cnd.model_validate_json(elements_cnd_path.read_text()) + types = {visit.node.type for visit in cnd.iter()} + assert {"admonition", "layout", "table", "terms", "list", "quote", "figure"} <= types + assert cnd.doc.status == "Approved" and cnd.doc.extra["reference"] == "PRJ-042" + assert cnd.footnotes[0].children[1].label == "fn-second" + + +def test_incoming_reads_edges_from_texts(elements_cnd_path) -> None: + cnd = Cnd.model_validate_json(elements_cnd_path.read_text()) + assert [node.type for node in cnd.incoming("thm-main")] == ["paragraph"] From 99e6a6873f10b4c1504c13055b727f4fa416006f Mon Sep 17 00:00:00 2001 From: Maxanox Date: Tue, 13 Oct 2026 10:35:10 +0200 Subject: [PATCH 05/78] feat(core): traversal enters items, cells and footnotes; slot and path in the context Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS --- fixtures/traversal.json | 793 +++++++++++++++++++++++++++++++++---- scripts/regen_traversal.py | 9 +- src/cnd/core/cnd.py | 22 +- src/cnd/core/nodes.py | 103 +++-- tests/conftest.py | 1 - tests/test_traverse.py | 93 ++++- 6 files changed, 904 insertions(+), 117 deletions(-) diff --git a/fixtures/traversal.json b/fixtures/traversal.json index 8d1f032..1749f7c 100644 --- a/fixtures/traversal.json +++ b/fixtures/traversal.json @@ -3,367 +3,1016 @@ { "id": "00000000-0000-4000-b000-000000000001", "type": "paragraph", - "depth": 0 + "depth": 0, + "path": "nodes/0" }, { "id": "00000000-0000-4000-b000-000000000002", "type": "heading", - "depth": 0 + "depth": 0, + "path": "nodes/1" }, { "id": "00000000-0000-4000-b000-000000000003", "type": "paragraph", - "depth": 1 + "depth": 1, + "path": "nodes/1/children/0" }, { "id": "00000000-0000-4000-b000-000000000004", "type": "heading", - "depth": 1 + "depth": 1, + "path": "nodes/1/children/1" }, { "id": "00000000-0000-4000-b000-000000000005", "type": "paragraph", - "depth": 2 + "depth": 2, + "path": "nodes/1/children/1/children/0" }, { "id": "00000000-0000-4000-b000-000000000006", "type": "paragraph", - "depth": 2 + "depth": 2, + "path": "nodes/1/children/1/children/1" }, { "id": "00000000-0000-4000-c000-000000000001", "type": "figure", - "depth": 2 + "depth": 2, + "path": "nodes/1/children/1/children/2" }, { "id": "00000000-0000-4000-b000-000000000007", "type": "table", - "depth": 3 + "depth": 3, + "path": "nodes/1/children/1/children/2/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000005", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/1/children/2/children/0/cells/0/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000006", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/1/children/2/children/0/cells/1/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000007", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/1/children/2/children/0/cells/2/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000008", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/1/children/2/children/0/cells/3/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000009", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/1/children/2/children/0/cells/4/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000010", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/1/children/2/children/0/cells/5/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000011", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/1/children/2/children/0/cells/6/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000012", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/1/children/2/children/0/cells/7/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000013", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/1/children/2/children/0/cells/8/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000014", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/1/children/2/children/0/cells/9/children/0" }, { "id": "00000000-0000-4000-b000-000000000008", "type": "heading", - "depth": 1 + "depth": 1, + "path": "nodes/1/children/2" }, { "id": "00000000-0000-4000-b000-000000000009", "type": "paragraph", - "depth": 2 + "depth": 2, + "path": "nodes/1/children/2/children/0" }, { "id": "00000000-0000-4000-c000-000000000002", "type": "figure", - "depth": 2 + "depth": 2, + "path": "nodes/1/children/2/children/1" }, { "id": "00000000-0000-4000-b000-000000000010", "type": "table", - "depth": 3 + "depth": 3, + "path": "nodes/1/children/2/children/1/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000015", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/2/children/1/children/0/cells/0/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000016", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/2/children/1/children/0/cells/1/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000017", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/2/children/1/children/0/cells/2/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000018", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/2/children/1/children/0/cells/3/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000019", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/2/children/1/children/0/cells/4/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000020", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/2/children/1/children/0/cells/5/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000021", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/2/children/1/children/0/cells/6/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000022", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/2/children/1/children/0/cells/7/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000023", + "type": "paragraph", + "depth": 4, + "path": "nodes/1/children/2/children/1/children/0/cells/8/children/0" }, { "id": "00000000-0000-4000-b000-000000000011", "type": "heading", - "depth": 0 + "depth": 0, + "path": "nodes/2" }, { "id": "00000000-0000-4000-b000-000000000012", "type": "heading", - "depth": 1 + "depth": 1, + "path": "nodes/2/children/0" }, { "id": "00000000-0000-4000-b000-000000000013", "type": "heading", - "depth": 2 + "depth": 2, + "path": "nodes/2/children/0/children/0" }, { "id": "00000000-0000-4000-b000-000000000014", "type": "paragraph", - "depth": 3 + "depth": 3, + "path": "nodes/2/children/0/children/0/children/0" }, { "id": "00000000-0000-4000-b000-000000000015", "type": "paragraph", - "depth": 3 + "depth": 3, + "path": "nodes/2/children/0/children/0/children/1" }, { "id": "00000000-0000-4000-c000-000000000003", "type": "figure", - "depth": 3 + "depth": 3, + "path": "nodes/2/children/0/children/0/children/2" }, { "id": "00000000-0000-4000-b000-000000000016", "type": "table", - "depth": 4 + "depth": 4, + "path": "nodes/2/children/0/children/0/children/2/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000024", + "type": "paragraph", + "depth": 5, + "path": "nodes/2/children/0/children/0/children/2/children/0/cells/0/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000025", + "type": "paragraph", + "depth": 5, + "path": "nodes/2/children/0/children/0/children/2/children/0/cells/1/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000026", + "type": "paragraph", + "depth": 5, + "path": "nodes/2/children/0/children/0/children/2/children/0/cells/2/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000027", + "type": "paragraph", + "depth": 5, + "path": "nodes/2/children/0/children/0/children/2/children/0/cells/3/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000028", + "type": "paragraph", + "depth": 5, + "path": "nodes/2/children/0/children/0/children/2/children/0/cells/4/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000029", + "type": "paragraph", + "depth": 5, + "path": "nodes/2/children/0/children/0/children/2/children/0/cells/5/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000030", + "type": "paragraph", + "depth": 5, + "path": "nodes/2/children/0/children/0/children/2/children/0/cells/6/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000031", + "type": "paragraph", + "depth": 5, + "path": "nodes/2/children/0/children/0/children/2/children/0/cells/7/children/0" }, { "id": "00000000-0000-4000-b000-000000000017", "type": "heading", - "depth": 1 + "depth": 1, + "path": "nodes/2/children/1" }, { "id": "00000000-0000-4000-b000-000000000018", "type": "paragraph", - "depth": 2 + "depth": 2, + "path": "nodes/2/children/1/children/0" }, { "id": "00000000-0000-4000-b000-000000000019", "type": "heading", - "depth": 0 + "depth": 0, + "path": "nodes/3" }, { "id": "00000000-0000-4000-c000-000000000004", "type": "figure", - "depth": 1 + "depth": 1, + "path": "nodes/3/children/0" }, { "id": "00000000-0000-4000-b000-000000000020", "type": "table", - "depth": 2 + "depth": 2, + "path": "nodes/3/children/0/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000032", + "type": "paragraph", + "depth": 3, + "path": "nodes/3/children/0/children/0/cells/0/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000033", + "type": "paragraph", + "depth": 3, + "path": "nodes/3/children/0/children/0/cells/1/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000034", + "type": "paragraph", + "depth": 3, + "path": "nodes/3/children/0/children/0/cells/2/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000035", + "type": "paragraph", + "depth": 3, + "path": "nodes/3/children/0/children/0/cells/3/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000036", + "type": "paragraph", + "depth": 3, + "path": "nodes/3/children/0/children/0/cells/4/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000037", + "type": "paragraph", + "depth": 3, + "path": "nodes/3/children/0/children/0/cells/5/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000038", + "type": "paragraph", + "depth": 3, + "path": "nodes/3/children/0/children/0/cells/6/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000039", + "type": "paragraph", + "depth": 3, + "path": "nodes/3/children/0/children/0/cells/7/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000040", + "type": "paragraph", + "depth": 3, + "path": "nodes/3/children/0/children/0/cells/8/children/0" + } + ], + "elements.cnd": [ + { + "id": "00000000-0000-4000-d000-000000000002", + "type": "heading", + "depth": 0, + "path": "nodes/0" + }, + { + "id": "00000000-0000-4000-d000-000000000001", + "type": "paragraph", + "depth": 1, + "path": "nodes/0/children/0" + }, + { + "id": "00000000-0000-4000-d000-000000000003", + "type": "admonition", + "depth": 1, + "path": "nodes/0/children/1" + }, + { + "id": "00000000-0000-4000-d000-000000000004", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/1/children/0" + }, + { + "id": "00000000-0000-4000-d000-000000000005", + "type": "list", + "depth": 1, + "path": "nodes/0/children/2" + }, + { + "id": "00000000-0000-4000-d000-000000000006", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/2/items/0/children/0" + }, + { + "id": "00000000-0000-4000-d000-000000000007", + "type": "list", + "depth": 2, + "path": "nodes/0/children/2/items/0/children/1" + }, + { + "id": "00000000-0000-4000-d000-000000000008", + "type": "paragraph", + "depth": 3, + "path": "nodes/0/children/2/items/0/children/1/items/0/children/0" + }, + { + "id": "00000000-0000-4000-d000-000000000009", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/2/items/1/children/0" + }, + { + "id": "00000000-0000-4000-d000-000000000010", + "type": "terms", + "depth": 1, + "path": "nodes/0/children/3" + }, + { + "id": "00000000-0000-4000-d000-000000000011", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/3/items/0/children/0" + }, + { + "id": "00000000-0000-4000-d000-000000000012", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/3/items/0/children/1" + }, + { + "id": "00000000-0000-4000-d000-000000000013", + "type": "table", + "depth": 1, + "path": "nodes/0/children/4" + }, + { + "id": "00000000-0000-4000-d000-000000000014", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/4/cells/0/children/0" + }, + { + "id": "00000000-0000-4000-d000-000000000015", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/4/cells/1/children/0" + }, + { + "id": "00000000-0000-4000-d000-000000000016", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/4/cells/2/children/0" + }, + { + "id": "00000000-0000-4000-d000-000000000017", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/4/cells/3/children/0" + }, + { + "id": "00000000-0000-4000-d000-000000000018", + "type": "layout", + "depth": 1, + "path": "nodes/0/children/5" + }, + { + "id": "00000000-0000-4000-d000-000000000019", + "type": "image", + "depth": 2, + "path": "nodes/0/children/5/cells/0/children/0" + }, + { + "id": "00000000-0000-4000-d000-000000000020", + "type": "image", + "depth": 2, + "path": "nodes/0/children/5/cells/1/children/0" + }, + { + "id": "00000000-0000-4000-d000-000000000021", + "type": "figure", + "depth": 1, + "path": "nodes/0/children/6" + }, + { + "id": "00000000-0000-4000-d000-000000000022", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/6/children/0" + }, + { + "id": "00000000-0000-4000-d000-000000000023", + "type": "quote", + "depth": 1, + "path": "nodes/0/children/7" + }, + { + "id": "00000000-0000-4000-d000-000000000024", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/7/children/0" + }, + { + "id": "00000000-0000-4000-d000-000000000025", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/7/children/1" + }, + { + "id": "00000000-0000-4000-d000-000000000026", + "type": "code", + "depth": 1, + "path": "nodes/0/children/8" + }, + { + "id": "00000000-0000-4000-d000-000000000027", + "type": "math", + "depth": 1, + "path": "nodes/0/children/9" + }, + { + "id": "00000000-0000-4000-d000-000000000029", + "type": "paragraph", + "depth": 0, + "path": "footnotes/0/children/0" + }, + { + "id": "00000000-0000-4000-d000-000000000030", + "type": "paragraph", + "depth": 0, + "path": "footnotes/0/children/1" } ], "full_coverage.cnd": [ { "id": "00000000-0000-4000-e000-000000000001", "type": "heading", - "depth": 0 + "depth": 0, + "path": "nodes/0" }, { "id": "00000000-0000-4000-e000-000000000002", "type": "paragraph", - "depth": 1 + "depth": 1, + "path": "nodes/0/children/0" }, { "id": "00000000-0000-4000-e000-000000000003", "type": "list", - "depth": 1 + "depth": 1, + "path": "nodes/0/children/1" + }, + { + "id": "00000000-0000-4000-c000-000000000001", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/1/items/0/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000002", + "type": "list", + "depth": 2, + "path": "nodes/0/children/1/items/0/children/1" + }, + { + "id": "00000000-0000-4000-c000-000000000003", + "type": "paragraph", + "depth": 3, + "path": "nodes/0/children/1/items/0/children/1/items/0/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000004", + "type": "paragraph", + "depth": 3, + "path": "nodes/0/children/1/items/0/children/1/items/1/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000005", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/1/items/1/children/0" }, { "id": "00000000-0000-4000-e000-000000000004", "type": "list", - "depth": 1 + "depth": 1, + "path": "nodes/0/children/2" + }, + { + "id": "00000000-0000-4000-c000-000000000006", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/2/items/0/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000007", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/2/items/1/children/0" }, { "id": "00000000-0000-4000-e000-000000000005", "type": "terms", - "depth": 1 + "depth": 1, + "path": "nodes/0/children/3" + }, + { + "id": "00000000-0000-4000-c000-000000000008", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/3/items/0/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000009", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/3/items/1/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000010", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/3/items/2/children/0" }, { "id": "00000000-0000-4000-e000-000000000006", "type": "quote", - "depth": 1 + "depth": 1, + "path": "nodes/0/children/4" + }, + { + "id": "00000000-0000-4000-c000-000000000011", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/4/children/0" }, { "id": "00000000-0000-4000-e000-000000000007", "type": "code", - "depth": 1 + "depth": 1, + "path": "nodes/0/children/5" }, { "id": "00000000-0000-4000-e000-000000000008", "type": "math", - "depth": 1 + "depth": 1, + "path": "nodes/0/children/6" }, { "id": "00000000-0000-4000-e000-000000000030", "type": "heading", - "depth": 1 + "depth": 1, + "path": "nodes/0/children/7" }, { "id": "00000000-0000-4000-e000-000000000010", "type": "figure", - "depth": 2 + "depth": 2, + "path": "nodes/0/children/7/children/0" }, { "id": "00000000-0000-4000-e000-000000000011", "type": "table", - "depth": 3 + "depth": 3, + "path": "nodes/0/children/7/children/0/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000012", + "type": "paragraph", + "depth": 4, + "path": "nodes/0/children/7/children/0/children/0/cells/0/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000013", + "type": "paragraph", + "depth": 4, + "path": "nodes/0/children/7/children/0/children/0/cells/1/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000014", + "type": "paragraph", + "depth": 4, + "path": "nodes/0/children/7/children/0/children/0/cells/2/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000015", + "type": "paragraph", + "depth": 4, + "path": "nodes/0/children/7/children/0/children/0/cells/3/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000016", + "type": "paragraph", + "depth": 4, + "path": "nodes/0/children/7/children/0/children/0/cells/4/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000017", + "type": "paragraph", + "depth": 4, + "path": "nodes/0/children/7/children/0/children/0/cells/5/children/0" }, { "id": "00000000-0000-4000-e000-000000000012", - "type": "table", - "depth": 2 + "type": "layout", + "depth": 2, + "path": "nodes/0/children/7/children/1" + }, + { + "id": "00000000-0000-4000-c000-000000000018", + "type": "paragraph", + "depth": 3, + "path": "nodes/0/children/7/children/1/cells/0/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000019", + "type": "paragraph", + "depth": 3, + "path": "nodes/0/children/7/children/1/cells/1/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000020", + "type": "paragraph", + "depth": 3, + "path": "nodes/0/children/7/children/1/cells/2/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000021", + "type": "paragraph", + "depth": 3, + "path": "nodes/0/children/7/children/1/cells/3/children/0" }, { "id": "00000000-0000-4000-e000-000000000013", "type": "figure", - "depth": 2 + "depth": 2, + "path": "nodes/0/children/7/children/2" }, { "id": "00000000-0000-4000-e000-000000000014", "type": "image", - "depth": 3 + "depth": 3, + "path": "nodes/0/children/7/children/2/children/0" }, { "id": "00000000-0000-4000-e000-000000000015", "type": "figure", - "depth": 2 + "depth": 2, + "path": "nodes/0/children/7/children/3" }, { "id": "00000000-0000-4000-e000-000000000016", "type": "code", - "depth": 3 + "depth": 3, + "path": "nodes/0/children/7/children/3/children/0" }, { "id": "00000000-0000-4000-e000-000000000017", "type": "figure", - "depth": 2 + "depth": 2, + "path": "nodes/0/children/7/children/4" }, { "id": "00000000-0000-4000-e000-000000000018", "type": "figure", - "depth": 3 + "depth": 3, + "path": "nodes/0/children/7/children/4/children/0" }, { "id": "00000000-0000-4000-e000-000000000019", "type": "image", - "depth": 4 + "depth": 4, + "path": "nodes/0/children/7/children/4/children/0/children/0" }, { "id": "00000000-0000-4000-e000-000000000021", "type": "figure", - "depth": 3 + "depth": 3, + "path": "nodes/0/children/7/children/4/children/1" }, { "id": "00000000-0000-4000-e000-000000000022", "type": "image", - "depth": 4 + "depth": 4, + "path": "nodes/0/children/7/children/4/children/1/children/0" }, { "id": "00000000-0000-4000-e000-000000000023", "type": "figure", - "depth": 2 + "depth": 2, + "path": "nodes/0/children/7/children/5" }, { "id": "00000000-0000-4000-e000-000000000024", "type": "image", - "depth": 3 + "depth": 3, + "path": "nodes/0/children/7/children/5/children/0" }, { "id": "00000000-0000-4000-e000-000000000025", "type": "figure", - "depth": 2 + "depth": 2, + "path": "nodes/0/children/7/children/6" }, { "id": "00000000-0000-4000-e000-000000000026", "type": "image", - "depth": 2 + "depth": 2, + "path": "nodes/0/children/7/children/7" }, { "id": "00000000-0000-4000-e000-000000000027", "type": "image", - "depth": 2 + "depth": 2, + "path": "nodes/0/children/7/children/8" }, { "id": "00000000-0000-4000-e000-000000000040", "type": "heading", - "depth": 0 + "depth": 0, + "path": "nodes/1" }, { "id": "00000000-0000-4000-e000-000000000041", "type": "paragraph", - "depth": 1 + "depth": 1, + "path": "nodes/1/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000022", + "type": "paragraph", + "depth": 0, + "path": "footnotes/0/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000023", + "type": "paragraph", + "depth": 0, + "path": "footnotes/1/children/0" } ], "minimal.cnd": [ { "id": "550e8400-e29b-41d4-a716-446655440001", "type": "heading", - "depth": 0 + "depth": 0, + "path": "nodes/0" }, { "id": "550e8400-e29b-41d4-a716-446655440002", "type": "paragraph", - "depth": 1 + "depth": 1, + "path": "nodes/0/children/0" } ], "rich_content.cnd": [ { "id": "00000000-0000-4000-d000-000000000001", "type": "heading", - "depth": 0 + "depth": 0, + "path": "nodes/0" }, { "id": "00000000-0000-4000-d000-000000000002", "type": "paragraph", - "depth": 1 + "depth": 1, + "path": "nodes/0/children/0" }, { "id": "00000000-0000-4000-d000-000000000003", "type": "terms", - "depth": 1 + "depth": 1, + "path": "nodes/0/children/1" + }, + { + "id": "00000000-0000-4000-c000-000000000001", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/1/items/0/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000002", + "type": "paragraph", + "depth": 2, + "path": "nodes/0/children/1/items/1/children/0" }, { "id": "00000000-0000-4000-d000-000000000004", "type": "figure", - "depth": 1 + "depth": 1, + "path": "nodes/0/children/2" }, { "id": "00000000-0000-4000-d000-000000000005", "type": "code", - "depth": 2 + "depth": 2, + "path": "nodes/0/children/2/children/0" }, { "id": "00000000-0000-4000-d000-000000000006", "type": "image", - "depth": 1 + "depth": 1, + "path": "nodes/0/children/3" }, { "id": "00000000-0000-4000-d000-000000000007", "type": "figure", - "depth": 1 + "depth": 1, + "path": "nodes/0/children/4" }, { "id": "00000000-0000-4000-d000-000000000008", "type": "image", - "depth": 2 + "depth": 2, + "path": "nodes/0/children/4/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000003", + "type": "paragraph", + "depth": 0, + "path": "footnotes/0/children/0" } ], "structured.cnd": [ { "id": "00000000-0000-4000-a000-000000000001", "type": "heading", - "depth": 0 + "depth": 0, + "path": "nodes/0" }, { "id": "00000000-0000-4000-a000-000000000002", "type": "heading", - "depth": 1 + "depth": 1, + "path": "nodes/0/children/0" }, { "id": "00000000-0000-4000-a000-000000000003", "type": "paragraph", - "depth": 2 + "depth": 2, + "path": "nodes/0/children/0/children/0" }, { "id": "00000000-0000-4000-a000-000000000005", "type": "figure", - "depth": 2 + "depth": 2, + "path": "nodes/0/children/0/children/1" }, { "id": "00000000-0000-4000-a000-000000000004", "type": "table", - "depth": 3 + "depth": 3, + "path": "nodes/0/children/0/children/1/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000001", + "type": "paragraph", + "depth": 4, + "path": "nodes/0/children/0/children/1/children/0/cells/0/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000002", + "type": "paragraph", + "depth": 4, + "path": "nodes/0/children/0/children/1/children/0/cells/1/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000003", + "type": "paragraph", + "depth": 4, + "path": "nodes/0/children/0/children/1/children/0/cells/2/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000004", + "type": "paragraph", + "depth": 4, + "path": "nodes/0/children/0/children/1/children/0/cells/3/children/0" } ], "unpaginated.cnd": [ { "id": "00000000-0000-4000-f000-000000000001", "type": "heading", - "depth": 0 + "depth": 0, + "path": "nodes/0" }, { "id": "00000000-0000-4000-f000-000000000002", "type": "paragraph", - "depth": 1 + "depth": 1, + "path": "nodes/0/children/0" }, { "id": "00000000-0000-4000-f000-000000000003", "type": "figure", - "depth": 1 + "depth": 1, + "path": "nodes/0/children/1" }, { "id": "00000000-0000-4000-f000-000000000004", "type": "code", - "depth": 2 + "depth": 2, + "path": "nodes/0/children/1/children/0" + }, + { + "id": "00000000-0000-4000-c000-000000000001", + "type": "paragraph", + "depth": 0, + "path": "footnotes/0/children/0" } ] } diff --git a/scripts/regen_traversal.py b/scripts/regen_traversal.py index fdbabe8..2bcb9cc 100644 --- a/scripts/regen_traversal.py +++ b/scripts/regen_traversal.py @@ -17,8 +17,13 @@ for path in sorted(FIXTURES.glob("*.cnd")): cnd = Cnd.model_validate_json(path.read_text()) vectors[path.name] = [ - {"id": str(v.node.id), "type": v.node.type, "depth": v.ctx.depth} - for v in cnd.iter() + { + "id": str(v.node.id), + "type": v.node.type, + "depth": v.ctx.depth, + "path": "/".join(map(str, v.ctx.path)), + } + for v in cnd.iter_all() ] (FIXTURES / "traversal.json").write_text(json.dumps(vectors, indent=2) + "\n") diff --git a/src/cnd/core/cnd.py b/src/cnd/core/cnd.py index bbc3e03..568c1ce 100644 --- a/src/cnd/core/cnd.py +++ b/src/cnd/core/cnd.py @@ -167,6 +167,24 @@ def iter( _totals=self._position_totals, ) + def iter_all(self) -> Iterator[NodeTraverse]: + """The body walk, then the content of every footnote. + + Footnote content nodes are nodes in every respect (ids, labels, + locations) but stay out of the body walk, so a consumer splitting + the body never mixes notes into running text (docs/proposals/0013). + Positions inside a footnote count within that footnote; ``slot`` is + ``"footnotes"`` and ``slot_index`` the pool index. + """ + yield from self.iter() + for index, note in enumerate(self.footnotes): + yield from iter_nodes( + note.children, + _base_path=("footnotes", index, "children"), + _slot="footnotes", + _slot_index=index, + ) + def __iter__(self) -> Iterator[NodeTraverse]: return self.iter() @@ -180,7 +198,7 @@ def resolve(self, label: str) -> LinkTarget | None: """ if self._label_index is None: index: dict[str, LinkTarget] = {} - for visit in self.iter(): + for visit in self.iter_all(): if visit.node.label is not None: index.setdefault(visit.node.label, visit.node) for entry in (*self.bibliography, *self.footnotes): @@ -200,7 +218,7 @@ def incoming(self, label: str) -> list[CndNode]: if self._incoming_index is None: index: dict[str, list[CndNode]] = {} seen: dict[str, set[UUID]] = {} - for visit in self.iter(): + for visit in self.iter_all(): node = visit.node for link in edges_of(node): if not hasattr(link, "label"): diff --git a/src/cnd/core/nodes.py b/src/cnd/core/nodes.py index 41bdcac..cc06feb 100644 --- a/src/cnd/core/nodes.py +++ b/src/cnd/core/nodes.py @@ -247,8 +247,8 @@ class NodeTraverseContext: pairs suitable for an "x/y" display): - ``doc_index``/``doc_count`` — position among all nodes of the walk. - - ``sibling_index``/``sibling_count`` — position among the parent's - children. + - ``sibling_index``/``sibling_count`` — position among the siblings + of the same slot list. - ``page_index``/``page_count`` — position among the nodes that begin on the same page (``location.page``). Both are ``None`` on an unpaginated CND, where no page exists to be positioned within — the @@ -258,6 +258,14 @@ class NodeTraverseContext: They are document facts, not walk artifacts: pruning a branch with ``max_depth``/``stop_predicate`` does not shift the positions of the nodes that are still visited. + + - ``slot``/``slot_index`` — where the node's list sits in its parent: + ``"children"`` (index ``None``), ``"items"`` or ``"cells"`` with the + entry index, ``"footnotes"`` with the pool index, ``None`` at the top + level. + - ``path`` — the node's full position, e.g. + ``("nodes", 0, "children", 4, "cells", 2, "children", 0)``. It feeds + the document hash (§10). """ depth: int @@ -269,6 +277,9 @@ class NodeTraverseContext: sibling_count: int = 0 page_index: int | None = None page_count: int | None = None + slot: str | None = None + slot_index: int | None = None + path: tuple[str | int, ...] = () @dataclass(frozen=True) @@ -282,13 +293,34 @@ class NodeTraverse: StopPredicate = Callable[[CndNode, NodeTraverseContext], bool] -def _children_of(node: CndNode) -> list["CndNode"]: - """The child list of a children-bearing node (heading, figure).""" - if isinstance(node, (HeadingNode, FigureNode)): - return node.children +@dataclass(frozen=True) +class ChildSlot: + """One list of nested nodes inside a container, and where it sits: + ``field`` is ``"children"``, ``"items"`` or ``"cells"``; ``index`` is + the item or cell index (``None`` for ``"children"``).""" + + field: str + index: int | None + nodes: list["CndNode"] + + +def child_slots(node: "CndNode") -> list[ChildSlot]: + """Every nested-node list of ``node``, in document order (principle 2: + nested nodes always live in ``children``).""" + if isinstance(node, (HeadingNode, FigureNode, QuoteNode, AdmonitionNode)): + return [ChildSlot("children", None, node.children)] + if isinstance(node, (ListNode, TermsNode)): + return [ChildSlot("items", i, item.children) for i, item in enumerate(node.items)] + if isinstance(node, (TableNode, LayoutNode)): + return [ChildSlot("cells", i, cell.children) for i, cell in enumerate(node.cells)] return [] +def _children_of(node: "CndNode") -> list["CndNode"]: + """All nested nodes of ``node``, every slot flattened in order.""" + return [child for slot in child_slots(node) for child in slot.nodes] + + def position_totals(nodes: list[CndNode]) -> tuple[int, dict[int, int]]: """One cheap pre-pass over the tree: total node count and per-page node counts (keyed on ``location.page``). Feeds the ``*_count`` @@ -343,8 +375,12 @@ def iter_nodes( max_depth: int | None = None, stop_predicate: StopPredicate | None = None, _totals: tuple[int, dict[int, int]] | None = None, + _base_path: tuple[str | int, ...] = ("nodes",), + _slot: str | None = None, + _slot_index: int | None = None, ) -> Iterator[NodeTraverse]: - """Walk a node tree depth-first, yielding each node with its context. + """Walk a node tree depth-first in reading order, entering every + container, yielding each node with its context. The tree is normatively in document reading order, so the walk order is reading order and the derived position attributes on the context are @@ -352,18 +388,15 @@ def iter_nodes( reuse a cached ``position_totals`` result. """ totals = _totals if _totals is not None else position_totals(nodes) - root_ctx = NodeTraverseContext(depth=0, heading_path=[], parent=None) - yield from _iter_nodes(nodes, root_ctx, max_depth, stop_predicate, totals, _WalkCounters()) + root_ctx = NodeTraverseContext( + depth=0, heading_path=[], parent=None, slot=_slot, slot_index=_slot_index + ) + yield from _iter_nodes( + nodes, root_ctx, max_depth, stop_predicate, totals, _WalkCounters(), _base_path + ) -def _iter_nodes( - nodes: list[CndNode], - ctx: NodeTraverseContext, - max_depth: int | None, - stop_predicate: StopPredicate | None, - totals: tuple[int, dict[int, int]], - counters: _WalkCounters, -) -> Iterator[NodeTraverse]: +def _iter_nodes(nodes, ctx, max_depth, stop_predicate, totals, counters, base_path): doc_count, page_counts = totals for sibling_index, node in enumerate(nodes, start=1): doc_index, page_index = counters.advance(node) @@ -379,25 +412,37 @@ def _iter_nodes( page_count=( page_counts[node.location.page] if node.location is not None else None ), + slot=ctx.slot, + slot_index=ctx.slot_index, + path=(*base_path, sibling_index - 1), ) yield NodeTraverse(node=node, ctx=visit_ctx) - children = _children_of(node) - if not children: + slots = [slot for slot in child_slots(node) if slot.nodes] + if not slots: continue if (max_depth is not None and ctx.depth >= max_depth) or ( stop_predicate is not None and stop_predicate(node, visit_ctx) ): - counters.skip_subtree(children) + counters.skip_subtree(_children_of(node)) continue - child_ctx = NodeTraverseContext( - depth=ctx.depth + 1, - heading_path=( - node.heading_path if isinstance(node, HeadingNode) else ctx.heading_path - ), - parent=node, - ) - yield from _iter_nodes( - children, child_ctx, max_depth, stop_predicate, totals, counters + heading_path = ( + node.heading_path if isinstance(node, HeadingNode) else ctx.heading_path ) + for slot in slots: + child_ctx = NodeTraverseContext( + depth=ctx.depth + 1, + heading_path=heading_path, + parent=node, + slot=slot.field, + slot_index=slot.index, + ) + prefix = ( + (*visit_ctx.path, slot.field) + if slot.index is None + else (*visit_ctx.path, slot.field, slot.index, "children") + ) + yield from _iter_nodes( + slot.nodes, child_ctx, max_depth, stop_predicate, totals, counters, prefix + ) diff --git a/tests/conftest.py b/tests/conftest.py index f3f88a0..8504dd2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -57,6 +57,5 @@ def elements_cnd_path() -> Path: "test_visitor.py", # ported by plan 1, Tasks 4-7 "test_hashing.py", - "test_traverse.py", "test_validate.py", ] diff --git a/tests/test_traverse.py b/tests/test_traverse.py index a0da543..c711c45 100644 --- a/tests/test_traverse.py +++ b/tests/test_traverse.py @@ -1,12 +1,18 @@ import json from pathlib import Path +from uuid import UUID + from ids import FIGURE001_ID, HEADING001_ID, HEADING002_ID, PARA001_ID, TABLE001_ID from cnd.core.cnd import Cnd from cnd.core.nodes import FigureNode, HeadingNode, ParagraphNode, TableNode, iter_nodes +# The four paragraphs inside the cells of structured.cnd's table. +CELL_IDS = [UUID(f"00000000-0000-4000-c000-00000000000{i}") for i in range(1, 5)] + + def _load(path: Path) -> Cnd: return Cnd.model_validate_json(path.read_text()) @@ -25,6 +31,7 @@ def test_cnd_is_directly_iterable( PARA001_ID, FIGURE001_ID, TABLE001_ID, + *CELL_IDS, ] def test_cnd_iter_accepts_traversal_options( @@ -94,7 +101,7 @@ def test_iter_nodes_accepts_a_node_subtree( ids = [visit.node.id for visit in iter_nodes([section])] - assert ids == [HEADING002_ID, PARA001_ID, FIGURE001_ID, TABLE001_ID] + assert ids == [HEADING002_ID, PARA001_ID, FIGURE001_ID, TABLE001_ID, *CELL_IDS] def test_consumer_can_filter_by_node_type( self, minimal_cnd_path: Path, @@ -102,7 +109,7 @@ def test_consumer_can_filter_by_node_type( cnd = _load(minimal_cnd_path) texts = [ - visit.node.text + visit.node.text.value for visit in cnd if isinstance(visit.node, ParagraphNode) ] @@ -170,16 +177,16 @@ def test_positions_on_structured_cnd( by_id = {v.node.id: v.ctx for v in cnd} - # 5 nodes total, all beginning on page 1. + # 9 nodes total (the walk enters the table cells), all on page 1. para = by_id[PARA001_ID] - assert (para.doc_index, para.doc_count) == (3, 5) + assert (para.doc_index, para.doc_count) == (3, 9) assert (para.sibling_index, para.sibling_count) == (1, 2) - assert (para.page_index, para.page_count) == (3, 5) + assert (para.page_index, para.page_count) == (3, 9) table = by_id[TABLE001_ID] - assert (table.doc_index, table.doc_count) == (5, 5) + assert (table.doc_index, table.doc_count) == (5, 9) assert (table.sibling_index, table.sibling_count) == (1, 1) - assert (table.page_index, table.page_count) == (5, 5) + assert (table.page_index, table.page_count) == (5, 9) root = by_id[HEADING001_ID] assert (root.doc_index, root.sibling_index) == (1, 1) @@ -230,8 +237,8 @@ def test_position_totals_helper(self, structured_cnd_path: Path) -> None: cnd = _load(structured_cnd_path) doc_count, page_counts = position_totals(cnd.nodes) - assert doc_count == 5 - assert page_counts == {1: 5} + assert doc_count == 9 + assert page_counts == {1: 9} class TestTraversalConformance: @@ -248,8 +255,13 @@ def _computed(self) -> dict: fixtures_dir = Path(__file__).parent.parent / "fixtures" return { path.name: [ - {"id": str(v.node.id), "type": v.node.type, "depth": v.ctx.depth} - for v in Cnd.model_validate_json(path.read_text()).iter() + { + "id": str(v.node.id), + "type": v.node.type, + "depth": v.ctx.depth, + "path": "/".join(map(str, v.ctx.path)), + } + for v in Cnd.model_validate_json(path.read_text()).iter_all() ] for path in sorted(fixtures_dir.glob("*.cnd")) } @@ -278,3 +290,62 @@ def test_a_node_precedes_its_children(self, full_coverage_cnd_path) -> None: if visit.ctx.parent is not None: assert visit.ctx.parent.id in seen seen.add(visit.node.id) + + +class TestContainers: + """0.5 containers: items, cells, term definitions, footnotes (proposal 0013).""" + + def _cnd(self, elements_cnd_path): + return Cnd.model_validate_json(elements_cnd_path.read_text()) + + def test_the_walk_enters_items_cells_and_terms(self, elements_cnd_path) -> None: + values = [ + v.node.text.value for v in self._cnd(elements_cnd_path).iter() + if v.node.type == "paragraph" + ] + for expected in ("Compile the source", "with fonts", "Brest", "A standoff span with a kind."): + assert expected in values + + def test_slot_and_path_name_the_container_entry(self, elements_cnd_path) -> None: + visits = { + v.node.text.value: v.ctx for v in self._cnd(elements_cnd_path).iter() + if v.node.type == "paragraph" + } + brest = visits["Brest"] + assert (brest.slot, brest.slot_index) == ("cells", 2) + assert "/".join(map(str, brest.path)) == "nodes/0/children/4/cells/2/children/0" + fonts = visits["with fonts"] + assert fonts.parent.type == "list" and fonts.slot == "items" + + def test_siblings_are_counted_within_one_slot(self, elements_cnd_path) -> None: + visits = { + v.node.text.value: v.ctx for v in self._cnd(elements_cnd_path).iter() + if v.node.type == "paragraph" + } + second = visits["Marks of different kinds may overlap."] + assert (second.sibling_index, second.sibling_count) == (2, 2) + + def test_the_body_walk_never_enters_footnotes(self, elements_cnd_path) -> None: + values = [ + v.node.text.value for v in self._cnd(elements_cnd_path).iter() + if v.node.type == "paragraph" + ] + assert "Details live here." not in values + + def test_iter_all_adds_footnote_content_after_the_body(self, elements_cnd_path) -> None: + visits = list(self._cnd(elements_cnd_path).iter_all()) + tail = visits[-2:] + assert [v.node.text.value for v in tail] == [ + "Details live here.", "A second paragraph, with code.", + ] + assert tail[0].ctx.slot == "footnotes" and tail[0].ctx.parent is None + assert "/".join(map(str, tail[1].ctx.path)) == "footnotes/0/children/1" + + def test_resolve_reaches_labels_inside_footnotes(self, elements_cnd_path) -> None: + assert self._cnd(elements_cnd_path).resolve("fn-second").type == "paragraph" + + def test_pruning_skips_every_container(self, elements_cnd_path) -> None: + cnd = self._cnd(elements_cnd_path) + kept = [v.node.type for v in cnd.iter(stop_predicate=lambda n, c: n.type == "table")] + all_ = [v.node.type for v in cnd.iter()] + assert kept.count("paragraph") == all_.count("paragraph") - 4 From cdec18384607af23ad655cedc22d6772bf79c2d5 Mon Sep 17 00:00:00 2001 From: Maxanox Date: Tue, 13 Oct 2026 10:37:16 +0200 Subject: [PATCH 06/78] feat(core): validate marks, text spans, doc.extra and footnote content (proposal 0013) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS --- src/cnd/core/validate.py | 164 +++++++++++++++++++++++++++++++++++++-- tests/conftest.py | 1 - tests/test_validate.py | 110 +++++++++++++++++++++++--- 3 files changed, 256 insertions(+), 19 deletions(-) diff --git a/src/cnd/core/validate.py b/src/cnd/core/validate.py index 485b854..6832c29 100644 --- a/src/cnd/core/validate.py +++ b/src/cnd/core/validate.py @@ -12,12 +12,19 @@ The function is a value function — CND in, list of violations out — so it crosses any boundary (docs/adr/0019 §5). + +Invariants checked: ids unique across the whole CND; labels unique; +edges resolve in their own domain; ``text_span`` inside its text; marks +in bounds, canonically ordered, merged per kind and never reaching into +an atomic mark; pagination all-or-nothing; bibliography floor; no +``doc.extra`` key repeating a named metadata field. """ from dataclasses import dataclass -from cnd.core.cnd import BibEntry, Cnd, Footnote -from cnd.core.nodes import CndNode +from cnd.core.cnd import BibEntry, Cnd, DocMetadata, Footnote +from cnd.core.nodes import CndNode, texts_of +from cnd.core.text import ATOMIC_MARK_KINDS, RichText # The lifted bibliographic fields; an entry satisfies the content floor # with a `formatted` string or with at least one of these. @@ -60,13 +67,29 @@ def validate(cnd: Cnd) -> list[Violation]: been raised by the time a ``Cnd`` exists. """ violations: list[Violation] = [] + violations += _check_id_uniqueness(cnd) violations += _check_label_uniqueness(cnd) violations += _check_link_domains(cnd) + violations += _check_text_spans(cnd) + violations += _check_marks(cnd) violations += _check_pagination(cnd) violations += _check_bibliography_floor(cnd) + violations += _check_doc_extra(cnd) return violations +def _all_texts(cnd: Cnd): + """Every RichText of the CND with a handle for messages: the node + texts of the body and the footnotes, then formatted bibliography + entries.""" + for visit in cnd.iter_all(): + for text in texts_of(visit.node): + yield text, f"{visit.node.type} {visit.node.id}" + for entry in cnd.bibliography: + if entry.formatted is not None: + yield entry.formatted, f"bibliography entry {entry.id}" + + def _check_label_uniqueness(cnd: Cnd) -> list[Violation]: """Labels are globally unique within a CND (docs/adr/0017). @@ -92,7 +115,7 @@ def claim(label: str | None, owner: str) -> None: return seen[label] = owner - for visit in cnd.iter(): + for visit in cnd.iter_all(): claim(visit.node.label, f"{visit.node.type} {visit.node.id}") for entry in cnd.bibliography: claim(entry.label, f"bibliography entry {entry.id}") @@ -122,11 +145,9 @@ def _check_link_domains(cnd: Cnd) -> list[Violation]: ("cites", BibEntry, "a bibliography entry"), ("footnotes", Footnote, "a footnote"), ) - for visit in cnd.iter(): - node = visit.node - where = f"{node.type} {node.id}" + for text, where in _all_texts(cnd): for family, expected, described in families: - for link in getattr(node, family): + for link in getattr(text, family): target = cnd.resolve(link.label) if target is None: violations.append( @@ -172,7 +193,7 @@ def _check_pagination(cnd: Cnd) -> list[Violation]: """ located: list[CndNode] = [] unlocated: list[CndNode] = [] - for visit in cnd.iter(): + for visit in cnd.iter_all(): (located if visit.node.location is not None else unlocated).append(visit.node) if not located or not unlocated: @@ -215,3 +236,130 @@ def _check_bibliography_floor(cnd: Cnd) -> list[Violation]: def _has_structured_fields(entry: BibEntry) -> bool: return any(getattr(entry, name) for name in _BIB_STRUCTURED_FIELDS) + + +def _check_id_uniqueness(cnd: Cnd) -> list[Violation]: + """Every id is unique across the whole CND (§2): body nodes, footnote + content nodes and the entries of both pools.""" + seen: dict[str, str] = {} + violations: list[Violation] = [] + owners = [(v.node.id, f"{v.node.type} {v.node.id}") for v in cnd.iter_all()] + owners += [(e.id, f"bibliography entry {e.id}") for e in cnd.bibliography] + owners += [(f.id, f"footnote {f.id}") for f in cnd.footnotes] + for ident, owner in owners: + key = str(ident) + if key in seen: + violations.append( + Violation( + "id-not-unique", f"id is already used by {seen[key]}", owner + ) + ) + continue + seen[key] = owner + return violations + + +def _check_text_spans(cnd: Cnd) -> list[Violation]: + """Every ``text_span`` lies inside the ``value`` it indexes (§5).""" + violations: list[Violation] = [] + for text, where in _all_texts(cnd): + length = len(text.value) + for edge in text.edges(): + span = edge.text_span + if span is None: + continue + if len(span) != 2 or not 0 <= span[0] <= span[1] <= length: + name = getattr(edge, "label", None) or edge.href + violations.append( + Violation( + "text-span-out-of-bounds", + f"text_span {span} is outside a text of {length} code points", + f"@{name} on {where}", + ) + ) + return violations + + +def _check_marks(cnd: Cnd) -> list[Violation]: + """Mark invariants (docs/proposals/0013, Marks): bounds, canonical + order, same-kind merge, atomic verbatim.""" + violations: list[Violation] = [] + for text, where in _all_texts(cnd): + violations += _mark_violations(text, where) + return violations + + +def _mark_violations(text: RichText, where: str) -> list[Violation]: + out: list[Violation] = [] + length = len(text.value) + for mark in text.marks: + if not 0 <= mark.start < mark.end <= length: + out.append( + Violation( + "mark-out-of-bounds", + f"{mark.kind} mark [{mark.start}, {mark.end}) is outside a text of {length} code points", + where, + ) + ) + keys = [(m.start, m.end, m.kind) for m in text.marks] + if keys != sorted(keys): + out.append( + Violation( + "mark-not-canonical", + "marks are not sorted by (start, end, kind)", + where, + ) + ) + by_kind: dict[str, list] = {} + for mark in text.marks: + by_kind.setdefault(mark.kind, []).append(mark) + for kind, marks in by_kind.items(): + ordered = sorted(marks, key=lambda m: (m.start, m.end)) + for prev, cur in zip(ordered, ordered[1:]): + if cur.start <= prev.end: + out.append( + Violation( + "mark-same-kind-overlap", + f"two {kind} marks overlap or touch at {cur.start}; merge them", + where, + ) + ) + spans = [(i, m.start, m.end, f"{m.kind} mark") for i, m in enumerate(text.marks)] + spans += [ + (None, e.text_span[0], e.text_span[1], "edge span") + for e in text.edges() + if e.text_span is not None and len(e.text_span) == 2 + ] + for index, atom in enumerate(text.marks): + if atom.kind not in ATOMIC_MARK_KINDS: + continue + for other, start, end, what in spans: + if other == index: + continue + disjoint = end <= atom.start or start >= atom.end + covers = start <= atom.start and end >= atom.end + if not (disjoint or covers): + out.append( + Violation( + "mark-atomic-crossed", + f"{what} [{start}, {end}) reaches inside the {atom.kind} mark " + f"[{atom.start}, {atom.end})", + where, + ) + ) + return out + + +def _check_doc_extra(cnd: Cnd) -> list[Violation]: + """No ``doc.extra`` key repeats a named metadata field (§3), or the + document would carry two contradictory values.""" + named = set(DocMetadata.model_fields) + return [ + Violation( + "doc-extra-shadows-field", + "an extra key repeats a named metadata field", + f"doc.extra[{key!r}]", + ) + for key in sorted(cnd.doc.extra) + if key in named + ] diff --git a/tests/conftest.py b/tests/conftest.py index 8504dd2..3fcd81a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -57,5 +57,4 @@ def elements_cnd_path() -> Path: "test_visitor.py", # ported by plan 1, Tasks 4-7 "test_hashing.py", - "test_validate.py", ] diff --git a/tests/test_validate.py b/tests/test_validate.py index 869b0be..edffe73 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -9,6 +9,8 @@ class below builds the smallest CND that violates one rule, so a from pathlib import Path from uuid import UUID, uuid4 +from factories import rt + from cnd import ( BibEntry, Cnd, @@ -16,19 +18,20 @@ class below builds the smallest CND that violates one rule, so a Footnote, FootnoteRef, HeadingNode, + Mark, NodeLocation, NodeRef, ParagraphNode, validate, ) -from cnd.core.nodes import CiteRef, LinkRef +from cnd.core.text import CiteRef, LinkRef FIXTURES = Path(__file__).parent.parent / "fixtures" def _cnd(nodes, **kwargs) -> Cnd: return Cnd( - cnd_version="0.3.0", + cnd_version="0.5.0", built_at=datetime(2026, 7, 22), doc=DocMetadata(title="T"), nodes=nodes, @@ -36,8 +39,13 @@ def _cnd(nodes, **kwargs) -> Cnd: ) +_EDGE_FAMILIES = ("refs", "cites", "footnotes", "links") + + def _para(text: str = "p", **kwargs) -> ParagraphNode: - return ParagraphNode(id=uuid4(), type="paragraph", text=text, **kwargs) + """A paragraph; edge families go into its RichText, the rest on the node.""" + edges = {k: kwargs.pop(k) for k in _EDGE_FAMILIES if k in kwargs} + return ParagraphNode(id=uuid4(), type="paragraph", text=rt(text, **edges), **kwargs) def _rules(cnd: Cnd) -> list[str]: @@ -60,7 +68,7 @@ def test_two_nodes_may_not_share_a_label(self) -> None: def test_a_node_may_not_collide_with_a_pool_entry(self) -> None: cnd = _cnd( [_para(label="shared")], - footnotes=[Footnote(id=uuid4(), label="shared", text="n")], + footnotes=[Footnote(id=uuid4(), label="shared", children=[_para("n")])], ) assert _rules(cnd) == ["label-not-unique"] @@ -90,7 +98,7 @@ def test_a_cite_may_not_resolve_to_a_node(self) -> None: def test_a_footnote_edge_may_not_resolve_to_a_bibliography_entry(self) -> None: cnd = _cnd( [_para(footnotes=[FootnoteRef(label="smith")])], - bibliography=[BibEntry(id=uuid4(), label="smith", formatted="Smith.")], + bibliography=[BibEntry(id=uuid4(), label="smith", formatted=rt("Smith."))], ) assert _rules(cnd) == ["link-wrong-domain"] @@ -98,7 +106,7 @@ def test_a_footnote_edge_may_not_resolve_to_a_bibliography_entry(self) -> None: def test_a_ref_may_not_resolve_to_a_pool_entry(self) -> None: cnd = _cnd( [_para(refs=[NodeRef(label="fn-a")])], - footnotes=[Footnote(id=uuid4(), label="fn-a", text="n")], + footnotes=[Footnote(id=uuid4(), label="fn-a", children=[_para("n")])], ) assert _rules(cnd) == ["link-wrong-domain"] @@ -120,8 +128,8 @@ def test_each_family_resolving_in_its_own_domain_is_clean(self) -> None: footnotes=[FootnoteRef(label="fn-a")], ), ], - bibliography=[BibEntry(id=uuid4(), label="smith", formatted="Smith.")], - footnotes=[Footnote(id=uuid4(), label="fn-a", text="n")], + bibliography=[BibEntry(id=uuid4(), label="smith", formatted=rt("Smith."))], + footnotes=[Footnote(id=uuid4(), label="fn-a", children=[_para("n")])], ) assert _rules(cnd) == [] @@ -160,7 +168,7 @@ def test_a_child_without_a_location_is_caught(self) -> None: id=uuid4(), type="heading", level=1, - text="H", + text=rt("H"), heading_path=["H"], location=NodeLocation(page=1), children=[_para()], @@ -180,7 +188,7 @@ def test_an_entry_with_neither_formatted_nor_fields_is_empty(self) -> None: def test_formatted_alone_satisfies_the_floor(self) -> None: cnd = _cnd( [_para()], - bibliography=[BibEntry(id=uuid4(), label="a", formatted="Smith, J.")], + bibliography=[BibEntry(id=uuid4(), label="a", formatted=rt("Smith, J."))], ) assert _rules(cnd) == [] @@ -224,3 +232,85 @@ def test_a_violation_renders_readably(self) -> None: [violation] = validate(cnd) assert str(violation).startswith("[link-unresolved] @absent on paragraph ") + + +def _doc(nodes, **kw): + return Cnd( + cnd_version="0.5.0", + built_at=datetime(2026, 10, 13), + doc=kw.pop("doc", DocMetadata(title="T")), + nodes=nodes, + **kw, + ) + + +def _p(text, label=None): + return ParagraphNode(id=uuid4(), type="paragraph", label=label, text=text) + + +class TestMarks: + def test_a_valid_text_has_no_violation(self) -> None: + text = rt("ab cd", marks=[Mark(start=0, end=2, kind="strong"), Mark(start=1, end=4, kind="emph")]) + assert _rules(_doc([_p(text)])) == [] + + def test_bounds(self) -> None: + for start, end in ((2, 2), (-1, 1), (0, 6)): + text = rt("hello", marks=[Mark(start=start, end=end, kind="strong")]) + assert "mark-out-of-bounds" in _rules(_doc([_p(text)])) + + def test_canonical_order(self) -> None: + text = rt("abcd", marks=[Mark(start=2, end=3, kind="emph"), Mark(start=0, end=1, kind="emph")]) + assert "mark-not-canonical" in _rules(_doc([_p(text)])) + + def test_same_kind_marks_that_touch_must_be_merged(self) -> None: + text = rt("abcd", marks=[Mark(start=0, end=2, kind="strong"), Mark(start=2, end=4, kind="strong")]) + assert "mark-same-kind-overlap" in _rules(_doc([_p(text)])) + + def test_nothing_reaches_inside_an_atomic_mark(self) -> None: + inside = rt("a = b", marks=[Mark(start=0, end=5, kind="math"), Mark(start=1, end=2, kind="strong")]) + partial_edge = rt("call fn now", marks=[Mark(start=5, end=7, kind="code")], + links=[{"href": "https://x.test", "text_span": [6, 11]}]) + for text in (inside, partial_edge): + assert "mark-atomic-crossed" in _rules(_doc([_p(text)])) + + def test_a_span_covering_the_whole_atomic_mark_is_allowed(self) -> None: + text = rt("run code now", marks=[Mark(start=0, end=12, kind="strong"), Mark(start=4, end=8, kind="code")], + links=[{"href": "https://x.test", "text_span": [4, 8]}]) + assert "mark-atomic-crossed" not in _rules(_doc([_p(text)])) + + +class TestSpansAndMetadata: + def test_text_span_out_of_bounds(self) -> None: + text = rt("short", links=[{"href": "https://x.test", "text_span": [2, 9]}]) + assert "text-span-out-of-bounds" in _rules(_doc([_p(text)])) + + def test_extra_must_not_shadow_a_field(self) -> None: + cnd = _doc([], doc=DocMetadata(title="T", status="Draft", extra={"status": "Final", "client": "A"})) + assert _rules(cnd) == ["doc-extra-shadows-field"] + + +class TestFootnoteContent: + def test_ids_are_unique_across_body_footnotes_and_pools(self) -> None: + shared = uuid4() + body = ParagraphNode(id=shared, type="paragraph", text=rt("body", footnotes=[{"label": "fn"}])) + note = Footnote(id=uuid4(), label="fn", children=[ParagraphNode(id=shared, type="paragraph", text=rt("n"))]) + assert "id-not-unique" in _rules(_doc([body], footnotes=[note])) + clash = Footnote(id=shared, label="fn", children=[]) + assert "id-not-unique" in _rules(_doc([body], footnotes=[clash])) + + def test_labels_inside_footnotes_join_global_uniqueness(self) -> None: + note = Footnote(id=uuid4(), label="fn", children=[_p(rt("n"), label="dup")]) + cnd = _doc([_p(rt("body", footnotes=[{"label": "fn"}]), label="dup")], footnotes=[note]) + assert "label-not-unique" in _rules(cnd) + + def test_edges_inside_footnotes_must_resolve(self) -> None: + note = Footnote(id=uuid4(), label="fn", children=[_p(rt("see", refs=[NodeRef(label="nowhere")]))]) + cnd = _doc([_p(rt("body", footnotes=[{"label": "fn"}]))], footnotes=[note]) + assert "link-unresolved" in _rules(cnd) + + def test_edges_inside_a_bibliography_entry_are_checked(self) -> None: + entry = BibEntry(id=uuid4(), label="k", formatted=rt("K", refs=[NodeRef(label="nowhere")])) + assert "link-unresolved" in _rules(_doc([], bibliography=[entry])) + + def test_the_elements_fixture_is_valid(self, elements_cnd_path) -> None: + assert validate(Cnd.model_validate_json(elements_cnd_path.read_text())) == [] From d5374add6b8102143c79c7d36dcb7e7796a4acd4 Mon Sep 17 00:00:00 2001 From: Maxanox Date: Tue, 13 Oct 2026 10:39:05 +0200 Subject: [PATCH 07/78] =?UTF-8?q?feat(core):=200.5=20hashing=20=E2=80=94?= =?UTF-8?q?=20defaults=20dropped,=20nested=20nodes=20excluded,=20paths=20i?= =?UTF-8?q?n=20the=20document=20hash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS --- fixtures/hashes.json | 255 ++++++++++++++++++++++++++++------------ scripts/regen_hashes.py | 2 +- src/cnd/core/hashing.py | 106 ++++++++--------- tests/conftest.py | 2 - tests/test_hashing.py | 145 +++++++++++++++++++---- 5 files changed, 351 insertions(+), 159 deletions(-) diff --git a/fixtures/hashes.json b/fixtures/hashes.json index b477486..3a5394b 100644 --- a/fixtures/hashes.json +++ b/fixtures/hashes.json @@ -1,103 +1,204 @@ { "comprehensive.cnd": { - "content_hash": "sha256:d885b95f08f4a9fd11c2d621f015abb74f096b9b1dca5dd8da1c63dd00ae8935", + "content_hash": "sha256:368456327822a8af3078a2c63b9555039b65c828cdb88062c6d613f3e4358d93", "node_hashes": [ - "sha256:906415d6e8dab98d99e21fcaa8e37507f98b4fe14fcfe6473988f500cbb2b2c9", - "sha256:32e333241822c0036921fd27d25451758772c3d8d35b8267f1f481dde892ad3b", - "sha256:f6bac43180f4363ba7457cbe9c624555fc75a721f9e2f06343536f3ab9883911", - "sha256:3313cb3b7bcefbe02214546dacab1e38a204939c98f502a1515a1c6ce0774fc7", - "sha256:6eeac9174ca6e24c3397ae34665aac9c1e25290a50380a62e6ba15305022268a", - "sha256:ad026ccdb67ddf1d7d31723bfb5a242c1bd036dd8bef0750203bd87840ac8909", - "sha256:21c32d99ad0b4a5f984ed7b8b3b872e38db271023affa97893a810ae2cf5a3c3", - "sha256:86b7f0c4835084e4647472810d7b0df5aca519fe4dc61b6bd8fee2ca1e78c6c3", - "sha256:de661cd715e57f29558de12627801f0f660b3de2062eb14f3c666c5db72c5ecf", - "sha256:7527072adedad8c6ce33e4bb1588da0dee1583b81aa0355cd231e6744c2b06b8", - "sha256:2cca769ef21383abc0cee6fd3db14cf26f49761b7b33ac4cffb3f69ed13c02f9", - "sha256:071aaccf8fa186076b86b8a610eae841f5f48fffb562f327b4ecfa5676363de7", - "sha256:07dbebccb9d2cebad0ba10a62cbd6123c5c21f5405196a4122d959c4f46c8cc2", - "sha256:1f189ecfb4c0d967928f295e841c5b31fb291c5d1901c4405e6c71323e8bbb50", - "sha256:fd2cb151f52c2789d1f6acf3a66a4c7eb7939d47bf54354257af9671a45b087a", - "sha256:7e5c8b8a8ff5da33d3f392e349dd2009bcae64e282674e84ba8c78b27b961c9f", - "sha256:f6019994d76ca1389a368e3a207ac28d0503ddda6419f1cf04c711c4fefa52ef", - "sha256:33469977b6cc365e1c7ad2158f4b085f33f9f8e464ad52a820b5c51764523fbf", - "sha256:d1e6286acd81b553260e23dcfe33849cebf25e4a639afa9c15df5512f9375ae9", - "sha256:b7c534e1d89da4a89351072c618e34c40594a3b4da6bcbfb35be7b6854768313", - "sha256:84ef307f317cbed05f4dc588168ca6a0c32d6ae0bc61d799c352c479fff33247", - "sha256:9ba985f917dfd4cfff70ba86ce5ea3b334ac859d256eb543cdd0e099fae582f4", - "sha256:eb24872aa044e2b80e537ec45732c5b2eb19916ead681415d74d57fc0aec6cb2", - "sha256:829bd2998db7a384764623a487f1ca8baeb844e659cd39adf16e3105b04a19a5" + "sha256:a9fc277da36e1e49435a9a110aa1375bd08d43fe19a396cc62f502b36c18db0b", + "sha256:9a89f7bf630d84025172d2f39a1a59ddc70e840a4015d46cc5bb430e05f541cb", + "sha256:dbada7080911e314cadd20ccf7a52d5b04979cc1019dc5ea2ebbc3e4fd4220e6", + "sha256:de511792b666fe26b358391c2a9a2ee59801959b9a822dd5b2a999109122bdbe", + "sha256:ad94aed599bbc27ecc3fddc711b39b2809cf0b055ba9fada8b4d175e03204472", + "sha256:91d2291d2a64af17e483249debd8f42018b68435df05775568958bbb1e19f9b3", + "sha256:7683c1f2e9f6b92f44abb77ac9e38c436e14cb011ef07aa4526b9d0aae7ec152", + "sha256:f5b4a2f59a014006af7d8560f22dd2e17b6730874599fc4f132542468c4b86a8", + "sha256:cd02d7624cd431cf94265c3748a2129e7f8cbd0c05ad1e31ed250e3d9dd98a83", + "sha256:a685a0bfec1609ab8539eb1140645bf5b7a0fca44b4a380e035cd87a2c0edf1e", + "sha256:ca6cd3688f0ee20dd14d9b4fb2b6247f9438088cb66e33f49ebd104579747e61", + "sha256:e150e264e7e8f6d109933da5cf16b5116bca1eb6a2b19a8e3d138076834bd4b9", + "sha256:51f98c54f35ce27c4c9a9bfece118c95703fc74bfe5af7106c2faa51db54214a", + "sha256:d71ab3360e488c300d4debc45399a024858c5d35452119822c5474fdaf491cff", + "sha256:5c8631256c08a0f8baf9c681152034917fd56b851b22af05e13847514b132534", + "sha256:1c642769222c9ecc63431e1433f18193bf7c9c69fcb27e8fed07f9c56705f47a", + "sha256:b80f2aeb486e97960515119902459d972274e04cef9c5121561ea66657cf36ff", + "sha256:5bd82fcb41222484162f03ab0822446799d47cc3cfe50b82521a7db2b16dba7c", + "sha256:63d1930e1093206e2e17e28a85dc3983d0df7d761cb7406b0a4c086db8234559", + "sha256:9a2ba0cc6c0e42d2f945a7af688371652750b4740d1caccaa022c54b29dfd5bf", + "sha256:d96f9da3ea238b9f646a00c8c691d7ed0f9d5ab4e37caaf5bddede8f6c0178ee", + "sha256:3528727be13313cdb0687dee2b586ef1e7863aa2d3a67b702feb258fa24c3498", + "sha256:fc8c0dd1bcd3e1d05694901149dc7f7cd3ccc630588a96b84daa4b23e8caf4db", + "sha256:063b3b40d85322933ffc0e14348c9635e20ca17818738bb354dd7aca472990bb", + "sha256:e16e57e10652e96e99e37b93e33ed0902681e57bbb8f68c862b24240cb15888b", + "sha256:4d923e20a1d89e71d518fe167a564a652e1381d9dd91178b282396b673d6017e", + "sha256:393c2f80b7f8d52c385022db9e8b70f62cc15f80cc6ea13cf8fc71661aba1694", + "sha256:3818315b8c8daea10e96d852c63e56023c3a741cdcf7b1da24ff45bf5bdd91ba", + "sha256:7ab90edb0daee2c03d61fc171694500218a8cbd73fd7786959b889deb6337c52", + "sha256:5e9651f51cf1de88eb959c264e2cd43d4a747bda655f220a4a4b7f84c51142c1", + "sha256:6d9029529d1568f5a928d43c6ec781c5484ed876e7a4408f4c6dac3e028efa0d", + "sha256:96ba9c4b536ecf6d0d54ade346e357a0987eee5ea7aa4567021a7a329d88c972", + "sha256:428ba4c12b6c4e0c4158750e5ddceb8b73a808e14c409cccef57ae8e66c7a00c", + "sha256:ec5aae37a540f0e5d7f94fb2155a5ece6b2d32ddcb23a5e954e12d0312dbf7f3", + "sha256:d5ab91b3adbb7f3cc2b620e03863e28c2823b6204499a5caba0ce95c7b306b03", + "sha256:29cafba4ed388512afe20cd4dbc94969531941bee401ca4ffb8b259916320586", + "sha256:547ab58d7fc90d3dfb9b5902fad3450edb80338bcf0c4bc7a7e0199b55927eb5", + "sha256:b5c9a15422fc224e0ccd1b864e5480068773a836d09580e6fc4830353d7a8387", + "sha256:91810789943f38752701ca1352067c5d382a49191036242baaea34bb8076696f", + "sha256:8482255763bc1547e820ad3a4cc2a89bef98c9199b2c10c7d836fac311056e3c", + "sha256:484575cd731e9899453197fb9aff9c152ef79b8e47f13a5b044cb7a224c9929b", + "sha256:c2df3d046f0e5eb542b1156b6e43b0b6d9b84842f4db3a596bcf094096ba2aea", + "sha256:7d2dc51847c8f4e504826ba407f84bab9028f63681e93a22cc5c25b5c49e0782", + "sha256:2028c06e0da95294f040c21ec4124cc88e1b74c241f50271a7e3c995537c76a9", + "sha256:2e82036101883c3e111239df9a6e6e09c734fdf5fca1786941d851f362e1ddf6", + "sha256:39cbc8ad58ab1c3bfc9dfbf75cd81fb6d7c5a54e54a3c047dd099f96eee164ce", + "sha256:8fbb5a42c83a940a1beadd3a652a1987fc4d2f8491fbc4712459d8f22482d9bc", + "sha256:8eb167f82f21f41f0ce1932822af0ae45453dae6b1fe820af29eac538b22ad70", + "sha256:02a8a26df267e898b8f70663de88c135cd19fc6db91c19c69962276f46d9386f", + "sha256:9a25b6b66fa25d4ef6f080b3373c72f2ba285ba45882fb3d3106e082c3b519a9", + "sha256:145b937e2a4eae46be4b6b1578c1ee44250a4c79b7d23d20b86a07aa70032c33", + "sha256:75d50114125beab6ada6847f5f2cbcf3237cab08a9062507f64f4dd83d3cf16b", + "sha256:499c0b2562d56c74eddeb2873ed8b492e32968401c24ae647fd08d415b2633c1", + "sha256:670cacb0feac9dc0abfbc55d8c9e4008f3f09e6610bec512a951937155a5a500", + "sha256:e150e264e7e8f6d109933da5cf16b5116bca1eb6a2b19a8e3d138076834bd4b9", + "sha256:cf898e2bbf39c7e47999f7308d19d12fd5a0919a34ecb198ab2c5cbeb24fb2f3", + "sha256:6a76ff0affd06da34818f7b83e7f228446042783699e459210e0a66a8371a1d1", + "sha256:b80f2aeb486e97960515119902459d972274e04cef9c5121561ea66657cf36ff", + "sha256:d0c80c87df5834d224ae2a00ccb16bec922d4bc8b54e572719888394b8112f12", + "sha256:b6e9a4c3ee1f4c66e00902fac8295a999a8accbf8f9673a4e8d13a2bc9cbe5bd" + ] + }, + "elements.cnd": { + "content_hash": "sha256:196688ba8a9512a1980548d6e46e1a17b301ecb371e4ed392e234a9cead75780", + "node_hashes": [ + "sha256:10139a4138a4059712a1e252f2ddcbf1daaef4bbdf2511b9a2350920acfeaf21", + "sha256:d13822672087bac2fe65569d661fcdbb0decc351410fdc46db0433527b1aa33e", + "sha256:bf1d04b865b08941aaf2a703c533f3011103ba330925a249ccc77e9924e72822", + "sha256:fee7da1df8f58efd0e49288bbb3b2f6a51f9ce0988ff9c0b338ef693fc2df4a4", + "sha256:e2dbca9166931595754a70dcf21b18d6e24cd93e723acd62582ba6d2e0b886ac", + "sha256:656d9d7de47e37c739865e689e0f738d766f7e7fccad98a3910bd4004d16e4e3", + "sha256:c31fc76a80c2300b1c5281fbb4d20d8054607360fd0e0bbff356b4de4bdde16b", + "sha256:b7c14a79ef57ce8084aeaa16a531c8596b8bec2d4b7d51c29ee42eb31ececee3", + "sha256:39383450d7a0f4a775411b07b67a74512d935eb5c0bf6810e2903f3405729751", + "sha256:5d8976226d11af2d5949b4cab075b1c35aec3f9dd9cc7f4d9eec411304c253a8", + "sha256:61c9dfed9e55d3ea6907807d157624978d3190f9916284028ca3b1cc5fda1d9b", + "sha256:c0ef5fafbe54484dff2d1134348521800aaa23d64dfc43cb5a2fce0f4abeb6be", + "sha256:58588c4075e2f790f066aaac4bc058c5186f80886399db2eebfd3893918c4194", + "sha256:4a733eb7c8b8edee6812d69066b7dae58c1a1f2f71416077992ffb6b416e9fca", + "sha256:bdb337fb1a42100d2905f5e2c8f3ff9e847a1bb55a179439eb55a0c2361f5f1b", + "sha256:52f80e81522fb0d8f4ba4f8f31470d9bead09f3f8df519dbf38205856c03888e", + "sha256:0fc99ca477f29c6631b7b5976129203963f7004f96608befd65aabdd0490a626", + "sha256:30afc6815122c3d87bafd7400705c5891cc844b80dc1124955458abe119afb51", + "sha256:372c71b95f8131797d864c199c98c9a4f7bda42d315d9035e2c0bc691e1800c2", + "sha256:27a173502dbe1df45e9d408a232997830d76bdd85441bd5c93ea43de0adf387b", + "sha256:287fc9259c3cb47f27d4ac468204f86ec9b1b54d976318143cf4524100d587aa", + "sha256:c0a91438ba624681f088566c8bfaa5fcd3c3daa7188087a633f0210e417ae0f5", + "sha256:9851b64772615bfe8dd32edf8f2cf3decc2604e7e77ed772456bfbd0f4e4895a", + "sha256:c6d5259003dc5c7c61e2576047c686de21a9841f65fd83d62320113126499741", + "sha256:759802084607339bc34794285596850018367c833f6ea5798b84fc0a316553e5", + "sha256:e81edbcd75ca83af8d1e3cc5433b70ffc5fcc2ecafb8bfe5905b90bd9017727a", + "sha256:0c9c958e5b11536484f46070890f69b15877454d4625849c5eeedc1803a66700", + "sha256:d541430b52d75272970fb5d554e9361c53164623438834ba77777b0002f1eb97", + "sha256:b06b2dafb3fe8420dbd2bfaa254398c68149fd97e0e7eefdd64c6d66eef0a6f7" ] }, "full_coverage.cnd": { - "content_hash": "sha256:60ab06866bf6b47675244515130c0bdbde8c2de10baa2180dbbaca8f3682e28e", + "content_hash": "sha256:7fe97481324da973c009f6b6db513e0e1ad6ce43c64b5035d971dc6af291ff31", "node_hashes": [ - "sha256:009cd420fd99d473bc0aa863efb1ea102fe6bf71dfb04b5b085d5e9ae9efc7c9", - "sha256:24645afa6acf11bb18d5abc3cf2e778f27b347ab296b0445d5c0dec64721971b", - "sha256:ba1a32c096307abbaf65de7dc6232bc4f7559b3920a2d87ec2961c9954fe58fa", - "sha256:5b86e602b89a4b9cf33322340e5104291a004511b32d8fd908577ff33508e99f", - "sha256:421c61dde238862d314ca77fde7369bf1911131c5acd8bb13ff7676def5eeffd", - "sha256:20b640abac0ed54e4b0a72f1437a6af9db4c24a4ddba9df0b6d975f9c7700003", - "sha256:9477d1cb824d9d44ab23dce16897b8b913b53c333cc3abebc9624aef3856d264", - "sha256:a67b135d6fa7b654d454f08d5e80164744cf650877dd596c42f80f454d9c3447", - "sha256:dc47629c73897d20b93160ce7a44852ed2fac944f91504cb2b761293e314a598", - "sha256:1efd4f2a95e78cdcc84cf5d00c968717c465f68ac693cfe92196799d5c4542b4", - "sha256:b6f7b1a8b5e72a5fc2391e4a2b93f6098b60c293e032f6fcb7eb28acf251c742", - "sha256:51244418db4f6b3685c1078f29bbb040aac5471da6a8850950b0b97eccb5a94a", - "sha256:0325dfcb1014da8487721637e77cec58dae451de79e4cc5f425059aaad5d3a76", - "sha256:079876ac065386d4ba568fddf4118b9bec0053308a48b8ca3ebf473e068f567b", - "sha256:af4137c3a15bea8e5d3eca42cb29df16fcd5dcc379b6758f2f4011786e5c942e", - "sha256:068359e1f8ddeddb836af40037f09cd05363d63da482ecc72ad962d3fe35921a", - "sha256:a4d12de3772498e48b7ef4f4b8508f2b031c17d003135046dd8665a4b87b82e3", - "sha256:612aab7592b80c9f1b01745492b6e143ad76a198d1c432ef2741fdaaa86eb976", - "sha256:91ed9097d6fb90e405e2459243dfd15ff94b1de78107eead14a47eeac2b60d09", - "sha256:40601c48185ff08ecb20c8342a1d63d51a777e988992097670f18cfa3068e531", - "sha256:fa3724d6489f5e473f5ad62776c00899a9e8d545f148fb9d5be4078360168c8d", - "sha256:5cd259c28fcd6be1894ee42a62c49be26d0af22aa341240ccaffac4cfef8c382", - "sha256:3ea4616d4fafefa6a2c10f42a04182783ad1fbd5a75c7cf8da8cc5d7635c62f5", - "sha256:aa3843cb2761c077ea5a43127ce114efc1a1fdd57413060cd77369f67e4e17ff", - "sha256:28d3408d535665ddb11e2ee902a09a893ce48bb32b4ff92697385ba7011cb8ed", - "sha256:b4a062eee91aa38e777926e171f6cd061d3b31ef9dbf8fdaab28dcfcf40c55e5", - "sha256:ef8d9d001f0163aeb19098d101bb4a4179a9ef199e6badd51552d85a72bfa5b5", - "sha256:e857b69f22aadd406e91b380723efcaf0d3f0b026442a3de2470f0ba985916d0" + "sha256:8ffcd16a381454478cf1f5db5eba9ecc9fd1deb1e6d5ca3da4b2f9cb85c7eba5", + "sha256:c2782840983a5d90acae291dfdb5d12b45a2e902a43f720b4c112585ab4a906a", + "sha256:74c6587067a6e3ff0bca5dbe0e7698a2469a794fc34af4fb906f8564fbecf9a0", + "sha256:3212cf1d8068041f05b1466d0b1cac2f4c45b6b9703dae037ab250c27e28d376", + "sha256:74c6587067a6e3ff0bca5dbe0e7698a2469a794fc34af4fb906f8564fbecf9a0", + "sha256:790a338a20efc5d3d8aae1e131678b254398eb9fe864ef7cb3d37f18343dcd10", + "sha256:c0ece497a5add966bfa38691f2df94ad1dabee4e69090649c4cc16c04e0649c5", + "sha256:5290818393024df936094bd9f61ca18ea243f84c5d9486f35c0e55e2f4763c74", + "sha256:1f028677d9e5fbe7481fa0a8291bc40f6012265ffa40a2a6ef2aaf0544aba5dd", + "sha256:5ab6a6aac717cb8fbdaa073f7a9a7c4f02739945cf1e364c41529cc3ed538b04", + "sha256:f4330423165f785f6b54e4c5dc9b4d5b8bd1418cc6dc7a37d646127a92624928", + "sha256:34694ce108d58d8d0fe9c5700a14a0f8508cd5a8877de334f1e74aed30b81eb7", + "sha256:e151bb11a4fa17b5ae1c9e77f860572586b72315f89adbf3f53cb52eb1790eb9", + "sha256:800d8975ba9b0b4773102f4409cb989e0ab7469de08fe13efc49ceb36c8886d9", + "sha256:909a35b315d1a0b1bad9f736eb5e626935237634835abc44cb518f2bec24eae4", + "sha256:7d391af6eec3cab87f3896a22f8aaad5f9a20dd91d4f1483f1331683ef8cac39", + "sha256:7d6645f6a880ef6045eb55b3003b2a2834a371bccffe0959bbc40b3754a0b0e0", + "sha256:9a0eb325cbd7880678fac5a7e8114faaf380c9a0e26b9578e90428d78825de00", + "sha256:722b5fcdbb818e3ccf49c1d335aad4e5f7c4824dfc4a785d37bc9aa3ca6119bc", + "sha256:10ec585b02672e10500c05c88682cc0c98c9235d2c1996f4f7d8b04d8dd9f497", + "sha256:ae6a40e3ae125f86982b24c8aa19d418a6072e307e61ed06c7e2bade1953934e", + "sha256:ee39fbc8da795ae7d9eed50df7cd06b120105978af8a59afff053db4a2115053", + "sha256:cd02d7624cd431cf94265c3748a2129e7f8cbd0c05ad1e31ed250e3d9dd98a83", + "sha256:53ce88ab2c57c82523fe8b8b8f9b389ee0fa5f41c6be93ccc583512d464fc3cf", + "sha256:781ac10f526ecdfe7add94e6f16c6217e531d63deb97e775eda3ff29e6a8d80e", + "sha256:08e2c379fe9b71a890ad6eb51ab88bb6022512461d18f79c1e717ee9072da89a", + "sha256:12584a46a40f5a9369fdbef4207f321206bdaf61632b6f890d0d29b3c6027143", + "sha256:99ae35bfe5383fc2ca319d37e2de304b4bc694114a8f2737b5b87111db52dbeb", + "sha256:651df4bc0c6361017465834e359d56e7c03e116de068c94eb2cfe2ba458d4a05", + "sha256:3d9b7fcd6868ea3ff9c2d58bc15c56ff3a1f31f495211b0cea6822eba8006b2e", + "sha256:ee1f2bbc3cd1e76e6a407416071c5ee9f999e306a272402c0251b252b8a6a59b", + "sha256:4a627ba6a519df18becb9ce44cd0e4ad3ad8aef95c984b43820939991c4ab7e2", + "sha256:9bc575ab95e1584dad192bd6a7fe11ab5190260515bcddcda6c2ba466ca4cbfc", + "sha256:1a96c4fa6e6fc27096beddf257dd0216fd62553bdeb4345e512f1b2217a7d3dd", + "sha256:e9d7f9a2185894bbb9f7c2c0a025dc1a7b3eadd7914bf92eb7b8255dbd6efee6", + "sha256:73616f920a3adad51e7e629248024f1b139572f09d52632f2b62ce118d1ce3ea", + "sha256:996042e98050278cc0564f373270cff9b51f71e89a29fa321476d903b2115811", + "sha256:5075a37494042d34c7e68ce246ab3b85389f6767072ae48337f01d718c36553e", + "sha256:aa1896cdfc6f80ae8fe6fb0d4a9fa80c3a061fa82bb7ee4b0f024ed781123e29", + "sha256:2564d6f4300aff1141faaf868ccf8dc27d3a5832751bc7bc7ec1b13bb3e7bf4f", + "sha256:545471fed96d7c84f38b7b806748d49cab9129962cc984aa126d23668630b847", + "sha256:992baff9fb121037463b5df0bf556ee95ef1227c34c8ce965621ecb31a76b93c", + "sha256:7348de69f7baf4b7a17f90fc6c771547faf1e5156609e3599a8c25c15253b443", + "sha256:7d87d8fa4224b40eb63eee0d0b0dbbe5b6dd6f0bd8552773651ce014f18101c6", + "sha256:af966a0eb5a828fb9183300b60a78d19d6ed1813f5737de7718ab548a3aa2315", + "sha256:dfa7090a1722c43c198385f493c12f729e03165f83bb11d207bcfc21626be412", + "sha256:64c878284d44d64d02f4f1266cb290c8e02ae7b63854cb8767562219bd79e9ba", + "sha256:b3f3d3483b698e62b69637cfe38bef52f3a160491cc8b594af49ff2d6dd7c68c", + "sha256:fe6fbe9bdb313ddecd5a73fe41900a5cc0f0083dbfb6f8776493dd75159dad2d", + "sha256:497b8c9441911d580f8f8f003d1babf3571e73c94d3e9541d52295117a7e486b", + "sha256:5025a900420019cebf063c9781f542881dda9fb1aeab5a6211842bf925418f87" ] }, "minimal.cnd": { - "content_hash": "sha256:c67e994214d9f1dd72b7828a62d977a0fccb9c0eddc986a11c2eb376729fd9b9", + "content_hash": "sha256:6b07c2ce36d8f666a7185da153f5f496840f755602c6bae523447f673358d285", "node_hashes": [ - "sha256:87cc66306fc4a35762a786fd4e7224f64d3c2e3acf929242ad3eff2ab4e01d2d", - "sha256:55b3c1ce4baebc0a8341ae4314fafe6500958f3daa53a8da5d7d5d6b716268e3" + "sha256:4ea5956b8cf28fa6199a702c79a60f4e37c69bdd929aa24e4f345dcb25e23d45", + "sha256:44687c68e97b799c68617ac15027556b42474e01dce05c5a45235eaade147428" ] }, "rich_content.cnd": { - "content_hash": "sha256:aa860fd2da5335cc51a11b4d2fda005e1d98ffc0c6afdc90c68b037b0a0c8628", + "content_hash": "sha256:6299c3cd08cd50802b9e4f795a1028165447132c9f27bdfb3d1d1aaf0653f4ec", "node_hashes": [ - "sha256:9f99c63b2403dc44f5d0c27d9f56f8fa0499b67861df4a248f79b9fac8a61dfd", - "sha256:2e0157d012e8fd5f165d09fec2991a897d31bf8da09dd3665f31ca7c1a20320e", - "sha256:3985aec4aa29f898afe6a37f2bd8c929a7fe877e2f9e71bd3204ea92555183cc", - "sha256:711a5653798ee696c873f0617e09a32a485d5c6de5b67a22bf7977ae652072d4", - "sha256:6496f1bfffccc181b0e608f08f21a70208ebb1b425fb2d3dbb9941796e7bc4f5", - "sha256:2a22a870b2c3708c43d574d4e4e8800f37cd1f2474618b06cac72cafcecfd5a4", - "sha256:ba55c59025ed23fdd0a996d58c8ef7af6c989b2dd0cdecd7ccd2917bc8fd255d", - "sha256:eaf5e5a919c505914d80f59bfea15ed98bf1cddf4f87ef740d337d52ab61ee58" + "sha256:a185cacb6f3f662e23ef9c5fe28568ca89bb875a08cc643d16eab802b3ae0ec3", + "sha256:4d6ec9164e969534a5ec56a67f3992af452f058777ca03d0f4995af199d7f9a7", + "sha256:7d7d8f4a710aa16b0c585e613cdd7c319a24a8d4ec5c59228c57f534a8e36153", + "sha256:03aa5989f516ca4d6e084dd2743238979adaf3c58ec105ba42be51d9e6832625", + "sha256:b164a95f5bdf6c46e5ffb58cb1858fd027fe8e6f6ef8fce98e8deff16c27b44f", + "sha256:411fa5bbdf2f3b498db05dbf71ed2bd6f9cc2c8dab0c6f874eb590cafc3c3ba7", + "sha256:3f34992cc63d7cca8316b8ada56c4e6765c99d0980f4b8e933fefdca6000ef75", + "sha256:337c040cb7e262c71d1c71f053d77cdeb763706a1f84dab2a4a0e40c7bc714b9", + "sha256:1394ba53eacf69a2772fa62cb39fcf7ac166d627f4641241e4e4dec8390ba092", + "sha256:0cf28473d831958f470434d423a354694ae20a2502206177d5a138f5ae7649d2", + "sha256:2f0cbbd983afd05978057a556776d91ccf1ce8386ff13699dcefae5bfce1a67e" ] }, "structured.cnd": { - "content_hash": "sha256:91ac6d4dace8968a405ca083f35b2d644a356b11b19a0f2a9d2bbd51f42351a4", + "content_hash": "sha256:5c7463aaa55546a09fad606e8bed653089605f5a771e0513a6416eda255f0da5", "node_hashes": [ - "sha256:4b6aadfce65b4e75244ea37b17be682f85147d32cfec5d940364a5c97712b0fd", - "sha256:7ece4107495277ac78a3000aa71bb9c82b57600a6e8597489eb4f548c67d282d", - "sha256:86f10207fb88a0954e531dfc7610900d87846e3d52b274d033731665fd5228e8", - "sha256:f62848f884555b824532264b468767b575564d2314468b730a58d3f7813f1db9", - "sha256:9c2d8998d097c6bd7e1e126848955b98f9c71cb4f4d09245a2973a65de9bf7a7" + "sha256:22b81b8f70506ed963be33a7dd4aa1323922081676d28d943db37e49e60f0ba0", + "sha256:272bbcf408bb1d4d0ac2fbde661ee2c895786073957b082ac63c9e70eaba6ef1", + "sha256:a0ffc46fd057c5f3148318ba6991ddde3a70873679975d53cfb5f010faa34306", + "sha256:708f995253929256bfa9f249d20c8fbcd39160fe30715c4a896b6bcfa9147393", + "sha256:8eeb75ecf3a3e00361924de03eea9afa5a80b81bb3c48fbc2b09deb9f211831c", + "sha256:72dd3b51e37cda60ccdb2659dbad804a9da19fa89be8566fddcbbb61413bb479", + "sha256:bb628c032e0d25637b3f3834d225148bf9a380d281c7688fe63ff51131545fae", + "sha256:e50c9614e1f100c49b0df7c6c60c9a2cfa345dbcbcc5a5b1bee182981c7b8002", + "sha256:b38acbd46690175e429de814fa6dab746d524da2010e929b450ac61d4b8c4d1a" ] }, "unpaginated.cnd": { - "content_hash": "sha256:386a37de51b7ad63895844a211fa895029a6d1f24b952d9e2c61b2a0cc5b6e48", + "content_hash": "sha256:b4e498fd8858812f5795bfe95bd88e234ff47de4d46539e837bf0671b6e0bf25", "node_hashes": [ - "sha256:8ddeca982de15f3a26e43b8ed064d6111f6c022f06009c9f3e879802269b552b", - "sha256:c99a94f0d75abfa966871e0f0dc4f125d9c7debaab98e44d0bde1dd902dbfe6f", - "sha256:a6c43b8f14a5a16ed9b401f9a9f8519c926c1cd3d743115dc43102a8fc250f23", - "sha256:defc190164a544ebe1c706da556dd31efe5ee69ac96257ec200409315748ba68" + "sha256:d366deb8802e608f563b507c653919363ca8b5713630fc2c2a39225916ca9d26", + "sha256:5b7d1604a5a89916bafb5a73b019e21d18a51f784df383d596c000eb4147bac0", + "sha256:44079a81b1b691c939e47506ca6a79765a07333d432397cbe73d5a6ad7c9ce3b", + "sha256:8e64524b38f8ba0344c1d20ad20a8a249a426f8d9b60651a2ec597740fed03da", + "sha256:0ddd3e3e7be66aad5cb729f3dc20e3f1dac2cf9679b050ad2675463953b6573f" ] } } diff --git a/scripts/regen_hashes.py b/scripts/regen_hashes.py index 0bb561b..95d088f 100644 --- a/scripts/regen_hashes.py +++ b/scripts/regen_hashes.py @@ -18,7 +18,7 @@ cnd = Cnd.model_validate_json(path.read_text()) vectors[path.name] = { "content_hash": content_hash(cnd), - "node_hashes": [node_hash(v.node) for v in cnd.iter()], + "node_hashes": [node_hash(v.node) for v in cnd.iter_all()], } (FIXTURES / "hashes.json").write_text(json.dumps(vectors, indent=2) + "\n") diff --git a/src/cnd/core/hashing.py b/src/cnd/core/hashing.py index d3014d1..a8dfcf5 100644 --- a/src/cnd/core/hashing.py +++ b/src/cnd/core/hashing.py @@ -10,14 +10,20 @@ digest of the *input artifact* and is only comparable between two CNDs from the same producer over the same source. -**What is hashed.** Content, and nothing a producer resolved for -display. Excluded under the current field set: the node ``id``, its -``location``, the resolved ``number``, its ``counter_label``, and the -CND's ``built_at``, ``source`` and ``cnd_version``. The exclusion is a principle plus a -list, so the list is maintained as the field set evolves. The ``number`` -case is the one that would otherwise break silently: inserting a heading -renumbers everything after it, so a hash covering ``number`` would -report every following node as changed. +**What is hashed.** Three rules (docs/proposals/0013): + +1. *Defaults are dropped.* The preimage holds only the fields a producer + set to a non-default value, so an omitted empty list and a written one + hash the same, and a future optional field cannot move an existing + hash. +2. *Nested nodes are excluded.* A node's hash is node-local: the + ``children`` of the node and of its list items, term items and cells + are not part of it. The tree is covered by ``content_hash``. +3. *Resolved presentation state is excluded:* the node ``id``, its + ``location`` and its ``numbering`` (inserting a heading renumbers + everything after it, so a hash covering ``numbering`` would report + every following node as changed), and the CND's ``built_at``, + ``source`` and ``cnd_version``. **Canonicalisation.** RFC 8785 (JCS) over the hashable subset, with strings normalised to Unicode NFC first. Both are external references @@ -42,57 +48,50 @@ ALGORITHM = "sha256" # Resolved presentation state — computed for display, changes without the -# authored content changing. `children` is excluded for a different -# reason: see `node_hash`. -_EXCLUDED_NODE_FIELDS = frozenset( - {"id", "location", "number", "counter_label", "children"} -) +# authored content changing. +_EXCLUDED_NODE_FIELDS = frozenset({"id", "location", "numbering"}) +# Container entries whose `children` hold nested nodes (principle 2). +_CONTAINER_ENTRY_FIELDS = ("items", "cells") def node_hash(node: CndNode) -> str: """The content hash of a single node, as ``"sha256:"``. - ``counter_label`` is excluded alongside ``number``: it is the same - resolved presentation state, and it changes when the document language - changes without the authored content changing. + Over the node's own fields with every default-valued field dropped + (docs/proposals/0013): an omitted empty list and a written one hash the + same, and a future optional field cannot move an existing hash. - **Node-local**: a node's children are not hashed into it. A heading - whose subsection changed has not itself changed, and treating it as - changed would defeat the reconciliation pass that recognises a - moved-but-unchanged node (docs/adr/0018). The tree as a whole is - covered by ``content_hash``, which folds every node's hash. + **Node-local.** Every nested node is excluded, wherever it sits — the + node's ``children`` and the ``children`` of its list items, term items + and cells. A list whose item text changed has not itself changed; the + item's paragraph has. Only those structural keys are stripped: a + ``children`` key inside ``state_metadata`` is content. """ - payload = node.model_dump(mode="json", exclude=_EXCLUDED_NODE_FIELDS) + payload = node.model_dump( + mode="json", exclude=_EXCLUDED_NODE_FIELDS, exclude_defaults=True + ) + payload.pop("children", None) + for field in _CONTAINER_ENTRY_FIELDS: + for entry in payload.get(field, []): + entry.pop("children", None) return _digest(payload) def content_hash(cnd: Cnd) -> str: - """The content hash of a whole CND, as ``"sha256:"``. - - Folds every node hash in reading order, both out-of-tree pools, and - the ``doc`` metadata. - - Each node's hash is paired with its depth, because nesting is - structure and not presentation — re-parenting a section under a - different heading changes the document even though the reading order - and every node-local hash are untouched. - - **The pools are content.** A footnote's text and a bibliography - entry's fields are authored material; leaving them out would let a - rewritten footnote pass as an unchanged document — a silent - false negative of exactly the kind excluding ``number`` is meant to - avoid in the other direction. They sit out of the tree for - referencing reasons, not because they are metadata. - - Excluded: ``built_at``, ``source`` and ``cnd_version``. The same - content built twice, reached from two different input formats, or - expressed under two format versions is the same content; - ``source.hash`` answers the separate question of whether the input - artifact moved. + """The content hash of a whole CND. + + Folds the ``doc`` metadata, every node of the body and of the footnotes + paired with its full **path** (``nodes/0/children/4/cells/2/children/0``), + and both pools. The path, not only the depth: moving a paragraph from + one cell to the next keeps depth and reading order but changes the + document. Excluded: ``built_at``, ``source``, ``cnd_version``. """ payload = { - "doc": cnd.doc.model_dump(mode="json"), - "nodes": [[visit.ctx.depth, node_hash(visit.node)] for visit in cnd.iter()], + "doc": cnd.doc.model_dump(mode="json", exclude_defaults=True), + "nodes": [ + ["/".join(map(str, visit.ctx.path)), node_hash(visit.node)] + for visit in cnd.iter_all() + ], "bibliography": [pool_entry_hash(entry) for entry in cnd.bibliography], "footnotes": [pool_entry_hash(note) for note in cnd.footnotes], } @@ -100,14 +99,13 @@ def content_hash(cnd: Cnd) -> str: def pool_entry_hash(entry: BibEntry | Footnote) -> str: - """The content hash of one pool entry, as ``"sha256:"``. - - Only ``id`` is excluded — non-durable, like a node's (docs/adr/0015). - A pool entry carries no resolved presentation state of its own: it - has no ``location`` and no ``number``, so the exclusion set is - narrower than a node's rather than the same one reused. - """ - return _digest(entry.model_dump(mode="json", exclude={"id"})) + """The content hash of one pool entry. ``id`` is excluded, and a + footnote's ``children`` too: its content nodes are hashed as nodes and + folded into ``content_hash`` by path.""" + exclude = {"id", "children"} if isinstance(entry, Footnote) else {"id"} + return _digest( + entry.model_dump(mode="json", exclude=exclude, exclude_defaults=True) + ) def _digest(payload: Any) -> str: diff --git a/tests/conftest.py b/tests/conftest.py index 3fcd81a..658398e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,6 +55,4 @@ def elements_cnd_path() -> Path: "test_reconcile.py", "test_render.py", "test_visitor.py", - # ported by plan 1, Tasks 4-7 - "test_hashing.py", ] diff --git a/tests/test_hashing.py b/tests/test_hashing.py index 0ba4196..4665051 100644 --- a/tests/test_hashing.py +++ b/tests/test_hashing.py @@ -19,12 +19,22 @@ DocMetadata, Footnote, HeadingNode, + Cell, + LayoutNode, + ListItem, + ListNode, NodeLocation, + Numbering, ParagraphNode, SourceInfo, + TableNode, + TermItem, + TermsNode, content_hash, node_hash, ) +from cnd.core.hashing import _digest +from factories import rt FIXTURES = Path(__file__).parent.parent / "fixtures" VECTORS = FIXTURES / "hashes.json" @@ -32,7 +42,7 @@ def _cnd(nodes, **kwargs) -> Cnd: return Cnd( - cnd_version="0.3.0", + cnd_version="0.5.0", built_at=datetime(2026, 7, 22), doc=DocMetadata(title="T"), nodes=nodes, @@ -41,7 +51,7 @@ def _cnd(nodes, **kwargs) -> Cnd: def _para(text: str = "p", **kwargs) -> ParagraphNode: - return ParagraphNode(id=uuid4(), type="paragraph", text=text, **kwargs) + return ParagraphNode(id=uuid4(), type="paragraph", text=rt(text), **kwargs) def _heading(text: str = "H", children=(), **kwargs) -> HeadingNode: @@ -49,7 +59,7 @@ def _heading(text: str = "H", children=(), **kwargs) -> HeadingNode: id=uuid4(), type="heading", level=1, - text=text, + text=rt(text), heading_path=[text], children=list(children), **kwargs, @@ -70,22 +80,22 @@ def test_the_location_is_excluded(self) -> None: _para("x", location=NodeLocation(page=1)) ) - def test_the_resolved_number_is_excluded(self) -> None: + def test_the_resolved_numbering_is_excluded(self) -> None: """The case that would break silently: inserting a heading - renumbers every following node, and a hash covering `number` + renumbers every following node, and a hash covering `numbering` would report each of them as changed.""" - assert node_hash(_heading("H", number="2.1")) == node_hash( - _heading("H", number="3.1") + assert node_hash(_heading("H", numbering=Numbering(value="2.1"))) == node_hash( + _heading("H", numbering=Numbering(value="3.1")) ) - def test_the_counter_label_is_excluded(self) -> None: - """Same resolved presentation state as `number` — it changes with - the document language, not with the authored content.""" - assert node_hash(_heading("H", number="1", counter_label="Section")) == ( - node_hash(_heading("H", number="1", counter_label="Abschnitt")) - ) - assert node_hash(_heading("H", number="1")) == node_hash( - _heading("H", number="1", counter_label="Section") + def test_the_counter_name_is_excluded(self) -> None: + """The counter word is part of `numbering` — it changes with the + document language, not with the authored content.""" + assert node_hash( + _heading("H", numbering=Numbering(value="1", name="Section")) + ) == node_hash(_heading("H", numbering=Numbering(value="1", name="Abschnitt"))) + assert node_hash(_heading("H", numbering=Numbering(value="1"))) == node_hash( + _heading("H", numbering=Numbering(value="1", name="Section")) ) def test_children_are_excluded(self) -> None: @@ -108,9 +118,14 @@ def test_the_type_is_covered(self) -> None: def test_link_labels_are_covered(self) -> None: from cnd import NodeRef - assert node_hash(_para("x", refs=[NodeRef(label="a")])) != node_hash( - _para("x", refs=[NodeRef(label="b")]) - ) + def para(label): + return ParagraphNode( + id=uuid4(), + type="paragraph", + text=rt("x", refs=[NodeRef(label=label)]), + ) + + assert node_hash(para("a")) != node_hash(para("b")) class TestUnicodeNormalisation: @@ -162,9 +177,9 @@ def test_reading_order_is_covered(self) -> None: def test_renumbering_alone_does_not_change_the_document_hash(self) -> None: assert content_hash( - _cnd([_heading("A", number="1"), _heading("B", number="2")]) + _cnd([_heading("A", numbering=Numbering(value="1")), _heading("B", numbering=Numbering(value="2"))]) ) == content_hash( - _cnd([_heading("A", number="2"), _heading("B", number="3")]) + _cnd([_heading("A", numbering=Numbering(value="2")), _heading("B", numbering=Numbering(value="3"))]) ) def test_repagination_alone_does_not_change_the_document_hash(self) -> None: @@ -186,8 +201,8 @@ def test_the_format_version_is_excluded(self) -> None: def test_a_rewritten_footnote_changes_the_document_hash(self) -> None: """The pools sit out of the tree for referencing reasons, not because they are metadata — their text is authored content.""" - a = _cnd([_para("x")], footnotes=[Footnote(id=uuid4(), label="a", text="one")]) - b = _cnd([_para("x")], footnotes=[Footnote(id=uuid4(), label="a", text="two")]) + a = _cnd([_para("x")], footnotes=[Footnote(id=uuid4(), label="a", children=[_para("one")])]) + b = _cnd([_para("x")], footnotes=[Footnote(id=uuid4(), label="a", children=[_para("two")])]) assert content_hash(a) != content_hash(b) @@ -199,8 +214,8 @@ def test_an_edited_bibliography_entry_changes_the_document_hash(self) -> None: def test_a_pool_entry_id_is_excluded(self) -> None: """Pool ids are as non-durable as node ids (docs/adr/0015).""" - a = _cnd([_para("x")], footnotes=[Footnote(id=uuid4(), label="a", text="n")]) - b = _cnd([_para("x")], footnotes=[Footnote(id=uuid4(), label="a", text="n")]) + a = _cnd([_para("x")], footnotes=[Footnote(id=uuid4(), label="a", children=[_para("n")])]) + b = _cnd([_para("x")], footnotes=[Footnote(id=uuid4(), label="a", children=[_para("n")])]) assert content_hash(a) == content_hash(b) @@ -213,6 +228,86 @@ def test_reparenting_changes_the_document_hash(self) -> None: assert content_hash(flat) != content_hash(nested) +class TestDefaultsAreDropped: + def test_hash_equals_the_minimal_payload(self) -> None: + """Omitted and default-valued fields never enter the preimage, so a + future optional field cannot move an existing hash.""" + assert node_hash(_para("x")) == _digest( + {"type": "paragraph", "text": {"value": "x"}} + ) + + def test_explicit_empty_lists_hash_like_omitted_ones(self) -> None: + with_lists = ParagraphNode.model_validate( + { + "id": str(uuid4()), + "type": "paragraph", + "text": {"value": "x", "marks": [], "refs": []}, + } + ) + assert node_hash(with_lists) == node_hash(_para("x")) + + +class TestNestedNodesAreExcluded: + def test_a_list_does_not_change_when_an_item_text_does(self) -> None: + a = ListNode(id=uuid4(), type="list", items=[ListItem(children=[_para("one")])]) + b = ListNode(id=uuid4(), type="list", items=[ListItem(children=[_para("two")])]) + assert node_hash(a) == node_hash(b) + + def test_a_list_changes_when_a_checkbox_does(self) -> None: + a = ListNode(id=uuid4(), type="list", items=[ListItem(checked=False)]) + b = ListNode(id=uuid4(), type="list", items=[ListItem(checked=True)]) + assert node_hash(a) != node_hash(b) + + def test_a_table_does_not_change_when_a_cell_content_does(self) -> None: + a = TableNode( + id=uuid4(), type="table", cells=[Cell(row=0, col=0, children=[_para("1")])] + ) + b = TableNode( + id=uuid4(), type="table", cells=[Cell(row=0, col=0, children=[_para("2")])] + ) + assert node_hash(a) == node_hash(b) + + def test_a_term_is_the_terms_node_own_content(self) -> None: + a = TermsNode(id=uuid4(), type="terms", items=[TermItem(term=rt("A"))]) + b = TermsNode(id=uuid4(), type="terms", items=[TermItem(term=rt("B"))]) + assert node_hash(a) != node_hash(b) + + def test_state_metadata_children_key_is_content(self) -> None: + a = _para("x", state_metadata={"children": 1}) + b = _para("x", state_metadata={"children": 2}) + assert node_hash(a) != node_hash(b) + + def test_numbering_is_excluded(self) -> None: + assert node_hash(_heading("H", numbering=Numbering(value="2.1"))) == node_hash( + _heading("H", numbering=Numbering(value="3.1", name="Section")) + ) + + +class TestDocumentHashSeesStructure: + def test_moving_a_paragraph_between_cells_changes_the_document(self) -> None: + def grid(first, second): + return LayoutNode( + id=uuid4(), + type="layout", + cells=[ + Cell(row=0, col=0, children=first), + Cell(row=0, col=1, children=second), + ], + ) + + a = _cnd([grid([_para("x"), _para("y")], [])]) + b = _cnd([grid([_para("x")], [_para("y")])]) + assert content_hash(a) != content_hash(b) + + def test_editing_footnote_content_changes_the_document(self) -> None: + def note(value): + return Footnote(id=uuid4(), label="fn", children=[_para(value)]) + + assert content_hash(_cnd([], footnotes=[note("a")])) != content_hash( + _cnd([], footnotes=[note("b")]) + ) + + class TestNotSerialised: def test_no_hash_field_reaches_the_wire(self) -> None: cnd = _cnd([_para("x")]) @@ -233,7 +328,7 @@ def _computed(self) -> dict: cnd = Cnd.model_validate_json(path.read_text()) vectors[path.name] = { "content_hash": content_hash(cnd), - "node_hashes": [node_hash(v.node) for v in cnd.iter()], + "node_hashes": [node_hash(v.node) for v in cnd.iter_all()], } return vectors From 5f19cfa7b28bf42971e911a20fc6625596d5aa4a Mon Sep 17 00:00:00 2001 From: Maxanox Date: Tue, 13 Oct 2026 10:52:05 +0200 Subject: [PATCH 08/78] docs: spec for format 0.5.0, ADRs 0025-0028 (proposal 0013) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS --- docs/README.md | 8 +- docs/adr/0016-content-hashing.md | 4 +- .../0025-inline-edges-belong-to-rich-text.md | 72 ++ docs/adr/0026-open-vocabularies.md | 67 ++ ...7-no-compatibility-guarantee-before-1-0.md | 58 ++ docs/adr/0028-content-hashing-0-5.md | 117 +++ ...nd-0.5.0-rich-text-and-block-containers.md | 2 +- spec/cnd-spec.md | 967 +++++++++++++----- 8 files changed, 1032 insertions(+), 263 deletions(-) create mode 100644 docs/adr/0025-inline-edges-belong-to-rich-text.md create mode 100644 docs/adr/0026-open-vocabularies.md create mode 100644 docs/adr/0027-no-compatibility-guarantee-before-1-0.md create mode 100644 docs/adr/0028-content-hashing-0-5.md diff --git a/docs/README.md b/docs/README.md index fcb97bb..c3ae850 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,7 +23,7 @@ conventions. | [0013](adr/0013-rename-link-span-to-text-span.md) | Rename the link-family `span` field to `text_span` | accepted | | [0014](adr/0014-relation-to-doclang.md) | CND's relation to DocLang — coexistence and interoperability, not convergence | accepted | | [0015](adr/0015-cnds-immutable-ids-not-durable.md) | CNDs are immutable build artifacts; node ids are not durable | accepted | -| [0016](adr/0016-content-hashing.md) | Derived content hashing — canonical serialisation and excluded presentation state | accepted | +| [0016](adr/0016-content-hashing.md) | Derived content hashing — canonical serialisation and excluded presentation state | superseded by 0028 | | [0017](adr/0017-cross-references-resolve-by-label.md) | Cross-references resolve by target label; drop the id from link families | accepted | | [0018](adr/0018-reconciliation-reference-algorithm.md) | Reconciliation (diff, id inheritance) is a versioned reference algorithm, not a format guarantee | accepted | | [0019](adr/0019-production-topology-two-doors.md) | Production topology — a declarative door and a direct door over one shared contract | accepted | @@ -32,6 +32,10 @@ conventions. | [0022](adr/0022-rust-succession-plan.md) | Rust succession plan — Python is the reference until the format freezes | proposed | | [0023](adr/0023-pure-typst-authoring-package.md) | Pure-Typst authoring package for CND metadata | accepted | | [0024](adr/0024-hyperlinks-as-a-node-family.md) | Hyperlinks are a fourth forward family, href-keyed and outside label resolution | accepted | +| [0025](adr/0025-inline-edges-belong-to-rich-text.md) | Inline edges belong to rich text, not to the node | accepted | +| [0026](adr/0026-open-vocabularies.md) | Open vocabularies — known values, free values, a fallback per field | accepted | +| [0027](adr/0027-no-compatibility-guarantee-before-1-0.md) | No compatibility guarantee before format 1.0.0 | accepted | +| [0028](adr/0028-content-hashing-0-5.md) | Content hashing, 0.5 amendments — defaults dropped, nested nodes excluded, paths in the document hash | accepted | ## Proposals (docs/proposals/) @@ -49,4 +53,4 @@ conventions. | [0010](proposals/0010-counter-label.md) | Capture the counter label as its own field, beside the number | implemented | | [0011](proposals/0011-decorative-content.md) | Content marked decorative is never emitted | implemented | | [0012](proposals/0012-placeholder-context.md) | Figure placeholders carry every piece of context the node holds | implemented | -| [0013](proposals/0013-cnd-0.5.0-rich-text-and-block-containers.md) | CND 0.5.0 — rich text, block containers, and the elements a document actually uses | approved | +| [0013](proposals/0013-cnd-0.5.0-rich-text-and-block-containers.md) | CND 0.5.0 — rich text, block containers, and the elements a document actually uses | approved (core implemented) | diff --git a/docs/adr/0016-content-hashing.md b/docs/adr/0016-content-hashing.md index f5b6077..644b75b 100644 --- a/docs/adr/0016-content-hashing.md +++ b/docs/adr/0016-content-hashing.md @@ -1,10 +1,10 @@ --- title: Derived content hashing — canonical serialisation and excluded presentation state -status: accepted +status: superseded date: 2026-07-22 tags: [hashing, identity, derived, change-detection] related: [0008, 0012, 0015] -superseded-by: null +superseded-by: 0028 --- # ADR 0016 — Derived content hashing diff --git a/docs/adr/0025-inline-edges-belong-to-rich-text.md b/docs/adr/0025-inline-edges-belong-to-rich-text.md new file mode 100644 index 0000000..2b47f18 --- /dev/null +++ b/docs/adr/0025-inline-edges-belong-to-rich-text.md @@ -0,0 +1,72 @@ +--- +title: Inline edges belong to rich text, not to the node +status: accepted +date: 2026-10-13 +tags: [schema, breaking-change, refs, rich-text, 0.5.0] +related: [0008, 0009, 0013, 0024] +superseded-by: null +--- + +# ADR 0025 — Inline edges belong to rich text + +## Status +Accepted. Updates where ADRs 0009 and 0024 place the link families; their +other decisions stand. + +## Context +Up to format 0.4.0, the four link families (`refs`, `cites`, +`footnotes`, `links`) live on the node base (ADR 0009, ADR 0024), and an +edge's `text_span` locates its marker in "the node's rendered text" +(ADR 0013). That phrase is well defined for a paragraph, which has one +text. It is ambiguous for every node that has several: a table (which +cell?), a figure (the caption, or something else?), a term list (which +term?). Two implementations that render the same node differently place +the same span on different characters, and nothing in the file says +which one was meant. + +Format 0.5.0 makes the ambiguity worse and the fix possible at once +(docs/proposals/0013). Prose becomes a `RichText` object — a `value` +string with standoff marks — in every slot that holds prose, and nodes +gain more texts: a figure has a caption and a description, an +admonition a title, a quote an attribution. Marks need to know which +text they index; so do edges. Keeping edges on the node would need a +locator for every multi-text node (`"in": "cells[3]"`), a path string +every consumer must parse and that breaks when the structure moves. +Defining one canonical rendered text per node would tie every span to a +rendering rule, so that changing the rule shifts every span. + +## Decision +The four families live on `RichText`, beside `marks`. Every span — a +mark or an edge's `text_span` — indexes the `value` of the `RichText` +that holds it, in Unicode code points (ADR 0013). + +1. **An edge belongs to the text where its marker opens.** Its owning + node is the nearest node that holds that text; an edge in a + bibliography entry's `formatted` text is owned by the entry. + `text_span` stays nullable, for a marker that renders no text (a + `form: "none"` citation) or one that does not close inside the text. +2. **The families keep their contracts.** They stay forward-only + (ADR 0008): a CND serializes no incoming edge. `refs`, `cites` and + `footnotes` stay label-keyed and resolve in their family's domain + (ADR 0017). `links` stays href-keyed and outside label resolution + (ADR 0024). +3. **No node carries a link family.** The node base keeps `id`, `label`, + `state_metadata` and `location`. + +## Consequences +- `NodeBase` loses `refs`, `cites`, `footnotes` and `links`. A 0.4.0 CND + does not parse as 0.5.0; under the pre-1.0 policy (ADR 0027) that is + allowed, and no upgrader is shipped. +- The SDK adds `texts_of(node)` — the node's own texts in document order, + never a nested node's — and `edges_of(node)`, the edges of those texts + flattened. Code that used to read one list per node reads one list per + node again. +- The derived incoming index (`Cnd.incoming`) and validation read texts. + Validation also checks the edges and marks of a bibliography entry's + `formatted`, and every `text_span` against the length of its own + `value`. +- There is no "rendered text of a node" left to define. Renderers may + render a node however they like without moving any span. +- A node with several texts reports the edges of each text separately in + the file; a consumer that wants every outbound edge of a node flattens + them, as `edges_of` does. diff --git a/docs/adr/0026-open-vocabularies.md b/docs/adr/0026-open-vocabularies.md new file mode 100644 index 0000000..0f8ccae --- /dev/null +++ b/docs/adr/0026-open-vocabularies.md @@ -0,0 +1,67 @@ +--- +title: Open vocabularies — known values, free values, a fallback per field +status: accepted +date: 2026-10-13 +tags: [schema, extensibility, 0.5.0] +related: [0010] +superseded-by: null +--- + +# ADR 0026 — Open vocabularies + +## Status +Accepted. + +## Context +Several CND fields take a value from a list the specification defines, +but must also accept values an author or producer invents. `figure.kind` +already worked this way (ADR 0010): `"image"` and `"table"` sit beside an +author's `"atom"` or `"theorem"`. Format 0.5.0 adds more fields of the +same nature — the kind of an admonition (`"warning"`, but also an +author's `"example"`), the kind of an inline mark, the kind of a table — +and existing ones already behave this way without saying so: `code.lang`, +`raw.format`, `source.type`. + +Each field had its own unwritten rules: whether a producer may invent a +value, what a consumer does with one it does not know, how a common +invented value becomes standard. A closed enum would force a +specification revision for every new producer or author convention. A +plain string with no contract leaves producers minting near-duplicates +(`"warn"` beside `"warning"`) and consumers failing in different ways. + +## Decision +These fields are **open vocabularies**, and they share one contract +(docs/proposals/0013, principle 3): + +1. **Known values** have a definition in the specification. A producer + uses a known value whenever one fits; it does not mint `"warn"` when + `"warning"` exists. +2. **Free values** are any other string, written as is. +3. **Consumers never fail on an unknown value.** Each open field + documents its fallback — ignore the value, treat it as unstated, or + render a generic form. +4. **Promotion.** A free value in wide use becomes a known value in a + minor version. + +The open vocabularies of format 0.5.0 are `figure.kind`, +`admonition.kind`, `Mark.kind`, `table.kind`, `code.lang`, `raw.format`, +`source.type`, and the keys of `doc.extra`. + +Closed enumerations stay closed where a consumer must understand the +value exactly to process the data. The node `type` discriminator is one: +an unknown node type cannot be read. + +## Consequences +- **No `x-` prefix.** A prefixed free value has to be renamed everywhere + on the day it becomes standard; that is the lesson of RFC 6648, which + deprecated the practice for protocol parameters. A free value is + written as is, and promotion changes nothing in existing files. +- **Every open field documents its fallback** in the specification + (spec §6.0). A field added later as an open vocabulary must come with + one, or it is not open. +- **Promotion happens in a minor version**: a new known value adds a + definition and never changes the meaning of an existing file. +- The schema types these fields as plain strings. The known values live + in the specification and, in the reference SDK, in constants such as + `KNOWN_MARK_KINDS` and `KNOWN_ADMONITION_KINDS`; validation never + rejects a free value. diff --git a/docs/adr/0027-no-compatibility-guarantee-before-1-0.md b/docs/adr/0027-no-compatibility-guarantee-before-1-0.md new file mode 100644 index 0000000..2d6d48b --- /dev/null +++ b/docs/adr/0027-no-compatibility-guarantee-before-1-0.md @@ -0,0 +1,58 @@ +--- +title: No compatibility guarantee before format 1.0.0 +status: accepted +date: 2026-10-13 +tags: [versioning, compatibility, policy] +related: [0015, 0022] +superseded-by: null +--- + +# ADR 0027 — No compatibility guarantee before 1.0.0 + +## Status +Accepted. + +## Context +The format has moved in minor versions so far: 0.3.0 reworked fields and +made the link families label-keyed, 0.4.0 added a family. Each change +was weighed partly on what it would break, and 0.4.0 was kept additive. +That habit was never written down as a policy, so it read as an implied +promise. + +Format 0.5.0 (docs/proposals/0013) redesigns the node model: prose +becomes rich text, the link families move into texts, and containers +hold block nodes. An additive version — keep the plain `text` strings, +add marks beside them — was considered and is cheaper to migrate, but +it leaves the underlying problems standing and needs a locator for +every multi-text node. Choosing it to preserve compatibility would trade +the format's design for continuity with a model that is not yet right. + +The format is young. ADR 0022 already treats it as not frozen: Python +stays the reference until the format freezes. Consumers already pin to +a release tag (spec §11), and a CND is an immutable build artifact +(ADR 0015) that a producer rebuilds rather than edits. + +## Decision +**Until `cnd_version` 1.0.0, a minor version may break anything** — field +names, node shapes, validation rules, content hashes. Design choices are +made on merit, not on how much they preserve. Consumers pin release +tags, and move to a new version by pinning its tag once their producer +emits it. + +The policy is stated in the specification (spec §11) so that it is +explicit, not inferred. + +## Consequences +- **No upgrader is promised** between pre-1.0 versions. One may be + written when it would carry information across; format 0.5.0 ships + none, because a 0.4 CND holds none of the new information and an + upgrade would only reshape it. +- Every breaking minor version states what it breaks in spec §11, as + 0.5.0 does. +- Content hashes are not comparable across pre-1.0 versions. In-process + reconciliation (ADR 0018) is unaffected: it hashes both sides with the + same library. +- The guarantee a consumer can rely on before 1.0.0 is the pinned tag: + a given release always reads and writes the same format. +- Reaching 1.0.0 is a separate decision, with its own ADR, and is when a + compatibility policy for minor versions is written. diff --git a/docs/adr/0028-content-hashing-0-5.md b/docs/adr/0028-content-hashing-0-5.md new file mode 100644 index 0000000..28bbf85 --- /dev/null +++ b/docs/adr/0028-content-hashing-0-5.md @@ -0,0 +1,117 @@ +--- +title: Content hashing, 0.5 amendments — defaults dropped, nested nodes excluded, paths in the document hash +status: accepted +date: 2026-10-13 +tags: [hashing, identity, derived, change-detection, 0.5.0] +related: [0016, 0018] +superseded-by: null +--- + +# ADR 0028 — Content hashing, 0.5 amendments + +## Status +Accepted. Supersedes ADR 0016, whose decision it restates in full and +amends. + +## Context +ADR 0016 defined the derived content hash: a node hash and a document +hash, computed by any consumer, independent of producer, answering "did +this change?". It was written for the 0.3.0 field set, where most +content was a flat string on the node and only a few node types held +nested nodes in `children`. + +Format 0.5.0 (docs/proposals/0013) changes three things the hash +depends on. + +1. **Optional lists are everywhere.** Every `RichText` carries `marks` + and four link families, each defaulting to an empty list that a + producer may omit. Under ADR 0016, `{"value": "x"}` and + `{"value": "x", "marks": []}` hash differently although they are the + same content, and every future optional field would move every hash, + as `links: []` did at 0.4.0. +2. **Nested nodes sit in more places.** A list item, a term item, a + table or layout cell and a footnote all hold block nodes in + `children`. Excluding only the node's own `children` would make a + list's hash change whenever any item's paragraph changed. +3. **Depth no longer captures placement.** Moving a paragraph from cell + 1 to cell 2 of a table keeps its depth and its place in reading order + and leaves every node-local hash unchanged, yet the document changed. + +`numbering` also replaces `number` and `counter_label`. + +## Decision +The SDK exposes `node_hash(node)`, `pool_entry_hash(entry)` and +`content_hash(cnd)`, and the specification defines the algorithm +normatively (spec §10). The hashes are **derived, never serialized** +into the CND. + +**Canonical serialization (unchanged from ADR 0016).** Take the hashable +value, normalize every string — keys and values — to Unicode **NFC**, +serialize with [RFC 8785 JSON Canonicalization Scheme +(JCS)](https://www.rfc-editor.org/rfc/rfc8785), hash the bytes with +**SHA-256**, and render as `"sha256:"`. Both canonicalizations are +external references, so an implementation in any language can reproduce +the bytes. + +**Rule 1 — defaults are dropped.** Before normalization, a field that the +schema declares with a default and that holds exactly that default is +removed: `null`, `[]` and `{}` where those are the defaults, `false` for +`ordered` and `is_header`, `1` for `rowspan` and `colspan`. This applies +at every level the schema models (a `RichText`, a `Mark`, a `Cell`, a +`ListItem`, a `DocDate`), not inside a free-form object +(`state_metadata`, `BibEntry.fields`). A required field is never +dropped, even when empty. + +**Rule 2 — a node hash is node-local.** It covers the node's own fields, +excluding: + +- `id` — not durable (ADR 0015); a hash covering it would inherit the + very instability the hash exists to route around; +- `location` and `numbering` — resolved presentation state, which changes + without the authored content changing; +- every nested node: the node's `children`, and the `children` of each + entry of its `items` and `cells`. The entries themselves (a cell's + coordinates, an item's `checked`, a term's `term`) stay. Only those + structural keys are removed; a `children` key inside `state_metadata` + is content. + +The exclusion is stated as a principle plus the current list — *exclude +resolved presentation state and nested nodes, hash everything else* — and +the list is maintained as the field set evolves. A pool entry is hashed +over its fields except `id`; a footnote entry also excludes `children`, +whose nodes are hashed as nodes. + +**Rule 3 — the document hash uses paths.** `content_hash(cnd)` hashes an +object with four keys: `doc` (defaults dropped); `nodes`, the +`[path, node_hash]` pairs of every node of the full walk (body, then +footnote content; spec §8) in walk order, with the path as a `/`-joined +string such as `nodes/0/children/4/cells/2/children/0`; and the entry +hashes of `bibliography` and of `footnotes`, in pool order. The CND's +`id`, `built_at`, `source` and `cnd_version` are excluded. + +## Consequences +- **Every hash changes at 0.5.0**, as at 0.4.0. `fixtures/hashes.json` + is regenerated, and a persisted pre-0.5 hash is not comparable. This is + allowed by the pre-1.0 policy (ADR 0027). In-process reconciliation + (ADR 0018) hashes both sides with one library and is unaffected. +- **Omission is free.** A producer may write or omit an empty list, and + a future optional field with a default does not move an existing + hash. The cost is that a second implementation must know each field's + default; the schema states it, and the conformance vectors catch a + mistake. +- **Excluding `numbering`** keeps change detection orthogonal to + renumbering: inserting a heading renumbers every following node, and + a hash covering it would report each of them as changed — precisely + what the moved-but-unchanged matching pass of ADR 0018 must not see. +- **Node-local hashes make reconciliation finer.** An edited list item + changes its paragraph's hash, not the list's; adding an item or a cell + changes the container's. +- **Placement is content.** Moving a node between cells, items or + sections changes the document hash even when depth and reading order + do not. +- The hash remains change-detection, not identity: two identical + paragraphs in one document share a hash. Pairing them across builds is + reconciliation's problem. +- The conformance corpus keeps its `CND → expected hash` vectors + (`fixtures/hashes.json`) so a non-Python implementation can prove it + computes the same bytes. diff --git a/docs/proposals/0013-cnd-0.5.0-rich-text-and-block-containers.md b/docs/proposals/0013-cnd-0.5.0-rich-text-and-block-containers.md index a4bbb3b..ce6f710 100644 --- a/docs/proposals/0013-cnd-0.5.0-rich-text-and-block-containers.md +++ b/docs/proposals/0013-cnd-0.5.0-rich-text-and-block-containers.md @@ -11,7 +11,7 @@ superseded-by: null ## Status -Approved, not yet implemented. This is a redesign of the node model, not an addition. Every CND +Approved (core implemented). This is a redesign of the node model, not an addition. Every CND changes shape, every content hash changes, and the SDK's models, schema, validation, hashing, traversal, renderers, converters, declaration, builder and reconciliation all move with it. The work ships as one diff --git a/spec/cnd-spec.md b/spec/cnd-spec.md index 96ed58c..23b3269 100644 --- a/spec/cnd-spec.md +++ b/spec/cnd-spec.md @@ -1,6 +1,6 @@ # CND Specification -Status: **draft v0.4** (`cnd_version: "0.4.0"`). This document is the +Status: **draft v0.5** (`cnd_version: "0.5.0"`). This document is the reference specification for the CND (Context Native Document) format. The JSON Schema at [`schema/cnd.schema.json`](schema/cnd.schema.json) is @@ -30,14 +30,19 @@ rendering is an SDK feature, not part of the format (§7). A CND is the interchange format between a document producer and any downstream consumer (search index, RAG pipeline, editor, etc.). It captures: -- Document-level metadata (title, authors, date, language). -- The document body as a tree of typed nodes (headings, paragraphs, tables, - quotes, code, math, figures, images, lists, definition lists). +- Document-level metadata (title, authors, date, language, the status and + version the author declares). +- The document body as a tree of typed nodes (headings, paragraphs, quotes, + code, math, figures, images, admonitions, lists, definition lists, + tables, layout grids). Containers hold their nested nodes in `children`, + including inside list items, term items and cells. +- Prose as rich text (§4.1): a string with standoff inline marks (bold, + superscript, inline code, inline math…) and its own link families. - Out-of-tree referenceable entities: a bibliography pool and a footnotes - pool. -- Forward-only link families from nodes to nodes (`refs`), to bibliography - entries (`cites`), to footnotes (`footnotes`), and to hyperlinks outside - the CND (`links`), independent of the tree structure. + pool, whose footnotes hold block nodes. +- Forward-only link families carried by each text, to nodes (`refs`), to + bibliography entries (`cites`), to footnotes (`footnotes`), and to + hyperlinks outside the CND (`links`), independent of the tree structure. ## 2. The CND @@ -46,28 +51,28 @@ Top-level JSON structure (see `Cnd` in `src/cnd/core/cnd.py`): | Field | Type | Description | |---|---|---| | `id` | UUID | CND identifier, generated if absent. | -| `cnd_version` | string | Version of the CND format the CND conforms to (`"0.4.0"` for this revision). | +| `cnd_version` | string | Version of the CND format the CND conforms to (`"0.5.0"` for this revision). | | `built_at` | datetime | When the CND was built. | | `source` | [`SourceInfo`](#21-source) \| null | The input artifact this CND was built from. | | `doc` | [`DocMetadata`](#3-document-metadata) | Metadata of the **work**. | | `nodes` | array of [node](#6-node-types) | Top-level document body. | | `bibliography` | array of [`BibEntry`](#51-bibliography-pool) | Bibliography pool. Always a list, defaults to empty — never null. | -| `footnotes` | array of [`Footnote`](#52-footnotes-pool) | Footnotes pool. Always a list, defaults to empty — never null. | - -**Global id uniqueness**: every `id` in a CND — node ids and pool-entry -ids alike — is unique across the whole CND. Ids are unique but **not -durable**: a producer is free to mint fresh ones on every build, and a -consumer MUST NOT treat an id as a handle that survives a rebuild -(docs/adr/0015). - -**Global label uniqueness**: every `label` in a CND — on nodes and on -pool entries alike — is unique across the whole CND. This is what lets a -link name its target by label alone. A link's resolution domain is -carried by the field it appears in (`refs` → nodes, `cites` → -`bibliography`, `footnotes` → `footnotes`); global uniqueness makes the -label unambiguous, and the family makes the expected *kind* of target -unambiguous. Both are checked — existence alone is not conformance -(§5). +| `footnotes` | array of [`Footnote`](#52-footnotes-pool) | Footnotes pool. Each entry holds block nodes in its `children`. Always a list, defaults to empty — never null. | + +**Global id uniqueness**: every `id` in a CND is unique across the whole +CND — the ids of body nodes, of footnote content nodes (§5.2), and of the +entries of both pools alike. Ids are unique but **not durable**: a +producer is free to mint fresh ones on every build, and a consumer MUST +NOT treat an id as a handle that survives a rebuild (docs/adr/0015). + +**Global label uniqueness**: every `label` in a CND — on body nodes, on +footnote content nodes, and on pool entries alike — is unique across the +whole CND. This is what lets a link name its target by label alone. A +link's resolution domain is carried by the field it appears in (`refs` → +nodes, `cites` → `bibliography`, `footnotes` → `footnotes`); global +uniqueness makes the label unambiguous, and the family makes the +expected *kind* of target unambiguous. Both are checked — existence +alone is not conformance (§5). **Derived, not serialized.** Data a consumer can compute from what the CND necessarily contains is never a field. That rule already governs @@ -76,25 +81,28 @@ reverse edges (§5), reading-order positions (§8) and pagination no field that merely restates something derivable. **Reading order (normative)**: the node tree is in document reading order — -a depth-first traversal of `nodes` (each node before its `children`, -siblings in list order) visits nodes exactly in the order they are read in -the compiled document. Producers MUST emit nodes in reading order. No -serialized field encodes position: document order, within-page order, and -within-parent order are all derived from the tree by consumers (the -reference SDK computes them during traversal, §8). Only `page` is -serialized, because page breaks are a layout result that cannot be -reconstructed from the tree. +a depth-first traversal of `nodes` (each node before its nested nodes, +siblings in list order, and the entries of a node's `items` or `cells` +in list order, §8) visits nodes exactly in the order they are read in +the compiled document. Producers MUST emit nodes in reading order, and +MUST emit the `cells` of a table or a layout in reading order too: the +walk, the node path (§8) and the document hash (§10) follow list order, +not the `row` and `col` coordinates. No serialized field encodes +position: document order, within-page order, and within-parent order are +all derived from the tree by consumers (the reference SDK computes them +during traversal, §8). Only `page` is serialized, because page breaks are +a layout result that cannot be reconstructed from the tree. **Decorative content (normative)**: content the source marks as decorative — present for presentation only, carrying no meaning — is not part of the document. A producer MUST NOT emit it: it yields no node, no -text inside another node's text, no pool entry, and no link edge. +text inside another text, no pool entry, and no link edge. Everything nested inside decorative content is decorative, whatever its kind. A label inside it does not exist in the CND, so a reference to it from kept content yields no edge (its rendered text, if any, stays in the -kept node's text). A footnote declared inside it gets no pool entry, and -no marker — its own or a re-reference — yields an edge to it; a kept -footnote keeps the label the built document shows, even when a +`value` of the kept text). A footnote declared inside it gets no pool +entry, and no marker — its own or a re-reference — yields an edge to it; +a kept footnote keeps the label the built document shows, even when a decorative one consumed a number before it. A marker written inside decorative content — a reference, a citation, a footnote re-reference — yields no edge, whatever it points at. The bibliography pool is the @@ -113,7 +121,7 @@ is unchanged and the input artifact is not the same thing. | Field | Type | Description | |---|---|---| -| `type` | string, required | The input format — `"typst"`, `"markdown"`, `"doclang"`… An **open string**: a new producer must not require a revision of this specification. | +| `type` | string, required | The input format — `"typst"`, `"markdown"`, `"doclang"`… An **open vocabulary** (§6.0): a new producer must not require a revision of this specification. | | `hash` | string, required | Self-describing digest of the input, e.g. `"sha256:…"`. | | `uri` | string \| null | An identifier in the producer's own space. | @@ -131,8 +139,9 @@ workstation path would leak a filesystem tree to every downstream reader. ### 2.2 Pagination `location` is nullable, and pagination is **all-or-nothing**: either -every node in a CND carries a `location`, or none does. A partially -paginated CND is invalid. +every node in a CND carries a `location`, or none does. "Every node" +includes footnote content nodes (§5.2): on a paginated CND they carry the +page where the note is printed. A partially paginated CND is invalid. This is what removes the "unknown page" versus "not paginated" ambiguity without adding a field. A markdown, HTML or hand-authored @@ -155,7 +164,26 @@ traversal positions (§8) are **undefined and absent**, not zero. | `date` | `DocDate` \| null | Partial or full document date (`year`, optional `month`, `day`). | | `keywords` | array of string | Free-form keywords. Defaults to `[]` and may be omitted. | | `description` | string \| null | Optional abstract/summary. | -| `lang` | string \| null | Document language code. | +| `lang` | string \| null | Document language code. The end of the inheritance chain of `RichText.lang` (§4.1). | +| `status` | string \| null | The document status **as the author declares it**, e.g. `"Approved"`. Free-form. | +| `version` | string \| null | The document version **as the author declares it**, e.g. `"1.2"`. Free-form. | +| `extra` | object of string → string | Any other title-page metadata, as string pairs. Defaults to `{}` and may be omitted. | + +```json +"doc": { "title": "Deployment guide", "status": "Approved", "version": "1.2", + "extra": { "client": "ACME", "reference": "PRJ-042" } } +``` + +`status` and `version` record what the author printed on the document, +such as a title page reading "Status: Approved, Version 1.2". They have +no relation to `cnd_version`, and they do not give a CND a version +history of its own (§11): they are authored values, not a build lineage. + +The keys of `extra` are an open vocabulary (§6.0) of which this +specification defines none; a consumer may use or ignore them. **An +`extra` key MUST NOT repeat the name of a `DocMetadata` field** — `extra` +included — or the document would carry two contradictory values for one +fact. A key in wide use becomes a named field in a minor version. ## 4. Node base @@ -165,32 +193,131 @@ Every node (see `NodeBase` in `src/cnd/core/nodes.py`) shares: |---|---|---| | `id` | UUID | Node identifier — unique within the CND, **not durable across builds** (§2, docs/adr/0015). | | `label` | string \| null | Human-assigned label (e.g. a Typst `