diff --git a/CHANGELOG.md b/CHANGELOG.md index f419063..4b2f89c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,52 @@ the minor version, fixes bump the patch version). ## [Unreleased] +The SDK implements CND **format 0.5.0** (docs/proposals/0013). This is a +**breaking** format change: a `"0.4.0"` CND does not parse as `"0.5.0"`, and +**every content hash changes** (spec §10, docs/adr/0028). No 0.4 → 0.5 +upgrader is shipped (spec §11): producers move by emitting 0.5. + +### Changed + +- **Rich text with marks**: prose is a `RichText` with standoff marks + (emphasis, strong, code, math, …), and the four link families moved + from the node into its texts (docs/adr/0025). +- **Block containers**: every body lives in `children`, so list items, + term items, table cells and footnotes hold nodes. `numbering` replaces + `number` and `counter_label`. +- **Declaration 0.2.0**: texts accept a string shorthand or a full + `RichText`, and an authored word is `numbering.name`. A 0.1.0 declaration, + like a CND stamped with another `cnd_version`, is refused with an error + that names both versions (`declaration-version-unsupported`, + `cnd-version-unsupported`). +- **Matching v2** (docs/adr/0029, extending docs/adr/0018): runs over the full + walk with spec §8 paths, keys footnote content by its note's label and + containers by their nested content too. A last pass reports + `matched_by: "container-hash"`. `fixtures/matching.json` is now v2. +- **Typst package**: the `cnd.table` parameter is renamed from + `content_kind:` to `kind:`, after the CND's `table.kind`. The + `cnd.metadata` state carries the hint under the dedicated key + `table-kind` (it was `content_kind`), so it never collides with a + document-level `kind` an author sets; an emitter reads `table-kind` + and writes `table.kind`. + +### Added + +- **New nodes and fields**: `admonition` and `layout` nodes, list-item + checkboxes (`checked`), figure `description`, code `title`, and + `doc.status`, `doc.version` and `doc.extra`. +- `MarkdownConverter(text_only=True)` and `MarkdownRenderer(text_only=…)`: + Markdown with no HTML at all. +- HTML output renders `links` edges as `` around the + text they span, for safe URLs only. +- `Cnd.to_json()`: the public writer, which omits every default-valued + field (the result parses back to the same model and the same hash). +- **Hyperlinks in declarations**: a declaration's full text form is the + CND's `RichText`, so it carries `links` like `refs`, `cites` and + `footnotes` (spec §12). +- `BaseVisitor.visit(cnd, full=True)` walks the footnotes' content too, + with spec §8 paths. + ## [0.8.0] - 2026-10-11 SDK release 0.8.0 still implements CND **format 0.4.0**: no schema change. diff --git a/README.md b/README.md index 5dec812..741783f 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,16 @@ copy it into a project (e.g. as a root file `cnd.typ`), then: #import "/cnd.typ" #cnd.metadata.update(it => it + (revision: "4.2")) -#cnd.table(table(columns: 2, [A], [B]), content_kind: "data") +#cnd.table(table(columns: 2, [A], [B]), kind: "data") #cnd.decorative(image("logo.png", width: 2cm)) ``` +`cnd.table(body, kind: ...)` records the table's kind for the emitter: +`"data"` (each row a record) or `"content"` (cells to read). The state +carries it under the dedicated key `table-kind`, which an emitter reads +and the CND writes as `table.kind` (spec §6.3); your own metadata, a +document-level `kind` included, is never touched. + `cnd.decorative(body)` marks content as purely decorative — a logo, an ornament, an icon — so no producer emits it (spec §2, "Decorative content"). It is Typst's `pdf.artifact`, so the rendered page is unchanged. @@ -107,10 +113,11 @@ the corpus matches, carrying all four vector kinds the same ADR requires (foreign format → declaration) is not here and never will be: it belongs with the producers, not the hub (same ADR, §2). -`diff` sits apart from the other four: matching v1 is a **versioned +`diff` sits apart from the other four: matching v2 is a **versioned reference algorithm, not a normative rule** -([`docs/adr/0018`](docs/adr/0018-reconciliation-reference-algorithm.md)). -Reproducing `matching.json` proves an implementation runs v1 — it is not +([`docs/adr/0018`](docs/adr/0018-reconciliation-reference-algorithm.md); +v2 itself is [`docs/adr/0029`](docs/adr/0029-matching-v2.md)). +Reproducing `matching.json` proves an implementation runs v2 — it is not something the format requires of anyone. ## Reconciliation @@ -141,6 +148,16 @@ format** — exact for labelled nodes, best-effort for the rest, and every pairing reports which pass made it so a caller can tell the two apart. Pass `only_exact=True` to inherit ids on labels alone. +The version implemented is **matching v2** +([`docs/adr/0029`](docs/adr/0029-matching-v2.md)): it runs over the full +walk (the body, then every footnote's content) with spec §8 paths, keys a +footnote's content by its note's label, and keys a container (list, +terms, table, layout, quote, admonition) on its own hash plus a digest of +its nested content. A last `container-hash` pass pairs a container whose +nested content was edited and that also moved. Twin headings or figures +with identical titles, and twin containers both edited and shifted, can +still swap. + Turning a foreign format into a CND (`cnd declare`) is a different kind of absence: it belongs with the producers permanently, since bundling it would make the hub depend on a satellite. diff --git a/docs/README.md b/docs/README.md index fcb97bb..572f279 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,15 +23,20 @@ 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 | +| [0018](adr/0018-reconciliation-reference-algorithm.md) | Reconciliation (diff, id inheritance) is a versioned reference algorithm, not a format guarantee | accepted, extended by 0029 | | [0019](adr/0019-production-topology-two-doors.md) | Production topology — a declarative door and a direct door over one shared contract | accepted | | [0020](adr/0020-repository-as-conformance-hub.md) | The repository is a conformance hub; the hub ships a conformance CLI | accepted | | [0021](adr/0021-artifact-naming-and-file-identity.md) | Artifact naming and file identity — the CND and the declaration | accepted | | [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 | +| [0029](adr/0029-matching-v2.md) | Matching v2 — the full walk, spec §8 paths and container keys | accepted, extends 0018 | ## Proposals (docs/proposals/) @@ -49,4 +54,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 | 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/adr/0029-matching-v2.md b/docs/adr/0029-matching-v2.md new file mode 100644 index 0000000..b535399 --- /dev/null +++ b/docs/adr/0029-matching-v2.md @@ -0,0 +1,127 @@ +--- +title: Matching v2 — the full walk, spec §8 paths and container keys +status: accepted +date: 2026-10-14 +tags: [identity, reconciliation, diff, sdk, non-normative, 0.5.0] +related: [0015, 0018, 0028] +superseded-by: null +--- + +# ADR 0029 — Matching v2 + +## Status +Accepted. Extends ADR 0018. ADR 0018's decision stands: reconciliation is a +versioned reference algorithm, not part of the format. This ADR records what +version 2 of that algorithm computes, as the SDK implements it in +`cnd.reconcile.match` (`MATCHER_VERSION = "v2"`). + +## Context +ADR 0018 pinned matching v1 for the 0.4 node model, in which most nodes were +leaves: a list item or a table cell was a string inside its node. + +Format 0.5.0 (docs/proposals/0013) changes what there is to match. + +- Every body lives in `children`, so list items, term items, cells, quotes, + admonitions and footnotes hold nodes. +- Footnote content is made of nodes, outside the body walk (spec §8). +- A node hash excludes nested nodes (ADR 0028), so two containers of the same + shape hash alike, whatever they contain. + +Run unchanged on this model, v1 would leave three problems: + +- nested nodes would go unmatched; +- footnote content would move whenever the pool is reordered; +- a surviving container could take the id of a deleted twin of the same shape. + +## Decision + +**The walk.** v2 matches every node of the full walk (spec §8): the body in +reading order, including nodes inside list items, term items, cells, quotes, +admonitions, figures and headings, then the content of each footnote in pool +order. That position is also each node's `order`, the tie-breaker. + +**The structural path.** It is the node's spec §8 path, such as +`("nodes", 0, "children", 4, "cells", 2, "children", 0)`. The first +paragraphs of two different cells are therefore in two different slots. + +**Footnote content is keyed by its note's label.** Inside a footnote, the pool +index in the path is replaced by the note's label: +`("footnotes", "fn-a", "children", 0)`. Pool entries already match on their +label (ADR 0018), and a pool has no "moved". Reordering the pool therefore +moves nothing, and a note's content matches against the same note's content. + +**Containers carry a descendants digest.** A container is a list, terms, +table, layout, quote or admonition. Its key in the hash passes is its node +hash plus a digest of its descendants' hashes, taken slot by slot in +preorder. Without the digest, two tables of the same shape hash alike, and a +deleted table's id could pass to a surviving one. + +Headings and figures are excluded from the digest on purpose: + +- their own hash already carries their text or caption, so it tells them + apart; +- a heading whose section changed has not itself changed (ADR 0028). With a + digest, every edit inside a section would unmatch its heading, and a moved + section would not be recognised. + +Every node that is not a container has an empty digest. Its key is its node +hash alone, as in v1. + +**The passes,** strongest first. A node matched by one pass is never +reconsidered by a later one: + +1. `label` — exact; survives edit and move. +2. `hash+path` — node hash, digest and structural path agree: unchanged, in + place. +3. `hash` — node hash and digest agree: moved but unchanged. +4. `path+type` — same slot, same type: edited content. +5. `container-hash` — for containers only, the node hash alone. It pairs a + container whose nested content was edited and that also shifted. Such a + container misses passes 2 and 3 (its digest changed) and pass 4 (its path + changed). Twins have already paired through the digest in passes 2 and 3, + so this pass sees only what is left. +6. Otherwise the node is unmatched: added on the new side, removed on the old + side. + +Within a pass, ties break by reading order. The last pass reports +`matched_by: "container-hash"`, distinct from `"hash"`. A caller can then tell +"unchanged container" from "container whose own fields survived". + +**Empty containers.** + +- An empty quote or admonition has one empty `children` slot. Its digest is + that of an empty slot, so it is a container: it can pair in pass 5. +- A list or terms with no items, and a table or layout with no cells, has no + slots at all. Its digest is empty, so it behaves like a leaf: it pairs on + its node hash in passes 2 and 3, and never enters pass 5. +- Either way, nothing nested distinguishes two empty twins, so they pair in + reading order. + +**Conformance vectors.** `fixtures/matching.json` carries +`"matcher_version": "v2"`, and the pairs are under `fixtures/reconcile/`. + +- Cases 08 to 10 cover nested nodes and footnote content. +- Case 11 covers the `container-hash` pass. + +Reproducing the vectors proves that an implementation runs v2. As under +ADR 0018, it is not a conformance requirement on the format. + +## Consequences +- **Finer reports.** An edited list item, or an edited paragraph in a cell, is + matched and reported on its own; v1 would have marked the whole list + changed. +- **Known limits.** + - Two headings, or two figures, with identical titles or captions hash + alike and carry no digest, so an edit or move can swap their ids. + - Two twin containers that were both edited and shifted reach pass 5 with + the same node hash, and pair in reading order, which can swap them. + - ADR 0018's combined case still holds for every node that is not a + container: a node that is both edited and shifted by an insertion above + it is reported as added, and its predecessor as removed (fixture case + 06). Labels remain the only exact identity. +- **No change to hashing.** The digest is a matching key only, computed from + node hashes. It is never serialised, and it is not a content hash. The node + hash (ADR 0028) is unchanged. +- **Versioning.** Any later change to the walk, the keys or the passes is + matching v3: a new `MATCHER_VERSION`, new vectors and a new ADR. It is not a + regeneration of v2's vectors. 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..08d6a1d 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 @@ -1,6 +1,6 @@ --- title: CND 0.5.0 — rich text, block containers, and the elements a document actually uses -status: approved +status: implemented date: 2026-10-12 tags: [schema, breaking-change, rich-text, marks, containers, 0.5.0] related: [0008, 0009, 0010, 0011, 0012, 0013, 0016, 0018, 0019, 0024] @@ -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 +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 @@ -523,7 +523,10 @@ kind class, and `layout` maps to a CSS grid. notation need a design of their own. - The declaration drops `number` and `counter_label` in favour of an authored `numbering.name`. The value stays derived by the builder. -- `links` in declarations remains out of scope (ADR 0024). +- `links` in declarations: the full object form is the CND's own + `RichText`, so a declaration carries hyperlinks like the other three + families. This closes the gap ADR 0024 recorded; the ADR itself is + unchanged. ## Versioning (§11) @@ -552,7 +555,8 @@ kind class, and `layout` maps to a CSS grid. - **Proposals**: 0002 is superseded by this one. 0010's `counter_label` is folded into `numbering.name`. - **Authoring package** (`cnd.typ`): the table hint is renamed from - `content_kind` to `kind`. Authoring helpers for the new elements are a + `content_kind` to `kind`; this supersedes the `content_kind` part of + ADR 0023 (the ADR is unchanged). Authoring helpers for the new elements are a producer concern, delivered with the producer that emits 0.5. ## Out of scope 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/declaration/article.cnd b/fixtures/declaration/article.cnd index 2ff41b1..cb32f77 100644 --- a/fixtures/declaration/article.cnd +++ b/fixtures/declaration/article.cnd @@ -1,7 +1,7 @@ { - "id": "8a77ef77-ef09-4ed6-9d64-9e0d0fa0bec7", - "cnd_version": "0.4.0", - "built_at": "2026-09-20T18:00:08.563568Z", + "id": "d00360f6-e9d2-4afd-b931-8dbde74d0110", + "cnd_version": "0.5.0", + "built_at": "2026-10-14T09:30:22.343724Z", "source": { "type": "markdown", "hash": "sha256:1f0a9c7e5b2d84e6", @@ -12,125 +12,180 @@ "authors": [ "Ada Rivest" ], - "date": null, "keywords": [ "protocol", "declaration" ], - "description": null, "lang": "en" }, "nodes": [ { - "id": "5f86cbb0-f5e1-401d-aadf-490ee6b63761", + "id": "e1a17fb4-f0a1-4231-b478-0e4583127dbe", "label": "sec-handshake", - "refs": [], - "cites": [], - "footnotes": [], - "links": [], - "state_metadata": {}, - "location": null, "type": "heading", "level": 1, - "number": null, - "counter_label": null, - "text": "Handshake", + "text": { + "value": "Handshake" + }, "heading_path": [ "Handshake" ], "children": [ { - "id": "45809637-8e37-4a47-89f3-54af5d0f909f", - "label": null, - "refs": [ - { - "label": "lst-open", - "text_span": [ - 39, - 57 - ] - } - ], - "cites": [ - { - "label": "rivest1978", - "text_span": null, - "form": "prose", - "supplement": null - } - ], - "footnotes": [ - { - "label": "fn-token", - "text_span": null - } - ], - "links": [], - "state_metadata": {}, - "location": null, + "id": "4e3131d5-2dc7-4fbf-b8ef-5fc76157f8b5", "type": "paragraph", - "text": "The client opens with a version token, as the listing shows, following Rivest. See the note on opacity.", - "lang": null + "text": { + "value": "The client opens with a version token, as the listing shows, following Rivest. See the note on opacity.", + "marks": [ + { + "start": 24, + "end": 37, + "kind": "emph" + } + ], + "refs": [ + { + "label": "lst-open", + "text_span": [ + 39, + 57 + ] + } + ], + "cites": [ + { + "label": "rivest1978", + "form": "prose" + } + ], + "footnotes": [ + { + "label": "fn-token" + } + ] + } }, { - "id": "79e0c3ce-a086-4d8f-bf9b-54b26d4aca75", + "id": "1571fb75-9ac5-4df5-8f7d-692f2e8045d7", + "type": "paragraph", + "text": { + "value": "The handshake is specified at example.org.", + "links": [ + { + "href": "https://example.org/handshake", + "text_span": [ + 30, + 41 + ] + } + ] + } + }, + { + "id": "ca664790-c544-4873-a07f-795f9758e081", "label": "lst-open", - "refs": [], - "cites": [], - "footnotes": [], - "links": [], - "state_metadata": {}, - "location": null, "type": "figure", "kind": "code", - "caption": "Opening frame", - "number": null, - "counter_label": "Listing", + "caption": { + "value": "Opening frame" + }, "children": [ { - "id": "ec87328f-9a3e-4d20-bf24-43b34770d395", - "label": null, - "refs": [], - "cites": [], - "footnotes": [], - "links": [], - "state_metadata": {}, - "location": null, + "id": "e35dc54f-4aed-4b0e-9db7-bbfd148f2a42", "type": "code", "text": "OPEN v1\n", "lang": "text", - "block": true + "title": "open.txt" } - ], - "raw": null + ] }, { - "id": "bc7266cc-7c49-4e8d-b72d-ed836cbdc9d6", - "label": null, - "refs": [], - "cites": [], - "footnotes": [], - "links": [], - "state_metadata": {}, - "location": null, + "id": "2bf31c37-ffa6-44a5-8b50-7560d158d88b", "type": "list", "ordered": true, - "tight": true, "items": [ { - "text": "Negotiate the version.", "number": 1, - "children": [] + "children": [ + { + "id": "0811ad51-3bf8-4c05-9284-eaeda129a8a0", + "type": "paragraph", + "text": { + "value": "Negotiate the version." + } + } + ] }, { - "text": "Exchange capabilities.", "number": 2, - "children": [] + "children": [ + { + "id": "017a5f8f-dc7a-415b-874c-07bbaee81515", + "type": "paragraph", + "text": { + "value": "Exchange capabilities." + } + }, + { + "id": "3fe4e946-4eb4-43b2-8e8c-0b77563fcc01", + "type": "list", + "items": [ + { + "checked": true, + "children": [ + { + "id": "ab9c4cb8-c959-41eb-8727-bd2323fc1cd6", + "type": "paragraph", + "text": { + "value": "Compression" + } + } + ] + }, + { + "checked": false, + "children": [ + { + "id": "aac8298a-2b77-414f-b9bd-961125d4cf31", + "type": "paragraph", + "text": { + "value": "Encryption" + } + } + ] + } + ] + } + ] }, { - "text": "Confirm.", "number": 3, - "children": [] + "children": [ + { + "id": "8f663766-cf4e-40ba-8801-cce8ad8f8c70", + "type": "paragraph", + "text": { + "value": "Confirm." + } + } + ] + } + ] + }, + { + "id": "549cdfa0-9e62-469b-97e4-aaa2e3c1c743", + "type": "admonition", + "kind": "note", + "title": { + "value": "Opacity" + }, + "children": [ + { + "id": "a4c17cfd-26f9-4a32-9f12-a206cb57f251", + "type": "paragraph", + "text": { + "value": "Intermediaries never read the token." + } } ] } @@ -139,27 +194,30 @@ ], "bibliography": [ { - "id": "b0ad710d-a62c-4628-a9e9-a56097e2c2bb", + "id": "744225b1-5195-41e4-aba4-82dbf1a9dcd8", "label": "rivest1978", - "formatted": null, "type": "article", "authors": [ "Rivest", "R." ], "title": "On data banks and privacy homomorphisms", - "year": 1978, - "container": null, - "doi": null, - "url": null, - "fields": {} + "year": 1978 } ], "footnotes": [ { - "id": "4137626d-313c-4912-b711-67cf7fd7fce8", + "id": "f8e700b4-f7f5-4bd0-98a6-6f1e006adc58", "label": "fn-token", - "text": "The version token is opaque to intermediaries." + "children": [ + { + "id": "b3b0758a-7b93-483e-900c-ac6661083f84", + "type": "paragraph", + "text": { + "value": "The version token is opaque to intermediaries." + } + } + ] } ] } diff --git a/fixtures/declaration/article.decl.yaml b/fixtures/declaration/article.decl.yaml index c3f9b0c..b4b50db 100644 --- a/fixtures/declaration/article.decl.yaml +++ b/fixtures/declaration/article.decl.yaml @@ -1,15 +1,17 @@ # A declaration — the source form the builder compiles into a CND # (docs/adr/0019). Note what is absent by construction: no node has an -# `id`, a `location`, a `number`, or a `heading_path`. The builder -# supplies all of those. What is here is authored content only — -# including `counter_label`, whose word is authored, not derived -# (docs/proposals/0010). +# `id`, a `location`, a `numbering.value`, or a `heading_path`. The +# builder supplies all of those. What is here is authored content only. +# +# A text is a plain string (the shorthand) or the full rich-text object +# when a producer computes offsets for marks and links +# (docs/proposals/0013, "Declaration"). # # article.cnd is this declaration's expected build output (`cnd build`, # unnumbered). Compare the two after erasing every `id` and `built_at`: # ids are minted fresh per build (docs/adr/0015), and `built_at` is a # timestamp — neither is reproducible across builds. -declaration_version: "0.1.0" +declaration_version: "0.2.0" source: type: markdown @@ -30,40 +32,67 @@ nodes: text: Handshake children: - type: paragraph - text: >- - The client opens with a version token, as the listing shows, - following Rivest. See the note on opacity. - refs: - - label: lst-open - text_span: [39, 57] - cites: - - label: rivest1978 - form: prose - footnotes: - - label: fn-token + text: + value: >- + The client opens with a version token, as the listing shows, + following Rivest. See the note on opacity. + marks: + - { start: 24, end: 37, kind: emph } + refs: + - label: lst-open + text_span: [39, 57] + cites: + - label: rivest1978 + form: prose + footnotes: + - label: fn-token + + - type: paragraph + text: + value: The handshake is specified at example.org. + links: + - href: https://example.org/handshake + text_span: [30, 41] - type: figure label: lst-open kind: code - # The displayed counter word. Authored, not derived: the builder - # has no localized name for a "code" figure, so if the author wants - # "Listing 1" rather than nothing, the word lives here. - counter_label: Listing caption: Opening frame children: - type: code text: "OPEN v1\n" lang: text + title: open.txt - type: list ordered: true items: # An authored start override — the declaration keeps this, the # running ordinal it does not (that is the builder's to resolve). - - text: Negotiate the version. - number: 1 - - text: Exchange capabilities. - - text: Confirm. + - number: 1 + children: + - type: paragraph + text: Negotiate the version. + - children: + - type: paragraph + text: Exchange capabilities. + - type: list + items: + - checked: true + children: + - { type: paragraph, text: Compression } + - checked: false + children: + - { type: paragraph, text: Encryption } + - children: + - { type: paragraph, text: Confirm. } + + - type: admonition + kind: note + title: Opacity + children: + - type: paragraph + text: Intermediaries never read the token. bibliography: - label: rivest1978 @@ -74,4 +103,6 @@ bibliography: footnotes: - label: fn-token - text: The version token is opaque to intermediaries. + children: + - type: paragraph + text: The version token is opaque to intermediaries. diff --git a/fixtures/declaration/numbered.cnd b/fixtures/declaration/numbered.cnd index 3ecac5d..fdcba0a 100644 --- a/fixtures/declaration/numbered.cnd +++ b/fixtures/declaration/numbered.cnd @@ -1,232 +1,255 @@ { - "id": "8de95ece-72b9-466a-b740-0d1d1064e8a7", - "cnd_version": "0.4.0", - "built_at": "2026-09-20T18:00:08.566373Z", - "source": null, + "id": "7fae4aba-3c1a-4e38-9cf6-d82066742dde", + "cnd_version": "0.5.0", + "built_at": "2026-10-14T09:30:22.351551Z", "doc": { "title": "Numbered, declared", - "authors": [], - "date": null, - "keywords": [], - "description": null, "lang": "en" }, "nodes": [ { - "id": "d0050a1c-ab31-4ef5-abb5-a77b1b69b8c5", - "label": null, - "refs": [], - "cites": [], - "footnotes": [], - "links": [], - "state_metadata": {}, - "location": null, + "id": "9bef48f7-b8fa-4eb8-ba94-5f6963a5dfe4", "type": "heading", "level": 1, - "number": "1", - "counter_label": null, - "text": "Measurements", + "numbering": { + "value": "1" + }, + "text": { + "value": "Measurements" + }, "heading_path": [ "1 Measurements" ], "children": [ { - "id": "a43592c7-4c8d-4518-bc27-b3e9416040a6", - "label": null, - "refs": [], - "cites": [], - "footnotes": [], - "links": [], - "state_metadata": {}, - "location": null, + "id": "372dc6a2-d636-45d1-a115-a5da02dc663d", "type": "figure", "kind": "image", - "caption": "The bench", - "number": "1", - "counter_label": null, + "caption": { + "value": "The bench" + }, + "numbering": { + "value": "1" + }, "children": [ { - "id": "bf951f66-856b-4943-a7d3-12d8a1f8586d", - "label": null, - "refs": [], - "cites": [], - "footnotes": [], - "links": [], - "state_metadata": {}, - "location": null, + "id": "c6116281-0b38-4c21-a1b3-f500445137c1", "type": "image", "path": "bench.png", "alt": "The measurement bench" } - ], - "raw": null + ] }, { - "id": "51275c31-667c-400f-9034-1f0d6efddbf3", - "label": null, - "refs": [], - "cites": [], - "footnotes": [], - "links": [], - "state_metadata": {}, - "location": null, + "id": "bb38d224-80e7-4b38-aa0f-175bd107a7f6", "type": "figure", - "kind": null, - "caption": "Raw readings", - "number": "1", - "counter_label": null, + "caption": { + "value": "Raw readings" + }, + "numbering": { + "value": "1" + }, "children": [ { - "id": "6e86919b-f83d-4d32-bce9-a00c3087375b", - "label": null, - "refs": [], - "cites": [], - "footnotes": [], - "links": [], - "state_metadata": {}, - "location": null, + "id": "d2c9bcfc-c8cd-4274-a6a1-296926b46af2", "type": "table", - "kind": "table", - "content_kind": null, + "kind": "data", "cells": [ { "row": 0, "col": 0, - "rowspan": 1, - "colspan": 1, "is_header": true, - "text": "run" + "children": [ + { + "id": "2dfcebb4-58da-49d4-afdd-e1587d35fa3d", + "type": "paragraph", + "text": { + "value": "run" + } + } + ] }, { "row": 0, "col": 1, - "rowspan": 1, - "colspan": 1, "is_header": true, - "text": "value" + "children": [ + { + "id": "381ef955-5506-4050-8a2b-6b059891c6d0", + "type": "paragraph", + "text": { + "value": "value" + } + } + ] }, { "row": 1, "col": 0, - "rowspan": 1, - "colspan": 1, - "is_header": false, - "text": "1" + "children": [ + { + "id": "94f248a6-a120-41ab-971d-12cc189f3533", + "type": "paragraph", + "text": { + "value": "1" + } + } + ] }, { "row": 1, "col": 1, - "rowspan": 1, - "colspan": 1, - "is_header": false, - "text": "3.14" + "children": [ + { + "id": "948fc904-1b7c-4e6c-b681-673bd6e1213c", + "type": "paragraph", + "text": { + "value": "3.14" + } + } + ] } - ], - "raw": null + ] } - ], - "raw": null + ] }, { - "id": "bd03da37-900e-468d-a699-f1a19cae400c", - "label": null, - "refs": [], - "cites": [], - "footnotes": [], - "links": [], - "state_metadata": {}, - "location": null, + "id": "9bb26a59-70ea-41ea-af6d-6d0331ec6526", "type": "heading", "level": 3, - "number": "1.0.1", - "counter_label": null, - "text": "Deep dive", + "numbering": { + "value": "1.0.1" + }, + "text": { + "value": "Deep dive" + }, "heading_path": [ "1 Measurements", "1.0.1 Deep dive" ], "children": [ { - "id": "93e341d1-fb0e-48ef-bdd2-3bca7ba9d103", - "label": null, - "refs": [], - "cites": [], - "footnotes": [], - "links": [], - "state_metadata": {}, - "location": null, + "id": "7f12aa2a-cc84-4443-baf7-4d281310ab1e", "type": "math", "text": "E = mc^2", - "raw": null, - "number": "(1)", - "counter_label": null, - "block": true + "numbering": { + "value": "(1)" + } }, { - "id": "28c73afe-893f-45cc-a9d5-0beda7b9f874", - "label": null, - "refs": [], - "cites": [], - "footnotes": [], - "links": [], - "state_metadata": {}, - "location": null, + "id": "af532068-f018-4c9a-acfc-7baa8acd006e", "type": "paragraph", - "text": "An inline aside follows.", - "lang": null + "text": { + "value": "An inline aside follows: a^2 + b^2.", + "marks": [ + { + "start": 25, + "end": 34, + "kind": "math", + "raw": { + "format": "typst", + "value": "a^2 + b^2" + } + } + ], + "footnotes": [ + { + "label": "fn-eq" + } + ] + } }, { - "id": "d2061347-169c-4e71-b729-696180321ed4", - "label": null, - "refs": [], - "cites": [], - "footnotes": [], - "links": [], - "state_metadata": {}, - "location": null, - "type": "math", - "text": "a^2 + b^2", - "raw": null, - "number": null, - "counter_label": null, - "block": false + "id": "a73400a0-5d08-4265-8515-701281a2777c", + "label": "lst-open", + "type": "figure", + "kind": "code", + "caption": { + "value": "Opening frame" + }, + "numbering": { + "value": "1", + "name": "Listing" + }, + "children": [ + { + "id": "e3726af3-184b-40de-afb5-bd13125b2fc3", + "type": "code", + "text": "OPEN v1\n", + "lang": "text" + } + ] } ] }, { - "id": "152671a4-2156-4d3a-9b0a-dfa02a47d9f7", - "label": null, - "refs": [], - "cites": [], - "footnotes": [], - "links": [], - "state_metadata": {}, - "location": null, + "id": "769865fc-3847-4088-b772-bc34fb9aff8e", "type": "figure", "kind": "image", - "caption": "The second image", - "number": "2", - "counter_label": null, + "caption": { + "value": "The second image" + }, + "numbering": { + "value": "2" + }, "children": [ { - "id": "0b061147-2dcd-473d-8c42-56ccee172e7f", - "label": null, - "refs": [], - "cites": [], - "footnotes": [], - "links": [], - "state_metadata": {}, - "location": null, - "type": "image", - "path": "detail.png", - "alt": "Detail view" + "id": "eced9d2b-f692-4e33-8b62-9ae9519a0971", + "type": "layout", + "cells": [ + { + "row": 0, + "col": 0, + "children": [ + { + "id": "1ab0bb24-8451-4450-87c8-995d7a745623", + "type": "image", + "path": "detail.png", + "alt": "Detail view" + } + ] + }, + { + "row": 0, + "col": 1, + "children": [ + { + "id": "71ba7810-0764-448b-8233-ccd5f9ab3396", + "type": "image", + "path": "overview.png", + "alt": "Overview" + } + ] + } + ] } - ], - "raw": null + ] } ] } ], - "bibliography": [], - "footnotes": [] + "footnotes": [ + { + "id": "f49b0bec-f504-4b00-a06b-0ed04a832721", + "label": "fn-eq", + "children": [ + { + "id": "5d338d4c-0339-47a7-bdda-b2a7ff5e0ada", + "type": "paragraph", + "text": { + "value": "The aside rests on" + } + }, + { + "id": "54fd900f-c802-47a7-bfc6-e00d9b4845b8", + "label": "eq-note", + "type": "math", + "text": "c^2 = a^2 + b^2", + "numbering": { + "value": "(2)" + } + } + ] + } + ] } diff --git a/fixtures/declaration/numbered.decl.yaml b/fixtures/declaration/numbered.decl.yaml index a3e85c7..a0cdb54 100644 --- a/fixtures/declaration/numbered.decl.yaml +++ b/fixtures/declaration/numbered.decl.yaml @@ -2,13 +2,15 @@ # a skipped heading level (1 -> 3, shown as "1.0.1" — the house style # shows the gap rather than papering over it), per-kind figure counters # (the kind-less figure wrapping a table draws from the "table" counter, -# not "image"), and block-vs-inline math (only block math is numbered). +# not "image"), an authored counter word (`numbering.name`), and math: +# every math node is numbered, inline math is a mark and is not, and an +# equation inside a footnote counts after the body (full-walk order). # # numbered.cnd is this declaration's expected build output (`cnd build # --numbering`). Compare the two after erasing every `id` and `built_at`: # ids are minted fresh per build (docs/adr/0015), and `built_at` is a # timestamp — neither is reproducible across builds. -declaration_version: "0.1.0" +declaration_version: "0.2.0" doc: title: Numbered, declared @@ -31,11 +33,12 @@ nodes: caption: Raw readings children: - type: table + kind: data cells: - - { row: 0, col: 0, is_header: true, text: run } - - { row: 0, col: 1, is_header: true, text: value } - - { row: 1, col: 0, text: "1" } - - { row: 1, col: 1, text: "3.14" } + - { row: 0, col: 0, is_header: true, children: [{ type: paragraph, text: run }] } + - { row: 0, col: 1, is_header: true, children: [{ type: paragraph, text: value }] } + - { row: 1, col: 0, children: [{ type: paragraph, text: "1" }] } + - { row: 1, col: 1, children: [{ type: paragraph, text: "3.14" }] } - type: heading level: 3 @@ -43,17 +46,44 @@ nodes: children: - type: math text: E = mc^2 - block: true - type: paragraph - text: An inline aside follows. - - type: math - text: a^2 + b^2 - block: false + text: + value: "An inline aside follows: a^2 + b^2." + marks: + - start: 25 + end: 34 + kind: math + raw: { format: typst, value: "a^2 + b^2" } + footnotes: + - label: fn-eq + - type: figure + label: lst-open + kind: code + # The displayed counter word. Authored, not derived: the + # builder has no localized name for a "code" figure, so if the + # author wants "Listing 1" rather than nothing, the word lives + # here. + numbering: { name: Listing } + caption: Opening frame + children: + - type: code + text: "OPEN v1\n" + lang: text - type: figure kind: image caption: The second image children: - - type: image - path: detail.png - alt: Detail view + - type: layout + cells: + - { row: 0, col: 0, children: [{ type: image, path: detail.png, alt: Detail view }] } + - { row: 0, col: 1, children: [{ type: image, path: overview.png, alt: Overview }] } + +footnotes: + - label: fn-eq + children: + - type: paragraph + text: The aside rests on + - type: math + label: eq-note + text: c^2 = a^2 + b^2 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/elements_explicit.cnd b/fixtures/elements_explicit.cnd new file mode 100644 index 0000000..dafc31b --- /dev/null +++ b/fixtures/elements_explicit.cnd @@ -0,0 +1,660 @@ +{ + "id": "00000000-0000-4000-d000-000000000000", + "cnd_version": "0.5.0", + "built_at": "2026-10-13T00:00:00Z", + "source": null, + "doc": { + "title": "Elements of format 0.5", + "authors": [], + "date": null, + "keywords": [], + "description": null, + "lang": "en", + "status": "Approved", + "version": "1.2", + "extra": { + "client": "ACME", + "reference": "PRJ-042" + } + }, + "nodes": [ + { + "id": "00000000-0000-4000-d000-000000000002", + "label": null, + "state_metadata": {}, + "location": null, + "type": "heading", + "level": 1, + "numbering": { + "value": "1", + "name": "Section" + }, + "text": { + "value": "Elements of format 0.5", + "lang": null, + "marks": [], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + }, + "heading_path": [ + "Elements of format 0.5" + ], + "children": [ + { + "id": "00000000-0000-4000-d000-000000000001", + "label": null, + "state_metadata": {}, + "location": null, + "type": "paragraph", + "text": { + "value": "Pressure rose 103 times, see Theorem 1 and the note, after Knuth: E = mc^2 holds.", + "lang": null, + "marks": [ + { + "start": 9, + "end": 13, + "kind": "strong", + "raw": null + }, + { + "start": 16, + "end": 17, + "kind": "sup", + "raw": null + }, + { + "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", + "supplement": null + } + ], + "footnotes": [ + { + "label": "fn-detail", + "text_span": [ + 43, + 51 + ] + } + ], + "links": [] + } + }, + { + "id": "00000000-0000-4000-d000-000000000003", + "label": null, + "state_metadata": {}, + "location": null, + "type": "admonition", + "kind": "warning", + "title": { + "value": "Careful", + "lang": null, + "marks": [], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + }, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000004", + "label": null, + "state_metadata": {}, + "location": null, + "type": "paragraph", + "text": { + "value": "Never edit a CND in place.", + "lang": null, + "marks": [], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + } + } + ] + }, + { + "id": "00000000-0000-4000-d000-000000000005", + "label": null, + "state_metadata": {}, + "location": null, + "type": "list", + "ordered": true, + "items": [ + { + "number": 1, + "checked": true, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000006", + "label": null, + "state_metadata": {}, + "location": null, + "type": "paragraph", + "text": { + "value": "Compile the source", + "lang": null, + "marks": [], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + } + }, + { + "id": "00000000-0000-4000-d000-000000000007", + "label": null, + "state_metadata": {}, + "location": null, + "type": "list", + "ordered": false, + "items": [ + { + "number": null, + "checked": null, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000008", + "label": null, + "state_metadata": {}, + "location": null, + "type": "paragraph", + "text": { + "value": "with fonts", + "lang": null, + "marks": [], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + } + } + ] + } + ] + } + ] + }, + { + "number": 2, + "checked": false, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000009", + "label": null, + "state_metadata": {}, + "location": null, + "type": "paragraph", + "text": { + "value": "Validate the output", + "lang": null, + "marks": [], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + } + } + ] + } + ] + }, + { + "id": "00000000-0000-4000-d000-000000000010", + "label": null, + "state_metadata": {}, + "location": null, + "type": "terms", + "items": [ + { + "term": { + "value": "Mark", + "lang": null, + "marks": [ + { + "start": 0, + "end": 4, + "kind": "emph", + "raw": null + } + ], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + }, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000011", + "label": null, + "state_metadata": {}, + "location": null, + "type": "paragraph", + "text": { + "value": "A standoff span with a kind.", + "lang": null, + "marks": [], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + } + }, + { + "id": "00000000-0000-4000-d000-000000000012", + "label": null, + "state_metadata": {}, + "location": null, + "type": "paragraph", + "text": { + "value": "Marks of different kinds may overlap.", + "lang": null, + "marks": [], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + } + } + ] + } + ] + }, + { + "id": "00000000-0000-4000-d000-000000000013", + "label": null, + "state_metadata": {}, + "location": null, + "type": "table", + "kind": "data", + "cells": [ + { + "row": 0, + "col": 0, + "rowspan": 1, + "colspan": 1, + "is_header": true, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000014", + "label": null, + "state_metadata": {}, + "location": null, + "type": "paragraph", + "text": { + "value": "Station", + "lang": null, + "marks": [], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + } + } + ] + }, + { + "row": 0, + "col": 1, + "rowspan": 1, + "colspan": 1, + "is_header": true, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000015", + "label": null, + "state_metadata": {}, + "location": null, + "type": "paragraph", + "text": { + "value": "hPa", + "lang": null, + "marks": [], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + } + } + ] + }, + { + "row": 1, + "col": 0, + "rowspan": 1, + "colspan": 1, + "is_header": false, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000016", + "label": null, + "state_metadata": {}, + "location": null, + "type": "paragraph", + "text": { + "value": "Brest", + "lang": null, + "marks": [], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + } + } + ] + }, + { + "row": 1, + "col": 1, + "rowspan": 1, + "colspan": 1, + "is_header": false, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000017", + "label": null, + "state_metadata": {}, + "location": null, + "type": "paragraph", + "text": { + "value": "1013", + "lang": null, + "marks": [], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + } + } + ] + } + ] + }, + { + "id": "00000000-0000-4000-d000-000000000018", + "label": null, + "state_metadata": {}, + "location": null, + "type": "layout", + "cells": [ + { + "row": 0, + "col": 0, + "rowspan": 1, + "colspan": 1, + "is_header": false, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000019", + "label": null, + "state_metadata": {}, + "location": null, + "type": "image", + "path": "a.png", + "alt": "Before" + } + ] + }, + { + "row": 0, + "col": 1, + "rowspan": 1, + "colspan": 1, + "is_header": false, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000020", + "label": null, + "state_metadata": {}, + "location": null, + "type": "image", + "path": "b.png", + "alt": "After" + } + ] + } + ] + }, + { + "id": "00000000-0000-4000-d000-000000000021", + "label": "thm-main", + "state_metadata": {}, + "location": null, + "type": "figure", + "kind": "theorem", + "caption": { + "value": "Mass–energy equivalence", + "lang": null, + "marks": [], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + }, + "description": { + "value": "States that energy equals mass times the speed of light squared.", + "lang": null, + "marks": [], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + }, + "numbering": { + "value": "1", + "name": "Theorem" + }, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000022", + "label": null, + "state_metadata": {}, + "location": null, + "type": "paragraph", + "text": { + "value": "Energy and mass are equivalent.", + "lang": null, + "marks": [], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + } + } + ], + "raw": null + }, + { + "id": "00000000-0000-4000-d000-000000000023", + "label": null, + "state_metadata": {}, + "location": null, + "type": "quote", + "attribution": { + "value": "A. Einstein", + "lang": null, + "marks": [ + { + "start": 0, + "end": 11, + "kind": "emph", + "raw": null + } + ], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + }, + "children": [ + { + "id": "00000000-0000-4000-d000-000000000024", + "label": null, + "state_metadata": {}, + "location": null, + "type": "paragraph", + "text": { + "value": "First paragraph of the quote.", + "lang": null, + "marks": [], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + } + }, + { + "id": "00000000-0000-4000-d000-000000000025", + "label": null, + "state_metadata": {}, + "location": null, + "type": "paragraph", + "text": { + "value": "Second paragraph.", + "lang": null, + "marks": [], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + } + } + ] + }, + { + "id": "00000000-0000-4000-d000-000000000026", + "label": null, + "state_metadata": {}, + "location": null, + "type": "code", + "text": "print('hi')\n", + "lang": "python", + "title": "main.py" + }, + { + "id": "00000000-0000-4000-d000-000000000027", + "label": null, + "state_metadata": {}, + "location": null, + "type": "math", + "text": "a^2 + b^2 = c^2", + "raw": { + "format": "typst", + "value": "a^2 + b^2 = c^2" + }, + "numbering": { + "value": "(1)", + "name": null + } + } + ] + } + ], + "bibliography": [ + { + "id": "00000000-0000-4000-d000-000000000031", + "label": "knuth1984", + "formatted": { + "value": "Knuth, D. The TeXbook. Addison-Wesley, 1984.", + "lang": null, + "marks": [ + { + "start": 10, + "end": 21, + "kind": "emph", + "raw": null + } + ], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + }, + "type": "book", + "authors": [ + "Donald Knuth" + ], + "title": "The TeXbook", + "year": 1984, + "container": null, + "doi": null, + "url": null, + "fields": {} + } + ], + "footnotes": [ + { + "id": "00000000-0000-4000-d000-000000000028", + "label": "fn-detail", + "children": [ + { + "id": "00000000-0000-4000-d000-000000000029", + "label": null, + "state_metadata": {}, + "location": null, + "type": "paragraph", + "text": { + "value": "Details live here.", + "lang": null, + "marks": [], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + } + }, + { + "id": "00000000-0000-4000-d000-000000000030", + "label": "fn-second", + "state_metadata": {}, + "location": null, + "type": "paragraph", + "text": { + "value": "A second paragraph, with code.", + "lang": null, + "marks": [ + { + "start": 25, + "end": 29, + "kind": "code", + "raw": null + } + ], + "refs": [], + "cites": [], + "footnotes": [], + "links": [] + } + } + ] + } + ] +} 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/hashes.json b/fixtures/hashes.json index b477486..2c43677 100644 --- a/fixtures/hashes.json +++ b/fixtures/hashes.json @@ -1,103 +1,238 @@ { "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" + ] + }, + "elements_explicit.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/fixtures/matching.json b/fixtures/matching.json index 976b42e..ec0f542 100644 --- a/fixtures/matching.json +++ b/fixtures/matching.json @@ -1,5 +1,5 @@ { - "matcher_version": "v1", + "matcher_version": "v2", "cases": { "01-label-survives-edit-and-move": { "why": "A labelled node is paired even when both its content and its slot changed.", @@ -128,18 +128,36 @@ "footnotes": [] }, "07-pools-key-on-label": { - "why": "Pool entries carry a required label, so the pool diff is exact and order-independent.", + "why": "Pool entries carry a required label, so the pool diff is exact and order-independent. A footnote's content is nodes: the rewritten note is reported as a changed paragraph, its entry stays unchanged.", "nodes": [ { "status": "unchanged", "matched_by": "label", "old_id": "00000000-0000-4000-c007-000000000001", "new_id": "00000000-0000-4000-c007-000000000002" + }, + { + "status": "changed", + "matched_by": "path+type", + "old_id": "00000000-0000-4000-c007-000000000201", + "new_id": "00000000-0000-4000-c007-000000000202" + }, + { + "status": "added", + "matched_by": null, + "old_id": null, + "new_id": "00000000-0000-4000-c007-000000000203" + }, + { + "status": "removed", + "matched_by": null, + "old_id": "00000000-0000-4000-c007-000000000200", + "new_id": null } ], "footnotes": [ { - "status": "changed", + "status": "unchanged", "label": "fn-b" }, { @@ -151,6 +169,135 @@ "label": "fn-a" } ] + }, + "08-list-item-edited": { + "why": "Format 0.5 is finer: the list itself is unchanged and only the item's paragraph is changed, where 0.4 marked the whole list changed.", + "nodes": [ + { + "status": "unchanged", + "matched_by": "path+type", + "old_id": "00000000-0000-4000-c008-000000000001", + "new_id": "00000000-0000-4000-c008-000000000005" + }, + { + "status": "unchanged", + "matched_by": "hash+path", + "old_id": "00000000-0000-4000-c008-000000000002", + "new_id": "00000000-0000-4000-c008-000000000006" + }, + { + "status": "changed", + "matched_by": "path+type", + "old_id": "00000000-0000-4000-c008-000000000003", + "new_id": "00000000-0000-4000-c008-000000000007" + }, + { + "status": "unchanged", + "matched_by": "hash+path", + "old_id": "00000000-0000-4000-c008-000000000004", + "new_id": "00000000-0000-4000-c008-000000000008" + } + ], + "footnotes": [] + }, + "09-cells-are-distinct-slots": { + "why": "The edited paragraph pairs with the paragraph of its own cell; the emptied cell's paragraph is removed.", + "nodes": [ + { + "status": "unchanged", + "matched_by": "path+type", + "old_id": "00000000-0000-4000-c009-000000000001", + "new_id": "00000000-0000-4000-c009-000000000004" + }, + { + "status": "changed", + "matched_by": "path+type", + "old_id": "00000000-0000-4000-c009-000000000003", + "new_id": "00000000-0000-4000-c009-000000000005" + }, + { + "status": "removed", + "matched_by": null, + "old_id": "00000000-0000-4000-c009-000000000002", + "new_id": null + } + ], + "footnotes": [] + }, + "10-footnote-content": { + "why": "Footnote content is matched like body content. The pool was reordered, yet the untouched note is unchanged in place and the edited one pairs with its own note's paragraph.", + "nodes": [ + { + "status": "unchanged", + "matched_by": "hash+path", + "old_id": "00000000-0000-4000-c010-000000000001", + "new_id": "00000000-0000-4000-c010-000000000002" + }, + { + "status": "unchanged", + "matched_by": "hash+path", + "old_id": "00000000-0000-4000-c010-000000000201", + "new_id": "00000000-0000-4000-c010-000000000202" + }, + { + "status": "changed", + "matched_by": "path+type", + "old_id": "00000000-0000-4000-c010-000000000200", + "new_id": "00000000-0000-4000-c010-000000000203" + } + ], + "footnotes": [ + { + "status": "unchanged", + "label": "fn-b" + }, + { + "status": "unchanged", + "label": "fn-a" + } + ] + }, + "11-container-edited-and-shifted": { + "why": "The table kept its own hash but its cell was edited and a paragraph was inserted above it: it is paired on its node hash alone (moved), the unedited cell moves with it, and the edited cell's paragraph is added while the old one is removed.", + "nodes": [ + { + "status": "added", + "matched_by": null, + "old_id": null, + "new_id": "00000000-0000-4000-c011-000000000005" + }, + { + "status": "moved", + "matched_by": "hash", + "old_id": "00000000-0000-4000-c011-000000000001", + "new_id": "00000000-0000-4000-c011-000000000006" + }, + { + "status": "moved", + "matched_by": "container-hash", + "old_id": "00000000-0000-4000-c011-000000000002", + "new_id": "00000000-0000-4000-c011-000000000007" + }, + { + "status": "moved", + "matched_by": "hash", + "old_id": "00000000-0000-4000-c011-000000000003", + "new_id": "00000000-0000-4000-c011-000000000008" + }, + { + "status": "added", + "matched_by": null, + "old_id": null, + "new_id": "00000000-0000-4000-c011-000000000009" + }, + { + "status": "removed", + "matched_by": null, + "old_id": "00000000-0000-4000-c011-000000000004", + "new_id": null + } + ], + "footnotes": [] } } } 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/reconcile/01-label-survives-edit-and-move/new.cnd b/fixtures/reconcile/01-label-survives-edit-and-move/new.cnd index 5d7860a..57927a2 100644 --- a/fixtures/reconcile/01-label-survives-edit-and-move/new.cnd +++ b/fixtures/reconcile/01-label-survives-edit-and-move/new.cnd @@ -1,5 +1,6 @@ { - "cnd_version": "0.3.0", + "id": "00000000-0000-4000-c001-000000000901", + "cnd_version": "0.5.0", "built_at": "2026-07-23T00:00:00Z", "doc": { "title": "Reconcile fixture" @@ -7,16 +8,18 @@ "nodes": [ { "id": "00000000-0000-4000-c001-000000000003", + "label": "sec-b", "type": "paragraph", - "text": "beta rewritten", - "label": "sec-b" + "text": { + "value": "beta rewritten" + } }, { "id": "00000000-0000-4000-c001-000000000004", "type": "paragraph", - "text": "alpha" + "text": { + "value": "alpha" + } } - ], - "bibliography": [], - "footnotes": [] + ] } diff --git a/fixtures/reconcile/01-label-survives-edit-and-move/old.cnd b/fixtures/reconcile/01-label-survives-edit-and-move/old.cnd index 1bc4958..44c1943 100644 --- a/fixtures/reconcile/01-label-survives-edit-and-move/old.cnd +++ b/fixtures/reconcile/01-label-survives-edit-and-move/old.cnd @@ -1,5 +1,6 @@ { - "cnd_version": "0.3.0", + "id": "00000000-0000-4000-c001-000000000900", + "cnd_version": "0.5.0", "built_at": "2026-07-23T00:00:00Z", "doc": { "title": "Reconcile fixture" @@ -8,15 +9,17 @@ { "id": "00000000-0000-4000-c001-000000000001", "type": "paragraph", - "text": "alpha" + "text": { + "value": "alpha" + } }, { "id": "00000000-0000-4000-c001-000000000002", + "label": "sec-b", "type": "paragraph", - "text": "beta", - "label": "sec-b" + "text": { + "value": "beta" + } } - ], - "bibliography": [], - "footnotes": [] + ] } diff --git a/fixtures/reconcile/02-unchanged-in-place/new.cnd b/fixtures/reconcile/02-unchanged-in-place/new.cnd index f340925..6defd0d 100644 --- a/fixtures/reconcile/02-unchanged-in-place/new.cnd +++ b/fixtures/reconcile/02-unchanged-in-place/new.cnd @@ -1,5 +1,6 @@ { - "cnd_version": "0.3.0", + "id": "00000000-0000-4000-c002-000000000901", + "cnd_version": "0.5.0", "built_at": "2026-07-23T00:00:00Z", "doc": { "title": "Reconcile fixture" @@ -8,14 +9,16 @@ { "id": "00000000-0000-4000-c002-000000000003", "type": "paragraph", - "text": "alpha" + "text": { + "value": "alpha" + } }, { "id": "00000000-0000-4000-c002-000000000004", "type": "paragraph", - "text": "beta" + "text": { + "value": "beta" + } } - ], - "bibliography": [], - "footnotes": [] + ] } diff --git a/fixtures/reconcile/02-unchanged-in-place/old.cnd b/fixtures/reconcile/02-unchanged-in-place/old.cnd index df7514e..2a6dccc 100644 --- a/fixtures/reconcile/02-unchanged-in-place/old.cnd +++ b/fixtures/reconcile/02-unchanged-in-place/old.cnd @@ -1,5 +1,6 @@ { - "cnd_version": "0.3.0", + "id": "00000000-0000-4000-c002-000000000900", + "cnd_version": "0.5.0", "built_at": "2026-07-23T00:00:00Z", "doc": { "title": "Reconcile fixture" @@ -8,14 +9,16 @@ { "id": "00000000-0000-4000-c002-000000000001", "type": "paragraph", - "text": "alpha" + "text": { + "value": "alpha" + } }, { "id": "00000000-0000-4000-c002-000000000002", "type": "paragraph", - "text": "beta" + "text": { + "value": "beta" + } } - ], - "bibliography": [], - "footnotes": [] + ] } diff --git a/fixtures/reconcile/03-moved-but-unchanged/new.cnd b/fixtures/reconcile/03-moved-but-unchanged/new.cnd index 67e9241..ddc6873 100644 --- a/fixtures/reconcile/03-moved-but-unchanged/new.cnd +++ b/fixtures/reconcile/03-moved-but-unchanged/new.cnd @@ -1,5 +1,6 @@ { - "cnd_version": "0.3.0", + "id": "00000000-0000-4000-c003-000000000901", + "cnd_version": "0.5.0", "built_at": "2026-07-23T00:00:00Z", "doc": { "title": "Reconcile fixture" @@ -8,19 +9,23 @@ { "id": "00000000-0000-4000-c003-000000000003", "type": "paragraph", - "text": "inserted" + "text": { + "value": "inserted" + } }, { "id": "00000000-0000-4000-c003-000000000004", "type": "paragraph", - "text": "alpha" + "text": { + "value": "alpha" + } }, { "id": "00000000-0000-4000-c003-000000000005", "type": "paragraph", - "text": "beta" + "text": { + "value": "beta" + } } - ], - "bibliography": [], - "footnotes": [] + ] } diff --git a/fixtures/reconcile/03-moved-but-unchanged/old.cnd b/fixtures/reconcile/03-moved-but-unchanged/old.cnd index 533568e..dbb377f 100644 --- a/fixtures/reconcile/03-moved-but-unchanged/old.cnd +++ b/fixtures/reconcile/03-moved-but-unchanged/old.cnd @@ -1,5 +1,6 @@ { - "cnd_version": "0.3.0", + "id": "00000000-0000-4000-c003-000000000900", + "cnd_version": "0.5.0", "built_at": "2026-07-23T00:00:00Z", "doc": { "title": "Reconcile fixture" @@ -8,14 +9,16 @@ { "id": "00000000-0000-4000-c003-000000000001", "type": "paragraph", - "text": "alpha" + "text": { + "value": "alpha" + } }, { "id": "00000000-0000-4000-c003-000000000002", "type": "paragraph", - "text": "beta" + "text": { + "value": "beta" + } } - ], - "bibliography": [], - "footnotes": [] + ] } diff --git a/fixtures/reconcile/04-same-slot-edited/new.cnd b/fixtures/reconcile/04-same-slot-edited/new.cnd index cb3e12c..3569c49 100644 --- a/fixtures/reconcile/04-same-slot-edited/new.cnd +++ b/fixtures/reconcile/04-same-slot-edited/new.cnd @@ -1,5 +1,6 @@ { - "cnd_version": "0.3.0", + "id": "00000000-0000-4000-c004-000000000901", + "cnd_version": "0.5.0", "built_at": "2026-07-23T00:00:00Z", "doc": { "title": "Reconcile fixture" @@ -8,14 +9,16 @@ { "id": "00000000-0000-4000-c004-000000000003", "type": "paragraph", - "text": "alpha" + "text": { + "value": "alpha" + } }, { "id": "00000000-0000-4000-c004-000000000004", "type": "paragraph", - "text": "beta edited" + "text": { + "value": "beta edited" + } } - ], - "bibliography": [], - "footnotes": [] + ] } diff --git a/fixtures/reconcile/04-same-slot-edited/old.cnd b/fixtures/reconcile/04-same-slot-edited/old.cnd index ca8a551..ff70ff2 100644 --- a/fixtures/reconcile/04-same-slot-edited/old.cnd +++ b/fixtures/reconcile/04-same-slot-edited/old.cnd @@ -1,5 +1,6 @@ { - "cnd_version": "0.3.0", + "id": "00000000-0000-4000-c004-000000000900", + "cnd_version": "0.5.0", "built_at": "2026-07-23T00:00:00Z", "doc": { "title": "Reconcile fixture" @@ -8,14 +9,16 @@ { "id": "00000000-0000-4000-c004-000000000001", "type": "paragraph", - "text": "alpha" + "text": { + "value": "alpha" + } }, { "id": "00000000-0000-4000-c004-000000000002", "type": "paragraph", - "text": "beta" + "text": { + "value": "beta" + } } - ], - "bibliography": [], - "footnotes": [] + ] } diff --git a/fixtures/reconcile/05-added-and-removed/new.cnd b/fixtures/reconcile/05-added-and-removed/new.cnd index 91fec50..a2b5d21 100644 --- a/fixtures/reconcile/05-added-and-removed/new.cnd +++ b/fixtures/reconcile/05-added-and-removed/new.cnd @@ -1,5 +1,6 @@ { - "cnd_version": "0.3.0", + "id": "00000000-0000-4000-c005-000000000901", + "cnd_version": "0.5.0", "built_at": "2026-07-23T00:00:00Z", "doc": { "title": "Reconcile fixture" @@ -9,12 +10,12 @@ "id": "00000000-0000-4000-c005-000000000002", "type": "heading", "level": 1, - "text": "gamma", + "text": { + "value": "gamma" + }, "heading_path": [ "gamma" ] } - ], - "bibliography": [], - "footnotes": [] + ] } diff --git a/fixtures/reconcile/05-added-and-removed/old.cnd b/fixtures/reconcile/05-added-and-removed/old.cnd index 3fe76b2..909cc2d 100644 --- a/fixtures/reconcile/05-added-and-removed/old.cnd +++ b/fixtures/reconcile/05-added-and-removed/old.cnd @@ -1,5 +1,6 @@ { - "cnd_version": "0.3.0", + "id": "00000000-0000-4000-c005-000000000900", + "cnd_version": "0.5.0", "built_at": "2026-07-23T00:00:00Z", "doc": { "title": "Reconcile fixture" @@ -8,9 +9,9 @@ { "id": "00000000-0000-4000-c005-000000000001", "type": "paragraph", - "text": "alpha" + "text": { + "value": "alpha" + } } - ], - "bibliography": [], - "footnotes": [] + ] } diff --git a/fixtures/reconcile/06-combined-case-fails/new.cnd b/fixtures/reconcile/06-combined-case-fails/new.cnd index 1513166..973f4bc 100644 --- a/fixtures/reconcile/06-combined-case-fails/new.cnd +++ b/fixtures/reconcile/06-combined-case-fails/new.cnd @@ -1,5 +1,6 @@ { - "cnd_version": "0.3.0", + "id": "00000000-0000-4000-c006-000000000901", + "cnd_version": "0.5.0", "built_at": "2026-07-23T00:00:00Z", "doc": { "title": "Reconcile fixture" @@ -8,19 +9,23 @@ { "id": "00000000-0000-4000-c006-000000000003", "type": "paragraph", - "text": "inserted" + "text": { + "value": "inserted" + } }, { "id": "00000000-0000-4000-c006-000000000004", "type": "paragraph", - "text": "alpha" + "text": { + "value": "alpha" + } }, { "id": "00000000-0000-4000-c006-000000000005", "type": "paragraph", - "text": "beta edited" + "text": { + "value": "beta edited" + } } - ], - "bibliography": [], - "footnotes": [] + ] } diff --git a/fixtures/reconcile/06-combined-case-fails/old.cnd b/fixtures/reconcile/06-combined-case-fails/old.cnd index 8e45fc9..3c67253 100644 --- a/fixtures/reconcile/06-combined-case-fails/old.cnd +++ b/fixtures/reconcile/06-combined-case-fails/old.cnd @@ -1,5 +1,6 @@ { - "cnd_version": "0.3.0", + "id": "00000000-0000-4000-c006-000000000900", + "cnd_version": "0.5.0", "built_at": "2026-07-23T00:00:00Z", "doc": { "title": "Reconcile fixture" @@ -8,14 +9,16 @@ { "id": "00000000-0000-4000-c006-000000000001", "type": "paragraph", - "text": "alpha" + "text": { + "value": "alpha" + } }, { "id": "00000000-0000-4000-c006-000000000002", "type": "paragraph", - "text": "beta" + "text": { + "value": "beta" + } } - ], - "bibliography": [], - "footnotes": [] + ] } diff --git a/fixtures/reconcile/07-pools-key-on-label/new.cnd b/fixtures/reconcile/07-pools-key-on-label/new.cnd index a8425f8..3400d97 100644 --- a/fixtures/reconcile/07-pools-key-on-label/new.cnd +++ b/fixtures/reconcile/07-pools-key-on-label/new.cnd @@ -1,5 +1,6 @@ { - "cnd_version": "0.3.0", + "id": "00000000-0000-4000-c007-000000000901", + "cnd_version": "0.5.0", "built_at": "2026-07-23T00:00:00Z", "doc": { "title": "Reconcile fixture" @@ -7,22 +8,39 @@ "nodes": [ { "id": "00000000-0000-4000-c007-000000000002", + "label": "p", "type": "paragraph", - "text": "alpha", - "label": "p" + "text": { + "value": "alpha" + } } ], - "bibliography": [], "footnotes": [ { "id": "00000000-0000-4000-c007-000000000102", "label": "fn-b", - "text": "second rewritten" + "children": [ + { + "id": "00000000-0000-4000-c007-000000000202", + "type": "paragraph", + "text": { + "value": "second rewritten" + } + } + ] }, { "id": "00000000-0000-4000-c007-000000000103", "label": "fn-c", - "text": "third" + "children": [ + { + "id": "00000000-0000-4000-c007-000000000203", + "type": "paragraph", + "text": { + "value": "third" + } + } + ] } ] } diff --git a/fixtures/reconcile/07-pools-key-on-label/old.cnd b/fixtures/reconcile/07-pools-key-on-label/old.cnd index c4cbaa3..2ec762b 100644 --- a/fixtures/reconcile/07-pools-key-on-label/old.cnd +++ b/fixtures/reconcile/07-pools-key-on-label/old.cnd @@ -1,5 +1,6 @@ { - "cnd_version": "0.3.0", + "id": "00000000-0000-4000-c007-000000000900", + "cnd_version": "0.5.0", "built_at": "2026-07-23T00:00:00Z", "doc": { "title": "Reconcile fixture" @@ -7,22 +8,39 @@ "nodes": [ { "id": "00000000-0000-4000-c007-000000000001", + "label": "p", "type": "paragraph", - "text": "alpha", - "label": "p" + "text": { + "value": "alpha" + } } ], - "bibliography": [], "footnotes": [ { "id": "00000000-0000-4000-c007-000000000100", "label": "fn-a", - "text": "first" + "children": [ + { + "id": "00000000-0000-4000-c007-000000000200", + "type": "paragraph", + "text": { + "value": "first" + } + } + ] }, { "id": "00000000-0000-4000-c007-000000000101", "label": "fn-b", - "text": "second" + "children": [ + { + "id": "00000000-0000-4000-c007-000000000201", + "type": "paragraph", + "text": { + "value": "second" + } + } + ] } ] } diff --git a/fixtures/reconcile/08-list-item-edited/new.cnd b/fixtures/reconcile/08-list-item-edited/new.cnd new file mode 100644 index 0000000..c69fd93 --- /dev/null +++ b/fixtures/reconcile/08-list-item-edited/new.cnd @@ -0,0 +1,49 @@ +{ + "id": "00000000-0000-4000-c008-000000000901", + "cnd_version": "0.5.0", + "built_at": "2026-07-23T00:00:00Z", + "doc": { + "title": "Reconcile fixture" + }, + "nodes": [ + { + "id": "00000000-0000-4000-c008-000000000005", + "type": "list", + "items": [ + { + "children": [ + { + "id": "00000000-0000-4000-c008-000000000006", + "type": "paragraph", + "text": { + "value": "alpha" + } + } + ] + }, + { + "children": [ + { + "id": "00000000-0000-4000-c008-000000000007", + "type": "paragraph", + "text": { + "value": "beta edited" + } + } + ] + }, + { + "children": [ + { + "id": "00000000-0000-4000-c008-000000000008", + "type": "paragraph", + "text": { + "value": "gamma" + } + } + ] + } + ] + } + ] +} diff --git a/fixtures/reconcile/08-list-item-edited/old.cnd b/fixtures/reconcile/08-list-item-edited/old.cnd new file mode 100644 index 0000000..fb49bd1 --- /dev/null +++ b/fixtures/reconcile/08-list-item-edited/old.cnd @@ -0,0 +1,49 @@ +{ + "id": "00000000-0000-4000-c008-000000000900", + "cnd_version": "0.5.0", + "built_at": "2026-07-23T00:00:00Z", + "doc": { + "title": "Reconcile fixture" + }, + "nodes": [ + { + "id": "00000000-0000-4000-c008-000000000001", + "type": "list", + "items": [ + { + "children": [ + { + "id": "00000000-0000-4000-c008-000000000002", + "type": "paragraph", + "text": { + "value": "alpha" + } + } + ] + }, + { + "children": [ + { + "id": "00000000-0000-4000-c008-000000000003", + "type": "paragraph", + "text": { + "value": "beta" + } + } + ] + }, + { + "children": [ + { + "id": "00000000-0000-4000-c008-000000000004", + "type": "paragraph", + "text": { + "value": "gamma" + } + } + ] + } + ] + } + ] +} diff --git a/fixtures/reconcile/09-cells-are-distinct-slots/new.cnd b/fixtures/reconcile/09-cells-are-distinct-slots/new.cnd new file mode 100644 index 0000000..7acf53d --- /dev/null +++ b/fixtures/reconcile/09-cells-are-distinct-slots/new.cnd @@ -0,0 +1,33 @@ +{ + "id": "00000000-0000-4000-c009-000000000901", + "cnd_version": "0.5.0", + "built_at": "2026-07-23T00:00:00Z", + "doc": { + "title": "Reconcile fixture" + }, + "nodes": [ + { + "id": "00000000-0000-4000-c009-000000000004", + "type": "table", + "cells": [ + { + "row": 0, + "col": 0 + }, + { + "row": 0, + "col": 1, + "children": [ + { + "id": "00000000-0000-4000-c009-000000000005", + "type": "paragraph", + "text": { + "value": "second cell, edited" + } + } + ] + } + ] + } + ] +} diff --git a/fixtures/reconcile/09-cells-are-distinct-slots/old.cnd b/fixtures/reconcile/09-cells-are-distinct-slots/old.cnd new file mode 100644 index 0000000..4d95d65 --- /dev/null +++ b/fixtures/reconcile/09-cells-are-distinct-slots/old.cnd @@ -0,0 +1,42 @@ +{ + "id": "00000000-0000-4000-c009-000000000900", + "cnd_version": "0.5.0", + "built_at": "2026-07-23T00:00:00Z", + "doc": { + "title": "Reconcile fixture" + }, + "nodes": [ + { + "id": "00000000-0000-4000-c009-000000000001", + "type": "table", + "cells": [ + { + "row": 0, + "col": 0, + "children": [ + { + "id": "00000000-0000-4000-c009-000000000002", + "type": "paragraph", + "text": { + "value": "first cell" + } + } + ] + }, + { + "row": 0, + "col": 1, + "children": [ + { + "id": "00000000-0000-4000-c009-000000000003", + "type": "paragraph", + "text": { + "value": "second cell" + } + } + ] + } + ] + } + ] +} diff --git a/fixtures/reconcile/10-footnote-content/new.cnd b/fixtures/reconcile/10-footnote-content/new.cnd new file mode 100644 index 0000000..e2ae567 --- /dev/null +++ b/fixtures/reconcile/10-footnote-content/new.cnd @@ -0,0 +1,53 @@ +{ + "id": "00000000-0000-4000-c010-000000000901", + "cnd_version": "0.5.0", + "built_at": "2026-07-23T00:00:00Z", + "doc": { + "title": "Reconcile fixture" + }, + "nodes": [ + { + "id": "00000000-0000-4000-c010-000000000002", + "type": "paragraph", + "text": { + "value": "See the notes.", + "footnotes": [ + { + "label": "fn-a" + }, + { + "label": "fn-b" + } + ] + } + } + ], + "footnotes": [ + { + "id": "00000000-0000-4000-c010-000000000102", + "label": "fn-b", + "children": [ + { + "id": "00000000-0000-4000-c010-000000000202", + "type": "paragraph", + "text": { + "value": "second" + } + } + ] + }, + { + "id": "00000000-0000-4000-c010-000000000103", + "label": "fn-a", + "children": [ + { + "id": "00000000-0000-4000-c010-000000000203", + "type": "paragraph", + "text": { + "value": "first, edited" + } + } + ] + } + ] +} diff --git a/fixtures/reconcile/10-footnote-content/old.cnd b/fixtures/reconcile/10-footnote-content/old.cnd new file mode 100644 index 0000000..0562b21 --- /dev/null +++ b/fixtures/reconcile/10-footnote-content/old.cnd @@ -0,0 +1,53 @@ +{ + "id": "00000000-0000-4000-c010-000000000900", + "cnd_version": "0.5.0", + "built_at": "2026-07-23T00:00:00Z", + "doc": { + "title": "Reconcile fixture" + }, + "nodes": [ + { + "id": "00000000-0000-4000-c010-000000000001", + "type": "paragraph", + "text": { + "value": "See the notes.", + "footnotes": [ + { + "label": "fn-a" + }, + { + "label": "fn-b" + } + ] + } + } + ], + "footnotes": [ + { + "id": "00000000-0000-4000-c010-000000000100", + "label": "fn-a", + "children": [ + { + "id": "00000000-0000-4000-c010-000000000200", + "type": "paragraph", + "text": { + "value": "first" + } + } + ] + }, + { + "id": "00000000-0000-4000-c010-000000000101", + "label": "fn-b", + "children": [ + { + "id": "00000000-0000-4000-c010-000000000201", + "type": "paragraph", + "text": { + "value": "second" + } + } + ] + } + ] +} diff --git a/fixtures/reconcile/11-container-edited-and-shifted/new.cnd b/fixtures/reconcile/11-container-edited-and-shifted/new.cnd new file mode 100644 index 0000000..c389f77 --- /dev/null +++ b/fixtures/reconcile/11-container-edited-and-shifted/new.cnd @@ -0,0 +1,56 @@ +{ + "id": "00000000-0000-4000-c011-000000000901", + "cnd_version": "0.5.0", + "built_at": "2026-07-23T00:00:00Z", + "doc": { + "title": "Reconcile fixture" + }, + "nodes": [ + { + "id": "00000000-0000-4000-c011-000000000005", + "type": "paragraph", + "text": { + "value": "inserted" + } + }, + { + "id": "00000000-0000-4000-c011-000000000006", + "type": "paragraph", + "text": { + "value": "intro" + } + }, + { + "id": "00000000-0000-4000-c011-000000000007", + "type": "table", + "cells": [ + { + "row": 0, + "col": 0, + "children": [ + { + "id": "00000000-0000-4000-c011-000000000008", + "type": "paragraph", + "text": { + "value": "a" + } + } + ] + }, + { + "row": 0, + "col": 1, + "children": [ + { + "id": "00000000-0000-4000-c011-000000000009", + "type": "paragraph", + "text": { + "value": "b edited" + } + } + ] + } + ] + } + ] +} diff --git a/fixtures/reconcile/11-container-edited-and-shifted/old.cnd b/fixtures/reconcile/11-container-edited-and-shifted/old.cnd new file mode 100644 index 0000000..2fc6b61 --- /dev/null +++ b/fixtures/reconcile/11-container-edited-and-shifted/old.cnd @@ -0,0 +1,49 @@ +{ + "id": "00000000-0000-4000-c011-000000000900", + "cnd_version": "0.5.0", + "built_at": "2026-07-23T00:00:00Z", + "doc": { + "title": "Reconcile fixture" + }, + "nodes": [ + { + "id": "00000000-0000-4000-c011-000000000001", + "type": "paragraph", + "text": { + "value": "intro" + } + }, + { + "id": "00000000-0000-4000-c011-000000000002", + "type": "table", + "cells": [ + { + "row": 0, + "col": 0, + "children": [ + { + "id": "00000000-0000-4000-c011-000000000003", + "type": "paragraph", + "text": { + "value": "a" + } + } + ] + }, + { + "row": 0, + "col": 1, + "children": [ + { + "id": "00000000-0000-4000-c011-000000000004", + "type": "paragraph", + "text": { + "value": "b" + } + } + ] + } + ] + } + ] +} 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/traversal.json b/fixtures/traversal.json index 8d1f032..c335a49 100644 --- a/fixtures/traversal.json +++ b/fixtures/traversal.json @@ -3,367 +3,1192 @@ { "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" + } + ], + "elements_explicit.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/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/pyproject.toml b/pyproject.toml index 689d27d..36cbdac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,8 @@ dev = [ # Test-only: a real CommonMark parser to check the Markdown export keeps # the CND structure (tests/test_markdown_structure.py). "markdown-it-py>=3.0", + # Test-only: the footnote syntax, to check footnote markers resolve. + "mdit-py-plugins>=0.5", ] [tool.hatch.build.targets.wheel] diff --git a/schema/cnd-declaration.schema.json b/schema/cnd-declaration.schema.json index 76d2fb1..7a6fd3d 100644 --- a/schema/cnd-declaration.schema.json +++ b/schema/cnd-declaration.schema.json @@ -61,6 +61,119 @@ "title": "CiteRef", "type": "object" }, + "DeclAdmonitionNode": { + "additionalProperties": false, + "properties": { + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Label" + }, + "state_metadata": { + "additionalProperties": true, + "title": "State Metadata", + "type": "object" + }, + "type": { + "const": "admonition", + "title": "Type", + "type": "string" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/RichText" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "children": { + "items": { + "discriminator": { + "mapping": { + "admonition": "#/$defs/DeclAdmonitionNode", + "code": "#/$defs/DeclCodeNode", + "figure": "#/$defs/DeclFigureNode", + "heading": "#/$defs/DeclHeadingNode", + "image": "#/$defs/DeclImageNode", + "layout": "#/$defs/DeclLayoutNode", + "list": "#/$defs/DeclListNode", + "math": "#/$defs/DeclMathNode", + "paragraph": "#/$defs/DeclParagraphNode", + "quote": "#/$defs/DeclQuoteNode", + "table": "#/$defs/DeclTableNode", + "terms": "#/$defs/DeclTermsNode" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/DeclHeadingNode" + }, + { + "$ref": "#/$defs/DeclParagraphNode" + }, + { + "$ref": "#/$defs/DeclQuoteNode" + }, + { + "$ref": "#/$defs/DeclCodeNode" + }, + { + "$ref": "#/$defs/DeclMathNode" + }, + { + "$ref": "#/$defs/DeclImageNode" + }, + { + "$ref": "#/$defs/DeclFigureNode" + }, + { + "$ref": "#/$defs/DeclAdmonitionNode" + }, + { + "$ref": "#/$defs/DeclListNode" + }, + { + "$ref": "#/$defs/DeclTermsNode" + }, + { + "$ref": "#/$defs/DeclTableNode" + }, + { + "$ref": "#/$defs/DeclLayoutNode" + } + ] + }, + "title": "Children", + "type": "array" + } + }, + "required": [ + "type", + "kind" + ], + "title": "DeclAdmonitionNode", + "type": "object" + }, "DeclBibEntry": { "additionalProperties": false, "description": "A bibliography entry in a declaration \u2014 ``BibEntry`` minus ``id``.\n\nThe content floor still applies at build time: an entry must carry\n``formatted``, a structured field, or both. It is a builder check, not\na shape the schema expresses.", @@ -74,6 +187,9 @@ { "type": "string" }, + { + "$ref": "#/$defs/RichText" + }, { "type": "null" } @@ -172,6 +288,104 @@ "title": "DeclBibEntry", "type": "object" }, + "DeclCell": { + "additionalProperties": false, + "description": "One cell of a declared ``table`` or ``layout``; content is nodes.", + "properties": { + "row": { + "title": "Row", + "type": "integer" + }, + "col": { + "title": "Col", + "type": "integer" + }, + "rowspan": { + "default": 1, + "minimum": 1, + "title": "Rowspan", + "type": "integer" + }, + "colspan": { + "default": 1, + "minimum": 1, + "title": "Colspan", + "type": "integer" + }, + "is_header": { + "default": false, + "title": "Is Header", + "type": "boolean" + }, + "children": { + "items": { + "discriminator": { + "mapping": { + "admonition": "#/$defs/DeclAdmonitionNode", + "code": "#/$defs/DeclCodeNode", + "figure": "#/$defs/DeclFigureNode", + "heading": "#/$defs/DeclHeadingNode", + "image": "#/$defs/DeclImageNode", + "layout": "#/$defs/DeclLayoutNode", + "list": "#/$defs/DeclListNode", + "math": "#/$defs/DeclMathNode", + "paragraph": "#/$defs/DeclParagraphNode", + "quote": "#/$defs/DeclQuoteNode", + "table": "#/$defs/DeclTableNode", + "terms": "#/$defs/DeclTermsNode" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/DeclHeadingNode" + }, + { + "$ref": "#/$defs/DeclParagraphNode" + }, + { + "$ref": "#/$defs/DeclQuoteNode" + }, + { + "$ref": "#/$defs/DeclCodeNode" + }, + { + "$ref": "#/$defs/DeclMathNode" + }, + { + "$ref": "#/$defs/DeclImageNode" + }, + { + "$ref": "#/$defs/DeclFigureNode" + }, + { + "$ref": "#/$defs/DeclAdmonitionNode" + }, + { + "$ref": "#/$defs/DeclListNode" + }, + { + "$ref": "#/$defs/DeclTermsNode" + }, + { + "$ref": "#/$defs/DeclTableNode" + }, + { + "$ref": "#/$defs/DeclLayoutNode" + } + ] + }, + "title": "Children", + "type": "array" + } + }, + "required": [ + "row", + "col" + ], + "title": "DeclCell", + "type": "object" + }, "DeclCodeNode": { "additionalProperties": false, "properties": { @@ -187,27 +401,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" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -234,10 +427,17 @@ "default": null, "title": "Lang" }, - "block": { - "default": true, - "title": "Block", - "type": "boolean" + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" } }, "required": [ @@ -249,7 +449,7 @@ }, "DeclFigureNode": { "additionalProperties": false, - "description": "A captioned float wrapper. Keeps ``kind`` (the authored counter\nselector) and ``counter_label`` (authored for a custom ``kind`` the\nbuilder has no localized word for), but not the resolved ``number``.", + "description": "A captioned wrapper. Keeps ``kind`` (the category, and the counter\nthe engine draws from) and ``numbering.name`` (authored for a custom\n``kind`` the builder has no localized word for).", "properties": { "label": { "anyOf": [ @@ -263,27 +463,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" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -311,6 +490,9 @@ { "type": "string" }, + { + "$ref": "#/$defs/RichText" + }, { "type": "null" } @@ -318,26 +500,42 @@ "default": null, "title": "Caption" }, - "counter_label": { + "description": { "anyOf": [ { "type": "string" }, + { + "$ref": "#/$defs/RichText" + }, { "type": "null" } ], "default": null, - "title": "Counter Label" + "title": "Description" + }, + "numbering": { + "anyOf": [ + { + "$ref": "#/$defs/DeclNumbering" + }, + { + "type": "null" + } + ], + "default": null }, "children": { "items": { "discriminator": { "mapping": { + "admonition": "#/$defs/DeclAdmonitionNode", "code": "#/$defs/DeclCodeNode", "figure": "#/$defs/DeclFigureNode", "heading": "#/$defs/DeclHeadingNode", "image": "#/$defs/DeclImageNode", + "layout": "#/$defs/DeclLayoutNode", "list": "#/$defs/DeclListNode", "math": "#/$defs/DeclMathNode", "paragraph": "#/$defs/DeclParagraphNode", @@ -354,9 +552,6 @@ { "$ref": "#/$defs/DeclParagraphNode" }, - { - "$ref": "#/$defs/DeclTableNode" - }, { "$ref": "#/$defs/DeclQuoteNode" }, @@ -366,17 +561,26 @@ { "$ref": "#/$defs/DeclMathNode" }, + { + "$ref": "#/$defs/DeclImageNode" + }, { "$ref": "#/$defs/DeclFigureNode" }, { - "$ref": "#/$defs/DeclImageNode" + "$ref": "#/$defs/DeclAdmonitionNode" }, { "$ref": "#/$defs/DeclListNode" }, { "$ref": "#/$defs/DeclTermsNode" + }, + { + "$ref": "#/$defs/DeclTableNode" + }, + { + "$ref": "#/$defs/DeclLayoutNode" } ] }, @@ -403,27 +607,83 @@ }, "DeclFootnote": { "additionalProperties": false, - "description": "A footnote in a declaration \u2014 ``Footnote`` minus ``id``.", + "description": "A footnote in a declaration \u2014 ``Footnote`` minus ``id``. Its content\nis nodes, which the builder mints ids for like any other node.", "properties": { "label": { "title": "Label", "type": "string" }, - "text": { - "title": "Text", - "type": "string" + "children": { + "items": { + "discriminator": { + "mapping": { + "admonition": "#/$defs/DeclAdmonitionNode", + "code": "#/$defs/DeclCodeNode", + "figure": "#/$defs/DeclFigureNode", + "heading": "#/$defs/DeclHeadingNode", + "image": "#/$defs/DeclImageNode", + "layout": "#/$defs/DeclLayoutNode", + "list": "#/$defs/DeclListNode", + "math": "#/$defs/DeclMathNode", + "paragraph": "#/$defs/DeclParagraphNode", + "quote": "#/$defs/DeclQuoteNode", + "table": "#/$defs/DeclTableNode", + "terms": "#/$defs/DeclTermsNode" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/DeclHeadingNode" + }, + { + "$ref": "#/$defs/DeclParagraphNode" + }, + { + "$ref": "#/$defs/DeclQuoteNode" + }, + { + "$ref": "#/$defs/DeclCodeNode" + }, + { + "$ref": "#/$defs/DeclMathNode" + }, + { + "$ref": "#/$defs/DeclImageNode" + }, + { + "$ref": "#/$defs/DeclFigureNode" + }, + { + "$ref": "#/$defs/DeclAdmonitionNode" + }, + { + "$ref": "#/$defs/DeclListNode" + }, + { + "$ref": "#/$defs/DeclTermsNode" + }, + { + "$ref": "#/$defs/DeclTableNode" + }, + { + "$ref": "#/$defs/DeclLayoutNode" + } + ] + }, + "title": "Children", + "type": "array" } }, "required": [ - "label", - "text" + "label" ], "title": "DeclFootnote", "type": "object" }, "DeclHeadingNode": { "additionalProperties": false, - "description": "A section heading. No ``number`` (the builder's counter engine\nresolves it) and no ``heading_path`` (derivable from tree position).\n``counter_label`` is kept: a heading has no ``kind`` to derive a word\nfrom, so any word (\"Chapter\", \"Partie\") is authored.\n\n``level`` starts at 1 \u2014 there is no level 0. Constraining it here\nmakes the invalid input unrepresentable rather than a counter-engine\ncrash at build time (ADR 0019 \u00a74's own philosophy: reject at the\ndoor, not downstream).", + "description": "A section heading. No ``heading_path`` (derivable from tree\nposition) and no resolved number.\n\n``level`` starts at 1 \u2014 there is no level 0. Constraining it here\nmakes the invalid input unrepresentable rather than a counter-engine\ncrash at build time (ADR 0019 \u00a74's own philosophy: reject at the\ndoor, not downstream).", "properties": { "label": { "anyOf": [ @@ -437,27 +697,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" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -474,29 +713,37 @@ "type": "integer" }, "text": { - "title": "Text", - "type": "string" - }, - "counter_label": { "anyOf": [ { "type": "string" }, + { + "$ref": "#/$defs/RichText" + } + ], + "title": "Text" + }, + "numbering": { + "anyOf": [ + { + "$ref": "#/$defs/DeclNumbering" + }, { "type": "null" } ], - "default": null, - "title": "Counter Label" + "default": null }, "children": { "items": { "discriminator": { "mapping": { + "admonition": "#/$defs/DeclAdmonitionNode", "code": "#/$defs/DeclCodeNode", "figure": "#/$defs/DeclFigureNode", "heading": "#/$defs/DeclHeadingNode", "image": "#/$defs/DeclImageNode", + "layout": "#/$defs/DeclLayoutNode", "list": "#/$defs/DeclListNode", "math": "#/$defs/DeclMathNode", "paragraph": "#/$defs/DeclParagraphNode", @@ -513,9 +760,6 @@ { "$ref": "#/$defs/DeclParagraphNode" }, - { - "$ref": "#/$defs/DeclTableNode" - }, { "$ref": "#/$defs/DeclQuoteNode" }, @@ -525,17 +769,26 @@ { "$ref": "#/$defs/DeclMathNode" }, + { + "$ref": "#/$defs/DeclImageNode" + }, { "$ref": "#/$defs/DeclFigureNode" }, { - "$ref": "#/$defs/DeclImageNode" + "$ref": "#/$defs/DeclAdmonitionNode" }, { "$ref": "#/$defs/DeclListNode" }, { "$ref": "#/$defs/DeclTermsNode" + }, + { + "$ref": "#/$defs/DeclTableNode" + }, + { + "$ref": "#/$defs/DeclLayoutNode" } ] }, @@ -566,27 +819,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" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -628,14 +860,49 @@ "title": "DeclImageNode", "type": "object" }, - "DeclListItem": { + "DeclLayoutNode": { "additionalProperties": false, - "description": "One item of a declaration list.\n\n``number`` is an **override**, not a resolved ordinal: absent means\n\"number this item sequentially\", present means an explicit start or\nskip (markdown's ``3.`` on the first item of an ordered list). This is\nthe one place the declaration keeps a ``number`` the CND resolves \u2014 it\nkeeps the *authored* half (the override) and drops the *derived* half\n(the running ordinal), the same line drawn for figure numbers.\n\nResolution rule the builder follows (pinned so two builders cannot\ndiverge): an item with no ``number`` takes the previous sibling's\nresolved number plus one, starting from 1; an item *with* a ``number``\ntakes it verbatim, and following items count on from there. So a\n``number`` **rebases** the rest of the list rather than only labelling\nits own item. Ignored on an ``ordered: false`` list, which has no\nordinals.", "properties": { - "text": { - "title": "Text", + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Label" + }, + "state_metadata": { + "additionalProperties": true, + "title": "State Metadata", + "type": "object" + }, + "type": { + "const": "layout", + "title": "Type", "type": "string" }, + "cells": { + "items": { + "$ref": "#/$defs/DeclCell" + }, + "title": "Cells", + "type": "array" + } + }, + "required": [ + "type" + ], + "title": "DeclLayoutNode", + "type": "object" + }, + "DeclListItem": { + "additionalProperties": false, + "description": "One item of a declaration list; its content is nodes.\n\n``number`` is an **override**, not a resolved ordinal: absent means\n\"number this item sequentially\", present means an explicit start or\nskip (markdown's ``3.`` on the first item of an ordered list). It keeps\nthe *authored* half (the override) and drops the *derived* half (the\nrunning ordinal), the same line drawn for ``numbering``.\n\nResolution rule the builder follows (pinned so two builders cannot\ndiverge): an item with no ``number`` takes the previous sibling's\nresolved number plus one, starting from 1; an item *with* a ``number``\ntakes it verbatim, and following items count on from there. So a\n``number`` **rebases** the rest of the list rather than only labelling\nits own item. Ignored on an ``ordered: false`` list, which has no\nordinals. A nested list is a ``list`` node among the children and\ncounts on its own.", + "properties": { "number": { "anyOf": [ { @@ -648,17 +915,80 @@ "default": null, "title": "Number" }, + "checked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Checked" + }, "children": { "items": { - "$ref": "#/$defs/DeclListItem" + "discriminator": { + "mapping": { + "admonition": "#/$defs/DeclAdmonitionNode", + "code": "#/$defs/DeclCodeNode", + "figure": "#/$defs/DeclFigureNode", + "heading": "#/$defs/DeclHeadingNode", + "image": "#/$defs/DeclImageNode", + "layout": "#/$defs/DeclLayoutNode", + "list": "#/$defs/DeclListNode", + "math": "#/$defs/DeclMathNode", + "paragraph": "#/$defs/DeclParagraphNode", + "quote": "#/$defs/DeclQuoteNode", + "table": "#/$defs/DeclTableNode", + "terms": "#/$defs/DeclTermsNode" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/DeclHeadingNode" + }, + { + "$ref": "#/$defs/DeclParagraphNode" + }, + { + "$ref": "#/$defs/DeclQuoteNode" + }, + { + "$ref": "#/$defs/DeclCodeNode" + }, + { + "$ref": "#/$defs/DeclMathNode" + }, + { + "$ref": "#/$defs/DeclImageNode" + }, + { + "$ref": "#/$defs/DeclFigureNode" + }, + { + "$ref": "#/$defs/DeclAdmonitionNode" + }, + { + "$ref": "#/$defs/DeclListNode" + }, + { + "$ref": "#/$defs/DeclTermsNode" + }, + { + "$ref": "#/$defs/DeclTableNode" + }, + { + "$ref": "#/$defs/DeclLayoutNode" + } + ] }, "title": "Children", "type": "array" } }, - "required": [ - "text" - ], "title": "DeclListItem", "type": "object" }, @@ -677,27 +1007,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" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -713,11 +1022,6 @@ "title": "Ordered", "type": "boolean" }, - "tight": { - "default": true, - "title": "Tight", - "type": "boolean" - }, "items": { "items": { "$ref": "#/$defs/DeclListItem" @@ -747,27 +1051,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" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -793,22 +1076,16 @@ ], "default": null }, - "counter_label": { + "numbering": { "anyOf": [ { - "type": "string" + "$ref": "#/$defs/DeclNumbering" }, { "type": "null" } ], - "default": null, - "title": "Counter Label" - }, - "block": { - "default": true, - "title": "Block", - "type": "boolean" + "default": null } }, "required": [ @@ -818,6 +1095,26 @@ "title": "DeclMathNode", "type": "object" }, + "DeclNumbering": { + "additionalProperties": false, + "description": "The authored half of ``Numbering``: the counter word alone.\n\nPresent means \"this element carries this word\" (\"Listing\",\n\"Th\u00e9or\u00e8me\"); the builder's counter engine supplies the value. There is\nno ``value`` field, so a declaration cannot carry one.", + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + } + }, + "title": "DeclNumbering", + "type": "object" + }, "DeclParagraphNode": { "additionalProperties": false, "properties": { @@ -833,27 +1130,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" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -865,20 +1141,15 @@ "type": "string" }, "text": { - "title": "Text", - "type": "string" - }, - "lang": { "anyOf": [ { "type": "string" }, { - "type": "null" + "$ref": "#/$defs/RichText" } ], - "default": null, - "title": "Lang" + "title": "Text" } }, "required": [ @@ -903,27 +1174,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" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -934,15 +1184,14 @@ "title": "Type", "type": "string" }, - "text": { - "title": "Text", - "type": "string" - }, "attribution": { "anyOf": [ { "type": "string" }, + { + "$ref": "#/$defs/RichText" + }, { "type": "null" } @@ -950,33 +1199,77 @@ "default": null, "title": "Attribution" }, - "block": { - "default": true, - "title": "Block", - "type": "boolean" - }, - "lang": { - "anyOf": [ - { - "type": "string" + "children": { + "items": { + "discriminator": { + "mapping": { + "admonition": "#/$defs/DeclAdmonitionNode", + "code": "#/$defs/DeclCodeNode", + "figure": "#/$defs/DeclFigureNode", + "heading": "#/$defs/DeclHeadingNode", + "image": "#/$defs/DeclImageNode", + "layout": "#/$defs/DeclLayoutNode", + "list": "#/$defs/DeclListNode", + "math": "#/$defs/DeclMathNode", + "paragraph": "#/$defs/DeclParagraphNode", + "quote": "#/$defs/DeclQuoteNode", + "table": "#/$defs/DeclTableNode", + "terms": "#/$defs/DeclTermsNode" + }, + "propertyName": "type" }, - { - "type": "null" - } - ], - "default": null, - "title": "Lang" + "oneOf": [ + { + "$ref": "#/$defs/DeclHeadingNode" + }, + { + "$ref": "#/$defs/DeclParagraphNode" + }, + { + "$ref": "#/$defs/DeclQuoteNode" + }, + { + "$ref": "#/$defs/DeclCodeNode" + }, + { + "$ref": "#/$defs/DeclMathNode" + }, + { + "$ref": "#/$defs/DeclImageNode" + }, + { + "$ref": "#/$defs/DeclFigureNode" + }, + { + "$ref": "#/$defs/DeclAdmonitionNode" + }, + { + "$ref": "#/$defs/DeclListNode" + }, + { + "$ref": "#/$defs/DeclTermsNode" + }, + { + "$ref": "#/$defs/DeclTableNode" + }, + { + "$ref": "#/$defs/DeclLayoutNode" + } + ] + }, + "title": "Children", + "type": "array" } }, "required": [ - "type", - "text" + "type" ], "title": "DeclQuoteNode", "type": "object" }, "DeclTableNode": { "additionalProperties": false, + "description": "``kind`` is the author's statement (``\"data\"``, ``\"content\"``, or\nany other value of the open vocabulary); the builder never guesses\none.", "properties": { "label": { "anyOf": [ @@ -990,27 +1283,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" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -1022,21 +1294,8 @@ "type": "string" }, "kind": { - "default": "table", - "enum": [ - "table", - "grid" - ], - "title": "Kind", - "type": "string" - }, - "content_kind": { "anyOf": [ { - "enum": [ - "data", - "content" - ], "type": "string" }, { @@ -1044,31 +1303,102 @@ } ], "default": null, - "title": "Content Kind" + "title": "Kind" }, "cells": { "items": { - "$ref": "#/$defs/TableCell" + "$ref": "#/$defs/DeclCell" + }, + "title": "Cells", + "type": "array" + } + }, + "required": [ + "type" + ], + "title": "DeclTableNode", + "type": "object" + }, + "DeclTermItem": { + "additionalProperties": false, + "properties": { + "term": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/RichText" + } + ], + "title": "Term" + }, + "children": { + "items": { + "discriminator": { + "mapping": { + "admonition": "#/$defs/DeclAdmonitionNode", + "code": "#/$defs/DeclCodeNode", + "figure": "#/$defs/DeclFigureNode", + "heading": "#/$defs/DeclHeadingNode", + "image": "#/$defs/DeclImageNode", + "layout": "#/$defs/DeclLayoutNode", + "list": "#/$defs/DeclListNode", + "math": "#/$defs/DeclMathNode", + "paragraph": "#/$defs/DeclParagraphNode", + "quote": "#/$defs/DeclQuoteNode", + "table": "#/$defs/DeclTableNode", + "terms": "#/$defs/DeclTermsNode" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/DeclHeadingNode" + }, + { + "$ref": "#/$defs/DeclParagraphNode" + }, + { + "$ref": "#/$defs/DeclQuoteNode" + }, + { + "$ref": "#/$defs/DeclCodeNode" + }, + { + "$ref": "#/$defs/DeclMathNode" + }, + { + "$ref": "#/$defs/DeclImageNode" + }, + { + "$ref": "#/$defs/DeclFigureNode" + }, + { + "$ref": "#/$defs/DeclAdmonitionNode" + }, + { + "$ref": "#/$defs/DeclListNode" + }, + { + "$ref": "#/$defs/DeclTermsNode" + }, + { + "$ref": "#/$defs/DeclTableNode" + }, + { + "$ref": "#/$defs/DeclLayoutNode" + } + ] }, - "title": "Cells", + "title": "Children", "type": "array" - }, - "raw": { - "anyOf": [ - { - "$ref": "#/$defs/RawSource" - }, - { - "type": "null" - } - ], - "default": null } }, "required": [ - "type" + "term" ], - "title": "DeclTableNode", + "title": "DeclTermItem", "type": "object" }, "DeclTermsNode": { @@ -1086,27 +1416,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" - }, "state_metadata": { "additionalProperties": true, "title": "State Metadata", @@ -1117,14 +1426,9 @@ "title": "Type", "type": "string" }, - "tight": { - "default": true, - "title": "Tight", - "type": "boolean" - }, "items": { "items": { - "$ref": "#/$defs/TermItem" + "$ref": "#/$defs/DeclTermItem" }, "title": "Items", "type": "array" @@ -1175,13 +1479,14 @@ "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", "type": "string" }, "authors": { + "default": [], "items": { "type": "string" }, @@ -1200,6 +1505,7 @@ "default": null }, "keywords": { + "default": [], "items": { "type": "string" }, @@ -1229,6 +1535,38 @@ ], "default": null, "title": "Lang" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Version" + }, + "extra": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "title": "Extra", + "type": "object" } }, "required": [ @@ -1266,8 +1604,72 @@ "title": "FootnoteRef", "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": { + "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" + }, + "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" + }, "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 ``[start, end)`` pair of Unicode code-point offsets\n(docs/adr/0013).", "properties": { "label": { "title": "Label", @@ -1314,18 +1716,14 @@ "title": "RawSource", "type": "object" }, - "SourceInfo": { - "description": "The input artifact the CND was built from.\n\n``hash`` is self-describing (``\"sha256:\u2026\"``). Its comparability is\nnarrow and normative: two hashes are comparable only between CNDs from\nthe **same producer over the same source**, where equal means unchanged\nand different means changed. Comparing across producers is meaningless.\n\n``uri`` is an identifier in the producer's own space and is **never\npromised resolvable** \u2014 a consumer must not dereference it.", + "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": { - "type": { - "title": "Type", - "type": "string" - }, - "hash": { - "title": "Hash", + "value": { + "title": "Value", "type": "string" }, - "uri": { + "lang": { "anyOf": [ { "type": "string" @@ -1335,72 +1733,84 @@ } ], "default": null, - "title": "Uri" - } - }, - "required": [ - "type", - "hash" - ], - "title": "SourceInfo", - "type": "object" - }, - "TableCell": { - "description": "Single cell inside a table node.", - "properties": { - "row": { - "title": "Row", - "type": "integer" + "title": "Lang" }, - "col": { - "title": "Col", - "type": "integer" + "marks": { + "default": [], + "items": { + "$ref": "#/$defs/Mark" + }, + "title": "Marks", + "type": "array" }, - "rowspan": { - "default": 1, - "title": "Rowspan", - "type": "integer" + "refs": { + "default": [], + "items": { + "$ref": "#/$defs/NodeRef" + }, + "title": "Refs", + "type": "array" }, - "colspan": { - "default": 1, - "title": "Colspan", - "type": "integer" + "cites": { + "default": [], + "items": { + "$ref": "#/$defs/CiteRef" + }, + "title": "Cites", + "type": "array" }, - "is_header": { - "default": false, - "title": "Is Header", - "type": "boolean" + "footnotes": { + "default": [], + "items": { + "$ref": "#/$defs/FootnoteRef" + }, + "title": "Footnotes", + "type": "array" }, - "text": { - "title": "Text", - "type": "string" + "links": { + "default": [], + "items": { + "$ref": "#/$defs/LinkRef" + }, + "title": "Links", + "type": "array" } }, "required": [ - "row", - "col", - "text" + "value" ], - "title": "TableCell", + "title": "RichText", "type": "object" }, - "TermItem": { - "description": "Single term/description pair of a definition list.", + "SourceInfo": { + "description": "The input artifact the CND was built from.\n\n``hash`` is self-describing (``\"sha256:\u2026\"``). Its comparability is\nnarrow and normative: two hashes are comparable only between CNDs from\nthe **same producer over the same source**, where equal means unchanged\nand different means changed. Comparing across producers is meaningless.\n\n``uri`` is an identifier in the producer's own space and is **never\npromised resolvable** \u2014 a consumer must not dereference it.", "properties": { - "term": { - "title": "Term", + "type": { + "title": "Type", "type": "string" }, - "description": { - "title": "Description", + "hash": { + "title": "Hash", "type": "string" + }, + "uri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Uri" } }, "required": [ - "term", - "description" + "type", + "hash" ], - "title": "TermItem", + "title": "SourceInfo", "type": "object" } }, @@ -1429,10 +1839,12 @@ "items": { "discriminator": { "mapping": { + "admonition": "#/$defs/DeclAdmonitionNode", "code": "#/$defs/DeclCodeNode", "figure": "#/$defs/DeclFigureNode", "heading": "#/$defs/DeclHeadingNode", "image": "#/$defs/DeclImageNode", + "layout": "#/$defs/DeclLayoutNode", "list": "#/$defs/DeclListNode", "math": "#/$defs/DeclMathNode", "paragraph": "#/$defs/DeclParagraphNode", @@ -1449,9 +1861,6 @@ { "$ref": "#/$defs/DeclParagraphNode" }, - { - "$ref": "#/$defs/DeclTableNode" - }, { "$ref": "#/$defs/DeclQuoteNode" }, @@ -1461,17 +1870,26 @@ { "$ref": "#/$defs/DeclMathNode" }, + { + "$ref": "#/$defs/DeclImageNode" + }, { "$ref": "#/$defs/DeclFigureNode" }, { - "$ref": "#/$defs/DeclImageNode" + "$ref": "#/$defs/DeclAdmonitionNode" }, { "$ref": "#/$defs/DeclListNode" }, { "$ref": "#/$defs/DeclTermsNode" + }, + { + "$ref": "#/$defs/DeclTableNode" + }, + { + "$ref": "#/$defs/DeclLayoutNode" } ] }, diff --git a/schema/cnd.schema.json b/schema/cnd.schema.json index 58666bb..5c476d8 100644 --- a/schema/cnd.schema.json +++ b/schema/cnd.schema.json @@ -1,5 +1,133 @@ { "$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, + "default": {}, + "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": { + "default": [], + "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 +143,13 @@ "formatted": { "anyOf": [ { - "type": "string" + "$ref": "#/$defs/RichText" }, { "type": "null" } ], - "default": null, - "title": "Formatted" + "default": null }, "type": { "anyOf": [ @@ -37,6 +164,7 @@ "title": "Type" }, "authors": { + "default": [], "items": { "type": "string" }, @@ -105,6 +233,7 @@ }, "fields": { "additionalProperties": true, + "default": {}, "title": "Fields", "type": "object" } @@ -116,6 +245,104 @@ "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, + "minimum": 1, + "title": "Rowspan", + "type": "integer" + }, + "colspan": { + "default": 1, + "minimum": 1, + "title": "Colspan", + "type": "integer" + }, + "is_header": { + "default": false, + "title": "Is Header", + "type": "boolean" + }, + "children": { + "default": [], + "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 +405,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,36 +424,9 @@ "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, + "default": {}, "title": "State Metadata", "type": "object" }, @@ -262,10 +462,17 @@ "default": null, "title": "Lang" }, - "block": { - "default": true, - "title": "Block", - "type": "boolean" + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" } }, "required": [ @@ -315,13 +522,14 @@ "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", "type": "string" }, "authors": { + "default": [], "items": { "type": "string" }, @@ -340,6 +548,7 @@ "default": null }, "keywords": { + "default": [], "items": { "type": "string" }, @@ -369,17 +578,49 @@ ], "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" + }, + "default": {}, + "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,36 +638,9 @@ "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, + "default": {}, "title": "State Metadata", "type": "object" }, @@ -461,47 +675,47 @@ "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": { + "default": [], "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 +732,6 @@ { "$ref": "#/$defs/ParagraphNode" }, - { - "$ref": "#/$defs/TableNode" - }, { "$ref": "#/$defs/QuoteNode" }, @@ -530,17 +741,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 +787,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 +798,72 @@ "title": "Label", "type": "string" }, - "text": { - "title": "Text", - "type": "string" + "children": { + "default": [], + "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 +898,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,36 +917,9 @@ "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, + "default": {}, "title": "State Metadata", "type": "object" }, @@ -693,33 +943,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": { @@ -729,13 +965,16 @@ "type": "array" }, "children": { + "default": [], "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 +991,6 @@ { "$ref": "#/$defs/ParagraphNode" }, - { - "$ref": "#/$defs/TableNode" - }, { "$ref": "#/$defs/QuoteNode" }, @@ -764,17 +1000,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 +1038,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,36 +1057,9 @@ "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, + "default": {}, "title": "State Metadata", "type": "object" }, @@ -893,42 +1111,96 @@ "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, + "default": {}, + "title": "State Metadata", + "type": "object" + }, + "location": { + "anyOf": [ + { + "$ref": "#/$defs/NodeLocation" + }, + { + "type": "null" + } + ], + "default": null + }, + "type": { + "const": "layout", + "title": "Type", + "type": "string" + }, + "cells": { + "default": [], + "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 +1213,86 @@ "default": null, "title": "Number" }, + "checked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Checked" + }, "children": { + "default": [], "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,36 +1311,9 @@ "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, + "default": {}, "title": "State Metadata", "type": "object" }, @@ -1029,12 +1338,8 @@ "title": "Ordered", "type": "boolean" }, - "tight": { - "default": true, - "title": "Tight", - "type": "boolean" - }, "items": { + "default": [], "items": { "$ref": "#/$defs/ListItem" }, @@ -1049,8 +1354,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,36 +1409,9 @@ "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, + "default": {}, "title": "State Metadata", "type": "object" }, @@ -1133,34 +1446,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": [ @@ -1186,7 +1481,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 ``[start, end)`` pair of Unicode code-point offsets\n(docs/adr/0013).", "properties": { "label": { "title": "Label", @@ -1214,8 +1509,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,36 +1555,9 @@ "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, + "default": {}, "title": "State Metadata", "type": "object" }, @@ -1284,10 +1578,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 +1607,138 @@ } ], "default": null, - "title": "Lang" + "title": "Label" + }, + "state_metadata": { + "additionalProperties": true, + "default": {}, + "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": { + "default": [], + "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,9 +1748,18 @@ } ], "default": null, - "title": "Label" + "title": "Lang" + }, + "marks": { + "default": [], + "items": { + "$ref": "#/$defs/Mark" + }, + "title": "Marks", + "type": "array" }, "refs": { + "default": [], "items": { "$ref": "#/$defs/NodeRef" }, @@ -1336,6 +1767,7 @@ "type": "array" }, "cites": { + "default": [], "items": { "$ref": "#/$defs/CiteRef" }, @@ -1343,6 +1775,7 @@ "type": "array" }, "footnotes": { + "default": [], "items": { "$ref": "#/$defs/FootnoteRef" }, @@ -1350,92 +1783,18 @@ "type": "array" }, "links": { + "default": [], "items": { "$ref": "#/$defs/LinkRef" }, "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 +1828,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,36 +1848,9 @@ "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, + "default": {}, "title": "State Metadata", "type": "object" }, @@ -1578,21 +1871,8 @@ "type": "string" }, "kind": { - "default": "table", - "enum": [ - "table", - "grid" - ], - "title": "Kind", - "type": "string" - }, - "content_kind": { "anyOf": [ { - "enum": [ - "data", - "content" - ], "type": "string" }, { @@ -1600,25 +1880,15 @@ } ], "default": null, - "title": "Content Kind" + "title": "Kind" }, "cells": { + "default": [], "items": { - "$ref": "#/$defs/TableCell" + "$ref": "#/$defs/Cell" }, "title": "Cells", "type": "array" - }, - "raw": { - "anyOf": [ - { - "$ref": "#/$defs/RawSource" - }, - { - "type": "null" - } - ], - "default": null } }, "required": [ @@ -1629,26 +1899,82 @@ "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": { + "default": [], + "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,36 +1993,9 @@ "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, + "default": {}, "title": "State Metadata", "type": "object" }, @@ -1716,12 +2015,8 @@ "title": "Type", "type": "string" }, - "tight": { - "default": true, - "title": "Tight", - "type": "boolean" - }, "items": { + "default": [], "items": { "$ref": "#/$defs/TermItem" }, @@ -1771,10 +2066,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 +2088,6 @@ { "$ref": "#/$defs/ParagraphNode" }, - { - "$ref": "#/$defs/TableNode" - }, { "$ref": "#/$defs/QuoteNode" }, @@ -1803,17 +2097,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" } ] }, @@ -1821,6 +2124,7 @@ "type": "array" }, "bibliography": { + "default": [], "items": { "$ref": "#/$defs/BibEntry" }, @@ -1828,6 +2132,7 @@ "type": "array" }, "footnotes": { + "default": [], "items": { "$ref": "#/$defs/Footnote" }, diff --git a/scripts/regen_converters_golden.py b/scripts/regen_converters_golden.py index 30784b5..02bf24e 100644 --- a/scripts/regen_converters_golden.py +++ b/scripts/regen_converters_golden.py @@ -25,6 +25,8 @@ ("unpaginated", MarkdownConverter), ("unpaginated", HtmlConverter), ("minimal", MarkdownConverter), + ("elements", MarkdownConverter), + ("elements", HtmlConverter), ] diff --git a/scripts/regen_declaration_golden.py b/scripts/regen_declaration_golden.py index 68e5c73..c2546c1 100644 --- a/scripts/regen_declaration_golden.py +++ b/scripts/regen_declaration_golden.py @@ -17,7 +17,6 @@ uv run python scripts/regen_declaration_golden.py """ -import json from pathlib import Path import yaml @@ -43,11 +42,9 @@ def main() -> None: ) built = build(decl, numbering=numbering) path = FIXTURES / cnd_name - path.write_text( - json.dumps(json.loads(built.model_dump_json()), indent=2, ensure_ascii=False) - + "\n", - encoding="utf-8", - ) + # The SDK's own writer: default-valued fields omitted, the shape + # `cnd build` emits (docs/proposals/0013). + path.write_text(built.to_json() + "\n", encoding="utf-8") print(f"wrote {path.relative_to(ROOT)}") diff --git a/scripts/regen_hashes.py b/scripts/regen_hashes.py index 0bb561b..3372e53 100644 --- a/scripts/regen_hashes.py +++ b/scripts/regen_hashes.py @@ -15,11 +15,11 @@ vectors = {} for path in sorted(FIXTURES.glob("*.cnd")): - cnd = Cnd.model_validate_json(path.read_text()) + cnd = Cnd.model_validate_json(path.read_text(encoding="utf-8")) 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") +(FIXTURES / "hashes.json").write_text(json.dumps(vectors, indent=2) + "\n", encoding="utf-8") print(f"wrote {len(vectors)} vectors") diff --git a/scripts/regen_reconcile_fixtures.py b/scripts/regen_reconcile_fixtures.py index e086438..03f28e1 100644 --- a/scripts/regen_reconcile_fixtures.py +++ b/scripts/regen_reconcile_fixtures.py @@ -9,6 +9,10 @@ Ids are literal and stable per file so a vector can name them. They are not durable across builds in the real world (ADR 0015) — here they are fixture coordinates, nothing more. + +Cases 08 to 10 exercise what format 0.5 made finer (docs/proposals/0013): +nodes inside list items and cells, and footnote content. Case 11 pins the +last pass matching v2 added (docs/adr/0029). """ import json @@ -22,15 +26,47 @@ def uid(case: int, n: int) -> str: def para(case: int, n: int, text: str, label: str | None = None) -> dict: - node = {"id": uid(case, n), "type": "paragraph", "text": text} + node = {"id": uid(case, n), "type": "paragraph", "text": {"value": text}} if label: node["label"] = label return node +def items(case: int, n: int, *paragraphs: dict) -> dict: + """A bullet list whose items each hold one of ``paragraphs``.""" + return { + "id": uid(case, n), + "type": "list", + "items": [{"children": [paragraph]} for paragraph in paragraphs], + } + + +def table(case: int, n: int, *cells: list[dict]) -> dict: + """A one-row table; each argument is one cell's children.""" + return { + "id": uid(case, n), + "type": "table", + "cells": [ + {"row": 0, "col": col, "children": children} + for col, children in enumerate(cells) + ], + } + + +def note(case: int, n: int, label: str, *children: dict) -> dict: + return {"id": uid(case, n), "label": label, "children": list(children)} + + +def cite_notes(case: int, n: int, *labels: str) -> dict: + """A paragraph whose text references every footnote in ``labels``.""" + node = para(case, n, "See the notes.") + node["text"]["footnotes"] = [{"label": label} for label in labels] + return node + + def cnd(nodes: list[dict]) -> dict: return { - "cnd_version": "0.3.0", + "cnd_version": "0.5.0", "built_at": "2026-07-23T00:00:00Z", "doc": {"title": "Reconcile fixture"}, "nodes": nodes, @@ -79,7 +115,7 @@ def cnd(nodes: list[dict]) -> dict: "id": uid(5, 2), "type": "heading", "level": 1, - "text": "gamma", + "text": {"value": "gamma"}, "heading_path": ["gamma"], } ] @@ -99,21 +135,80 @@ def cnd(nodes: list[dict]) -> dict: # 7 — pools match on their required label: exact, never heuristic. pool_old = cnd([para(7, 1, "alpha", label="p")]) pool_old["footnotes"] = [ - {"id": uid(7, 100), "label": "fn-a", "text": "first"}, - {"id": uid(7, 101), "label": "fn-b", "text": "second"}, + note(7, 100, "fn-a", para(7, 200, "first")), + note(7, 101, "fn-b", para(7, 201, "second")), ] pool_new = cnd([para(7, 2, "alpha", label="p")]) pool_new["footnotes"] = [ - {"id": uid(7, 102), "label": "fn-b", "text": "second rewritten"}, - {"id": uid(7, 103), "label": "fn-c", "text": "third"}, + note(7, 102, "fn-b", para(7, 202, "second rewritten")), + note(7, 103, "fn-c", para(7, 203, "third")), ] CASES["07-pools-key-on-label"] = ( pool_old, pool_new, "Pool entries carry a required label, so the pool diff is exact and " - "order-independent.", + "order-independent. A footnote's content is nodes: the rewritten note " + "is reported as a changed paragraph, its entry stays unchanged.", ) +# 8 — an edited list item is matched and reported on its own. +CASES["08-list-item-edited"] = ( + cnd([items(8, 1, para(8, 2, "alpha"), para(8, 3, "beta"), para(8, 4, "gamma"))]), + cnd([items(8, 5, para(8, 6, "alpha"), para(8, 7, "beta edited"), para(8, 8, "gamma"))]), + "Format 0.5 is finer: the list itself is unchanged and only the item's " + "paragraph is changed, where 0.4 marked the whole list changed.", +) + +# 9 — the path tells two cells apart. Cell 0 is emptied and cell 1 edited: +# with a path of depth and sibling index alone, both first paragraphs sat +# in the same slot and the edit would pair with cell 0's paragraph. +CASES["09-cells-are-distinct-slots"] = ( + cnd([table(9, 1, [para(9, 2, "first cell")], [para(9, 3, "second cell")])]), + cnd([table(9, 4, [], [para(9, 5, "second cell, edited")])]), + "The edited paragraph pairs with the paragraph of its own cell; the " + "emptied cell's paragraph is removed.", +) + +# 10 — reordering the footnote pool moves nothing: the content of a note +# is keyed by the note's label, not by its pool index. +notes_old = cnd([cite_notes(10, 1, "fn-a", "fn-b")]) +notes_old["footnotes"] = [ + note(10, 100, "fn-a", para(10, 200, "first")), + note(10, 101, "fn-b", para(10, 201, "second")), +] +notes_new = cnd([cite_notes(10, 2, "fn-a", "fn-b")]) +notes_new["footnotes"] = [ + note(10, 102, "fn-b", para(10, 202, "second")), + note(10, 103, "fn-a", para(10, 203, "first, edited")), +] +CASES["10-footnote-content"] = ( + notes_old, + notes_new, + "Footnote content is matched like body content. The pool was reordered, " + "yet the untouched note is unchanged in place and the edited one pairs " + "with its own note's paragraph.", +) + +# 11 — a container whose nested content was edited and which also shifted +# slot misses every earlier pass (its digest and its path both differ); the +# last pass, reported as container-hash (docs/adr/0029), pairs it on its own +# node hash alone. +CASES["11-container-edited-and-shifted"] = ( + cnd([para(11, 1, "intro"), table(11, 2, [para(11, 3, "a")], [para(11, 4, "b")])]), + cnd( + [ + para(11, 5, "inserted"), + para(11, 6, "intro"), + table(11, 7, [para(11, 8, "a")], [para(11, 9, "b edited")]), + ] + ), + "The table kept its own hash but its cell was edited and a paragraph was " + "inserted above it: it is paired on its node hash alone (moved), the " + "unedited cell moves with it, and the edited cell's paragraph is added " + "while the old one is removed.", +) + + def main() -> None: import sys @@ -126,10 +221,15 @@ def main() -> None: for name, (old_doc, new_doc, why) in CASES.items(): case_dir = FIXTURES / name case_dir.mkdir(parents=True, exist_ok=True) - (case_dir / "old.cnd").write_text(json.dumps(old_doc, indent=2) + "\n") - (case_dir / "new.cnd").write_text(json.dumps(new_doc, indent=2) + "\n") + # A literal document id too, so a regeneration is byte-stable. + case = int(name[:2]) + old = Cnd.model_validate({"id": uid(case, 900), **old_doc}) + new = Cnd.model_validate({"id": uid(case, 901), **new_doc}) + # The SDK's own writer: default-valued fields omitted. + (case_dir / "old.cnd").write_text(old.to_json() + "\n", encoding="utf-8") + (case_dir / "new.cnd").write_text(new.to_json() + "\n", encoding="utf-8") - report = diff(Cnd.model_validate(old_doc), Cnd.model_validate(new_doc)) + report = diff(old, new) vectors["cases"][name] = { "why": why, "nodes": [ @@ -148,7 +248,7 @@ def main() -> None: } (FIXTURES.parent / "matching.json").write_text( - json.dumps(vectors, indent=2) + "\n" + json.dumps(vectors, indent=2) + "\n", encoding="utf-8" ) print(f"wrote {len(CASES)} pairs + matching.json") diff --git a/scripts/regen_traversal.py b/scripts/regen_traversal.py index fdbabe8..87f9696 100644 --- a/scripts/regen_traversal.py +++ b/scripts/regen_traversal.py @@ -15,11 +15,16 @@ vectors = {} for path in sorted(FIXTURES.glob("*.cnd")): - cnd = Cnd.model_validate_json(path.read_text()) + cnd = Cnd.model_validate_json(path.read_text(encoding="utf-8")) 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") +(FIXTURES / "traversal.json").write_text(json.dumps(vectors, indent=2) + "\n", encoding="utf-8") print(f"wrote {len(vectors)} vectors, {sum(len(v) for v in vectors.values())} nodes") diff --git a/spec/cnd-spec.md b/spec/cnd-spec.md index 96ed58c..eaf8d32 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 +from kept content yields no edge (the text the reference displays, if +any, stays in the `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 `" + ) def render_code(self, node: CodeNode) -> str: cls = f' class="language-{escape(node.lang, quote=True)}"' if node.lang else "" - return ( - f"{escape(node.text)}" - ) + if node.title: + return ( + f'' + f'

{escape(node.title)}

' + f"
{escape(node.text)}
" + ) + return f"{escape(node.text)}" def render_math(self, node: MathNode) -> str: - number = " ".join(part for part in (node.counter_label, node.number) if part) - tag = "div" if node.block else "span" + number = _numbering(node) inner = f'{escape(node.text)}' if number: inner += f'{escape(number)}' - return f'<{tag}{_anchor(node)} class="cnd-math-block">{inner}' + return f'{inner}' def render_figure(self, node: FigureNode) -> str: - parts = [self.render(child) for child in node.children] + parts = [self.render(child) for child in expand_headings(node.children)] if not node.children and node.raw is not None: parts.append( f'
'
                 f"{escape(node.raw.value)}
" ) - caption = _caption_text(node) + caption = self._caption(node) if caption: - parts.append(f"
{escape(caption)}
") + parts.append(f"
{caption}
") kind = f' data-kind="{escape(node.kind, quote=True)}"' if node.kind else "" return f"" + "".join(parts) + "" + def _caption(self, node: FigureNode) -> str: + """``Figure 3: caption``, then the description as a paragraph.""" + counter = escape(_numbering(node)) + caption = self.render_text(node.caption) if node.caption is not None else "" + title = ": ".join(part for part in (counter, caption) if part) + if node.description is not None: + title += f'

{self.render_text(node.description)}

' + return title + def render_image(self, node: ImageNode) -> str: + """````; a path that is missing or fails + ``cnd.core.urls.is_safe_image_source`` renders the alt text in a + ``cnd-missing-image`` span instead.""" alt = escape(node.alt or "", quote=True) - if node.path: + if node.path and is_safe_image_source(node.path): src = escape(node.path, quote=True) return f'' return ( @@ -142,73 +269,69 @@ def render_image(self, node: ImageNode) -> str: def render_list(self, node: ListNode) -> str: tag = "ol" if node.ordered else "ul" - return f"<{tag}{_anchor(node)}>{_list_items(node.items)}" + items = "".join(self._item(item) for item in node.items) + return f"<{tag}{_anchor(node)}>{items}" + + def _item(self, item: ListItem) -> str: + box = "" + if item.checked is not None: + box = f' ' + return f"
  • {box}{self._flow(item.children)}
  • " def render_terms(self, node: TermsNode) -> str: items = "".join( - f"
    {escape(item.term)}
    {escape(item.description)}
    " + f"
    {self.render_text(item.term)}
    {self._flow(item.children)}
    " for item in node.items ) return f"{items}" -def _cell_html(cell: TableCell) -> str: - tag = "th" if cell.is_header else "td" - attrs = "" - if cell.rowspan != 1: - attrs += f' rowspan="{cell.rowspan}"' - if cell.colspan != 1: - attrs += f' colspan="{cell.colspan}"' - return f"<{tag}{attrs}>{escape(cell.text)}" - - -def _list_items(items: list[ListItem]) -> str: - out = [] - for item in items: - inner = escape(item.text) - if item.children: - inner += f"
      {_list_items(item.children)}
    " - out.append(f"
  • {inner}
  • ") - return "".join(out) - - -def _caption_text(node: FigureNode) -> str | None: - counter = " ".join(part for part in (node.counter_label, node.number) if part) - return ": ".join(part for part in (counter, node.caption) if part) or None - - class HtmlConverter(CndConverter): """Convert a whole ``Cnd`` into one standalone HTML document. The output is a complete file — doctype, ```` with charset, title and metadata, an inline stylesheet, and a ```` — never a fragment. It is self-contained apart from ``ImageNode.path``, which - is emitted verbatim as ``src`` and is the producer's identifier, not - a promise that it resolves. + is emitted as ``src`` and is the producer's identifier, not a promise + that it resolves; a path that is neither a safe URL nor a + ``data:image/`` URI (``cnd.core.urls``) renders the alt text instead. Structure, all of it **non-normative** (spec §7): - ````: ```` and ``<meta>`` for authors, description, - keywords and date from ``cnd.doc``; ``cnd_version`` and - ``built_at`` as ``data-`` attributes on ``<html>``. + keywords, date, status, version and every ``doc.extra`` pair from + ``cnd.doc``, except an ``extra`` key naming a meta element the + converter writes itself (skipped, with a warning); ``cnd_version`` + and ``built_at`` as ``data-`` attributes on ``<html>``. - ``<article>``: the body, assembled by walking the tree in reading - order and delegating each node to the injected renderer. Figures - render once, from the wrapper node. + order and delegating each block to the injected renderer. A + container (figure, list, table, quote…) renders once, from the + container node; only headings are descended into. - ``<section class="cnd-footnotes">`` and ``<section class="cnd-bibliography">`` from the pools, in pool - order, each entry anchored on its label so markers link to it. + order, each entry anchored on its label so markers link to it. A + footnote's blocks render like body blocks. - Markers appended after each block in a ``<span class= - "cnd-markers">``, as real ``<a href="#label">`` links; a marker - whose label does not resolve degrades to a - ``<span class="cnd-unresolved">`` and is reported in - ``ConversionResult.warnings``. + "cnd-markers">``, as real ``<a href="#label">`` links, for the + edges of every text in the block; a marker whose label does not + resolve degrades to a ``<span class="cnd-unresolved">`` and is + reported in ``ConversionResult.warnings``. + - ``links``: a hyperlink with a ``text_span`` is an ``<a + class="cnd-link">`` around the text it spans (the renderer's + job, see ``HtmlNodeRenderer``). Any other link goes to the marker + line as ``<a class="cnd-link" href="…">href</a>``: one without a + span, and one whose span does not render inline (empty, inside a + ``code`` or ``math`` atom, inside or across a link that wins), so + no link is lost. An href that fails ``cnd.core.urls.is_safe_url`` + is never emitted as a link: its spanned text stays plain and the + marker line carries the href as text. **Irreducibly dropped** — the same for every document (docs/proposals/0007): - - ``text_span`` — markers are appended after the block rather than - spliced in at their standoff offsets; only their relative order - survives. + - ``text_span`` of ``refs``, ``cites`` and ``footnotes`` — their + markers are appended after the block rather than spliced in at + their standoff offsets; only their relative order survives. - ``CiteRef.form`` and ``supplement`` beyond the ``none`` case: HTML has no citation model, so a bracketed link is all there is. - ``state_metadata`` and ``NodeLocation.page``: no target in HTML. @@ -219,12 +342,7 @@ class HtmlConverter(CndConverter): bibliography is emitted as reference strings, see ``format_bib_entry``. - ``RawSource`` survives only on a childless figure, as a ``<pre>``; - ``table.raw`` and ``math.raw`` are dropped in favour of the - structured content. - - ``links`` — **not yet rendered by this converter**; unlike the rest - of this list, this is a gap rather than an inherent limit: ``<a - href>`` is native to HTML, so a future revision can render it - (docs/adr/0024). + ``math.raw`` is dropped in favour of the structured content. This conversion does **not** round-trip. """ @@ -259,14 +377,14 @@ def convert(self, cnd: Cnd) -> ConversionResult: '<meta name="viewport" content="width=device-width, initial-scale=1">', f"<title>{escape(cnd.doc.title)}", ] - lines.extend(self._head_meta(cnd)) + lines.extend(self._head_meta(cnd, warnings)) lines.append(f"") lines.append("") lines.append("") lines.append("
    ") lines.extend(self._body(cnd, warnings)) lines.append("
    ") - lines.extend(self._footnotes_section(cnd)) + lines.extend(self._footnotes_section(cnd, warnings)) lines.extend(self._bibliography_section(cnd, warnings)) lines.append("") lines.append("") @@ -274,7 +392,7 @@ def convert(self, cnd: Cnd) -> ConversionResult: # -- head ----------------------------------------------------------- - def _head_meta(self, cnd: Cnd) -> list[str]: + def _head_meta(self, cnd: Cnd, warnings: list[str]) -> list[str]: doc = cnd.doc out = [] for author in doc.authors: @@ -291,24 +409,40 @@ def _head_meta(self, cnd: Cnd) -> list[str]: out.append( f'' ) + pairs = [("status", doc.status), ("version", doc.version)] + for name, value in doc.extra.items(): + if name.strip().lower() in _RESERVED_META: + warnings.append( + f"doc.extra key {name!r} names a meta element the converter " + f"writes itself; skipped" + ) + else: + pairs.append((name, value)) + for name, value in pairs: + if value: + out.append( + f'' + ) return out # -- body ----------------------------------------------------------- def _body(self, cnd: Cnd, warnings: list[str]) -> list[str]: - blocks: list[str] = [] - for visit in iter_body(cnd): - rendered = self.renderer.render(visit.node) - markers = self._markers(cnd, visit.node, warnings) - if markers: - rendered = f"{rendered}\n{markers}" - if rendered: - blocks.append(rendered) - return blocks + blocks = [self._block(cnd, visit.node, warnings) for visit in iter_body(cnd)] + return [block for block in blocks if block] + + def _block(self, cnd: Cnd, node: CndNode, warnings: list[str]) -> str: + rendered = self.renderer.render(node) + markers = self._markers(cnd, node, warnings) + if markers: + rendered = f"{rendered}\n{markers}" + return rendered def _markers(self, cnd: Cnd, node: CndNode, warnings: list[str]) -> str: + inline = self._inline_link_ids(node) rendered = [ - self._marker(marker) + self._marker(marker, (marker.text_index, id(marker.link)) in inline) for marker in resolve_markers(cnd, node, warnings) ] parts = [part for part in rendered if part] @@ -316,8 +450,30 @@ def _markers(self, cnd: Cnd, node: CndNode, warnings: list[str]) -> str: return "" return '' + " ".join(parts) + "" + def _inline_link_ids(self, node: CndNode) -> set[tuple[int, int]]: + """``(text_index, id(link))`` for every link of the block the + renderer already renders as an anchor. Keyed per text: one + ``LinkRef`` object may sit in two texts and render inline in only + one. A renderer without ``inline_links`` renders none, so all its + links go to the marker line.""" + inline_links = getattr(self.renderer, "inline_links", None) + if inline_links is None: + return set() + return { + (index, id(link)) + for index, (_, text) in enumerate(marker_texts(node)) + for link in inline_links(text) + } + @staticmethod - def _marker(marker: ResolvedMarker) -> str: + def _marker(marker: ResolvedMarker, inline: bool = False) -> str: + if marker.family == "links": + href = marker.link.href + if inline: + return "" # the renderer links the spanned text itself + if not is_safe_url(href): + return escape(href) + return f'
    {escape(href)}' label = escape(marker.label) href = escape(marker.label, quote=True) if marker.family == "cites" and getattr(marker.link, "form", None) == "none": @@ -335,13 +491,16 @@ def _marker(marker: ResolvedMarker) -> str: # -- pools ---------------------------------------------------------- - def _footnotes_section(self, cnd: Cnd) -> list[str]: + def _footnotes_section(self, cnd: Cnd, warnings: list[str]) -> list[str]: if not cnd.footnotes: return [] out = ['
    ', f"

    {escape(self.footnotes_title)}

    ", "
      "] for note in cnd.footnotes: anchor = escape(note.label, quote=True) - out.append(f'
    1. {escape(note.text)}
    2. ') + body = "".join( + self._block(cnd, child, warnings) for child in expand_headings(note.children) + ) + out.append(f'
    3. {body}
    4. ') out.extend(["
    ", "
    "]) return out @@ -358,7 +517,6 @@ def _bibliography_section(self, cnd: Cnd, warnings: list[str]) -> list[str]: if warning: warnings.append(warning) anchor = escape(entry.label, quote=True) - out.append(f'
  • {escape(reference)}
  • ') + out.append(f'
  • {self.renderer.render_text(reference)}
  • ') out.extend(["", ""]) return out - diff --git a/src/cnd/converters/markdown.py b/src/cnd/converters/markdown.py index ef28d15..c9001ae 100644 --- a/src/cnd/converters/markdown.py +++ b/src/cnd/converters/markdown.py @@ -4,8 +4,14 @@ ``cnd.core.render.MarkdownRenderer``. """ +import re + from cnd.core.cnd import BibEntry, Cnd, DocMetadata, Footnote -from cnd.core.render import MarkdownRenderer, NodeRenderer +from cnd.core.markdown_escape import escape_block, escape_destination, escape_inline +from cnd.core.nodes import CndNode, HeadingNode, ParagraphNode +from cnd.core.render import MarkdownRenderer, NodeRenderer, expand_headings +from cnd.core.text import RichText +from cnd.core.urls import is_safe_url from cnd.converters.base import ( CndConverter, @@ -16,16 +22,65 @@ resolve_markers, ) -__all__ = ["MarkdownConverter", "format_bib_entry"] +__all__ = ["MarkdownConverter", "format_bib_entry", "markdown_label"] + + +_LABEL_SAFE_PUNCTUATION = frozenset("-._:") + + +def _fold_stable(ch: str) -> bool: + """Whether every way a reader compares labels leaves ``ch`` itself. + + CommonMark says "Unicode case fold" (Python ``casefold``); markdown-it + and other JavaScript readers lower then upper. A character kept by + ``markdown_label`` must be a fixed point of both, and its upper-case + form must lower back to it, so two kept characters never meet: this + is what sends dotless ``ı`` (which uppercases to ``I``) to an escape. + """ + return ch.casefold() == ch and ch.lower() == ch and ch.upper().lower() == ch + + +def markdown_label(label: str) -> str: + """``label`` as the converter writes it in Markdown: letters, digits + (any script) and ``-._:`` as they are when case folding leaves them + unchanged, every other character percent-encoded as UTF-8 (``%`` + included). + + A label is free text (spec §4), but a GFM footnote label stops at a + space or a ``]`` and a bracketed marker must not turn into a link or + HTML. The mapping is injective — ``urllib.parse.unquote`` undoes it — + and it stays injective under the case-insensitive comparison + CommonMark, GFM and markdown-it apply to labels: an upper-case + letter, ``ß``, a final sigma, a ligature or a dotless ``ı`` is + encoded, so ``Fn`` and ``fn`` never meet (only the hex digits of an + escape change case, and they decode the same either way). A footnote marker and its definition, + written through this one function, still match. + """ + return "".join( + ch + if (ch.isalnum() or ch in _LABEL_SAFE_PUNCTUATION) and _fold_stable(ch) + else "".join(f"%{byte:02X}" for byte in ch.encode("utf-8", "surrogatepass")) + for ch in label + ) + + +def _label_text(label: str) -> str: + """A mapped label where Markdown parses inline syntax (a ref or cite + marker, the bibliography): an ``_`` cannot open emphasis there.""" + return escape_inline(markdown_label(label)) + +def format_bib_entry(entry: BibEntry) -> tuple[RichText, str | None]: + """The reference text for ``entry``, plus a warning when composed. -def format_bib_entry(entry: BibEntry) -> tuple[str, str | None]: - """The reference string for ``entry``, plus a warning when composed. + The caller renders the returned ``RichText`` in its own dialect + (``NodeRenderer.render_text``), which is how a journal title set in + italics stays italic. **This is not a citation-style implementation.** ``formatted`` — the - reference string as it was displayed in the built document, produced - by whatever style engine the producer ran — is preferred whenever it - is present, and is emitted verbatim. + reference as it was displayed in the built document, produced by + whatever style engine the producer ran — is preferred whenever it is + present, and is returned verbatim, marks included. Since CND 0.3.0 ``formatted`` is nullable: a hand author or a markdown producer has no style engine and emits structured fields @@ -38,7 +93,7 @@ def format_bib_entry(entry: BibEntry) -> tuple[str, str | None]: engine's job and is out of scope for the SDK (docs/proposals/0007). Callers who need real styling must run one and set ``formatted``. """ - if entry.formatted: + if entry.formatted is not None and entry.formatted.value: return entry.formatted, None parts: list[str] = [] if entry.authors: @@ -58,8 +113,8 @@ def format_bib_entry(entry: BibEntry) -> tuple[str, str | None]: f"composed a minimal unstyled reference from the lifted fields" ) if not parts: - return entry.label, warning - return ". ".join(parts), warning + return RichText(value=entry.label), warning + return RichText(value=". ".join(parts)), warning class MarkdownConverter(CndConverter): @@ -68,43 +123,52 @@ class MarkdownConverter(CndConverter): Output shape, all of it **non-normative** (spec §7): - YAML front matter from ``cnd.doc`` (title, authors, date, keywords, - description, lang) plus ``cnd_version`` and ``built_at``. + description, lang, status, version, and ``extra`` as a mapping) + plus ``cnd_version`` and ``built_at``. - The body, assembled by walking the tree in reading order and - delegating each node to the injected ``NodeRenderer``. Figures are - rendered once, by the renderer, from the wrapper node; the walk - does not descend into them. - - A footnotes section (``[^label]: text``) and a bibliography section, - both emitted from the pools in pool order — pools are out of tree - and out of reading order (spec §8). - - Per-node markers appended on their own line under each block: - ``[label]`` for ``refs``, pandoc-style ``[@label]`` / ``@label`` - for ``cites``, ``[^label]`` for ``footnotes``, and ``[href](href)`` - for ``links`` — the href is the only text a ``LinkRef`` carries, so - it stands in for the display text a Markdown link normally has. - Markers are ordered by ``text_span`` where one exists, then by - family (spec §8). A ``links`` marker is **appended**, like the - other three families, never spliced into the text at its span: - splicing would make marker position renderer-specific again and - would rewrite every existing golden document for a change that - only one family needs. + delegating each block to the injected ``NodeRenderer``. A container + (figure, list, table, quote…) renders once, from the container + node; only headings are descended into. + - A footnotes section (``[^label]: …``, further blocks indented four + spaces) and a bibliography section, both emitted from the pools in + pool order — pools are out of tree and out of reading order + (spec §8). Footnote blocks and references go through the renderer, + like the body. + - Per-block markers appended on their own line under each block, for + the edges of every text in the block: ``[label]`` for ``refs``, + pandoc-style ``[@label]`` / ``@label`` for ``cites``, ``[^label]`` + for ``footnotes``, and ``[href](href)`` for ``links`` — the href is + the only text a ``LinkRef`` carries, so it stands in for the display + text a Markdown link normally has. An href whose scheme is not + ``http``, ``https`` or ``mailto`` (``cnd.core.urls.is_safe_url``) + is written as plain text, never as a link. A line ending in an + href becomes a space in the marker's text and is percent-encoded + in its destination, so the marker stays on its line. Every label + (markers, footnote definitions, bibliography) goes through + ``markdown_label``, and a cite supplement is escaped. Markers are ordered text by text, + then by ``text_span`` where one exists, then by family (spec §8). A + ``links`` marker is **appended**, like the other three families, + never spliced into the text at its span: splicing would make marker + position renderer-specific again and would rewrite every existing + golden document for a change that only one family needs. The default renderer is ``MarkdownRenderer(tables="inline", figures="inline", escape=True, heading_numbers=True)``: a standalone document wants its content, not the ``[[figure:…]]`` placeholders - that serve chunking pipelines. It escapes free text (paragraphs, - headings, list items, terms, table cells, quotes, captions) so that a + that serve chunking pipelines. It escapes free text so that a Markdown parser reads the structure the CND has and not structure - the prose happens to spell; code and math text are emitted verbatim, - and the footnote and bibliography sections are not escaped yet. - It keeps heading ``number`` and ``counter_label`` as a prefix on the - heading line. Inject another renderer to change any of that. + the prose happens to spell; code and math are emitted verbatim. It + keeps heading numbering as a prefix on the heading line. + ``text_only=True`` makes it a Markdown output with no HTML at all + (see ``MarkdownRenderer``). Inject another renderer to change any of + that; ``text_only`` then belongs to the renderer you inject. **Irreducibly dropped** — properties of Markdown as a target, the same for every document (docs/proposals/0007): - ``text_span`` — markers are appended under the block, not spliced into the text at their standoff offsets, so marker *position* - inside a node's text is lost. Their relative order is preserved. + inside a text is lost. Their relative order is preserved. This holds for ``links`` exactly as it does for the other three families. - ``CiteRef.form`` — only the ``none`` (silent citation) and @@ -115,9 +179,11 @@ class MarkdownConverter(CndConverter): - Node ``id`` and ``label``: Markdown has no element identity, so cross-reference markers degrade to plain bracketed labels rather than links. - - ``RawSource`` (``raw`` on table, math, figure) and + - ``RawSource`` (``raw`` on math, figure and a ``math`` mark) and ``BibEntry.fields``: the producer's verbatim source and the full - structured bibliography entry have no Markdown target. + structured bibliography entry have no Markdown target, except that + a ``math`` mark renders its source between ``$`` and a ``math`` + block its LaTeX source in a ``math`` fence. - Whatever the injected renderer itself drops. This conversion does **not** round-trip. Nothing reconstructs a CND @@ -131,17 +197,24 @@ def __init__( self, renderer: NodeRenderer | None = None, *, + text_only: bool = False, footnotes_title: str = "Footnotes", bibliography_title: str = "Bibliography", ) -> None: - super().__init__(renderer) + if renderer is not None and text_only: + raise ValueError("text_only shapes the default renderer; set it on the renderer you inject") + super().__init__(renderer if renderer is not None else self.default_renderer(text_only=text_only)) self.footnotes_title = footnotes_title self.bibliography_title = bibliography_title @classmethod - def default_renderer(cls) -> NodeRenderer: + def default_renderer(cls, *, text_only: bool = False) -> NodeRenderer: return MarkdownRenderer( - tables="inline", figures="inline", escape=True, heading_numbers=True + tables="inline", + figures="inline", + escape=True, + heading_numbers=True, + text_only=text_only, ) def convert(self, cnd: Cnd) -> ConversionResult: @@ -149,7 +222,7 @@ def convert(self, cnd: Cnd) -> ConversionResult: sections = [ self._front_matter(cnd), self._body(cnd, warnings), - self._footnotes_section(cnd), + self._footnotes_section(cnd, warnings), self._bibliography_section(cnd, warnings), ] text = "\n\n".join(section for section in sections if section) @@ -172,6 +245,16 @@ def _front_matter(self, cnd: Cnd) -> str: lines.append(f"description: {_yaml_scalar(doc.description)}") if doc.lang: lines.append(f"lang: {_yaml_scalar(doc.lang)}") + if doc.status: + lines.append(f"status: {_yaml_scalar(doc.status)}") + if doc.version: + lines.append(f"version: {_yaml_scalar(doc.version)}") + if doc.extra: + lines.append("extra:") + lines.extend( + f" {_yaml_scalar(key)}: {_yaml_scalar(value)}" + for key, value in doc.extra.items() + ) lines.append(f"cnd_version: {_yaml_scalar(cnd.cnd_version)}") lines.append(f"built_at: {_yaml_scalar(cnd.built_at.isoformat())}") lines.append("---") @@ -180,65 +263,72 @@ def _front_matter(self, cnd: Cnd) -> str: # -- body ----------------------------------------------------------- def _body(self, cnd: Cnd, warnings: list[str]) -> str: - blocks: list[str] = [] - for visit in iter_body(cnd): - rendered = self.renderer.render(visit.node).strip() - markers = self._marker_line(cnd, visit.node, warnings) - if markers and rendered: - rendered = f"{rendered}\n{markers}" - elif markers: - rendered = markers - if rendered: - blocks.append(rendered) - return "\n\n".join(blocks) - - def _marker_line(self, cnd: Cnd, node, warnings: list[str]) -> str: - markers = resolve_markers(cnd, node, warnings) - # ``links`` is href-keyed, not label-keyed (spec §5), so it never - # goes through label resolution: ``resolve_markers`` only ever - # produces refs/cites/footnotes markers. Build the links markers - # here, with no resolution and no warning — a links edge is never - # invalid for naming something the CND doesn't carry — then merge - # and re-sort with the same ``sort_key`` the other three families - # already use (spec §8), so there is exactly one ordering rule. - markers.extend( - ResolvedMarker(family="links", link=link, target=None) - for link in node.links - ) - markers.sort(key=lambda marker: marker.sort_key) - rendered = [self._marker(marker) for marker in markers] + blocks = [self._block(cnd, visit.node, warnings) for visit in iter_body(cnd)] + return "\n\n".join(block for block in blocks if block) + + def _block(self, cnd: Cnd, node: CndNode, warnings: list[str]) -> str: + """One block and, on its own line under it, its markers.""" + rendered = self.renderer.render(node).strip() + markers = self._marker_line(cnd, node, warnings) + if markers and rendered: + # Only a paragraph or a heading takes its markers on the next + # line. Under any other block (list, table, quote, admonition, + # figure, code, math) a bare next line would read as a table + # row or a lazy continuation, so a blank line separates them. + tight = isinstance(node, (ParagraphNode, HeadingNode)) + return f"{rendered}{chr(10) if tight else chr(10) * 2}{markers}" + return markers or rendered + + def _marker_line(self, cnd: Cnd, node: CndNode, warnings: list[str]) -> str: + rendered = [self._marker(marker) for marker in resolve_markers(cnd, node, warnings)] return " ".join(part for part in rendered if part) @staticmethod def _marker(marker: ResolvedMarker) -> str: if marker.family == "refs": - return f"[{marker.label}]" + return f"[{_label_text(marker.label)}]" if marker.family == "footnotes": - return f"[^{marker.label}]" + return f"[^{markdown_label(marker.label)}]" if marker.family == "links": href = marker.link.href - return f"[{href}]({href})" + # A line ending would let the rest of the href start a block. + text = _single_line(href) + if not is_safe_url(href): + return escape_block(text) + return f"[{escape_inline(text)}]({escape_destination(href)})" form = getattr(marker.link, "form", None) supplement = getattr(marker.link, "supplement", None) + if supplement: + supplement = escape_inline(_single_line(supplement)) if form == "none": return "" if form in ("prose", "author", "year"): - base = f"@{marker.label}" + base = f"@{_label_text(marker.label)}" return f"{base} [{supplement}]" if supplement else base - inner = f"@{marker.label}" + inner = f"@{_label_text(marker.label)}" if supplement: inner = f"{inner}, {supplement}" return f"[{inner}]" # -- pools ---------------------------------------------------------- - def _footnotes_section(self, cnd: Cnd) -> str: + def _footnotes_section(self, cnd: Cnd, warnings: list[str]) -> str: if not cnd.footnotes: return "" lines = [f"## {self.footnotes_title}", ""] - lines.extend(_footnote_definition(note) for note in cnd.footnotes) + lines.extend(self._footnote_definition(cnd, note, warnings) for note in cnd.footnotes) return "\n".join(lines) + def _footnote_definition(self, cnd: Cnd, note: Footnote, warnings: list[str]) -> str: + """``[^label]: first block``; every further line is indented four + spaces, so several blocks stay inside the one footnote. The label + goes through ``markdown_label``, as the marker's does.""" + blocks = [self._block(cnd, child, warnings) for child in expand_headings(note.children)] + body = "\n\n".join(block for block in blocks if block) + lines = body.split("\n") + rest = [f" {line}" if line else "" for line in lines[1:]] + return "\n".join([f"[^{markdown_label(note.label)}]: {lines[0]}".rstrip(), *rest]) + def _bibliography_section(self, cnd: Cnd, warnings: list[str]) -> str: if not cnd.bibliography: return "" @@ -247,13 +337,13 @@ def _bibliography_section(self, cnd: Cnd, warnings: list[str]) -> str: reference, warning = format_bib_entry(entry) if warning: warnings.append(warning) - lines.append(f"- **{entry.label}** — {reference}") + lines.append(f"- **{_label_text(entry.label)}** — {self.renderer.render_text(reference)}") return "\n".join(lines) -def _footnote_definition(note: Footnote) -> str: - text = note.text.replace("\n", "\n ") - return f"[^{note.label}]: {text}" +def _single_line(text: str) -> str: + """``text`` with every line ending (CR, LF, CRLF) made a space.""" + return text.replace("\r\n", " ").replace("\r", " ").replace("\n", " ") def _yaml_scalar(value: str) -> str: @@ -261,8 +351,20 @@ def _yaml_scalar(value: str) -> str: Quoting unconditionally sidesteps the whole plain-scalar minefield (leading ``@``, a colon, ``yes``/``no``) without pulling in a YAML - library — the SDK's dependency floor is deliberate. + library — the SDK's dependency floor is deliberate. Every control + character and Unicode line break is written as an escape. """ escaped = value.replace("\\", "\\\\").replace('"', '\\"') escaped = escaped.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + escaped = _YAML_UNPRINTABLE.sub(lambda m: f"\\u{ord(m.group()):04x}", escaped) return f'"{escaped}"' + + +_YAML_UNPRINTABLE = re.compile( + "[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\u2028\u2029\ud800-\udfff\ufffe\uffff]" +) +"""What YAML forbids or reads as a line break inside a scalar: the C0 +controls other than tab and the two newlines (escaped by name), DEL, +the C1 controls (NEL among them), the Unicode line and paragraph +separators, lone surrogates (no UTF-8 file can hold one) and the two +noncharacters U+FFFE and U+FFFF.""" diff --git a/src/cnd/core/__init__.py b/src/cnd/core/__init__.py index 1a946dc..8c9c562 100644 --- a/src/cnd/core/__init__.py +++ b/src/cnd/core/__init__.py @@ -10,6 +10,11 @@ from cnd.core.hashing import content_hash, node_hash, pool_entry_hash from cnd.core.node_text import NodeTextMode from cnd.core.nodes import ( + KNOWN_ADMONITION_KINDS, + KNOWN_TABLE_KINDS, + ChildSlot, + AdmonitionNode, + Cell, CiteRef, CndNode, CodeNode, @@ -17,6 +22,7 @@ FootnoteRef, HeadingNode, ImageNode, + LayoutNode, LinkRef, ListItem, ListNode, @@ -30,33 +36,53 @@ QuoteNode, RawSource, StopPredicate, - TableCell, TableNode, TermItem, TermsNode, + child_slots, + edges_of, iter_nodes, position_totals, + texts_of, ) -from cnd.core.render import MarkdownRenderer, NodeRenderer +from cnd.core.text import ( + ATOMIC_MARK_KINDS, + KNOWN_MARK_KINDS, + Edge, + Mark, + Numbering, + RichText, +) +from cnd.core.render import MarkdownRenderer, NodeRenderer, expand_headings from cnd.core.validate import Violation, validate __all__ = [ + "ATOMIC_MARK_KINDS", + "AdmonitionNode", "BibEntry", + "Cell", + "ChildSlot", "CiteRef", "Cnd", "CndNode", "CodeNode", "DocDate", "DocMetadata", + "Edge", "FigureNode", "Footnote", "FootnoteRef", "HeadingNode", "ImageNode", + "KNOWN_ADMONITION_KINDS", + "KNOWN_MARK_KINDS", + "KNOWN_TABLE_KINDS", + "LayoutNode", "LinkRef", "LinkTarget", "ListItem", "ListNode", + "Mark", "MarkdownRenderer", "MathNode", "NodeBase", @@ -66,20 +92,25 @@ "NodeTextMode", "NodeTraverse", "NodeTraverseContext", + "Numbering", "ParagraphNode", "QuoteNode", "RawSource", + "RichText", "SourceInfo", "StopPredicate", - "TableCell", "TableNode", "TermItem", "TermsNode", "Violation", + "child_slots", "content_hash", + "edges_of", + "expand_headings", "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..8c5650c 100644 --- a/src/cnd/core/cnd.py +++ b/src/cnd/core/cnd.py @@ -1,3 +1,4 @@ +import json from collections.abc import Iterator from datetime import datetime from typing import Any, Union @@ -9,12 +10,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,14 +41,22 @@ 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 - authors: list[str] = Field(default_factory=list) + authors: list[str] = Field(default=[]) date: DocDate | None = None - keywords: list[str] = Field(default_factory=list) + keywords: list[str] = Field(default=[]) description: str | None = None lang: str | None = None + status: str | None = None + version: str | None = None + extra: dict[str, str] = Field(default={}) class SourceInfo(BaseModel): @@ -82,23 +93,25 @@ 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) + authors: list[str] = Field(default=[]) title: str | None = None year: int | None = None container: str | None = None doi: str | None = None url: str | None = None - fields: dict[str, Any] = Field(default_factory=dict) + fields: dict[str, Any] = Field(default={}) 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=[]) LinkTarget = Union[CndNode, BibEntry, Footnote] @@ -114,8 +127,8 @@ class Cnd(BaseModel): source: SourceInfo | None = None doc: DocMetadata nodes: list[CndNode] - bibliography: list[BibEntry] = Field(default_factory=list) - footnotes: list[Footnote] = Field(default_factory=list) + bibliography: list[BibEntry] = Field(default=[]) + footnotes: list[Footnote] = Field(default=[]) _incoming_index: dict[str, list[CndNode]] | None = PrivateAttr(default=None) _label_index: dict[str, LinkTarget] | None = PrivateAttr(default=None) @@ -130,7 +143,7 @@ def paginated(self) -> bool: all-or-nothing — a partially located CND is a validation error — so the first node settles it for the whole document. """ - for visit in self.iter(): + for visit in self.iter_all(): return visit.node.location is not None return False @@ -155,9 +168,56 @@ def iter( _totals=self._position_totals, ) + def iter_all( + self, + *, + max_depth: int | None = None, + stop_predicate: StopPredicate | None = None, + ) -> 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. ``max_depth`` and + ``stop_predicate`` apply to the body and to each footnote alike. + """ + yield from self.iter(max_depth=max_depth, stop_predicate=stop_predicate) + for index, note in enumerate(self.footnotes): + yield from iter_nodes( + note.children, + max_depth=max_depth, + stop_predicate=stop_predicate, + _base_path=("footnotes", index, "children"), + _slot="footnotes", + _slot_index=index, + ) + def __iter__(self) -> Iterator[NodeTraverse]: return self.iter() + def to_json(self, *, indent: int | None = 2) -> str: + """This CND as JSON, every default-valued field omitted. + + The one writer the SDK uses for a caller (the CLI, the fixture + scripts). Proposal 0013 lets a CND omit every field that holds its + default, and the finer 0.5 node model makes the empty lists worth + dropping. Omission changes nothing a reader sees: the result parses + back to the same model, and the content hash already drops defaults + (spec §10). + + Required fields are never dropped, even when empty + (``heading_path: []``), and free-form objects (``state_metadata``, + ``BibEntry.fields``) are written as they are. ``id`` is kept + explicitly: it has a default factory, and whether pydantic counts a + freshly generated value as "the default" is not something to rely + on for a document's identity. + """ + payload = self.model_dump(mode="json", exclude_defaults=True) + payload = {"id": str(self.id), **payload} + return json.dumps(payload, indent=indent, ensure_ascii=False) + def resolve(self, label: str) -> LinkTarget | None: """The node or pool entry carrying ``label``, or ``None``. @@ -168,7 +228,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): @@ -188,9 +248,11 @@ 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 (*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/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/src/cnd/core/inline.py b/src/cnd/core/inline.py new file mode 100644 index 0000000..593230d --- /dev/null +++ b/src/cnd/core/inline.py @@ -0,0 +1,424 @@ +"""Inline rendering of rich text: one layer for every output dialect. + +A ``RichText`` is a plain ``value`` plus standoff marks +(docs/proposals/0013). This module turns it into Markdown, HTML or plain +text, and every renderer and converter goes through it: none walks marks +on its own. + +It cuts ``value`` at every mark boundary, and at every edge boundary +when the caller asks for edges to be wrapped (``edge_wrap``), and builds +a tree of nested spans. Where two spans cross, the one that ends first +closes and the other is reopened after it, as text editors do, so the +output always nests. The HTML renderer wraps the ``links`` edges (the +linked text becomes an ````) and returns ``None`` for every other +edge; every other marker is appended after its block (see +``cnd.converters.base``), so no edge cut that renders nothing splits a +mark for no reason. + +Policy (spec §7): a dialect renders the marks it supports and leaves the +text of any other mark unchanged. ``value`` is always the plain text, so +dropping a mark never loses words. +""" + +import unicodedata +from collections.abc import Callable +from dataclasses import dataclass, field +from html import escape as html_escape +from typing import Literal + +from cnd.core.markdown_escape import escape_block +from cnd.core.text import ATOMIC_MARK_KINDS, Edge, Mark, RichText + +InlineDialect = Literal["markdown", "html", "plain"] + +EdgeWrap = Callable[[Edge], "tuple[str, str] | None"] +"""Given an edge, the text to put before and after the span it covers, +inserted verbatim; ``None`` leaves that edge out.""" + +_EDGE = "\x00edge" +"""The kind of an edge span; no mark kind can collide with it.""" + +HTML_TAGS = { + "strong": "strong", + "emph": "em", + "code": "code", + "strike": "s", + "sup": "sup", + "sub": "sub", + "underline": "u", + "highlight": "mark", +} +"""The HTML element of every known mark except ``math``.""" + +MARKDOWN_DELIMITERS = {"strong": "**", "emph": "*", "strike": "~~"} +"""Marks with a CommonMark or GFM delimiter. ``code`` and ``math`` are +atoms with their own syntax; the four others have no Markdown syntax.""" + +HTML_ONLY_MARKS = frozenset({"sup", "sub", "underline", "highlight"}) +"""Marks Markdown can only show as inline HTML. The text-only option +writes ``sup`` and ``sub`` as ``^`` and ``_`` (``TEXT_ONLY_PREFIXES``) +and drops the other two to plain text.""" + +TEXT_ONLY_PREFIXES = {"sup": "^", "sub": "_"} +"""What the text-only option writes before a ``sup`` or a ``sub``: the +covered text follows as is when it is one character, in parentheses +otherwise (``10^3``, ``x_(n+1)``).""" + + +@dataclass(eq=False) +class _Span: + """A mark or a wrapped edge clamped to its text, compared by identity.""" + + start: int + end: int + kind: str + mark: Mark | None = None + wrap: tuple[str, str] = ("", "") + edge: Edge | None = None + + +@dataclass +class _Piece: + """One rendered occurrence of a span: a mark split at a crossing + yields several pieces. ``span`` is ``None`` for the root.""" + + span: _Span | None + start: int + end: int = 0 + children: list["_Piece | tuple[int, int]"] = field(default_factory=list) + + +def render_inline( + text: RichText, + dialect: InlineDialect = "plain", + *, + escape: bool = False, + text_only: bool = False, + edge_wrap: EdgeWrap | None = None, +) -> str: + """Render ``text`` with its marks in ``dialect``. + + - ``"plain"`` returns ``value``: every mark is dropped, and a ``math`` + mark keeps its readable ``value``. + - ``"html"`` escapes the text and maps every known mark to its + element; ``math`` becomes ```` holding its source. + - ``"markdown"`` follows spec §7. ``escape`` escapes the plain + segments (never code or math) with ``escape_block``. ``text_only`` + never writes an HTML tag: ``sup`` and ``sub`` become ``^x`` and + ``_x`` (``^(text)``, ``_(text)`` over several characters; the + ``_`` written ``\\_`` under ``escape``), ``underline`` and + ``highlight`` plain text. + + ``edge_wrap``, when given, wraps the span of every edge with a + ``text_span`` in the strings it returns; edge spans split around + marks like marks do. Wrapped edges never nest in one another: an + edge whose span overlaps the span of a wrapped edge that starts + before it (or at the same offset and ends later, or is listed first + on an equal span) is left out, so the outer or first edge wins. An + edge inside a ``code`` or ``math`` atom is left out too. Which edges + end up wrapped is ``wrapped_edges``. ``"plain"`` ignores it. + + Unknown mark kinds render their covered text unchanged. Bounds are + clamped, so an unvalidated text with a stray mark never raises. + """ + if dialect == "plain": + return text.value + root = _layout(text, dialect, text_only, edge_wrap) + if dialect == "html": + return _render_html(text.value, root) + return _MarkdownWriter(text.value, escape=escape, text_only=text_only).render(root) + + +def wrapped_edges( + text: RichText, + edge_wrap: EdgeWrap, + dialect: Literal["markdown", "html"] = "html", + *, + text_only: bool = False, +) -> list[Edge]: + """The edges of ``text`` that ``render_inline`` with the same + arguments wraps, in the order of ``text.edges()``: an edge left out (no span, an empty + span, inside an atom, inside or across an edge that wins, ``None`` + from ``edge_wrap``) is not in the list. A caller that must not lose + an edge renders the others some other way.""" + root = _layout(text, dialect, text_only, edge_wrap) + seen: list[Edge] = [] + pieces = [root] + while pieces: + piece = pieces.pop() + span = piece.span + if span is not None and span.edge is not None and not any( + e is span.edge for e in seen + ): + seen.append(span.edge) + pieces.extend(c for c in piece.children if isinstance(c, _Piece)) + order = text.edges() + return sorted(seen, key=lambda e: next(i for i, x in enumerate(order) if x is e)) + + +def _layout( + text: RichText, dialect: InlineDialect, text_only: bool, edge_wrap: EdgeWrap | None +) -> _Piece: + spans = _spans(text, dialect, text_only) + _edge_spans(text, edge_wrap) + return _tree(text.value, spans) + + +def concat_texts(*parts: "str | RichText | None") -> RichText: + """One ``RichText`` from several parts, marks shifted to the new + offsets. Strings carry no marks; ``None`` parts are skipped. Edges are + not carried: the result is for rendering, not for resolution.""" + value = "" + marks: list[Mark] = [] + for part in parts: + if part is None: + continue + if isinstance(part, str): + value += part + continue + offset = len(value) + marks.extend( + mark.model_copy(update={"start": mark.start + offset, "end": mark.end + offset}) + for mark in part.marks + ) + value += part.value + return RichText(value=value, marks=marks) + + +def with_whole_mark(text: RichText, kind: str) -> RichText: + """``text`` with one ``kind`` mark over all of it; the same-kind marks + it already had merge into that one (Marks rule 3).""" + if not text.value: + return text + marks = [mark for mark in text.marks if mark.kind != kind] + marks.append(Mark(start=0, end=len(text.value), kind=kind)) + return text.model_copy(update={"marks": marks}) + + +def _spans(text: RichText, dialect: InlineDialect, text_only: bool) -> list[_Span]: + if dialect == "html": + supported = set(HTML_TAGS) | {"math"} + else: + supported = set(MARKDOWN_DELIMITERS) | ATOMIC_MARK_KINDS + supported |= set(TEXT_ONLY_PREFIXES) if text_only else HTML_ONLY_MARKS + value = text.value + spans: list[_Span] = [] + for mark in text.marks: + if mark.kind not in supported: + continue + start, end = max(mark.start, 0), min(mark.end, len(value)) + if dialect == "markdown" and mark.kind not in ATOMIC_MARK_KINDS: + # A delimiter next to a space never opens or closes: move the + # span's outer whitespace outside it. + while start < end and value[start].isspace(): + start += 1 + while end > start and value[end - 1].isspace(): + end -= 1 + if start < end: + spans.append(_Span(start, end, mark.kind, mark)) + return spans + + +def _edge_spans(text: RichText, edge_wrap: EdgeWrap | None) -> list[_Span]: + if edge_wrap is None: + return [] + candidates: list[_Span] = [] + for edge in text.edges(): + if not edge.text_span or len(edge.text_span) != 2: + continue + start = max(edge.text_span[0], 0) + end = min(edge.text_span[1], len(text.value)) + wrap = edge_wrap(edge) + if wrap is not None and start < end: + candidates.append(_Span(start, end, _EDGE, wrap=wrap, edge=edge)) + # Wrapped edges never nest: the outer or first one wins (a stable + # sort keeps list order on equal spans). + spans: list[_Span] = [] + for span in sorted(candidates, key=lambda s: (s.start, -s.end)): + if not spans or span.start >= max(s.end for s in spans): + spans.append(span) + return spans + + +def _tree(value: str, spans: list[_Span]) -> _Piece: + """Nest ``spans`` over ``value``; crossing spans are split.""" + atoms = [s for s in spans if s.kind in ATOMIC_MARK_KINDS] + cuts = {0, len(value)} + for span in spans: + cuts.update((span.start, span.end)) + # An atom is verbatim: no cut falls inside it (Marks rule 4). + cuts = sorted(c for c in cuts if not any(a.start < c < a.end for a in atoms)) + root = _Piece(span=None, start=0) + stack = [root] + for a, b in zip(cuts, cuts[1:]): + active = [s for s in spans if s.start <= a and b <= s.end] + keep = 1 + while keep < len(stack) and stack[keep].span in active: + keep += 1 + for piece in stack[keep:]: + piece.end = a + del stack[keep:] + open_spans = [p.span for p in stack[1:]] + for span in sorted( + (s for s in active if s not in open_spans), + key=lambda s: (-s.end, s.kind in ATOMIC_MARK_KINDS, s.kind != _EDGE, s.kind), + ): + piece = _Piece(span=span, start=a) + stack[-1].children.append(piece) + stack.append(piece) + stack[-1].children.append((a, b)) + for piece in stack[1:]: + piece.end = len(value) + root.end = len(value) + return root + + +def _render_html(value: str, piece: _Piece) -> str: + span = piece.span + if span is not None and span.kind == "math": + raw = span.mark.raw if span.mark is not None else None + source = raw.value if raw is not None else value[piece.start : piece.end] + return f'{html_escape(source)}' + if span is not None and span.kind == "code": + return f"{html_escape(value[piece.start : piece.end])}" + inner = "".join( + html_escape(value[child[0] : child[1]]) + if isinstance(child, tuple) + else _render_html(value, child) + for child in piece.children + ) + if span is None: + return inner + if span.kind == _EDGE: + return f"{span.wrap[0]}{inner}{span.wrap[1]}" + tag = HTML_TAGS[span.kind] + return f"<{tag}>{inner}" + + +def _is_punct(ch: str | None) -> bool: + return ch is not None and unicodedata.category(ch)[0] in "PS" + + +def _is_space(ch: str | None) -> bool: + return ch is None or ch.isspace() + + +def _left_flanking(prev: str | None, nxt: str | None) -> bool: + """CommonMark: a delimiter run that can open (prev/nxt are the + characters around it; ``None`` is a line edge).""" + if _is_space(nxt): + return False + return not _is_punct(nxt) or _is_space(prev) or _is_punct(prev) + + +def _right_flanking(prev: str | None, nxt: str | None) -> bool: + """CommonMark: a delimiter run that can close.""" + if _is_space(prev): + return False + return not _is_punct(prev) or _is_space(nxt) or _is_punct(nxt) + + +class _MarkdownWriter: + def __init__(self, value: str, *, escape: bool, text_only: bool) -> None: + self.value = value + self.escape = escape + self.text_only = text_only + + def render(self, piece: _Piece) -> str: + out: list[str] = [] + previous: tuple[_Piece, str] | None = None + for child in piece.children: + if isinstance(child, tuple): + chunk = self.value[child[0] : child[1]] + out.append(escape_block(chunk) if self.escape else chunk) + previous = None + continue + # Judged on what was written, not on the syntax chosen: a mark + # dropped to plain text can still end with a nested "*". + touches_star = ( + previous is not None + and previous[1].endswith("*") + and previous[0].end == child.start + ) + rendered, _ = self._piece(child, touches_star) + out.append(rendered) + previous = (child, rendered) + return "".join(out) + + def _piece(self, piece: _Piece, touches_star: bool) -> tuple[str, str]: + """The piece rendered, and the syntax used (a delimiter, ``"<"`` + for an HTML tag, ``""`` for none).""" + span = piece.span + assert span is not None + covered = self.value[piece.start : piece.end] + if span.kind == "code": + return _code_span(covered), "`" + if span.kind == "math": + raw = span.mark.raw if span.mark is not None else None + source = raw.value if raw is not None else covered + return f"${_one_line(source)}$", "$" + inner = self.render(piece) + if span.kind == _EDGE: + return f"{span.wrap[0]}{inner}{span.wrap[1]}", "" + delimiter = MARKDOWN_DELIMITERS.get(span.kind) + if delimiter is not None and self._can_delimit(piece, inner, delimiter, touches_star): + return f"{delimiter}{inner}{delimiter}", delimiter + if self.text_only: + prefix = TEXT_ONLY_PREFIXES.get(span.kind) + if prefix is None: + return inner, "" + if prefix == "_" and self.escape: + # A bare "_" could open or close emphasis. + prefix = "\\_" + if len(covered) != 1: + inner = f"({inner})" + return f"{prefix}{inner}", "" + tag = HTML_TAGS[span.kind] + return f"<{tag}>{inner}", "<" + + def _can_delimit( + self, piece: _Piece, inner: str, delimiter: str, touches_star: bool + ) -> bool: + """Whether ``delimiter`` around the piece parses back as this mark. + + It must open and close by CommonMark's flanking rules. Inside, the + delimiter touches the first and last characters of the rendered + ``inner``, which are a nested mark's own delimiter or tag when one + opens or closes at the edge, or an escape; outside, it touches the + characters around the piece in ``value``. A ``*`` opener never + directly follows a ``*`` closer, since the two runs would merge + into one. A ``~~`` never touches another ``~``: a run of three + tildes or more does not delimit, and at the start of a line it + opens a code fence that swallows the blocks after it. + """ + if not inner or (touches_star and delimiter.startswith("*")): + return False + value = self.value + prev = value[piece.start - 1] if piece.start > 0 else None + first, last = inner[0], inner[-1] + nxt = value[piece.end] if piece.end < len(value) else None + if delimiter.startswith("~") and "~" in (prev, first, last, nxt): + return False + return _left_flanking(prev, first) and _right_flanking(last, nxt) + + +def _one_line(text: str) -> str: + """Verbatim inline content on one line: a newline inside a code span + or a formula would otherwise let the next line start a block.""" + return text.replace("\r\n", " ").replace("\r", " ").replace("\n", " ") + + +def _code_span(text: str) -> str: + """A CommonMark code span holding ``text`` verbatim: the backtick run + is longer than any run inside, and a space pads content that starts + or ends with a backtick, or with a space on both sides.""" + text = _one_line(text) + longest = run = 0 + for ch in text: + run = run + 1 if ch == "`" else 0 + longest = max(longest, run) + fence = "`" * (longest + 1) + pad = text.startswith("`") or text.endswith("`") or ( + text.startswith(" ") and text.endswith(" ") and text.strip() != "" + ) + space = " " if pad else "" + return f"{fence}{space}{text}{space}{fence}" diff --git a/src/cnd/core/markdown_escape.py b/src/cnd/core/markdown_escape.py index 1ce0232..6a6a30f 100644 --- a/src/cnd/core/markdown_escape.py +++ b/src/cnd/core/markdown_escape.py @@ -67,3 +67,24 @@ def escape_block(text: str) -> str: """``escape_inline`` plus a block marker at the start of any line.""" text = text.replace("\r\n", "\n").replace("\r", "\n") return "\n".join(_escape_line_start(line) for line in escape_inline(text).split("\n")) + + +_DESTINATION_NEEDS_BRACKETS = re.compile(r"[\x00-\x20()<>\x7f]") + + +def escape_destination(url: str) -> str: + """``url`` as a link destination that parses back to ``url`` (a few + characters percent-encoded). + + CommonMark decodes backslash escapes and entities inside a + destination, so ``\\`` and ``&`` are escaped: ``javascript:`` + must not become ``javascript:`` once parsed. A destination holding a + space, a control character, a parenthesis or an angle bracket is + wrapped in ``<…>``; a line ending, which no destination may hold, is + percent-encoded. + """ + escaped = url.replace("\\", "\\\\").replace("&", "\\&") + escaped = escaped.replace("\r", "%0D").replace("\n", "%0A") + if _DESTINATION_NEEDS_BRACKETS.search(escaped): + return "<" + escaped.replace("<", "%3C").replace(">", "%3E") + ">" + return escaped diff --git a/src/cnd/core/node_text.py b/src/cnd/core/node_text.py index b5444aa..25b4305 100644 --- a/src/cnd/core/node_text.py +++ b/src/cnd/core/node_text.py @@ -4,17 +4,32 @@ from typing import Literal from uuid import UUID -from cnd.core.nodes import ListItem, TableNode +from cnd.core.nodes import ( + Cell, + CndNode, + CodeNode, + ImageNode, + MathNode, + ParagraphNode, + TableNode, + child_slots, + texts_of, +) # docs/proposals/0001-configurable-table-figure-rendering.md. # "placeholder" is the default everywhere: always the parseable placeholder, # regardless of content. "inline" always renders the content as text -# instead. "auto" defers to the node's own content_kind hint (see TableNode) -# with no classifier — an unset hint resolves to "placeholder" rather than -# being guessed. +# instead. "auto" defers to the table's ``kind`` (docs/proposals/0013): +# inline for "content"; "data", None and any unknown kind resolve to +# "placeholder" rather than being guessed. NodeTextMode = Literal["placeholder", "inline", "auto"] +def wants_inline_table(node: TableNode, mode: NodeTextMode) -> bool: + """Whether ``mode`` renders this table's cells rather than a placeholder.""" + return mode == "inline" or (mode == "auto" and node.kind == "content") + + def format_figure_placeholder( *, figure_id: UUID, @@ -26,13 +41,21 @@ def format_figure_placeholder( counter_label: str | None = None, shape: tuple[int, int] | None = None, row_labels: str | None = None, + description: str | None = None, + table_kind: str | None = None, + escape: Callable[[str], str] | None = None, ) -> str: """Build a parseable figure placeholder for chunk content. It carries everything the node says about itself except a table's body - cells (docs/proposals/0012). Attribute values are quoted, with ``\\`` - and ``"`` backslash-escaped, line breaks folded to spaces and ``]]`` - split, so the placeholder is always one line ending at its first ``]]``. + cells (docs/proposals/0012), in this order: kind, counter word, number, + caption, description, table kind, header row, size, row labels, + summary. Every value is plain text (marks dropped). Attribute values + are quoted, with ``\\`` and ``"`` backslash-escaped, line breaks folded + to spaces and ``]]`` split, so the placeholder is always one line + ending at its first ``]]``. ``escape``, when given, is applied to + every quoted value afterwards (a Markdown renderer passes its + free-text escaping, so a value cannot become Markdown syntax). """ attrs: dict[str, str] = {"kind": kind} if counter_label: @@ -41,6 +64,10 @@ def format_figure_placeholder( attrs["number"] = number if caption: attrs["caption"] = caption + if description: + attrs["description"] = description + if table_kind: + attrs["table_kind"] = table_kind if header_row: attrs["header"] = header_row if shape: @@ -49,11 +76,17 @@ def format_figure_placeholder( attrs["row_labels"] = row_labels if summary: attrs["summary"] = summary - formatted = " ".join(f'{key}="{_attr_value(value)}"' for key, value in attrs.items()) + quote = escape or (lambda value: value) + formatted = " ".join( + f'{key}="{quote(attr_value(value))}"' for key, value in attrs.items() + ) return f"[[figure:{figure_id} {formatted}]]" -def _attr_value(value: str) -> str: +def attr_value(value: str) -> str: + """A placeholder attribute value: ``\\`` and ``"`` backslash-escaped, + whitespace runs (line breaks included) folded to one space, ``]]`` + split.""" value = value.replace("\\", "\\\\").replace('"', '\\"') value = " ".join(value.split()) while "]]" in value: @@ -61,39 +94,33 @@ def _attr_value(value: str) -> str: return value -def render_list_markdown( - items: list[ListItem], - *, - ordered: bool, - depth: int = 0, - escape: Callable[[str], str] | None = None, -) -> str: - """Render a bullet or numbered list as markdown-like text. - - An ordered item prints its own ``number`` when the producer set one - (a list that starts at 5, or skips values), else its position. - """ - return "\n".join(_render_list_items(items, ordered, " " * depth, escape)) - - -def _render_list_items( - items: list[ListItem], - ordered: bool, - indent: str, - escape: Callable[[str], str] | None, -) -> list[str]: - lines: list[str] = [] - for index, item in enumerate(items, start=1): - number = item.number if item.number is not None else index - prefix = f"{number}." if ordered else "-" - text = escape(item.text) if escape else item.text - lines.append(f"{indent}{prefix} {text}") - if item.children: - # A child must start at its parent's content column, so the - # indent grows with the marker ("- " is 2, "1. " is 3, "10. " is 4). - child_indent = indent + " " * (len(prefix) + 1) - lines.extend(_render_list_items(item.children, ordered, child_indent, escape)) - return lines +def blocks_plain_text(nodes: list[CndNode]) -> str: + """The plain text of block nodes, in reading order, joined by spaces: + every text of every node (marks dropped), a code or math node's + source, an image's alt. Used where a one-line summary is needed.""" + parts: list[str] = [] + for node in nodes: + parts.extend(text.value for text in texts_of(node)) + if isinstance(node, (CodeNode, MathNode)): + parts.append(node.text) + elif isinstance(node, ImageNode) and node.alt: + parts.append(node.alt) + for slot in child_slots(node): + parts.append(blocks_plain_text(slot.nodes)) + return " ".join(part for part in parts if part) + + +def cell_plain_text(cell: Cell) -> str: + """A cell's content as one line of plain text.""" + return blocks_plain_text(cell.children) + + +def is_inline_cell(cell: Cell) -> bool: + """Whether a cell fits in a GFM table cell: empty, one paragraph, or + one image (docs/proposals/0013, "Rendering").""" + return len(cell.children) == 0 or ( + len(cell.children) == 1 and isinstance(cell.children[0], (ParagraphNode, ImageNode)) + ) def header_row_text(node: TableNode) -> str | None: @@ -103,25 +130,30 @@ def header_row_text(node: TableNode) -> str | None: id (docs/proposals/0001, "cheap to do on its own").""" if not node.cells: return None - header_rows = {cell.row for cell in node.cells if cell.is_header} - target_row = min(header_rows) if header_rows else 0 + flagged = {cell.row for cell in node.cells if cell.is_header} + target_row = min(flagged) if flagged else 0 row_cells = sorted((c for c in node.cells if c.row == target_row), key=lambda c: c.col) - texts = [c.text for c in row_cells if c.text] + texts = [text for text in map(cell_plain_text, row_cells) if text] return " | ".join(texts) if texts else None -def table_node_placeholder(node: TableNode) -> str: - """Placeholder string for a bare table or grid node. +def table_node_placeholder( + node: TableNode, escape: Callable[[str], str] | None = None +) -> str: + """Placeholder string for a bare table node. Caption and number live on a wrapping ``FigureNode`` when present — - a bare table placeholder carries only kind and header structure. + a bare table placeholder carries its kind, header structure, size and + row labels. ``escape`` is passed to ``format_figure_placeholder``. """ return format_figure_placeholder( figure_id=node.id, - kind=node.kind, + kind="table", + table_kind=node.kind, header_row=header_row_text(node), shape=table_shape(node), row_labels=row_labels_text(node), + escape=escape, ) @@ -139,7 +171,7 @@ def table_shape(node: TableNode) -> tuple[int, int] | None: return rows, cols -def _header_rows(node: TableNode) -> set[int]: +def header_rows(node: TableNode) -> set[int]: """Rows whose every cell is a header. A row merely *containing* a header cell is not one: row headers (the first column of a data table) flag a cell on every row. With no flag anywhere, row 0, the same @@ -155,15 +187,15 @@ def _header_rows(node: TableNode) -> set[int]: def row_labels_text(node: TableNode, cap: int = ROW_LABELS_CAP) -> str | None: """The first column's text below the header, joined — the labels a data table's rows are usually looked up by. None for a single-column table, - where the first column is the whole body. See :func:`_header_rows`.""" + where the first column is the whole body. See :func:`header_rows`.""" shape = table_shape(node) if shape is None or shape[1] < 2: return None - header_rows = _header_rows(node) + skipped = header_rows(node) labels = [ - cell.text + text for cell in sorted(node.cells, key=lambda c: c.row) - if cell.col == 0 and cell.row not in header_rows and cell.text + if cell.col == 0 and cell.row not in skipped and (text := cell_plain_text(cell)) ] if not labels: return None @@ -171,37 +203,43 @@ def row_labels_text(node: TableNode, cap: int = ROW_LABELS_CAP) -> str | None: return f"{kept} | … (+{len(labels) - cap})" if len(labels) > cap else kept -def _cell_text(text: str, escape: Callable[[str], str] | None) -> str: +def _gfm_cell(text: str, *, text_only: bool) -> str: """Cell text that cannot break the GFM row it sits in.""" - if escape: - text = escape(text) text = text.replace("\r\n", "\n").replace("\r", "\n") - return text.replace("|", "\\|").replace("\n", "
    ") - + return text.replace("|", "\\|").replace("\n", " " if text_only else "
    ") -def render_table_markdown(node: TableNode, *, escape: Callable[[str], str] | None = None) -> str: - """Render a table node's cells as a Markdown grid. - Spanned cells (``rowspan``/``colspan`` > 1) place their text once, at - the cell's own ``(row, col)`` — Markdown has no native merged-cell - syntax, so the rest of the span is left blank rather than repeated. - The separator row follows the header row (cells flagged ``is_header`` - when present, else row 0). Cell text has ``|`` escaped and newlines - turned into ``
    `` so the row keeps its column count; ``escape``, - when given, runs first on each cell. +def render_table_markdown( + node: TableNode, + *, + cell_text: Callable[[Cell], str] | None = None, + text_only: bool = False, +) -> str: + """Render a table node's cells as a GFM grid. + + ``cell_text`` turns a cell into inline Markdown; the default is the + cell's plain text. Call it only on a table whose cells pass + :func:`is_inline_cell`, or block content is flattened. Spanned cells + (``rowspan``/``colspan`` > 1) place their text once, at the cell's own + ``(row, col)`` — Markdown has no merged-cell syntax, so the rest of the + span is left blank rather than repeated. The separator row follows the + header row (cells flagged ``is_header`` when present, else row 0). + Each cell has ``|`` escaped and newlines turned into ``
    `` (a space + under ``text_only``) so the row keeps its column count. """ if not node.cells: return "" + render_cell = cell_text or cell_plain_text max_row = max(cell.row + cell.rowspan - 1 for cell in node.cells) max_col = max(cell.col + cell.colspan - 1 for cell in node.cells) grid: list[list[str]] = [["" for _ in range(max_col + 1)] for _ in range(max_row + 1)] - header_rows: set[int] = set() + flagged: set[int] = set() for cell in node.cells: - grid[cell.row][cell.col] = _cell_text(cell.text, escape) + grid[cell.row][cell.col] = _gfm_cell(render_cell(cell), text_only=text_only) if cell.is_header: - header_rows.add(cell.row) + flagged.add(cell.row) - header_row_index = min(header_rows) if header_rows else 0 + header_row_index = min(flagged) if flagged else 0 lines: list[str] = [] for row_index, row in enumerate(grid): lines.append("| " + " | ".join(row) + " |") @@ -216,9 +254,9 @@ def table_node_text(node: TableNode, *, mode: NodeTextMode = "placeholder") -> s Falls back to the placeholder if the resolved mode would render inline but the table has no cells to render (nothing to gain from switching). + Cells render as plain text here; ``MarkdownRenderer`` renders marks. """ - wants_inline = mode == "inline" or (mode == "auto" and node.content_kind == "content") - if wants_inline: + if wants_inline_table(node, mode): rendered = render_table_markdown(node) if rendered: return rendered diff --git a/src/cnd/core/nodes.py b/src/cnd/core/nodes.py index d23cb8d..ddc9ceb 100644 --- a/src/cnd/core/nodes.py +++ b/src/cnd/core/nodes.py @@ -5,6 +5,17 @@ from pydantic import BaseModel, Field +from cnd.core.text import ( # noqa: F401 (re-exported for existing imports) + CiteRef, + Edge, + FootnoteRef, + LinkRef, + NodeRef, + Numbering, + RawSource, + RichText, +) + class NodeLocation(BaseModel): """Layout facts the SDK cannot derive from the tree: the page where the @@ -25,175 +36,71 @@ 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 - +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).""" -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 +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) + state_metadata: dict[str, Any] = Field(default={}) 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) + children: list["CndNode"] = Field(default=[]) 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=[]) 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 @@ -201,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 - children: list["CndNode"] = Field(default_factory=list) + caption: RichText | None = None + description: RichText | None = None + numbering: Numbering | None = None + children: list["CndNode"] = Field(default=[]) 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=[]) + + 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=[]) 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) + items: list[ListItem] = Field(default=[]) 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=[]) class TermsNode(NodeBase): - """Definition list (Typst ``/ term: description`` items).""" + """Definition list.""" type: Literal["terms"] - tight: bool = True - items: list[TermItem] = Field(default_factory=list) + items: list[TermItem] = Field(default=[]) + + +class Cell(BaseModel): + """One cell of a ``table`` or a ``layout``; content is block nodes.""" + + row: int + col: int + rowspan: int = Field(default=1, ge=1) + colspan: int = Field(default=1, ge=1) + is_header: bool = False + children: list["CndNode"] = Field(default=[]) + + +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=[]) + + +class LayoutNode(NodeBase): + """A grid used for arrangement only; position carries no relation.""" + + type: Literal["layout"] + cells: list[Cell] = Field(default=[]) 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) @@ -283,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 @@ -294,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 @@ -305,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) @@ -318,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`` @@ -379,27 +375,33 @@ 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 document positions. ``_totals`` lets a caller (``Cnd.iter``) reuse a cached ``position_totals`` result. + + The ``path`` on each context is relative to the list given: it starts + at ``("nodes",)`` for any list unless a caller overrides the base. Only + ``Cnd.iter`` and ``Cnd.iter_all`` yield document paths in the sense of + spec section 8. """ 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) @@ -415,25 +417,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/src/cnd/core/render.py b/src/cnd/core/render.py index 4465de3..b33e21d 100644 --- a/src/cnd/core/render.py +++ b/src/cnd/core/render.py @@ -1,10 +1,14 @@ """Node renderers (docs/adr — rendering as an SDK renderer hierarchy). -Nodes are pure data: no node has a rendering method. Rendering a node to -text is done by a ``NodeRenderer`` — one abstract method per node type plus -a single concrete ``render()`` dispatch over the discriminated union. +Nodes are pure data: no node has a rendering method. Rendering a node +to text is done by a ``NodeRenderer`` — one abstract method per node +type plus a single concrete ``render()`` dispatch over the +discriminated union. ``MarkdownRenderer`` is the concrete content renderer; verbosity for tables/figures is constructor configuration, orthogonal to the format. +Every renderer renders the prose of a node (a ``RichText``) through +``render_text``, which goes through the one inline layer, +``cnd.core.inline``. "raw" is not a renderer: Pydantic's own ``repr()`` / ``model_dump_json()`` already are the raw representation. @@ -21,23 +25,32 @@ except ImportError: # pragma: no cover — Python 3.10, via pydantic's typing-extensions from typing_extensions import assert_never -from cnd.core.markdown_escape import escape_block +from cnd.core.inline import concat_texts, render_inline, with_whole_mark +from cnd.core.markdown_escape import escape_block, escape_destination, escape_inline from cnd.core.node_text import ( NodeTextMode, + attr_value, + cell_plain_text, format_figure_placeholder, header_row_text, - render_list_markdown, + header_rows, + is_inline_cell, render_table_markdown, row_labels_text, table_node_placeholder, table_shape, + wants_inline_table, ) from cnd.core.nodes import ( + KNOWN_ADMONITION_KINDS, + AdmonitionNode, + Cell, CndNode, CodeNode, FigureNode, HeadingNode, ImageNode, + LayoutNode, ListNode, MathNode, ParagraphNode, @@ -45,31 +58,36 @@ TableNode, TermsNode, ) +from cnd.core.text import RichText +from cnd.core.urls import is_safe_image_source - -_BACKTICK_RUN = re.compile(r"`+") -_DEST_NEEDS_BRACKETS = re.compile(r"[ ()<>]") +__all__ = ["MarkdownRenderer", "NodeRenderer", "expand_headings", "holds_several_blocks"] -def _image_destination(path: str) -> str: - if _DEST_NEEDS_BRACKETS.search(path): - return "<" + path.replace("<", "%3C").replace(">", "%3E") + ">" - return path +def _one_line(text: str) -> str: + """``text`` with every line ending (CR, LF, CRLF) made a space.""" + return text.replace("\r\n", " ").replace("\r", " ").replace("\n", " ") def _unix_newlines(text: str) -> str: return text.replace("\r\n", "\n").replace("\r", "\n") -_ALT_ESCAPE = re.compile(r"\\(?=[!-/:-@\[-`{-~]|\Z)|[\[\]]") +_ALT_ESCAPE = re.compile(r"\\(?=[!-/:-@\[-`{-~]|\Z)|[\[\]`<]") def _escape_alt(alt: str) -> str: - """Escape ``[``/``]``; double a backslash only where it would escape - something (before ASCII punctuation or at the end), as escape_inline does.""" + """Escape ``[``, ``]``, a backtick and ``<``; double a backslash only + where it would escape something (before ASCII punctuation or at the + end), as escape_inline does. A code span, an autolink or raw HTML + binds tighter than the image brackets, so an unescaped backtick or + ``<`` in the alt could reach into the destination.""" return _ALT_ESCAPE.sub(lambda m: "\\\\" if m.group() == "\\" else "\\" + m.group(), alt) +_BACKTICK_RUN = re.compile(r"`+") + + def _fence_for(text: str) -> str: """A backtick fence longer than any backtick run inside ``text``. @@ -81,14 +99,77 @@ def _fence_for(text: str) -> str: return "`" * max(3, longest + 1) +def _prefixed(lines: list[str], marker: str) -> str: + """Prefix every line (``"> "``, or ``">"`` on a blank line).""" + return "\n".join(f"{marker} {line}" if line else marker for line in lines) + + +def _indented(text: str, first: str, indent: str) -> str: + """``first`` before the first line, ``indent`` before every other + non-blank line: how a list item or a definition holds its blocks.""" + lines = text.split("\n") + head = f"{first}{lines[0]}".rstrip() + rest = [f"{indent}{line}" if line else "" for line in lines[1:]] + return "\n".join([head, *rest]) + + +def expand_headings(nodes: list[CndNode]) -> list[CndNode]: + """``nodes`` with every heading followed by its section, recursively, + in pre-order. + + A renderer emits only the line of a heading; the converters' body walk + visits the section under it. A heading nested in a container (a quote, + an item, a cell, a figure) or held by a footnote is not on that walk, + so whatever renders the container's or the footnote's blocks renders + this list instead of the raw children, or the section would be lost. + """ + out: list[CndNode] = [] + for node in nodes: + out.append(node) + if isinstance(node, HeadingNode): + out.extend(expand_headings(node.children)) + return out + + +def _is_one_inline_block(nodes: list[CndNode]) -> bool: + """Whether ``nodes`` is at most one paragraph or image: content a + tight sibling can follow on the next line. After any other block (a + quote, a list, a fence) the next line could continue that block, so + a blank line has to separate them.""" + expanded = expand_headings(nodes) + return not expanded or ( + len(expanded) == 1 and isinstance(expanded[0], (ParagraphNode, ImageNode)) + ) + + +def holds_several_blocks(nodes: list[CndNode]) -> bool: + """Whether an item, a cell or a definition holding ``nodes`` needs + its blocks apart, as a loose list item does. + + A nested list does not count: ``1. Parent`` over a nested list stays + tight, as in CommonMark (docs/proposals/0013, "Lists and terms hold + blocks"). A nested list followed by any other block does: tight, the + block's first line would continue the nested list's last item.""" + expanded = expand_headings(nodes) + if any(isinstance(node, ListNode) for node in expanded[:-1]): + return True + return sum(not isinstance(node, ListNode) for node in expanded) > 1 + + +def _is_loose(blocks_per_item: list[list[CndNode]]) -> bool: + """A list is tight unless one of its items holds several blocks + (``holds_several_blocks``).""" + return any(holds_several_blocks(nodes) for nodes in blocks_per_item) + + class NodeRenderer(ABC): """Renders any ``CndNode`` to text, one method per node type. - Subclasses implement every ``render_*`` method; ``render()`` is the - concrete entry point that dispatches on the node's type. The double - lock: the ABC machinery rejects incomplete subclasses at runtime, and - ``assert_never`` fails exhaustiveness at type-check when the union - grows. + Subclasses implement every ``render_*`` method and ``render_text``; + ``render()`` is the concrete entry point that dispatches on the node's + type. The double lock: the ABC machinery rejects incomplete subclasses + at runtime, and ``assert_never`` fails exhaustiveness at type-check + when the union grows. """ def render(self, node: CndNode) -> str: @@ -114,9 +195,17 @@ def render(self, node: CndNode) -> str: return self.render_list(node) case TermsNode(): return self.render_terms(node) + case AdmonitionNode(): + return self.render_admonition(node) + case LayoutNode(): + return self.render_layout(node) case _: assert_never(node) + @abstractmethod + def render_text(self, text: RichText) -> str: + """Render one rich text (a paragraph's text, a caption, a term…).""" + @abstractmethod def render_heading(self, node: HeadingNode) -> str: ... @@ -147,12 +236,22 @@ def render_list(self, node: ListNode) -> str: ... @abstractmethod def render_terms(self, node: TermsNode) -> str: ... + @abstractmethod + def render_admonition(self, node: AdmonitionNode) -> str: ... + + @abstractmethod + def render_layout(self, node: LayoutNode) -> str: ... + _CLOSING_HASHES = re.compile(r"(?:^|(?<=[ \t]))(#+)[ \t]*$") class MarkdownRenderer(NodeRenderer): - """Render nodes as CommonMark-ish Markdown text. + """Render nodes as CommonMark + GFM Markdown text. + + A container renders its nested nodes itself (a list its items' blocks, + a table its cells', a quote its children), so a caller renders the + top of a subtree once and never its descendants again. ``tables`` and ``figures`` set the verbosity for the two float-like node types (docs/proposals/0001): @@ -160,21 +259,31 @@ class MarkdownRenderer(NodeRenderer): - ``"placeholder"`` (default) — a parseable ``[[figure:id ...]]`` placeholder. - ``"inline"`` — always render content (cells / children) as text. - - ``"auto"`` — defer to the table's ``content_kind`` hint: inline when - ``"content"``, placeholder when ``"data"`` or unset (never guessed). - For a figure, ``auto`` looks at the wrapped table's hint. - - Two further flags default to off: - - - ``escape`` — escape free text (paragraphs, headings, list items, - terms, table cells, figure captions) so it cannot start Markdown - syntax. Code and math text are never escaped. - - ``heading_numbers`` — prefix each heading with its ``counter_label`` - and ``number`` when it has them (never escaped). - - Both default to off because plain-text consumers (chunk text, - embeddings) must not receive backslashes, and heading numbers would - change their text. ``MarkdownConverter`` turns both on. + - ``"auto"`` — defer to the table's ``kind``: inline when + ``"content"``, placeholder when ``"data"``, unset or unknown (never + guessed). For a figure, ``auto`` looks at the wrapped tables. + + A ``layout`` is not a table: it is always flattened, cell contents in + reading order. + + Three further flags default to off: + + - ``escape`` — escape free text (every rich text, image alt aside) so + it cannot start Markdown syntax. Code and math are never escaped. + - ``heading_numbers`` — prefix each heading with its numbering + (``name`` and ``value``, escaped under ``escape`` like free text). + - ``text_only`` — no HTML in the output: ``sup`` and ``sub`` render + as ``^x`` / ``_x`` (``^(text)`` / ``_(text)`` over several + characters), ``underline`` and ``highlight`` as plain text, a mark + Markdown cannot delimit at its position is dropped instead of + becoming an HTML tag, a table whose cells hold blocks becomes + linearized rows instead of an HTML table, and a line break in a + table cell becomes a space instead of ``
    ``. + + ``escape`` and ``heading_numbers`` default to off because plain-text + consumers (chunk text, embeddings) must not receive backslashes, and + heading numbers would change their text. ``MarkdownConverter`` turns + both on. """ def __init__( @@ -184,117 +293,280 @@ def __init__( figures: NodeTextMode = "placeholder", escape: bool = False, heading_numbers: bool = False, + text_only: bool = False, ) -> None: self.tables = tables self.figures = figures self.escape = escape self.heading_numbers = heading_numbers + self.text_only = text_only + + def render_text(self, text: RichText) -> str: + return render_inline(text, "markdown", escape=self.escape, text_only=self.text_only) - def _text(self, text: str) -> str: + def _plain(self, text: str) -> str: + """A plain string (a kind, a title, a column header) as Markdown.""" return escape_block(text) if self.escape else text + def _quoted(self, value: str) -> str: + """A placeholder's quoted value as Markdown: escaped under + ``escape`` so that it cannot become a link, an image or HTML.""" + return escape_inline(value) if self.escape else value + + def _blocks(self, nodes: list[CndNode], separator: str = "\n\n") -> str: + rendered = (self.render(node).strip("\n") for node in expand_headings(nodes)) + return separator.join(block for block in rendered if block) + + def _prefixed_blocks(self, nodes: list[CndNode], prefix: str, separator: str = "\n\n") -> str: + """``nodes`` rendered as blocks behind an inline ``prefix`` (a + definition's ``: ``, a task item's ``[ ] ``, a row's ``Label: ``). + + The prefix shares the first line with a paragraph or an image. Any + other first block (a fence, a list, a quote, a table, a heading) + only parses at the start of a line, so the prefix stands on a line + of its own and the blocks start on the next one; the caller indents + that line to the content column like the others. + """ + content = self._blocks(nodes, separator) + if not content: + return "" + if not prefix: + return content + first = next( + (node for node in expand_headings(nodes) if self.render(node).strip("\n")), None + ) + if isinstance(first, (ParagraphNode, ImageNode)): + return f"{prefix}{content}" + return f"{prefix.rstrip()}\n{content}" + def render_heading(self, node: HeadingNode) -> str: - text = self._text(node.text) + text = self.render_text(node.text) if self.escape and (m := _CLOSING_HASHES.search(text)): # CommonMark drops a closing "#" sequence from an ATX heading. text = f"{text[: m.start(1)]}\\{text[m.start(1):]}" - if self.heading_numbers: - prefix = " ".join(p for p in (node.counter_label, node.number) if p) - if prefix: - text = f"{prefix} {text}" + if self.heading_numbers and node.numbering is not None: + prefix = " ".join(p for p in (node.numbering.name, node.numbering.value) if p) + prefix = _one_line(prefix) + text = f"{escape_inline(prefix) if self.escape else prefix} {text}" return f"{'#' * node.level} {text}" def render_paragraph(self, node: ParagraphNode) -> str: - return self._text(node.text) - - def render_table(self, node: TableNode) -> str: - wants_inline = self.tables == "inline" or ( - self.tables == "auto" and node.content_kind == "content" - ) - if wants_inline: - rendered = render_table_markdown( - node, escape=self._text if self.escape else None - ) - if rendered: - return rendered - return table_node_placeholder(node) + return self.render_text(node.text) def render_quote(self, node: QuoteNode) -> str: - lines = self._text(_unix_newlines(node.text)).split("\n") - if node.attribution: - attribution = f"— {self._text(_unix_newlines(node.attribution))}" - lines += ["", *attribution.split("\n")] - return "\n".join(f"> {line}" if line else ">" for line in lines) + body = _unix_newlines(self._blocks(node.children)) + lines = body.split("\n") if body else [] + if node.attribution is not None: + attribution = f"— {_unix_newlines(self.render_text(node.attribution))}" + lines += ["", *attribution.split("\n")] if lines else attribution.split("\n") + return _prefixed(lines or [""], ">") + + def render_admonition(self, node: AdmonitionNode) -> str: + """A known kind is a GFM alert (``> [!WARNING]``), its title a bold + line under the marker. An unknown kind is a quote whose first line + is the title in bold, or the kind when there is no title.""" + title = ( + self.render_text(with_whole_mark(node.title, "strong")) + if node.title is not None + else None + ) + if node.kind in KNOWN_ADMONITION_KINDS: + head = [f"[!{node.kind.upper()}]"] + ([title] if title else []) + else: + head = [title or self.render_text(with_whole_mark(RichText(value=node.kind), "strong"))] + body = _unix_newlines(self._blocks(node.children)) + lines = _unix_newlines("\n".join(head)).split("\n") + if body: + lines += ["", *body.split("\n")] + return _prefixed(lines, ">") def render_code(self, node: CodeNode) -> str: fence = _fence_for(node.text) opening = f"{fence}{node.lang or ''}".rstrip() - return f"{opening}\n{node.text}\n{fence}" + # The closing fence goes on a line of its own: a text that already + # ends in a newline would otherwise gain a blank line in the block. + text = node.text[:-1] if node.text.endswith("\n") else node.text + block = f"{opening}\n{text}\n{fence}" + if node.title: + title = " ".join(_unix_newlines(node.title).split("\n")) + return f"{self._plain(title)}\n{block}" + return block def render_math(self, node: MathNode) -> str: - return node.text + """A fenced block with the info string ``math``, holding the + producer's source when it is LaTeX (``raw.format``, any case), else + ``text``: a ``math`` fence is read as LaTeX, so Typst or any other + source would display wrongly. The numbering ``value`` follows on + its own line, in parentheses.""" + raw = node.raw + latex = raw is not None and raw.format.lower() == "latex" + source = raw.value if latex else node.text + source = source[:-1] if source.endswith("\n") else source + fence = _fence_for(source) + block = f"{fence}math\n{source}\n{fence}" + value = node.numbering.value if node.numbering is not None else "" + if not value: + return block + if not (value.startswith("(") and value.endswith(")")): + value = f"({value})" + return f"{block}\n{self._plain(value)}" + + def render_image(self, node: ImageNode) -> str: + """``![alt](path)`` when the path is a safe source + (``cnd.core.urls.is_safe_image_source``), else the + ``[[image:id alt="…"]]`` placeholder. A line ending in the alt + becomes a space, and the destination is escaped so it parses back + to the checked path: neither can start a block of its own.""" + alt = _one_line(node.alt or "") + if node.path and is_safe_image_source(node.path): + return f"![{_escape_alt(alt)}]({escape_destination(node.path)})" + if alt: + return f'[[image:{node.id} alt="{self._quoted(attr_value(alt))}"]]' + return f"[[image:{node.id}]]" + + def render_list(self, node: ListNode) -> str: + """Bullets or numbers; ``checked`` adds ``[x]``/``[ ]``. An ordered + item prints its own ``number`` when the producer set one (a list + that starts at 5, or skips values), else its position. An item's + blocks start at its content column, so the indent grows with the + marker (``- `` is 2, ``1. `` is 3, ``10. `` is 4).""" + loose = _is_loose([item.children for item in node.items]) + separator = "\n\n" if loose else "\n" + out: list[str] = [] + for index, item in enumerate(node.items, start=1): + number = item.number if item.number is not None else index + marker = f"{number}." if node.ordered else "-" + box = "" if item.checked is None else ("[x] " if item.checked else "[ ] ") + content = self._prefixed_blocks(item.children, box, "\n\n" if loose else "\n") + out.append(_indented(content, f"{marker} ", " " * (len(marker) + 1))) + return separator.join(out) + + def render_terms(self, node: TermsNode) -> str: + """``**term**`` then ``: definition``; a definition's further + blocks are indented under it.""" + loose = any(not _is_one_inline_block(item.children) for item in node.items) + out: list[str] = [] + for item in node.items: + term = self.render_text(with_whole_mark(item.term, "strong")) + definition = self._prefixed_blocks(item.children, ": ") + if definition: + term = f"{term}\n{_indented(definition, '', ' ')}" + out.append(term) + return ("\n\n" if loose else "\n").join(out) + + def render_layout(self, node: LayoutNode) -> str: + cells = sorted(node.cells, key=lambda cell: (cell.row, cell.col)) + return self._blocks([child for cell in cells for child in cell.children]) + + def render_table(self, node: TableNode) -> str: + if not wants_inline_table(node, self.tables) or not node.cells: + return table_node_placeholder(node, escape=self._quoted) + if all(is_inline_cell(cell) for cell in node.cells): + return render_table_markdown(node, cell_text=self._gfm_cell, text_only=self.text_only) + if self.text_only: + return self._linear_rows(node) + return self._html_table(node) + + def _gfm_cell(self, cell: Cell) -> str: + return self.render(cell.children[0]) if cell.children else "" + + def _html_table(self, node: TableNode) -> str: + """A table whose cells hold blocks, as one HTML block. + + Markdown is not parsed inside an HTML block and a blank line ends + it, so the cells are rendered as HTML and every newline becomes + `` ``, which keeps a ``
    `` intact. The import is local
    +        because ``HtmlNodeRenderer`` lives with its converter: moving it
    +        into the core is a spec §7 decision (see ``cnd.converters.html``).
    +        """
    +        from cnd.converters.html import HtmlNodeRenderer
    +
    +        # The converter already appends every link to the marker line.
    +        return HtmlNodeRenderer(links=False).render_table(node).replace("\n", "
    ")
    +
    +    def _linear_rows(self, node: TableNode) -> str:
    +        """Under ``text_only``, a table whose cells hold blocks becomes one
    +        bullet per body row; each cell's blocks follow its column header
    +        (``Header: …``)."""
    +        skipped = header_rows(node)
    +        body = sorted({cell.row for cell in node.cells} - skipped)
    +        labels: dict[int, str] = {}
    +        if body and skipped:
    +            top = min(skipped)
    +            for cell in node.cells:
    +                if cell.row == top:
    +                    for col in range(cell.col, cell.col + max(cell.colspan, 1)):
    +                        labels[col] = cell_plain_text(cell)
    +        else:
    +            body = sorted({cell.row for cell in node.cells})
    +        rows: list[list[list[CndNode]]] = []
    +        out: list[str] = []
    +        for row in body:
    +            cells = sorted((c for c in node.cells if c.row == row), key=lambda c: c.col)
    +            rows.append([expand_headings(cell.children) for cell in cells])
    +            parts: list[str] = []
    +            for cell in cells:
    +                label = labels.get(cell.col)
    +                prefix = f"{self._plain(label)}: " if label else ""
    +                content = self._prefixed_blocks(cell.children, prefix)
    +                if not content:
    +                    continue
    +                parts.append(content)
    +            several = any(not _is_one_inline_block(children) for children in rows[-1])
    +            out.append(_indented(("\n\n" if several else "\n").join(parts), "- ", "  "))
    +        loose = any(not _is_one_inline_block(children) for cells in rows for children in cells)
    +        return ("\n\n" if loose else "\n").join(out)
     
         def render_figure(self, node: FigureNode) -> str:
             wants_inline = self.figures == "inline" or (
                 self.figures == "auto"
                 and any(
    -                isinstance(child, TableNode) and child.content_kind == "content"
    +                isinstance(child, TableNode) and child.kind == "content"
                     for child in node.children
                 )
             )
             if wants_inline and node.children:
    -            parts = [self.render(child) for child in node.children]
    -            caption_line = self._figure_caption_line(node)
    -            if caption_line:
    -                parts.append(caption_line)
    -            return "\n\n".join(parts)
    -        if wants_inline and node.raw is None:
    +            parts = [self.render(child) for child in expand_headings(node.children)]
    +            parts += self._figure_caption_parts(node)
    +            return "\n\n".join(part for part in parts if part)
    +        if wants_inline and node.raw is None and (node.caption or node.numbering):
                 # A figure carrying only raw source is unconvertible content: its
                 # placeholder must stay, a bare caption would hide that.
    -            caption_line = self._figure_caption_line(node)
    -            if caption_line and (node.caption or node.number):
    -                return caption_line
    +            return "\n\n".join(self._figure_caption_parts(node))
             table = self._figure_table(node)
    +        numbering = node.numbering
             return format_figure_placeholder(
                 figure_id=node.id,
                 kind=node.kind or self._infer_figure_kind(node),
    -            caption=node.caption,
    -            number=node.number,
    -            counter_label=node.counter_label,
    +            caption=node.caption.value if node.caption else None,
    +            description=node.description.value if node.description else None,
    +            number=numbering.value if numbering else None,
    +            counter_label=numbering.name if numbering else None,
    +            table_kind=table.kind if table else None,
                 header_row=header_row_text(table) if table else None,
                 shape=table_shape(table) if table else None,
                 row_labels=row_labels_text(table) if table else None,
                 summary=self._figure_summary(node),
    +            escape=self._quoted,
             )
     
    -    def render_image(self, node: ImageNode) -> str:
    -        if node.path:
    -            return f"![{_escape_alt(node.alt or '')}]({_image_destination(node.path)})"
    -        if node.alt:
    -            return f'[[image:{node.id} alt="{node.alt}"]]'
    -        return f"[[image:{node.id}]]"
    -
    -    def render_list(self, node: ListNode) -> str:
    -        return render_list_markdown(
    -            node.items,
    -            ordered=node.ordered,
    -            escape=self._text if self.escape else None,
    -        )
    -
    -    def render_terms(self, node: TermsNode) -> str:
    -        return "\n".join(
    -            f"**{self._text(item.term)}**\n: {self._text(item.description)}" for item in node.items
    -        )
    -
    -    def _figure_caption_line(self, node: FigureNode) -> str | None:
    +    def _figure_caption_parts(self, node: FigureNode) -> list[str]:
    +        """The caption line (``*Figure 3: caption*``), then the description
    +        as a paragraph (docs/proposals/0013, "Figures")."""
             # Composing "Figure 3" from its parts is the renderer's job, which is
             # why the format keeps them apart (docs/proposals/0010).
    -        counter = " ".join(
    -            part for part in (node.counter_label, node.number) if part
    +        numbering = node.numbering
    +        counter = (
    +            " ".join(p for p in (numbering.name, numbering.value) if p) if numbering else ""
             )
    -        caption = self._text(node.caption) if node.caption else None
    -        title = ": ".join(part for part in (counter, caption) if part)
    -        return f"*{title}*" if title else None
    +        separator = ": " if counter and node.caption else ""
    +        title = concat_texts(counter, separator, node.caption)
    +        parts: list[str] = []
    +        if title.value:
    +            parts.append(self.render_text(with_whole_mark(title, "emph")))
    +        if node.description is not None:
    +            parts.append(self.render_text(node.description))
    +        return parts
     
         @staticmethod
         def _infer_figure_kind(node: FigureNode) -> str:
    @@ -317,8 +589,6 @@ def _child_summary(child: CndNode) -> str:
             match child:
                 case ImageNode():
                     return child.alt or child.path or "image"
    -            case TableNode():
    -                return child.kind
                 case CodeNode():
                     return f"code ({child.lang})" if child.lang else "code"
                 case _:
    diff --git a/src/cnd/core/text.py b/src/cnd/core/text.py
    new file mode 100644
    index 0000000..c4a7de5
    --- /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=[])
    +    refs: list[NodeRef] = Field(default=[])
    +    cites: list[CiteRef] = Field(default=[])
    +    footnotes: list[FootnoteRef] = Field(default=[])
    +    links: list[LinkRef] = Field(default=[])
    +
    +    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/src/cnd/core/urls.py b/src/cnd/core/urls.py
    new file mode 100644
    index 0000000..a5e24b5
    --- /dev/null
    +++ b/src/cnd/core/urls.py
    @@ -0,0 +1,47 @@
    +"""Which URLs a renderer may emit as a link target or an image source.
    +
    +A CND carries an ``href`` (``LinkRef``) or an image ``path`` as the
    +producer wrote it. A renderer that turns one into a live link must not
    +hand a reader ``javascript:`` or another scheme that runs code, so every
    +renderer asks the same question here.
    +"""
    +
    +import re
    +
    +__all__ = ["is_safe_image_source", "is_safe_url"]
    +
    +SAFE_SCHEMES = frozenset({"http", "https", "mailto"})
    +"""Schemes a renderer may link to; a URL without a scheme is relative."""
    +
    +_SCHEME = re.compile(r"([A-Za-z][A-Za-z0-9+.\-]*):")
    +_LEADING = re.compile(r"^[\s\x00-\x20\ufeff]+")
    +_TAB_OR_NEWLINE = re.compile(r"[\t\n\r]")
    +
    +
    +def _normalized(url: str) -> str:
    +    """``url`` as its most lenient reader reads its scheme: leading C0
    +    controls and every leading Unicode whitespace (and a BOM) stripped,
    +    as a browser strips the first and a Markdown parser or a ``trim()``
    +    the second; every tab and newline removed (WHATWG URL). Stripping
    +    more than one reader does only makes the check stricter."""
    +    return _TAB_OR_NEWLINE.sub("", _LEADING.sub("", url))
    +
    +
    +def url_scheme(url: str) -> str | None:
    +    """The lower-cased scheme of ``url``, or ``None`` when it has none (a
    +    relative path, a fragment, a query)."""
    +    match = _SCHEME.match(_normalized(url))
    +    return match.group(1).lower() if match else None
    +
    +
    +def is_safe_url(url: str) -> bool:
    +    """Whether ``url`` may become a link target: its scheme is ``http``,
    +    ``https`` or ``mailto`` (any case), or it has no scheme at all."""
    +    scheme = url_scheme(url)
    +    return scheme is None or scheme in SAFE_SCHEMES
    +
    +
    +def is_safe_image_source(path: str) -> bool:
    +    """Whether ``path`` may become an image ``src``: a safe URL
    +    (``is_safe_url``) or an inline ``data:image/…`` URI."""
    +    return is_safe_url(path) or _normalized(path).lower().startswith("data:image/")
    diff --git a/src/cnd/core/validate.py b/src/cnd/core/validate.py
    index 485b854..094d363 100644
    --- a/src/cnd/core/validate.py
    +++ b/src/cnd/core/validate.py
    @@ -12,12 +12,20 @@
     
     The function is a value function — CND in, list of violations out — so
     it crosses any boundary (docs/adr/0019 §5).
    +
    +Invariants checked: ``cnd_version`` is the version this SDK implements;
    +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 CND_VERSION, BibEntry, Cnd, DocMetadata, Footnote
    +from cnd.core.nodes import CndNode, texts_of
    +from cnd.core.text import ATOMIC_MARK_KINDS, LinkRef, RichText
     
     # The lifted bibliographic fields; an entry satisfies the content floor
     # with a `formatted` string or with at least one of these.
    @@ -60,13 +68,49 @@ def validate(cnd: Cnd) -> list[Violation]:
         been raised by the time a ``Cnd`` exists.
         """
         violations: list[Violation] = []
    +    violations += _check_cnd_version(cnd)
    +    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 _check_cnd_version(cnd: Cnd) -> list[Violation]:
    +    """The CND declares the format version this SDK implements (spec §11).
    +
    +    Before 1.0.0 a minor version may change what any field means
    +    (docs/adr/0027), so a CND stamped with another version is not
    +    conformant to this one even when its shape happens to parse.
    +    """
    +    if cnd.cnd_version == CND_VERSION:
    +        return []
    +    return [
    +        Violation(
    +            "cnd-version-unsupported",
    +            f"this SDK implements CND {CND_VERSION} only; there is no "
    +            f"upgrader between pre-1.0 versions",
    +            f"cnd_version {cnd.cnd_version}",
    +        )
    +    ]
    +
    +
    +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 +136,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 +166,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 +214,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 +257,138 @@ 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 = edge.href if isinstance(edge, LinkRef) else edge.label
    +                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 mark.start == mark.end and 0 <= mark.start <= length:
    +            out.append(
    +                Violation(
    +                    "mark-out-of-bounds",
    +                    f"{mark.kind} mark [{mark.start}, {mark.end}) is empty",
    +                    where,
    +                )
    +            )
    +        elif 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/src/cnd/declaration.py b/src/cnd/declaration.py
    index 048f159..25096ee 100644
    --- a/src/cnd/declaration.py
    +++ b/src/cnd/declaration.py
    @@ -20,53 +20,77 @@
     - ``id`` — the builder mints node ids; they are not durable anyway
       (ADR 0015), so a producer supplying them would only be supplying noise;
     - ``location`` — an unpaginated source has no page (ADR 0019);
    -- ``number`` — the resolved running counter value ("2.1.1", "3"), which
    -  needs a counter engine the declarative producer does not have. Its one
    -  authored half — a list item's start override — survives on
    -  ``DeclListItem``;
    +- ``numbering.value`` — the resolved running counter value ("2.1.1",
    +  "3"), which needs a counter engine the declarative producer does not
    +  have;
     - ``heading_path`` — derivable from the node's position in the tree.
     
     What it keeps is authored content: the ``label`` (the only durable
    -identity, and it lives in the source — ADR 0015/0017), the link families
    -keyed by label, and every content field. It also keeps **``counter_label``**
    -on the counter-bearing nodes — unlike ``number`` this has an authored half
    -the builder cannot derive: a heading or equation has no ``kind`` to derive
    -a word from, and a figure with a custom ``kind`` carries a word the author
    -supplied and nothing else encodes (the very reason it is a field, per
    -proposal 0010). Dropping it while keeping the list-number override would
    -be the same authored/derived split resolved two opposite ways. The value objects that carry no
    -presentation state — table cells, the link edges, dates, raw source — are
    -the CND's own, reused rather than re-declared, because a table cell is a
    -table cell in either form. Only the node types are defined fresh, so the
    -declaration is free to evolve without dragging the CND along.
    +identity, and it lives in the source — ADR 0015/0017), every text with
    +its marks and link families, and every content field. It also keeps
    +**``numbering.name``** on the counter-bearing nodes: unlike the value,
    +the counter word has an authored half the builder cannot derive — a
    +heading or equation has no ``kind`` to derive a word from, and a figure
    +with a custom ``kind`` carries a word only the author knows
    +(docs/proposals/0013, "Numbering"). A list item likewise keeps
    +``number``, as an authored *override* of the sequential ordinal.
    +
    +**Shorthand** (docs/proposals/0013, "Declaration"). Wherever the CND
    +holds a ``RichText``, a declaration accepts a plain string, which builds
    +``{"value": ""}`` with no marks and no edges, or the full object.
    +The full object *is* the CND's ``RichText``, so everything a text can
    +carry — marks, ``refs``, ``cites``, ``footnotes`` and ``links`` — is
    +available to a producer that computes offsets. The shorthand lives here
    +only: the CND models stay strict about their shape.
    +
    +The value objects that carry no presentation state — ``RichText``, its
    +marks and edges, ``RawSource``, the document metadata — are the CND's
    +own, reused rather than re-declared. Everything that holds nested nodes
    +(list items, term items, cells, footnotes) is declared fresh, because a
    +CND node requires an ``id`` a declaration never carries.
     """
     
     from typing import Annotated, Any, Literal, Union
     
    -from pydantic import BaseModel, ConfigDict, Field
    +from pydantic import AfterValidator, BaseModel, ConfigDict, Field, model_validator
     
     # Authored-content value objects, shared verbatim with the CND: they carry
     # no id and no presentation state, so there is nothing to strip.
     from cnd.core.cnd import DocDate, DocMetadata, SourceInfo
    -from cnd.core.nodes import (
    +from cnd.core.text import (
         CiteRef,
         FootnoteRef,
    +    LinkRef,
    +    Mark,
         NodeRef,
         RawSource,
    -    TableCell,
    -    TermItem,
    +    RichText,
     )
     
    -DECLARATION_VERSION = "0.1.0"
    +DECLARATION_VERSION = "0.2.0"
     """The version of the declaration schema (ADR 0019 §3).
     
    -Versioned independently of ``cnd_version``: the declaration is a distinct,
    -non-normative surface, and its first cut is not tied to the format's 0.3.0.
    -Without this field the first evolution of the declaration would break every
    -producer silently.
    +Versioned independently of ``cnd_version``. ``0.2.0`` is the first
    +breaking change: texts became rich text, node-level link families moved
    +into them, ``number`` and ``counter_label`` became ``numbering.name``, and
    +list items, term items, cells and footnotes hold nodes
    +(docs/proposals/0013). A ``0.1.0`` declaration is refused by the builder
    +rather than misread.
     """
     
     
    +def _as_rich_text(value: "str | RichText") -> RichText:
    +    """The shorthand: a plain string is a text with no marks and no edges."""
    +    return RichText(value=value) if isinstance(value, str) else value
    +
    +
    +DeclText = Annotated[Union[str, RichText], AfterValidator(_as_rich_text)]
    +"""A ``RichText`` slot in a declaration: a plain string or the full object.
    +
    +Typed as the union so the generated schema says ``anyOf: [string,
    +RichText]``; after validation the value is always a ``RichText``."""
    +
    +
     class _Strict(BaseModel):
         """Base for the declaration-specific models: an unknown field is a
         **rejection**, not a silent drop.
    @@ -79,12 +103,13 @@ class _Strict(BaseModel):
         not need silent tolerance here — ``declaration_version`` signals a
         version mismatch explicitly.
     
    -    **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
    -    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
    +    **This strictness stops at the reused value objects.**
    +    ``extra="forbid"`` is per model and does not reach what the
    +    declaration shares with the CND (``RichText``, its marks and edges,
    +    ``DocMetadata``): those keep the CND's lenient config, since forbidding
    +    on them would change CND parsing. A stray key on a node, a list item,
    +    a term item, a cell, a footnote or at the top level is caught; one
    +    *inside* a text object or a link edge is not. The open extension bags
         (``state_metadata``, ``fields``) are dict-typed and unaffected — they
         still hold arbitrary keys.
         """
    @@ -92,26 +117,32 @@ class _Strict(BaseModel):
         model_config = ConfigDict(extra="forbid")
     
     
    +class DeclNumbering(_Strict):
    +    """The authored half of ``Numbering``: the counter word alone.
    +
    +    Present means "this element carries this word" ("Listing",
    +    "Théorème"); the builder's counter engine supplies the value. There is
    +    no ``value`` field, so a declaration cannot carry one.
    +    """
    +
    +    name: str | None = None
    +
    +
     class DeclNodeBase(_Strict):
         """Shared fields for every declaration node.
     
         The CND's ``NodeBase`` minus ``id`` and ``location``: a declaration
    -    supplies neither. ``label`` is the durable identity and is authored;
    -    the link families are authored and resolve by label (ADR 0017).
    +    supplies neither. ``label`` is the durable identity and is authored.
    +    The link families live in the node's texts, as in the CND.
         """
     
         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)
         state_metadata: dict[str, Any] = Field(default_factory=dict)
     
     
     class DeclHeadingNode(DeclNodeBase):
    -    """A section heading. No ``number`` (the builder's counter engine
    -    resolves it) and no ``heading_path`` (derivable from tree position).
    -    ``counter_label`` is kept: a heading has no ``kind`` to derive a word
    -    from, so any word ("Chapter", "Partie") is authored.
    +    """A section heading. No ``heading_path`` (derivable from tree
    +    position) and no resolved number.
     
         ``level`` starts at 1 — there is no level 0. Constraining it here
         makes the invalid input unrepresentable rather than a counter-engine
    @@ -120,46 +151,34 @@ class DeclHeadingNode(DeclNodeBase):
     
         type: Literal["heading"]
         level: Annotated[int, Field(ge=1)]
    -    text: str
    -    counter_label: str | None = None
    +    text: DeclText
    +    numbering: DeclNumbering | None = None
         children: list["DeclNode"] = Field(default_factory=list)
     
     
     class DeclParagraphNode(DeclNodeBase):
         type: Literal["paragraph"]
    -    text: str
    -    lang: str | None = None
    -
    -
    -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)
    -    raw: RawSource | None = None
    +    text: DeclText
     
     
     class DeclQuoteNode(DeclNodeBase):
         type: Literal["quote"]
    -    text: str
    -    attribution: str | None = None
    -    block: bool = True
    -    lang: str | None = None
    +    attribution: DeclText | None = None
    +    children: list["DeclNode"] = Field(default_factory=list)
     
     
     class DeclCodeNode(DeclNodeBase):
         type: Literal["code"]
         text: str
         lang: str | None = None
    -    block: bool = True
    +    title: str | None = None
     
     
     class DeclMathNode(DeclNodeBase):
         type: Literal["math"]
         text: str
         raw: RawSource | None = None
    -    counter_label: str | None = None
    -    block: bool = True
    +    numbering: DeclNumbering | None = None
     
     
     class DeclImageNode(DeclNodeBase):
    @@ -169,27 +188,34 @@ class DeclImageNode(DeclNodeBase):
     
     
     class DeclFigureNode(DeclNodeBase):
    -    """A captioned float wrapper. Keeps ``kind`` (the authored counter
    -    selector) and ``counter_label`` (authored for a custom ``kind`` the
    -    builder has no localized word for), but not the resolved ``number``."""
    +    """A captioned wrapper. Keeps ``kind`` (the category, and the counter
    +    the engine draws from) and ``numbering.name`` (authored for a custom
    +    ``kind`` the builder has no localized word for)."""
     
         type: Literal["figure"]
         kind: str | None = None
    -    caption: str | None = None
    -    counter_label: str | None = None
    +    caption: DeclText | None = None
    +    description: DeclText | None = None
    +    numbering: DeclNumbering | None = None
         children: list["DeclNode"] = Field(default_factory=list)
         raw: RawSource | None = None
     
     
    +class DeclAdmonitionNode(DeclNodeBase):
    +    type: Literal["admonition"]
    +    kind: str
    +    title: DeclText | None = None
    +    children: list["DeclNode"] = Field(default_factory=list)
    +
    +
     class DeclListItem(_Strict):
    -    """One item of a declaration list.
    +    """One item of a declaration list; its content is nodes.
     
         ``number`` is an **override**, not a resolved ordinal: absent means
         "number this item sequentially", present means an explicit start or
    -    skip (markdown's ``3.`` on the first item of an ordered list). This is
    -    the one place the declaration keeps a ``number`` the CND resolves — it
    -    keeps the *authored* half (the override) and drops the *derived* half
    -    (the running ordinal), the same line drawn for figure numbers.
    +    skip (markdown's ``3.`` on the first item of an ordered list). It keeps
    +    the *authored* half (the override) and drops the *derived* half (the
    +    running ordinal), the same line drawn for ``numbering``.
     
         Resolution rule the builder follows (pinned so two builders cannot
         diverge): an item with no ``number`` takes the previous sibling's
    @@ -197,46 +223,85 @@ class DeclListItem(_Strict):
         takes it verbatim, and following items count on from there. So a
         ``number`` **rebases** the rest of the list rather than only labelling
         its own item. Ignored on an ``ordered: false`` list, which has no
    -    ordinals.
    +    ordinals. A nested list is a ``list`` node among the children and
    +    counts on its own.
         """
     
    -    text: str
         number: int | None = None
    -    children: list["DeclListItem"] = Field(default_factory=list)
    +    checked: bool | None = None
    +    children: list["DeclNode"] = Field(default_factory=list)
     
     
     class DeclListNode(DeclNodeBase):
         type: Literal["list"]
         ordered: bool = False
    -    tight: bool = True
         items: list[DeclListItem] = Field(default_factory=list)
     
     
    +class DeclTermItem(_Strict):
    +    term: DeclText
    +    children: list["DeclNode"] = Field(default_factory=list)
    +
    +
     class DeclTermsNode(DeclNodeBase):
         type: Literal["terms"]
    -    tight: bool = True
    -    items: list[TermItem] = Field(default_factory=list)
    +    items: list[DeclTermItem] = Field(default_factory=list)
    +
    +
    +class DeclCell(_Strict):
    +    """One cell of a declared ``table`` or ``layout``; content is nodes."""
    +
    +    row: int
    +    col: int
    +    rowspan: int = Field(default=1, ge=1)
    +    colspan: int = Field(default=1, ge=1)
    +    is_header: bool = False
    +    children: list["DeclNode"] = Field(default_factory=list)
    +
    +
    +class DeclTableNode(DeclNodeBase):
    +    """``kind`` is the author's statement (``"data"``, ``"content"``, or
    +    any other value of the open vocabulary); the builder never guesses
    +    one."""
    +
    +    type: Literal["table"]
    +    kind: str | None = None
    +    cells: list[DeclCell] = Field(default_factory=list)
    +
    +
    +class DeclLayoutNode(DeclNodeBase):
    +    type: Literal["layout"]
    +    cells: list[DeclCell] = Field(default_factory=list)
     
     
     DeclNode = Annotated[
         Union[
             DeclHeadingNode,
             DeclParagraphNode,
    -        DeclTableNode,
             DeclQuoteNode,
             DeclCodeNode,
             DeclMathNode,
    -        DeclFigureNode,
             DeclImageNode,
    +        DeclFigureNode,
    +        DeclAdmonitionNode,
             DeclListNode,
             DeclTermsNode,
    +        DeclTableNode,
    +        DeclLayoutNode,
         ],
         Field(discriminator="type"),
     ]
     
    -DeclListItem.model_rebuild()
    -DeclHeadingNode.model_rebuild()
    -DeclFigureNode.model_rebuild()
    +for _model in (
    +    DeclHeadingNode,
    +    DeclQuoteNode,
    +    DeclFigureNode,
    +    DeclAdmonitionNode,
    +    DeclListItem,
    +    DeclTermItem,
    +    DeclCell,
    +):
    +    _model.model_rebuild()
     
     
     class DeclBibEntry(_Strict):
    @@ -248,7 +313,7 @@ class DeclBibEntry(_Strict):
         """
     
         label: str
    -    formatted: str | None = None
    +    formatted: DeclText | None = None
         type: str | None = None
         authors: list[str] = Field(default_factory=list)
         title: str | None = None
    @@ -260,10 +325,11 @@ class DeclBibEntry(_Strict):
     
     
     class DeclFootnote(_Strict):
    -    """A footnote in a declaration — ``Footnote`` minus ``id``."""
    +    """A footnote in a declaration — ``Footnote`` minus ``id``. Its content
    +    is nodes, which the builder mints ids for like any other node."""
     
         label: str
    -    text: str
    +    children: list[DeclNode] = Field(default_factory=list)
     
     
     class Declaration(_Strict):
    @@ -288,33 +354,60 @@ class Declaration(_Strict):
         bibliography: list[DeclBibEntry] = Field(default_factory=list)
         footnotes: list[DeclFootnote] = Field(default_factory=list)
     
    +    @model_validator(mode="before")
    +    @classmethod
    +    def _refuse_other_versions(cls, data: Any) -> Any:
    +        """Refuse another ``declaration_version`` before checking the shape.
    +
    +        A 0.1.0 declaration has the fields 0.2.0 removed, so checked as
    +        0.2.0 it fails on "Extra inputs" that never name the real problem.
    +        Read first, the version gives the one error that does. A missing
    +        version is left to the shape check, which reports it as missing.
    +        """
    +        if isinstance(data, dict):
    +            version = data.get("declaration_version")
    +            if isinstance(version, str) and version != DECLARATION_VERSION:
    +                raise ValueError(
    +                    f"declaration-version-unsupported: declaration_version "
    +                    f"{version} is not supported; this SDK reads declaration "
    +                    f"version {DECLARATION_VERSION} only"
    +                )
    +        return data
    +
     
     __all__ = [
         "DECLARATION_VERSION",
         "Declaration",
    +    "DeclAdmonitionNode",
         "DeclBibEntry",
    +    "DeclCell",
         "DeclCodeNode",
         "DeclFigureNode",
         "DeclFootnote",
         "DeclHeadingNode",
         "DeclImageNode",
    +    "DeclLayoutNode",
         "DeclListItem",
         "DeclListNode",
         "DeclMathNode",
         "DeclNode",
         "DeclNodeBase",
    +    "DeclNumbering",
         "DeclParagraphNode",
         "DeclQuoteNode",
         "DeclTableNode",
    +    "DeclTermItem",
         "DeclTermsNode",
    +    "DeclText",
         # re-exported CND value objects the declaration reuses
         "CiteRef",
         "DocDate",
         "DocMetadata",
         "FootnoteRef",
    +    "LinkRef",
    +    "Mark",
         "NodeRef",
         "RawSource",
    +    "RichText",
         "SourceInfo",
    -    "TableCell",
    -    "TermItem",
     ]
    diff --git a/src/cnd/display/theme.py b/src/cnd/display/theme.py
    index 152fa1e..8248dd3 100644
    --- a/src/cnd/display/theme.py
    +++ b/src/cnd/display/theme.py
    @@ -21,12 +21,16 @@
     # swapped in row position: after the image<->quote color swap, olive-quote sat
     # next to amber-table (near-identical under protanopia); swapping their rows
     # moves olive clear of both amber and green so every adjacent pair stays
    -# distinct. Legend and summary both follow this dict order.
    +# distinct. Legend and summary both follow this dict order. The two 0.5
    +# kinds, admonition and layout, take their alphabetical rows with hues the
    +# first ten leave free (raspberry and slate).
     NODE_STYLE: dict[str, tuple[str, str]] = {
    +    "admonition": ("#b8336a", "A"),
         "code": ("#00a3c4", "C"),
         "figure": ("#d95926", "Fg"),
         "heading": ("#8a55f2", "H"),
         "quote": ("#8a8c1a", "Q"),
    +    "layout": ("#5b7083", "Ly"),
         "list": ("#9085e9", "L"),
         "math": ("#e66767", "M"),
         "paragraph": ("#008300", "P"),
    @@ -145,18 +149,30 @@ def stat_cell(label: str, value: str) -> Text:
     
     def document_panel(cnd: Cnd) -> Panel:
         doc = cnd.doc
    -    doc_table = Table.grid(expand=True)
    +    # Every value is CND data: wrapped in Text so Rich never reads it as markup.
    +    doc_table = Table.grid(expand=True, padding=(0, 1))
         doc_table.add_column(style="dim", ratio=1)
         doc_table.add_column(style="white", ratio=3)
    -    doc_table.add_row("title", doc.title)
    -    doc_table.add_row("version", f"v{cnd.cnd_version}")
    -    doc_table.add_row("paginated", "yes" if cnd.paginated else "no")
    +
    +    def add(key: str, value: str) -> None:
    +        doc_table.add_row(Text(key), Text(value))
    +
    +    add("title", doc.title)
    +    # The format version; `version` below is the author's own (spec §3).
    +    add("format", f"v{cnd.cnd_version}")
    +    add("paginated", "yes" if cnd.paginated else "no")
         if cnd.source:
    -        doc_table.add_row("source", f"{cnd.source.type}   {cnd.source.hash}")
    +        add("source", f"{cnd.source.type}   {cnd.source.hash}")
         if doc.lang:
    -        doc_table.add_row("lang", doc.lang)
    +        add("lang", doc.lang)
         if doc.authors:
    -        doc_table.add_row("authors", ", ".join(doc.authors))
    +        add("authors", ", ".join(doc.authors))
    +    if doc.status:
    +        add("status", doc.status)
    +    if doc.version:
    +        add("version", doc.version)
    +    for key, value in doc.extra.items():
    +        add(key, value)
         return Panel(
             doc_table,
             title="[bold]Document[/]",
    @@ -282,7 +298,7 @@ def pool_panel(title: str, rows: Sequence[tuple[str, str, str]]) -> Panel:
         grid.add_column(style="white", ratio=1)
         grid.add_column(style="dim", no_wrap=True, justify="right")
         for label, body, meta in rows:
    -        grid.add_row(label, body, meta)
    +        grid.add_row(Text(label), Text(body), Text(meta))
         return Panel(
             grid,
             title=f"[bold]{title}[/]",
    diff --git a/src/cnd/reconcile/__init__.py b/src/cnd/reconcile/__init__.py
    index f937154..9fe4c56 100644
    --- a/src/cnd/reconcile/__init__.py
    +++ b/src/cnd/reconcile/__init__.py
    @@ -1,4 +1,5 @@
    -"""Reconciliation — matching nodes across two builds (docs/adr/0018).
    +"""Reconciliation — matching nodes across two builds (docs/adr/0018;
    +matching v2, docs/adr/0029).
     
     A **versioned reference algorithm**, not part of the format: it is an SDK
     facility (docs/adr/0006) and creates no conformance obligation on a
    @@ -32,6 +33,7 @@
     from cnd.reconcile.inherit import ReconcileError, Reconciliation, reconcile
     from cnd.reconcile.match import (
         MATCHER_VERSION,
    +    PASS_CONTAINER_HASH,
         PASS_HASH,
         PASS_HASH_PATH,
         PASS_LABEL,
    @@ -52,6 +54,7 @@
     
     __all__ = [
         "MATCHER_VERSION",
    +    "PASS_CONTAINER_HASH",
         "PASS_HASH",
         "PASS_HASH_PATH",
         "PASS_LABEL",
    diff --git a/src/cnd/reconcile/diff.py b/src/cnd/reconcile/diff.py
    index a3391f4..2cfdd04 100644
    --- a/src/cnd/reconcile/diff.py
    +++ b/src/cnd/reconcile/diff.py
    @@ -151,10 +151,13 @@ def diff(old: Cnd, new: Cnd) -> CndDiff:
         the heuristic: a node that is both edited *and* shifted by an
         insertion above it misses the hash passes (its content changed) and
         the path pass (its slot moved), so it is reported as ``added`` with
    -    its predecessor ``removed``. This is the diff problem, not an
    -    oversight — sequence-alignment tools do better and still miss. The
    -    claim stays best-effort for unlabelled nodes and exact for labelled
    -    ones; a producer that wants certainty labels the node.
    +    its predecessor ``removed``. Matching v2 (docs/adr/0029) rescues
    +    containers whose own hash survived, through its ``container-hash``
    +    pass; every other node still fails this way. This is the diff
    +    problem, not an oversight — sequence-alignment tools do better and
    +    still miss. The claim stays best-effort for unlabelled nodes and
    +    exact for labelled ones; a producer that wants certainty labels the
    +    node.
         """
         old_entries = entries(old)
         new_entries = entries(new)
    diff --git a/src/cnd/reconcile/inherit.py b/src/cnd/reconcile/inherit.py
    index ed39e80..223e1fe 100644
    --- a/src/cnd/reconcile/inherit.py
    +++ b/src/cnd/reconcile/inherit.py
    @@ -30,7 +30,7 @@
     from uuid import UUID
     
     from cnd.core.cnd import BibEntry, Cnd, Footnote
    -from cnd.core.nodes import CndNode, FigureNode, HeadingNode
    +from cnd.core.nodes import CndNode, child_slots
     from cnd.reconcile.match import PASS_LABEL, match_nodes, match_pool
     
     
    @@ -90,7 +90,7 @@ def reconcile(
         result = new.model_copy(deep=True)
         _check(result, remap)
         _apply(result, remap)
    -    total = sum(1 for _ in result.iter()) + len(result.bibliography) + len(
    +    total = sum(1 for _ in result.iter_all()) + len(result.bibliography) + len(
             result.footnotes
         )
         return Reconciliation(
    @@ -133,7 +133,7 @@ def _check(cnd: Cnd, remap: dict[UUID, UUID]) -> None:
     
     def _all_ids(cnd: Cnd) -> set[UUID]:
         return (
    -        {visit.node.id for visit in cnd.iter()}
    +        {visit.node.id for visit in cnd.iter_all()}
             | {entry.id for entry in cnd.bibliography}
             | {note.id for note in cnd.footnotes}
         )
    @@ -143,9 +143,11 @@ def _apply(cnd: Cnd, remap: dict[UUID, UUID]) -> None:
         """Rewrite ids in place on the already-copied CND.
     
         Nodes and pool entries only: the link families carry labels, so no
    -    edge needs touching (docs/adr/0017).
    +    edge needs touching (docs/adr/0017). The nodes are every node of the
    +    body and of the footnotes' content, wherever they sit.
         """
    -    for node in _walk(cnd.nodes):
    +    content = [child for note in cnd.footnotes for child in note.children]
    +    for node in _walk([*cnd.nodes, *content]):
             replacement = remap.get(node.id)
             if replacement is not None:
                 node.id = replacement
    @@ -156,13 +158,14 @@ def _apply(cnd: Cnd, remap: dict[UUID, UUID]) -> None:
     
     
     def _walk(nodes: list[CndNode]):
    -    """Every node in the tree. A plain recursive walk rather than
    -    ``Cnd.iter``: that one caches derived positions on the instance, and
    +    """Every node in the tree, through every container slot (children,
    +    list and term items, cells). A plain recursive walk rather than
    +    ``Cnd.iter_all``: that one caches derived state on the instance, and
         rewriting ids underneath a cache invites a stale one."""
         for node in nodes:
             yield node
    -        if isinstance(node, (HeadingNode, FigureNode)):
    -            yield from _walk(node.children)
    +        for slot in child_slots(node):
    +            yield from _walk(slot.nodes)
     
     
     __all__ = ["ReconcileError", "Reconciliation", "reconcile"]
    diff --git a/src/cnd/reconcile/match.py b/src/cnd/reconcile/match.py
    index 8a9dddd..6ddb5af 100644
    --- a/src/cnd/reconcile/match.py
    +++ b/src/cnd/reconcile/match.py
    @@ -1,5 +1,5 @@
    -"""Node matching across two CNDs — reference algorithm "matching v1"
    -(docs/adr/0018).
    +"""Node matching across two CNDs — reference algorithm "matching v2"
    +(docs/adr/0029, which extends docs/adr/0018).
     
     The matcher answers "is this the same node as in the previous build?"
     for two built CNDs. It is a **versioned reference algorithm, not part of
    @@ -10,60 +10,101 @@
     The claim it makes is deliberately narrow: **exact for labelled nodes,
     best-effort for everything else**. A label is the only durable identity
     CND has (docs/adr/0015), so a labelled node survives both edit and move;
    -an unlabelled one is recovered by heuristics that can miss (see
    -``ADR 0018`` and the ``diff`` docstring for the documented failure).
    +an unlabelled one is recovered by heuristics that can miss (see ADR 0018,
    +ADR 0029 and the ``diff`` docstring for the documented failures).
    +
    +**What v2 walks.** The full walk (spec §8): every node of the body,
    +including those inside list items, term items, cells, quotes and
    +admonitions, then the content of every footnote. The structural path is
    +the spec §8 path, so the first paragraphs of two different cells sit in
    +two different slots; inside a footnote the pool index is replaced by the
    +note's label, so reordering the pool moves nothing. Finer nodes make the
    +matcher finer too: an edited list item is matched and reported on its
    +own, where a whole list used to be marked changed.
    +
    +**The container key.** A node hash excludes nested nodes (spec §10), so
    +two containers of the same shape hash alike. A container — a list,
    +terms, table, layout, quote or admonition — is therefore keyed on its
    +node hash plus a digest of its descendants' hashes (``subtree``), which
    +stops a surviving container taking the id of a deleted twin. Headings
    +and figures are left out on purpose: their hash already carries their
    +text or caption, and a heading whose section changed has not itself
    +changed (spec §10), so a moved heading stays recognisable. Every other
    +node has an empty digest, and its key is its node hash alone.
     
     The passes run strongest key first, and a node matched by an earlier
     pass is never reconsidered by a later one:
     
     1. ``label`` — exact, survives edit and move;
    -2. ``node_hash`` + structural path — unchanged, in place;
    -3. ``node_hash`` alone — moved but unchanged; recovers the nodes an
    -   insertion shifted;
    -4. structural path + ``type`` — same slot, edited content;
    -5. otherwise unmatched (a new node on the ``new`` side, a removed one on
    +2. ``hash+path`` — node hash, container digest and structural path
    +   agree: unchanged, in place;
    +3. ``hash`` — node hash and container digest agree: moved but
    +   unchanged; recovers the nodes an insertion shifted;
    +4. ``path+type`` — structural path and ``type`` agree: same slot,
    +   edited content;
    +5. ``container-hash`` — for containers only, the node hash alone: a
    +   container whose nested content was edited and that also shifted
    +   keeps its id (twins have already paired through the digest in
    +   passes 2 and 3);
    +6. otherwise unmatched (a new node on the ``new`` side, a removed one on
        the ``old`` side).
     
     Ties within a pass — equal-hash duplicates, two nodes competing for one
     slot — break by reading order: the earliest unmatched candidate wins.
    +
    +Known limits: two headings or figures with identical titles can swap,
    +and so can two twin containers that were both edited and shifted. The
    +combined case ADR 0018 documents (an edited node with an insertion
    +above it) still fails for every node that is not a container. The node
    +hash itself is unchanged by any of this.
     """
     
    +import hashlib
     from collections.abc import Callable, Iterable
     from dataclasses import dataclass
     from typing import Literal, Optional
     
     from cnd.core.cnd import BibEntry, Cnd, Footnote
     from cnd.core.hashing import node_hash, pool_entry_hash
    -from cnd.core.nodes import CndNode
    +from cnd.core.nodes import CndNode, FigureNode, HeadingNode, child_slots
     
    -MATCHER_VERSION = "v1"
    -"""The version of the reference algorithm implemented here (docs/adr/0018)."""
    +MATCHER_VERSION = "v2"
    +"""The version of the reference algorithm implemented here (docs/adr/0029)."""
     
    -MatchPass = Literal["label", "hash+path", "hash", "path+type"]
    +MatchPass = Literal["label", "hash+path", "hash", "path+type", "container-hash"]
     """Which pass matched a pair."""
     
     PASS_LABEL: MatchPass = "label"
     PASS_HASH_PATH: MatchPass = "hash+path"
     PASS_HASH: MatchPass = "hash"
     PASS_PATH_TYPE: MatchPass = "path+type"
    +PASS_CONTAINER_HASH: MatchPass = "container-hash"
     
    -StructuralPath = tuple[int, ...]
    -"""A node's slot in the tree: 1-based sibling indices from the root down."""
    +StructuralPath = tuple[str | int, ...]
    +"""A node's slot: its spec §8 path, such as ``("nodes", 0, "children", 4,
    +"cells", 2, "children", 0)``. Inside a footnote the pool index is
    +replaced by the footnote's label (``("footnotes", "fn-a", "children",
    +0)``): a pool is keyed by label and its order is not document order, so
    +reordering the pool must not move the notes' content (see ``entries``)."""
     
     
     @dataclass(frozen=True)
     class NodeEntry:
         """One node of a CND with everything the matcher keys on.
     
    -    ``order`` is the node's 1-based position in reading order and is what
    -    breaks ties: the tree is normatively in reading order, so "earliest
    -    candidate" is a document fact rather than a walk artifact.
    +    ``order`` is the node's 1-based position in the full walk (the body in
    +    reading order, then each footnote's content) and is what breaks ties:
    +    the tree is normatively in reading order, so "earliest candidate" is a
    +    document fact rather than a walk artifact.
         """
     
         node: CndNode
         path: StructuralPath
         hash: str
         order: int
    +    subtree: str = ""
    +    """Digest of the hashes of every nested node, in order; empty for a
    +    node without child slots. A matching key only, never a content hash."""
     
         @property
         def label(self) -> Optional[str]:
    @@ -123,36 +164,80 @@ class PoolMatching:
     
     
     def entries(cnd: Cnd) -> list[NodeEntry]:
    -    """Every node of ``cnd`` in reading order, keyed for matching.
    -
    -    The structural path is derived from the traversal context rather than
    -    from a second walk: the walk is depth-first pre-order, so truncating
    -    the running path to the current depth and appending the sibling index
    -    yields the absolute slot of every node.
    +    """Every node of ``cnd`` in full-walk order (the body, then each
    +    footnote's content), keyed for matching.
    +
    +    The structural path is the traversal context's ``path``, with one
    +    deliberate difference: a footnote's pool index is replaced by its
    +    label. Pool entries match on their label and a pool has no "moved"
    +    (``PoolDiff``), so the content of a note must not be reported moved
    +    because the pool was reordered.
         """
    +    hashes: dict[int, str] = {}
    +    subtrees: dict[int, str] = {}
         result: list[NodeEntry] = []
    -    path: list[int] = []
    -    for order, visit in enumerate(cnd.iter(), start=1):
    -        del path[visit.ctx.depth :]
    -        path.append(visit.ctx.sibling_index)
    +    for order, visit in enumerate(cnd.iter_all(), start=1):
    +        node = visit.node
             result.append(
                 NodeEntry(
    -                node=visit.node,
    -                path=tuple(path),
    -                hash=node_hash(visit.node),
    +                node=node,
    +                path=_matching_path(cnd, visit.ctx.path),
    +                hash=_hash_of(node, hashes),
                     order=order,
    +                subtree=(
    +                    ""
    +                    if isinstance(node, (HeadingNode, FigureNode))
    +                    else _subtree_of(node, hashes, subtrees)
    +                ),
                 )
             )
         return result
     
     
    +def _hash_of(node: CndNode, hashes: dict[int, str]) -> str:
    +    if id(node) not in hashes:
    +        hashes[id(node)] = node_hash(node)
    +    return hashes[id(node)]
    +
    +
    +def _subtree_of(
    +    node: CndNode, hashes: dict[int, str], subtrees: dict[int, str]
    +) -> str:
    +    """Digest of the descendants' hashes in preorder, slot by slot."""
    +    if id(node) in subtrees:
    +        return subtrees[id(node)]
    +    slots = child_slots(node)
    +    if not slots:
    +        subtrees[id(node)] = ""
    +        return ""
    +    parts: list[str] = []
    +    for slot in slots:
    +        parts.append("[")
    +        for child in slot.nodes:
    +            parts.append(_hash_of(child, hashes))
    +            parts.append(_subtree_of(child, hashes, subtrees))
    +        parts.append("]")
    +    digest = hashlib.sha256("|".join(parts).encode()).hexdigest()
    +    subtrees[id(node)] = digest
    +    return digest
    +
    +
    +def _matching_path(cnd: Cnd, path: tuple[str | int, ...]) -> StructuralPath:
    +    """``path`` with a footnote's pool index replaced by its label."""
    +    if path[0] == "footnotes":
    +        index = path[1]
    +        assert isinstance(index, int)
    +        return ("footnotes", cnd.footnotes[index].label, *path[2:])
    +    return path
    +
    +
     def pool_entries(pool: Iterable["BibEntry | Footnote"]) -> list[PoolEntry]:
         """A pool as matchable entries, in declaration order."""
         return [PoolEntry(entry=entry, hash=pool_entry_hash(entry)) for entry in pool]
     
     
     def match_nodes(old: Cnd, new: Cnd) -> NodeMatching:
    -    """Pair the nodes of ``old`` and ``new`` under matching v1."""
    +    """Pair the nodes of ``old`` and ``new`` under matching v2."""
         return match_entries(entries(old), entries(new))
     
     
    @@ -174,6 +259,7 @@ def match_entries(
             (PASS_HASH_PATH, _key_hash_path),
             (PASS_HASH, _key_hash),
             (PASS_PATH_TYPE, _key_path_type),
    +        (PASS_CONTAINER_HASH, _key_container_hash),
         ):
             paired, remaining_old, remaining_new = _run_pass(
                 remaining_old, remaining_new, key
    @@ -229,11 +315,18 @@ def _key_label(entry: NodeEntry) -> Optional[tuple]:
     
     
     def _key_hash_path(entry: NodeEntry) -> Optional[tuple]:
    -    return (entry.hash, entry.path)
    +    return (entry.hash, entry.subtree, entry.path)
     
     
     def _key_hash(entry: NodeEntry) -> Optional[tuple]:
    -    return (entry.hash,)
    +    return (entry.hash, entry.subtree)
    +
    +
    +def _key_container_hash(entry: NodeEntry) -> Optional[tuple]:
    +    """Last pass: a container that kept its own hash, whatever its nested
    +    content did. ``subtree`` is empty for non-containers (and for headings
    +    and figures), which this pass leaves alone."""
    +    return (entry.hash,) if entry.subtree else None
     
     
     def _key_path_type(entry: NodeEntry) -> Optional[tuple]:
    diff --git a/src/cnd/typst/cnd.typ b/src/cnd/typst/cnd.typ
    index 59be636..640854a 100644
    --- a/src/cnd/typst/cnd.typ
    +++ b/src/cnd/typst/cnd.typ
    @@ -18,19 +18,25 @@
     #let metadata = state("cnd.metadata", (:))
     
     // Wraps already-built content (typically a `table(..)` call) and brackets it
    -// with a `content_kind` hint ("data" | "content") in the cnd.metadata state.
    -// Pass the content in — don't let this function build it: the source snippet
    -// emitters record for a table comes from the table's own span, which must
    -// stay at the document's call site (ADR 0023).
    -#let table(body, content_kind: none) = {
    -  if content_kind == none {
    +// with the table's kind in the cnd.metadata state: "data" (a dataset of
    +// records) or "content" (cells to read). Any other string passes through:
    +// the kind is an open vocabulary. The author writes `kind:`; the state
    +// carries it under the dedicated key `table-kind`, which an emitter reads
    +// and the CND writes as `table.kind` (spec §6.3). A dedicated key never
    +// collides with the author's own metadata, such as a document-level `kind`,
    +// so the hint is set before the table and removed after it, with nothing
    +// to read back or restore. Pass the content in — don't let this function
    +// build it: the source snippet emitters record for a table comes from the
    +// table's own span, which must stay at the document's call site (ADR 0023).
    +#let table(body, kind: none) = {
    +  if kind == none {
         return body
       }
    -  metadata.update(it => it + (content_kind: content_kind))
    +  metadata.update(it => it + ("table-kind": kind))
       body
       metadata.update(it => {
         let d = it
    -    let _ = d.remove("content_kind", default: none)
    +    let _ = d.remove("table-kind", default: none)
         d
       })
     }
    diff --git a/src/cnd/visitors/base_visitor.py b/src/cnd/visitors/base_visitor.py
    index 7d094a5..412ebbd 100644
    --- a/src/cnd/visitors/base_visitor.py
    +++ b/src/cnd/visitors/base_visitor.py
    @@ -1,10 +1,12 @@
     from cnd.core.cnd import Cnd
     from cnd.core.nodes import (
    +    AdmonitionNode,
         CndNode,
         CodeNode,
         FigureNode,
         HeadingNode,
         ImageNode,
    +    LayoutNode,
         ListNode,
         MathNode,
         NodeTraverseContext,
    @@ -22,7 +24,18 @@ class BaseVisitor:
         """Visit a CND node tree with overridable per-type hooks.
     
         Override ``visit_*`` for node logic and ``should_stop_descent`` to prune
    -    branches. All hooks are no-ops by default.
    +    branches. All hooks are no-ops by default; there is one per node type,
    +    ``visit_``.
    +
    +    The walk is the body walk (spec §8) by default: it enters every
    +    container — list and term items, table and layout cells, quotes,
    +    admonitions, figures — so a paragraph inside a cell reaches
    +    ``visit_paragraph`` with the table as its ``ctx.parent``. Footnote
    +    content is not part of the body; ``visit(cnd, full=True)`` runs the full
    +    walk instead (``Cnd.iter_all``): the body, then each footnote's content,
    +    with spec §8 paths (``("footnotes", 0, "children", 0)``) and ``slot``
    +    ``"footnotes"``. Do not walk ``cnd.footnotes[i].children`` by hand: its
    +    paths would start at ``("nodes", 0)`` and name a body node.
     
         During traversal, ``current_node`` and ``current_ctx`` hold the node and
         context being visited.
    @@ -36,16 +49,30 @@ def visit(
             target: VisitTarget,
             *,
             max_depth: int | None = None,
    +        full: bool = False,
         ) -> None:
    -        """Traverse ``target`` and dispatch to per-type hooks."""
    +        """Traverse ``target`` and dispatch to per-type hooks.
    +
    +        ``full=True`` runs the full walk of a ``Cnd`` (the body, then each
    +        footnote's content); it needs a ``Cnd``, since a bare node list has
    +        no footnotes.
    +        """
             self.current_node = None
             self.current_ctx = None
    -        nodes = target.nodes if isinstance(target, Cnd) else target
    -        for step in iter_nodes(
    -            nodes,
    -            max_depth=max_depth,
    -            stop_predicate=self.should_stop_descent,
    -        ):
    +        if full:
    +            if not isinstance(target, Cnd):
    +                raise ValueError("full=True walks a Cnd's footnotes; pass a Cnd")
    +            steps = target.iter_all(
    +                max_depth=max_depth, stop_predicate=self.should_stop_descent
    +            )
    +        else:
    +            nodes = target.nodes if isinstance(target, Cnd) else target
    +            steps = iter_nodes(
    +                nodes,
    +                max_depth=max_depth,
    +                stop_predicate=self.should_stop_descent,
    +            )
    +        for step in steps:
                 self._dispatch(step.node, step.ctx)
     
         def should_stop_descent(
    @@ -79,6 +106,10 @@ def _dispatch(self, node: CndNode, ctx: NodeTraverseContext) -> None:
                 self.visit_list(node, ctx)
             elif isinstance(node, TermsNode):
                 self.visit_terms(node, ctx)
    +        elif isinstance(node, AdmonitionNode):
    +            self.visit_admonition(node, ctx)
    +        elif isinstance(node, LayoutNode):
    +            self.visit_layout(node, ctx)
             else:
                 self.visit_unknown(node, ctx)
     
    @@ -112,5 +143,11 @@ def visit_list(self, node: ListNode, ctx: NodeTraverseContext) -> None:
         def visit_terms(self, node: TermsNode, ctx: NodeTraverseContext) -> None:
             pass
     
    +    def visit_admonition(self, node: AdmonitionNode, ctx: NodeTraverseContext) -> None:
    +        pass
    +
    +    def visit_layout(self, node: LayoutNode, ctx: NodeTraverseContext) -> None:
    +        pass
    +
         def visit_unknown(self, node: CndNode, ctx: NodeTraverseContext) -> None:
             raise NotImplementedError(f"Unknown node type: {type(node).__name__}")
    diff --git a/src/cnd/visitors/node_display_visitor.py b/src/cnd/visitors/node_display_visitor.py
    index cc30eca..a0cead8 100644
    --- a/src/cnd/visitors/node_display_visitor.py
    +++ b/src/cnd/visitors/node_display_visitor.py
    @@ -11,24 +11,25 @@
     
     from cnd.core.cnd import Cnd
     from cnd.core.nodes import (
    -    CiteRef,
    +    AdmonitionNode,
         CndNode,
         CodeNode,
         FigureNode,
    -    FootnoteRef,
         HeadingNode,
         ImageNode,
    +    LayoutNode,
         ListNode,
         MathNode,
    -    NodeRef,
         NodeTraverseContext,
         ParagraphNode,
         QuoteNode,
         TableNode,
         TermsNode,
    +    texts_of,
     )
    -from cnd.core.node_text import render_table_markdown
    +from cnd.core.node_text import blocks_plain_text, render_table_markdown
     from cnd.core.render import MarkdownRenderer, NodeRenderer
    +from cnd.core.text import CiteRef, FootnoteRef, LinkRef, NodeRef, Numbering, RichText
     from cnd.display.theme import (
         NODE_STYLE,
         document_panel,
    @@ -45,11 +46,16 @@
     from cnd.visitors.base_visitor import BaseVisitor, VisitTarget
     
     
    -def _format_links(links: Sequence[NodeRef | CiteRef | FootnoteRef]) -> str:
    -    """Format a link family for display, appending text spans when present."""
    +#: The four link families, in the spec's enumeration order (spec §8).
    +_FAMILIES = ("refs", "cites", "footnotes", "links")
    +
    +
    +def _format_links(links: Sequence[NodeRef | CiteRef | FootnoteRef | LinkRef]) -> str:
    +    """Format a link family for display, appending text spans when present.
    +    A hyperlink has no label and shows its ``href``."""
         parts: list[str] = []
         for link in links:
    -        text = format_node_ref(link.label)
    +        text = link.href if isinstance(link, LinkRef) else format_node_ref(link.label)
             if link.text_span is not None:
                 span = link.text_span
                 text += f"[{span[0]}:{span[1]}]" if len(span) == 2 else str(span)
    @@ -57,6 +63,31 @@ def _format_links(links: Sequence[NodeRef | CiteRef | FootnoteRef]) -> str:
         return ", ".join(parts)
     
     
    +def _numbering_details(numbering: Numbering | None) -> dict[str, str]:
    +    """``number`` (the resolved value) and ``counter`` (the word), when set."""
    +    if numbering is None:
    +        return {}
    +    details = {"number": numbering.value}
    +    if numbering.name:
    +        details["counter"] = numbering.name
    +    return details
    +
    +
    +def _marks_detail(node: CndNode) -> dict[str, str]:
    +    """The mark kinds of the node's own texts, in first-seen order."""
    +    kinds: list[str] = []
    +    for text in texts_of(node):
    +        for mark in text.marks:
    +            if mark.kind not in kinds:
    +                kinds.append(mark.kind)
    +    return {"marks": ",".join(kinds)} if kinds else {}
    +
    +
    +def _plain(text: RichText | None) -> str:
    +    """A text's plain value — marks dropped, words kept (spec §7)."""
    +    return text.value if text is not None else ""
    +
    +
     class NodeDisplayVisitor(BaseVisitor):
         """Render a readable, colorized trace of each node while visiting a cnd.
     
    @@ -110,20 +141,22 @@ def _preview(self, text: str) -> str:
         @override
         def visit_heading(self, node: HeadingNode, ctx: NodeTraverseContext) -> None:
             self._counts["heading"] += 1
    -        title = f"H{node.level} {node.text}"
    -        details = {
    -            "number": node.number or "-",
    -            "children": str(len(node.children)),
    -        }
    +        title = f"H{node.level} {_plain(node.text)}"
    +        details = _numbering_details(node.numbering) or {"number": "-"}
    +        details["children"] = str(len(node.children))
    +        details.update(_marks_detail(node))
             self._render_node("heading", node, ctx, title, details)
     
         @override
         def visit_paragraph(self, node: ParagraphNode, ctx: NodeTraverseContext) -> None:
             self._counts["paragraph"] += 1
             details: dict[str, str] = {}
    -        if node.lang:
    -            details["lang"] = node.lang
    -        self._render_node("paragraph", node, ctx, self._preview(node.text), details)
    +        if node.text.lang:
    +            details["lang"] = node.text.lang
    +        details.update(_marks_detail(node))
    +        self._render_node(
    +            "paragraph", node, ctx, self._preview(_plain(node.text)), details
    +        )
     
         @override
         def visit_table(self, node: TableNode, ctx: NodeTraverseContext) -> None:
    @@ -131,23 +164,37 @@ def visit_table(self, node: TableNode, ctx: NodeTraverseContext) -> None:
             # Preview the actual cell content (inline pipe grid), like text nodes
             # preview their text; the label stays visible on the identity line.
             title = render_table_markdown(node) or node.label or "Table"
    -        details: dict[str, str] = {
    -            "cells": str(len(node.cells)),
    -            "kind": node.kind,
    -        }
    -        if node.content_kind:
    -            details["content_kind"] = node.content_kind
    +        details: dict[str, str] = {"cells": str(len(node.cells))}
    +        if node.kind:
    +            details["kind"] = node.kind
             self._render_node("table", node, ctx, self._preview(title), details)
     
    +    @override
    +    def visit_layout(self, node: LayoutNode, ctx: NodeTraverseContext) -> None:
    +        self._counts["layout"] += 1
    +        # A layout's content is shown by its own nodes, cell by cell.
    +        title = node.label or f"layout ({len(node.cells)} cells)"
    +        details = {"cells": str(len(node.cells))}
    +        self._render_node("layout", node, ctx, self._preview(title), details)
    +
         @override
         def visit_quote(self, node: QuoteNode, ctx: NodeTraverseContext) -> None:
             self._counts["quote"] += 1
    -        details: dict[str, str] = {}
    +        # The quoted blocks are the quote's children, shown as nodes.
    +        title = f"quote ({len(node.children)} blocks)"
    +        details: dict[str, str] = {"children": str(len(node.children))}
             if node.attribution:
    -            details["attribution"] = self._preview(node.attribution)
    -        if node.lang:
    -            details["lang"] = node.lang
    -        self._render_node("quote", node, ctx, self._preview(node.text), details)
    +            details["attribution"] = self._preview(_plain(node.attribution))
    +        details.update(_marks_detail(node))
    +        self._render_node("quote", node, ctx, title, details)
    +
    +    @override
    +    def visit_admonition(self, node: AdmonitionNode, ctx: NodeTraverseContext) -> None:
    +        self._counts["admonition"] += 1
    +        title = _plain(node.title) or node.kind
    +        details = {"kind": node.kind, "children": str(len(node.children))}
    +        details.update(_marks_detail(node))
    +        self._render_node("admonition", node, ctx, self._preview(title), details)
     
         @override
         def visit_code(self, node: CodeNode, ctx: NodeTraverseContext) -> None:
    @@ -155,31 +202,30 @@ def visit_code(self, node: CodeNode, ctx: NodeTraverseContext) -> None:
             details: dict[str, str] = {}
             if node.lang:
                 details["lang"] = node.lang
    +        if node.title:
    +            details["title"] = node.title
             self._render_node("code", node, ctx, self._preview(node.text), details)
     
         @override
         def visit_math(self, node: MathNode, ctx: NodeTraverseContext) -> None:
             self._counts["math"] += 1
    -        details: dict[str, str] = {}
    -        if node.number:
    -            details["number"] = node.number
    +        details = _numbering_details(node.numbering)
             self._render_node("math", node, ctx, self._preview(node.text), details)
     
         @override
         def visit_figure(self, node: FigureNode, ctx: NodeTraverseContext) -> None:
             self._counts["figure"] += 1
    -        title = node.caption or node.number or node.label or "Figure"
    +        number = node.numbering.value if node.numbering else None
    +        title = _plain(node.caption) or number or node.label or "Figure"
             details: dict[str, str] = {
                 "children": str(len(node.children)),
             }
             if node.kind:
                 details["kind"] = node.kind
    -        if node.number:
    -            details["number"] = node.number
    -        if node.counter_label:
    -            details["counter_label"] = node.counter_label
    -        if node.caption and node.caption != title:
    -            details["caption"] = self._preview(node.caption)
    +        details.update(_numbering_details(node.numbering))
    +        if node.description:
    +            details["description"] = self._preview(_plain(node.description))
    +        details.update(_marks_detail(node))
             self._render_node("figure", node, ctx, self._preview(title), details)
     
         @override
    @@ -201,8 +247,10 @@ def visit_list(self, node: ListNode, ctx: NodeTraverseContext) -> None:
             details: dict[str, str] = {
                 "ordered": str(node.ordered).lower(),
                 "items": str(len(node.items)),
    -            "tight": str(node.tight).lower(),
             }
    +        checked = sum(1 for item in node.items if item.checked)
    +        if any(item.checked is not None for item in node.items):
    +            details["checked"] = f"{checked}/{len(node.items)}"
             preview = self._preview(self._renderer.render(node))
             self._render_node("list", node, ctx, preview or title, details)
     
    @@ -210,10 +258,8 @@ def visit_list(self, node: ListNode, ctx: NodeTraverseContext) -> None:
         def visit_terms(self, node: TermsNode, ctx: NodeTraverseContext) -> None:
             self._counts["terms"] += 1
             title = f"terms ({len(node.items)} items)"
    -        details: dict[str, str] = {
    -            "items": str(len(node.items)),
    -            "tight": str(node.tight).lower(),
    -        }
    +        details: dict[str, str] = {"items": str(len(node.items))}
    +        details.update(_marks_detail(node))
             preview = self._preview(self._renderer.render(node))
             self._render_node("terms", node, ctx, preview or title, details)
     
    @@ -263,7 +309,7 @@ def sources(label: str) -> int:
                 rows = [
                     (
                         f"@{entry.label}",
    -                    self._preview(entry.formatted or entry.title or ""),
    +                    self._preview(_plain(entry.formatted) or entry.title or ""),
                         f"id={str(entry.id)[:8]}   cited by {sources(entry.label)}",
                     )
                     for entry in cnd.bibliography
    @@ -273,8 +319,10 @@ def sources(label: str) -> int:
                 rows = [
                     (
                         f"@{note.label}",
    -                    self._preview(note.text),
    -                    f"id={str(note.id)[:8]}   referenced by {sources(note.label)}",
    +                    # The note's content is block nodes; previewed as text.
    +                    self._preview(blocks_plain_text(note.children)),
    +                    f"id={str(note.id)[:8]}   {len(note.children)} block(s)"
    +                    f"   referenced by {sources(note.label)}",
                     )
                     for note in cnd.footnotes
                 ]
    @@ -310,7 +358,12 @@ def _render_node(
             details: dict[str, str],
         ) -> None:
             self._max_depth = max(self._max_depth, ctx.depth)
    -        self._refs_total += len(node.refs) + len(node.cites) + len(node.footnotes)
    +        families = self._families(node)
    +        # Cross-references are the label-keyed families; hyperlinks are
    +        # shown on the refs line but resolve nowhere (docs/adr/0024).
    +        self._refs_total += sum(
    +            len(families[name]) for name in ("refs", "cites", "footnotes")
    +        )
     
             body_parts: list[ConsoleRenderable] = []
             if title:
    @@ -368,14 +421,11 @@ def _meta_lines(
                 path.append(" > ".join(ctx.heading_path), "italic cyan")
                 lines.append(path)
     
    -        if self._show_refs and (node.refs or node.cites or node.footnotes):
    +        families = self._families(node)
    +        if self._show_refs and any(families.values()):
                 refs = Text()
                 first = True
    -            for name, links in (
    -                ("refs", node.refs),
    -                ("cites", node.cites),
    -                ("footnotes", node.footnotes),
    -            ):
    +            for name, links in families.items():
                     if not links:
                         continue
                     if not first:
    @@ -407,6 +457,16 @@ def _meta_lines(
     
             return lines
     
    +    @staticmethod
    +    def _families(node: CndNode) -> dict[str, list]:
    +        """The node's link families, gathered from all of its own texts
    +        (the families live in rich text, docs/adr/0025)."""
    +        texts = texts_of(node)
    +        return {
    +            name: [edge for text in texts for edge in getattr(text, name)]
    +            for name in _FAMILIES
    +        }
    +
         def _build_summary(self):
             total = sum(self._counts.values())
             return summary_panel(
    diff --git a/tests/conftest.py b/tests/conftest.py
    index 39a6bb9..d9e13bc 100644
    --- a/tests/conftest.py
    +++ b/tests/conftest.py
    @@ -33,3 +33,8 @@ def full_coverage_cnd_path() -> Path:
     @pytest.fixture
     def unpaginated_cnd_path() -> Path:
         return FIXTURES_DIR / "unpaginated.cnd"
    +
    +
    +@pytest.fixture
    +def elements_cnd_path() -> Path:
    +    return FIXTURES_DIR / "elements.cnd"
    diff --git a/tests/factories.py b/tests/factories.py
    new file mode 100644
    index 0000000..45a9c09
    --- /dev/null
    +++ b/tests/factories.py
    @@ -0,0 +1,31 @@
    +"""Small constructors shared by the 0.5 test modules."""
    +
    +from uuid import uuid4
    +
    +from cnd.core.nodes import Cell, ListItem, ParagraphNode
    +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)
    +
    +
    +def para(text: "str | RichText" = "p", **kwargs) -> ParagraphNode:
    +    """A paragraph with a fresh id; a string becomes its RichText."""
    +    if isinstance(text, str):
    +        text = rt(text)
    +    return ParagraphNode(type="paragraph", id=uuid4(), text=text, **kwargs)
    +
    +
    +def cell(row: int, col: int, text: str | None = None, **kwargs) -> Cell:
    +    """A table or layout cell; ``text`` becomes its one paragraph."""
    +    if text is not None:
    +        kwargs.setdefault("children", [para(text)])
    +    return Cell(row=row, col=col, **kwargs)
    +
    +
    +def item(*children, **kwargs) -> ListItem:
    +    """A list item; string children become paragraphs."""
    +    nodes = [para(child) if isinstance(child, str) else child for child in children]
    +    return ListItem(children=nodes, **kwargs)
    diff --git a/tests/golden/comprehensive.html b/tests/golden/comprehensive.html
    index 9f9d404..2f7e2d5 100644
    --- a/tests/golden/comprehensive.html
    +++ b/tests/golden/comprehensive.html
    @@ -1,5 +1,5 @@
     
    -
    +
     
     
     
    @@ -18,6 +18,11 @@
     table { border-collapse: collapse; }
     th, td { border: 1px solid currentColor; padding: .25rem .5rem; }
     pre { overflow-x: auto; padding: .75rem; }
    +.cnd-layout { display: grid; gap: 1rem; }
    +.cnd-admonition { border-left: .25rem solid currentColor; margin: 1rem 0;
    +                  padding: .25rem 1rem; }
    +.cnd-admonition-title { font-weight: bold; }
    +.cnd-code-title { font-family: monospace; font-size: .9em; margin-bottom: 0; }
     .cnd-markers { font-size: .85em; opacity: .75; }
     .cnd-unresolved { text-decoration: underline wavy; }
     .cnd-missing-image { font-style: italic; opacity: .7; }
    @@ -33,7 +38,7 @@ 

    1.1 Couche d'acquisition

    Les capteurs analogiques et numériques sont regroupés par bus de terrain. Voir le tableau des plages de mesure.

    [tab-plages-mesure]

    La fréquence d'échantillonnage nominale est de 10 Hz pour les boucles rapides.

    -
    CapteurPlage
    PressionPT-1010–16 bar
    PT-1020–25 bar
    DébitFT-2010–500 L/min
    Table 1: Plages de mesure des capteurs de terrain.
    +
    CapteurPlage
    PressionPT-1010–16 bar
    PT-1020–25 bar
    DébitFT-2010–500 L/min
    Table 1: Plages de mesure des capteurs de terrain.

    1.2 Couche de traitement

    [tab-plages-mesure] [tab-recap-signaux]

    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.

    diff --git a/tests/golden/comprehensive.md b/tests/golden/comprehensive.md index 1f501c9..2cc18a9 100644 --- a/tests/golden/comprehensive.md +++ b/tests/golden/comprehensive.md @@ -12,7 +12,7 @@ keywords: - "fixture" description: "Manifest de référence couvrant les cas limites du chunker : contenu pré-heading, imbrication profonde, refs intra/inter-chunks, tables complexes." lang: "fr" -cnd_version: "0.3.0" +cnd_version: "0.5.0" built_at: "2026-06-06T12:00:00+00:00" --- diff --git a/tests/golden/elements.html b/tests/golden/elements.html new file mode 100644 index 0000000..85b665c --- /dev/null +++ b/tests/golden/elements.html @@ -0,0 +1,59 @@ + + + + + +Elements of format 0.5 + + + + + + + +
    +

    Section 1 Elements of format 0.5

    +

    Pressure rose 103 times, see Theorem 1 and the note, after Knuth: E = m c^2 holds.

    +[thm-main] fn-detail [knuth1984] + +
    1. Compile the source
      • with fonts
    2. Validate the output
    +
    Mark

    A standoff span with a kind.

    Marks of different kinds may overlap.

    +
    StationhPa
    Brest1013
    +
    Before
    After
    +

    Energy and mass are equivalent.

    Theorem 1: Mass–energy equivalence

    States that energy equals mass times the speed of light squared.

    +

    First paragraph of the quote.

    Second paragraph.

    A. Einstein
    +

    main.py

    print('hi')
    +
    +
    a^2 + b^2 = c^2(1)
    +
    +
    +

    Footnotes

    +
      +
    1. Details live here.

      A second paragraph, with code.

    2. +
    +
    +
    +

    Bibliography

    +
      +
    • Knuth, D. The TeXbook. Addison-Wesley, 1984.
    • +
    +
    + + diff --git a/tests/golden/elements.md b/tests/golden/elements.md new file mode 100644 index 0000000..4251ca3 --- /dev/null +++ b/tests/golden/elements.md @@ -0,0 +1,70 @@ +--- +title: "Elements of format 0.5" +lang: "en" +status: "Approved" +version: "1.2" +extra: + "client": "ACME" + "reference": "PRJ-042" +cnd_version: "0.5.0" +built_at: "2026-10-13T00:00:00+00:00" +--- + +# Section 1 Elements of format 0.5 + +Pressure **rose** 103 times, see Theorem 1 and the note, after Knuth: $E = m c^2$ holds. +[thm-main] [^fn-detail] @knuth1984 + +> [!WARNING] +> **Careful** +> +> Never edit a CND in place. + +1. [x] Compile the source + - with fonts +2. [ ] Validate the output + +***Mark*** +: A standoff span with a kind. + + Marks of different kinds may overlap. + +| Station | hPa | +| --- | --- | +| Brest | 1013 | + +![Before](a.png) + +![After](b.png) + +Energy and mass are equivalent. + +*Theorem 1: Mass–energy equivalence* + +States that energy equals mass times the speed of light squared. + +> First paragraph of the quote. +> +> Second paragraph. +> +> — *A. Einstein* + +main.py +```python +print('hi') +``` + +```math +a^2 + b^2 = c^2 +``` +(1) + +## Footnotes + +[^fn-detail]: Details live here. + + A second paragraph, with `code`. + +## Bibliography + +- **knuth1984** — Knuth, D. *The TeXbook*. Addison-Wesley, 1984. diff --git a/tests/golden/full_coverage.html b/tests/golden/full_coverage.html index 79a3ec1..9eb075e 100644 --- a/tests/golden/full_coverage.html +++ b/tests/golden/full_coverage.html @@ -1,5 +1,5 @@ - + @@ -18,6 +18,11 @@ table { border-collapse: collapse; } th, td { border: 1px solid currentColor; padding: .25rem .5rem; } pre { overflow-x: auto; padding: .75rem; } +.cnd-layout { display: grid; gap: 1rem; } +.cnd-admonition { border-left: .25rem solid currentColor; margin: 1rem 0; + padding: .25rem 1rem; } +.cnd-admonition-title { font-weight: bold; } +.cnd-code-title { font-family: monospace; font-size: .9em; margin-bottom: 0; } .cnd-markers { font-size: .85em; opacity: .75; } .cnd-unresolved { text-decoration: underline wavy; } .cnd-missing-image { font-style: italic; opacity: .7; } @@ -28,17 +33,17 @@

    1 Vue d'ensemble

    Les mesures nominales sont récapitulées dans la table des mesures ; la structure atomique est illustrée plus bas.

    [durand2025, p. 42] fn-unit [tab-mesures] [durand2025] [fig-atom] -
    1. Étalonner les capteurs
      • Vérifier le zéro
      • Vérifier la pleine échelle
    2. Lancer l'acquisition
    +
    1. Étalonner les capteurs
      1. Vérifier le zéro
      2. Vérifier la pleine échelle
    2. Lancer l'acquisition
    • Alimentation 24 V redondée
    • Bus de terrain isolé galvaniquement
    Banc
    Ensemble mécanique portant les capteurs sous test.
    Cycle
    Séquence complète de montée et descente en charge.
    Dérive
    Écart de mesure observé entre deux étalonnages.
    -

    Mesurer, c'est comparer à une référence.

    Manuel de métrologie interne
    +

    Mesurer, c'est comparer à une référence.

    Manuel de métrologie interne
    def moyenne(valeurs):
         return sum(valeurs) / len(valeurs)
    e(t) = m(t) - r(t)(1)

    1.1 Résultats et figures

    [fig-schema] -
    CapteurNominal
    PT-0110 bar
    FT-02250 L/min
    Table 1: Mesures nominales par capteur.
    - +
    CapteurNominal
    PT-0110 bar
    FT-02250 L/min
    Table 1: Mesures nominales par capteur.
    +

    Zone

    Relevés bruts

    12.4

    12.6

    Vue d'ensemble du banc de mesure
    Figure 1: Schéma du banc de mesure.
    {
       "frequence_hz": 10,
    @@ -50,14 +55,14 @@ 

    1.1 Résultats et figures

    Logo du banc, image nue hors figure Image incorporée sans chemin extrait

    2 Références

    -

    Grille brute en annexe. Les travaux antérieurs couvrent la méthode, l'auteur, l'année et une citation muette.

    +

    Grille brute en annexe. Les travaux antérieurs couvrent la méthode, l'auteur, l'année et une citation muette.

    [grid-layout] fn-proto [nguyen2023] [durand2025] [nguyen2023]

    Footnotes

      -
    1. Toutes les valeurs sont exprimées en unités SI.
    2. -
    3. Le prototype de la grille figure au dossier d'essais 2026-041.
    4. +
    5. Toutes les valeurs sont exprimées en unités SI.

    6. +
    7. Le prototype de la grille figure au dossier d'essais 2026-041.

    diff --git a/tests/golden/full_coverage.md b/tests/golden/full_coverage.md index d967503..78ae6f4 100644 --- a/tests/golden/full_coverage.md +++ b/tests/golden/full_coverage.md @@ -12,7 +12,7 @@ keywords: - "figures" description: "Manifest de référence exerçant chaque type de nœud et chaque variante du format CND 0.2.0 : familles de liens, pools, figures imbriquées, tables fusionnées." lang: "fr" -cnd_version: "0.3.0" +cnd_version: "0.5.0" built_at: "2026-07-15T08:30:00+00:00" --- @@ -45,7 +45,10 @@ def moyenne(valeurs): return sum(valeurs) / len(valeurs) ``` +```math e(t) = m(t) - r(t) +``` +(1) ## 1.1 Résultats et figures [fig-schema] @@ -57,9 +60,13 @@ e(t) = m(t) - r(t) *Table 1: Mesures nominales par capteur.* -| Zone | Relevés bruts | | -| --- | --- | --- | -| | 12.4 | 12.6 | +Zone + +Relevés bruts + +12.4 + +12.6 ![Vue d'ensemble du banc de mesure](figures/banc.png) diff --git a/tests/golden/minimal.md b/tests/golden/minimal.md index 1392d56..c7ea15a 100644 --- a/tests/golden/minimal.md +++ b/tests/golden/minimal.md @@ -7,7 +7,7 @@ keywords: - "test" - "cnd" lang: "fr" -cnd_version: "0.3.0" +cnd_version: "0.5.0" built_at: "2026-05-20T10:00:00+00:00" --- diff --git a/tests/golden/unpaginated.html b/tests/golden/unpaginated.html index 3377506..4d20d7f 100644 --- a/tests/golden/unpaginated.html +++ b/tests/golden/unpaginated.html @@ -1,5 +1,5 @@ - + @@ -13,6 +13,11 @@ table { border-collapse: collapse; } th, td { border: 1px solid currentColor; padding: .25rem .5rem; } pre { overflow-x: auto; padding: .75rem; } +.cnd-layout { display: grid; gap: 1rem; } +.cnd-admonition { border-left: .25rem solid currentColor; margin: 1rem 0; + padding: .25rem 1rem; } +.cnd-admonition-title { font-weight: bold; } +.cnd-code-title { font-family: monospace; font-size: .9em; margin-bottom: 0; } .cnd-markers { font-size: .85em; opacity: .75; } .cnd-unresolved { text-decoration: underline wavy; } .cnd-missing-image { font-style: italic; opacity: .7; } @@ -29,7 +34,7 @@

    Handshake

    Footnotes

      -
    1. The version token is opaque to intermediaries.
    2. +
    3. The version token is opaque to intermediaries.

    diff --git a/tests/golden/unpaginated.md b/tests/golden/unpaginated.md index 08a74bb..afe1505 100644 --- a/tests/golden/unpaginated.md +++ b/tests/golden/unpaginated.md @@ -1,7 +1,7 @@ --- title: "Protocol notes" lang: "en" -cnd_version: "0.3.0" +cnd_version: "0.5.0" built_at: "2026-07-22T09:30:00+00:00" --- @@ -12,7 +12,6 @@ The client opens with a version token, as shown in the listing below. ```text OPEN v1 - ``` *Opening frame* diff --git a/tests/ids.py b/tests/ids.py index 12c39ce..caf04f7 100644 --- a/tests/ids.py +++ b/tests/ids.py @@ -5,6 +5,13 @@ PARA001_ID = UUID("00000000-0000-4000-a000-000000000003") TABLE001_ID = UUID("00000000-0000-4000-a000-000000000004") FIGURE001_ID = UUID("00000000-0000-4000-a000-000000000005") +# The paragraphs of the four cells of TABLE001, in cell order. +TABLE001_CELL_PARA_IDS = [ + UUID("00000000-0000-4000-c000-000000000001"), + UUID("00000000-0000-4000-c000-000000000002"), + UUID("00000000-0000-4000-c000-000000000003"), + UUID("00000000-0000-4000-c000-000000000004"), +] MINIMAL_HEADING_ID = UUID("550e8400-e29b-41d4-a716-446655440001") MINIMAL_PARAGRAPH_ID = UUID("550e8400-e29b-41d4-a716-446655440002") diff --git a/tests/legacy/article-0.1.0.decl.yaml b/tests/legacy/article-0.1.0.decl.yaml new file mode 100644 index 0000000..c3f9b0c --- /dev/null +++ b/tests/legacy/article-0.1.0.decl.yaml @@ -0,0 +1,77 @@ +# A declaration — the source form the builder compiles into a CND +# (docs/adr/0019). Note what is absent by construction: no node has an +# `id`, a `location`, a `number`, or a `heading_path`. The builder +# supplies all of those. What is here is authored content only — +# including `counter_label`, whose word is authored, not derived +# (docs/proposals/0010). +# +# article.cnd is this declaration's expected build output (`cnd build`, +# unnumbered). Compare the two after erasing every `id` and `built_at`: +# ids are minted fresh per build (docs/adr/0015), and `built_at` is a +# timestamp — neither is reproducible across builds. +declaration_version: "0.1.0" + +source: + type: markdown + hash: "sha256:1f0a9c7e5b2d84e6" + uri: articles/handshake.md + +doc: + title: The handshake, declared + authors: + - Ada Rivest + keywords: [protocol, declaration] + lang: en + +nodes: + - type: heading + label: sec-handshake + level: 1 + text: Handshake + children: + - type: paragraph + text: >- + The client opens with a version token, as the listing shows, + following Rivest. See the note on opacity. + refs: + - label: lst-open + text_span: [39, 57] + cites: + - label: rivest1978 + form: prose + footnotes: + - label: fn-token + + - type: figure + label: lst-open + kind: code + # The displayed counter word. Authored, not derived: the builder + # has no localized name for a "code" figure, so if the author wants + # "Listing 1" rather than nothing, the word lives here. + counter_label: Listing + caption: Opening frame + children: + - type: code + text: "OPEN v1\n" + lang: text + + - type: list + ordered: true + items: + # An authored start override — the declaration keeps this, the + # running ordinal it does not (that is the builder's to resolve). + - text: Negotiate the version. + number: 1 + - text: Exchange capabilities. + - text: Confirm. + +bibliography: + - label: rivest1978 + type: article + authors: [Rivest, R.] + title: On data banks and privacy homomorphisms + year: 1978 + +footnotes: + - label: fn-token + text: The version token is opaque to intermediaries. diff --git a/tests/legacy/minimal-0.4.0.cnd b/tests/legacy/minimal-0.4.0.cnd new file mode 100644 index 0000000..0158734 --- /dev/null +++ b/tests/legacy/minimal-0.4.0.cnd @@ -0,0 +1,54 @@ +{ + "cnd_version": "0.4.0", + "built_at": "2026-05-20T10:00:00Z", + "source": { + "type": "typst", + "hash": "sha256:abc123def4567890", + "uri": "main.typ" + }, + "doc": { + "title": "Test Document", + "authors": [ + "Jane Doe" + ], + "date": { + "year": 2026, + "month": 5, + "day": 20 + }, + "keywords": [ + "test", + "cnd" + ], + "description": null, + "lang": "fr" + }, + "nodes": [ + { + "id": "550e8400-e29b-41d4-a716-446655440001", + "type": "heading", + "level": 1, + "number": "1", + "text": "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 + } + } + ] + } + ], + "bibliography": [], + "footnotes": [] +} diff --git a/tests/test_builder.py b/tests/test_builder.py index df032a7..5a8e1ad 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -15,7 +15,8 @@ from pydantic import ValidationError from cnd.builder import BuildError, build -from cnd.core.cnd import CND_VERSION +from cnd.core.cnd import CND_VERSION, Cnd +from cnd.core.nodes import HeadingNode, Numbering from cnd.declaration import DECLARATION_VERSION, Declaration FIXTURES = Path(__file__).parent.parent / "fixtures" / "declaration" @@ -32,6 +33,16 @@ def _decl(nodes: list[dict], **top) -> Declaration: return Declaration.model_validate(payload) +def _p(text: str) -> dict: + """A declared paragraph, in the string shorthand.""" + return {"type": "paragraph", "text": text} + + +def _item(text: str, **kwargs) -> dict: + """A declared list item holding one paragraph.""" + return {"children": [_p(text)], **kwargs} + + class TestTranscription: def test_stamps_version_time_and_fresh_ids(self) -> None: decl = _decl( @@ -41,7 +52,7 @@ def test_stamps_version_time_and_fresh_ids(self) -> None: ]}, ], bibliography=[{"label": "k1", "title": "W", "year": 2020}], - footnotes=[{"label": "f1", "text": "note"}], + footnotes=[{"label": "f1", "children": [_p("note")]}], ) cnd = build(decl) @@ -49,9 +60,10 @@ def test_stamps_version_time_and_fresh_ids(self) -> None: assert cnd.cnd_version == CND_VERSION assert cnd.built_at.tzinfo is not None assert cnd.built_at.utcoffset().total_seconds() == 0 - ids = {v.node.id for v in cnd.iter()} + ids = {v.node.id for v in cnd.iter_all()} ids |= {cnd.id, cnd.bibliography[0].id, cnd.footnotes[0].id} - assert len(ids) == 5 # heading, paragraph, doc, bib entry, footnote + # heading, paragraph, footnote paragraph, doc, bib entry, footnote + assert len(ids) == 6 def test_two_builds_mint_different_ids(self) -> None: decl = _decl([{"type": "paragraph", "text": "p"}]) @@ -62,7 +74,7 @@ def test_text_passes_through_byte_for_byte(self) -> None: # Weird spacing is the point: any normalization shifts text_spans. text = " two spaces\tand a tab \n trailing " decl = _decl([{"type": "paragraph", "text": text}]) - assert build(decl).nodes[0].text == text + assert build(decl).nodes[0].text.value == text def test_heading_path_is_ancestors_plus_self_bare_when_unnumbered(self) -> None: decl = _decl( @@ -81,29 +93,30 @@ def test_heading_path_is_ancestors_plus_self_bare_when_unnumbered(self) -> None: sub = top.children[0] assert top.heading_path == ["Top"] assert sub.heading_path == ["Top", "Sub"] - assert top.number is None and sub.number is None + assert top.numbering is None and sub.numbering is None def test_never_paginated_and_authored_fields_survive(self) -> None: decl = _decl( [ {"type": "figure", "kind": "code", "label": "lst-a", - "counter_label": "Listing", "caption": "cap", "children": [ + "numbering": {"name": "Listing"}, "caption": "cap", "children": [ {"type": "code", "text": "x\n", "lang": "text"}, ]}, - {"type": "paragraph", "text": "see", "refs": [{"label": "lst-a"}], + {"type": "paragraph", + "text": {"value": "see", "refs": [{"label": "lst-a"}]}, "state_metadata": {"k": "v"}}, ], source={"type": "markdown", "hash": "sha256:00", "uri": "a.md"}, ) - cnd = build(decl) + cnd = build(decl, numbering=True) assert cnd.paginated is False - assert all(v.node.location is None for v in cnd.iter()) + assert all(v.node.location is None for v in cnd.iter_all()) figure = cnd.nodes[0] - assert figure.counter_label == "Listing" # authored, passed through - assert figure.number is None # engine off by default - assert cnd.nodes[1].refs[0].label == "lst-a" + # The authored word passes through; the engine supplies the value. + assert figure.numbering == Numbering(value="1", name="Listing") + assert cnd.nodes[1].text.refs[0].label == "lst-a" assert cnd.nodes[1].state_metadata == {"k": "v"} assert cnd.source is not None and cnd.source.uri == "a.md" @@ -111,35 +124,122 @@ def test_every_declaration_node_type_transcribes(self) -> None: decl = _decl( [ {"type": "heading", "level": 1, "text": "H"}, - {"type": "paragraph", "text": "p", "lang": "en"}, - {"type": "table", "kind": "grid", - "cells": [{"row": 0, "col": 0, "text": "c"}]}, - {"type": "quote", "text": "q", "attribution": "A"}, - {"type": "code", "text": "c", "lang": "py", "block": False}, - {"type": "math", "text": "x", "block": True}, - {"type": "figure", "caption": "f"}, + {"type": "paragraph", "text": {"value": "p", "lang": "en"}}, + {"type": "table", "kind": "data", + "cells": [{"row": 0, "col": 0, "children": [_p("c")]}]}, + {"type": "quote", "attribution": "A", "children": [_p("q")]}, + {"type": "code", "text": "c", "lang": "py", "title": "a.py"}, + {"type": "math", "text": "x"}, + {"type": "figure", "caption": "f", "description": "d"}, {"type": "image", "path": "i.png", "alt": "alt"}, - {"type": "list", "ordered": False, "items": [{"text": "i"}]}, - {"type": "terms", "items": [{"term": "t", "description": "d"}]}, + {"type": "list", "ordered": False, + "items": [_item("i", checked=True)]}, + {"type": "terms", "items": [{"term": "t", "children": [_p("d")]}]}, + {"type": "admonition", "kind": "warning", "title": "W", + "children": [_p("w")]}, + {"type": "layout", + "cells": [{"row": 0, "col": 1, "children": [_p("l")]}]}, ] ) cnd = build(decl) assert [n.type for n in cnd.nodes] == [ - "heading", "paragraph", "table", "quote", "code", - "math", "figure", "image", "list", "terms", + "heading", "paragraph", "table", "quote", "code", "math", + "figure", "image", "list", "terms", "admonition", "layout", ] - assert cnd.nodes[2].cells[0].text == "c" - assert cnd.nodes[4].block is False - assert cnd.nodes[9].items[0].term == "t" + assert cnd.nodes[1].text.lang == "en" + assert cnd.nodes[2].kind == "data" + assert cnd.nodes[2].cells[0].children[0].text.value == "c" + assert cnd.nodes[3].attribution.value == "A" + assert cnd.nodes[4].title == "a.py" + assert cnd.nodes[6].description.value == "d" + assert cnd.nodes[8].items[0].checked is True + assert cnd.nodes[9].items[0].term.value == "t" + assert cnd.nodes[10].kind == "warning" + assert cnd.nodes[11].cells[0].col == 1 + + def test_every_nested_node_gets_a_fresh_id(self) -> None: + """Nodes in items, term items, cells, containers and footnotes are + nodes in every respect: each one is minted an id.""" + decl = _decl( + [ + {"type": "list", "items": [_item("in an item")]}, + {"type": "terms", "items": [{"term": "t", "children": [_p("def")]}]}, + {"type": "table", "cells": [{"row": 0, "col": 0, "children": [_p("c")]}]}, + {"type": "admonition", "kind": "note", "children": [_p("a")]}, + ], + footnotes=[{"label": "fn", "children": [_p("note"), _p("more")]}], + ) + + cnd = build(decl) + + walked = list(cnd.iter_all()) + assert len(walked) == 10 + assert len({v.node.id for v in walked}) == 10 + + def test_a_heading_nested_in_a_container_knows_its_ancestors(self) -> None: + decl = _decl( + [ + {"type": "heading", "level": 1, "text": "Top", "children": [ + {"type": "list", "items": [ + {"children": [{"type": "heading", "level": 2, "text": "In"}]}, + ]}, + ]}, + ] + ) + + inner = build(decl).nodes[0].children[0].items[0].children[0] + + assert inner.heading_path == ["Top", "In"] + + def test_a_built_text_is_not_shared_with_the_declaration(self) -> None: + decl = _decl([_p("x")]) + cnd = build(decl) + + cnd.nodes[0].text.value = "changed" + + assert decl.nodes[0].text.value == "x" + + +class TestNoSharedState: + """The CND never shares a mutable object with its declaration: two + builds of one declaration are two independent documents.""" + + def test_mutating_the_declaration_after_a_build_leaves_the_cnd_alone( + self, + ) -> None: + from cnd.reconcile import diff + + decl = _decl( + [{"type": "paragraph", "text": "a", + "state_metadata": {"review": {"status": "draft"}}}, + {"type": "math", "text": "x", + "raw": {"format": "typst", "value": "$x$"}}], + source={"type": "markdown", "hash": "sha256:00", "uri": "a.md"}, + doc={"title": "T", "keywords": ["core"]}, + ) + c1 = build(decl) + + decl.nodes[0].state_metadata["review"]["status"] = "final" + decl.nodes[1].raw.value = "$y$" + decl.source.uri = "b.md" + decl.doc.keywords.append("docs") + c2 = build(decl) + + assert c1.nodes[0].state_metadata == {"review": {"status": "draft"}} + assert c1.nodes[1].raw.value == "$x$" + assert c1.source.uri == "a.md" + assert c1.doc.keywords == ["core"] + report = diff(c1, c2) + assert len(report.changed) == 2 class TestListOrdinals: def test_sequential_from_one_when_no_overrides(self) -> None: decl = _decl( [{"type": "list", "ordered": True, "items": [ - {"text": "a"}, {"text": "b"}, {"text": "c"}, + _item("a"), _item("b"), _item("c"), ]}] ) numbers = [i.number for i in build(decl).nodes[0].items] @@ -150,7 +250,7 @@ def test_override_rebases_the_rest_of_the_list(self) -> None: # their positional index (the rule pinned on DeclListItem). decl = _decl( [{"type": "list", "ordered": True, "items": [ - {"text": "a"}, {"text": "b", "number": 7}, {"text": "c"}, + _item("a"), _item("b", number=7), _item("c"), ]}] ) numbers = [i.number for i in build(decl).nodes[0].items] @@ -160,7 +260,7 @@ def test_start_override_on_first_item(self) -> None: # markdown's "3." on the first item of an ordered list. decl = _decl( [{"type": "list", "ordered": True, "items": [ - {"text": "a", "number": 3}, {"text": "b"}, + _item("a", number=3), _item("b"), ]}] ) numbers = [i.number for i in build(decl).nodes[0].items] @@ -169,25 +269,29 @@ def test_start_override_on_first_item(self) -> None: def test_nested_levels_count_independently(self) -> None: decl = _decl( [{"type": "list", "ordered": True, "items": [ - {"text": "a", "children": [{"text": "a1"}, {"text": "a2"}]}, - {"text": "b"}, + {"children": [ + _p("a"), + {"type": "list", "ordered": True, + "items": [_item("a1"), _item("a2")]}, + ]}, + _item("b"), ]}] ) items = build(decl).nodes[0].items assert [i.number for i in items] == [1, 2] - assert [i.number for i in items[0].children] == [1, 2] + assert [i.number for i in items[0].children[1].items] == [1, 2] def test_unordered_list_drops_overrides(self) -> None: decl = _decl( [{"type": "list", "ordered": False, "items": [ - {"text": "a", "number": 5}, {"text": "b"}, + _item("a", number=5), _item("b"), ]}] ) assert [i.number for i in build(decl).nodes[0].items] == [None, None] def test_resolution_is_independent_of_numbering_flag(self) -> None: decl = _decl( - [{"type": "list", "ordered": True, "items": [{"text": "a"}]}] + [{"type": "list", "ordered": True, "items": [_item("a")]}] ) assert build(decl).nodes[0].items[0].number == 1 assert build(decl, numbering=True).nodes[0].items[0].number == 1 @@ -215,7 +319,8 @@ def test_duplicate_label_is_a_build_error(self) -> None: def test_unresolved_ref_is_a_build_error(self) -> None: decl = _decl( - [{"type": "paragraph", "text": "a", "refs": [{"label": "ghost"}]}] + [{"type": "paragraph", + "text": {"value": "a", "refs": [{"label": "ghost"}]}}] ) violations = self._violations(decl) assert [v.rule for v in violations] == ["link-unresolved"] @@ -226,8 +331,8 @@ def test_wrong_domain_edge_is_a_build_error(self) -> None: decl = _decl( [ {"type": "paragraph", "text": "a", "label": "not-a-bib"}, - {"type": "paragraph", "text": "b", - "cites": [{"label": "not-a-bib"}]}, + {"type": "paragraph", + "text": {"value": "b", "cites": [{"label": "not-a-bib"}]}}, ] ) assert [v.rule for v in self._violations(decl)] == ["link-wrong-domain"] @@ -249,7 +354,8 @@ def test_unsupported_declaration_version(self) -> None: def test_where_is_label_first_never_a_minted_uuid(self) -> None: # An unlabelled node in violation: named by reading-order position. decl = _decl( - [{"type": "paragraph", "text": "a", "refs": [{"label": "ghost"}]}] + [{"type": "paragraph", + "text": {"value": "a", "refs": [{"label": "ghost"}]}}] ) [violation] = self._violations(decl) assert not re.search(UUID_HEX, violation.where) @@ -263,11 +369,60 @@ def test_level_zero_heading_is_rejected_at_parse(self) -> None: with pytest.raises(ValidationError): _decl([{"type": "heading", "level": 0, "text": "bad"}]) + def test_a_violation_in_a_footnote_is_named_by_its_note(self) -> None: + """Positions inside a footnote count within it (spec §8), so the + handle names the note: ``@fn-a#2`` is its second node.""" + decl = _decl( + [{"type": "paragraph", + "text": {"value": "a", "footnotes": [{"label": "fn-a"}]}}], + footnotes=[{"label": "fn-a", "children": [ + _p("fine"), + {"type": "paragraph", + "text": {"value": "b", "refs": [{"label": "ghost"}]}}, + ]}], + ) + [violation] = self._violations(decl) + assert violation.rule == "link-unresolved" + assert "@fn-a#2" in violation.where + assert not re.search(UUID_HEX, violation.where) + + def test_a_bad_mark_is_a_build_error(self) -> None: + """The full text form can carry offsets; the builder checks them + like any other invariant.""" + decl = _decl( + [{"type": "paragraph", + "text": {"value": "ab", "marks": [{"start": 1, "end": 9, "kind": "emph"}]}}] + ) + assert [v.rule for v in self._violations(decl)] == ["mark-out-of-bounds"] + + def test_authored_numbering_with_the_engine_off_is_a_build_error(self) -> None: + """A CND cannot carry a counter word without a value, and the + declaration never drops authored content silently.""" + decl = _decl( + [{"type": "figure", "label": "lst-a", "numbering": {"name": "Listing"}, + "children": [{"type": "code", "text": "x"}]}] + ) + [violation] = self._violations(decl) + assert violation.rule == "numbering-without-engine" + assert "@lst-a" in violation.where + assert build(decl, numbering=True).nodes[0].numbering.name == "Listing" + + def test_a_0_1_0_declaration_is_refused(self) -> None: + decl = _decl([_p("a")]).model_copy(update={"declaration_version": "0.1.0"}) + [violation] = self._violations(decl) + assert violation.rule == "declaration-version-unsupported" + # The message is version-aware: it names the refused version and + # the one this builder supports. + assert "0.1.0" in violation.where + assert DECLARATION_VERSION in violation.message + def test_error_message_joins_all_violations(self) -> None: decl = _decl( [ - {"type": "paragraph", "text": "a", "refs": [{"label": "g1"}]}, - {"type": "paragraph", "text": "b", "refs": [{"label": "g2"}]}, + {"type": "paragraph", + "text": {"value": "a", "refs": [{"label": "g1"}]}}, + {"type": "paragraph", + "text": {"value": "b", "refs": [{"label": "g2"}]}}, ] ) with pytest.raises(BuildError) as excinfo: @@ -290,10 +445,10 @@ def test_headings_dotted_with_reset(self) -> None: ) cnd = build(decl, numbering=True) a, b = cnd.nodes - assert a.number == "1" - assert [h.number for h in a.children] == ["1.1", "1.2"] - assert b.number == "2" - assert b.children[0].number == "2.1" + assert a.numbering.value == "1" + assert [h.numbering.value for h in a.children] == ["1.1", "1.2"] + assert b.numbering.value == "2" + assert b.children[0].numbering.value == "2.1" def test_skipped_level_shows_zero(self) -> None: # Level jumps 1 → 3: the pinned house style shows the gap as 0 @@ -306,7 +461,7 @@ def test_skipped_level_shows_zero(self) -> None: ] ) cnd = build(decl, numbering=True) - assert cnd.nodes[0].children[0].number == "1.0.1" + assert cnd.nodes[0].children[0].numbering.value == "1.0.1" def test_heading_path_elements_carry_the_number(self) -> None: decl = _decl( @@ -327,30 +482,51 @@ def test_figures_count_per_kind_with_inference(self) -> None: # must NOT advance the image counter. {"type": "figure", "caption": "b", "children": [ {"type": "table", - "cells": [{"row": 0, "col": 0, "text": "x"}]}, + "cells": [{"row": 0, "col": 0, "children": [_p("x")]}]}, ]}, {"type": "figure", "kind": "image", "caption": "c"}, ] ) cnd = build(decl, numbering=True) - assert [f.number for f in cnd.nodes] == ["1", "1", "2"] + assert [f.numbering.value for f in cnd.nodes] == ["1", "1", "2"] - def test_block_math_numbered_inline_math_not(self) -> None: + def test_math_nodes_numbered_inline_math_marks_not(self) -> None: + """Every math node is a block in 0.5; inline math is a mark inside + a paragraph, which no counter sees.""" decl = _decl( [ - {"type": "math", "text": "a", "block": True}, - {"type": "math", "text": "b", "block": False}, - {"type": "math", "text": "c", "block": True}, + {"type": "math", "text": "a"}, + {"type": "paragraph", "text": { + "value": "b", "marks": [{"start": 0, "end": 1, "kind": "math"}], + }}, + {"type": "math", "text": "c"}, ] ) cnd = build(decl, numbering=True) - assert [m.number for m in cnd.nodes] == ["(1)", None, "(2)"] + assert [cnd.nodes[0].numbering.value, cnd.nodes[2].numbering.value] == [ + "(1)", "(2)", + ] - def test_counter_label_is_never_invented(self) -> None: + def test_footnote_content_counts_after_the_body(self) -> None: + """The engine runs in full-walk order: the body, then each note.""" + decl = _decl( + [ + {"type": "math", "text": "a"}, + {"type": "paragraph", + "text": {"value": "x", "footnotes": [{"label": "fn"}]}}, + {"type": "math", "text": "b"}, + ], + footnotes=[{"label": "fn", "children": [ + {"type": "math", "label": "eq-note", "text": "c"}, + ]}], + ) + cnd = build(decl, numbering=True) + assert cnd.footnotes[0].children[0].numbering.value == "(3)" + + def test_counter_word_is_never_invented(self) -> None: decl = _decl([{"type": "figure", "kind": "image", "caption": "a"}]) figure = build(decl, numbering=True).nodes[0] - assert figure.number == "1" - assert figure.counter_label is None + assert figure.numbering == Numbering(value="1", name=None) def _scrub(obj): @@ -384,4 +560,15 @@ def test_declaration_builds_to_the_committed_cnd( built = build(decl, numbering=numbering) - assert _scrub(json.loads(built.model_dump_json())) == _scrub(expected) + assert _scrub(json.loads(built.to_json())) == _scrub(expected) + + @pytest.mark.parametrize("name", ["article.cnd", "numbered.cnd"]) + def test_every_heading_path_matches_the_walk(self, name: str) -> None: + """The heading path the builder derives is the one the walk sees: + each heading's path is its context's path plus itself.""" + cnd = Cnd.model_validate_json((FIXTURES / name).read_text()) + headings = [v for v in cnd.iter_all() if isinstance(v.node, HeadingNode)] + + assert headings + for visit in headings: + assert visit.node.heading_path[:-1] == visit.ctx.heading_path diff --git a/tests/test_cli.py b/tests/test_cli.py index e546380..2303a6c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,10 +10,15 @@ from pathlib import Path import pytest +import yaml +from cnd import Cnd, content_hash +from cnd.builder import build from cnd.cli import main +from cnd.declaration import DECLARATION_VERSION, Declaration FIXTURES = Path(__file__).parent.parent / "fixtures" +LEGACY = Path(__file__).parent / "legacy" def _run(capsys, *argv) -> tuple[int, str, str]: @@ -42,7 +47,7 @@ def test_a_violation_exits_nonzero_and_names_the_rule( broken = json.loads((FIXTURES / "rich_content.cnd").read_text()) # A cites edge pointing at a figure: the label resolves — labels are # unique document-wide — but not in the family's domain. - broken["nodes"][0]["children"][0]["cites"][0]["label"] = "lst-api" + broken["nodes"][0]["children"][0]["text"]["cites"][0]["label"] = "lst-api" path = tmp_path / "broken.cnd" path.write_text(json.dumps(broken)) @@ -62,7 +67,7 @@ def test_a_malformed_file_is_a_failure_not_a_crash( self, capsys, tmp_path: Path, ) -> None: path = tmp_path / "bad.cnd" - path.write_text('{"cnd_version": "0.3.0"}') + path.write_text('{"cnd_version": "0.5.0"}') code, _, err = _run(capsys, "validate", path) @@ -88,6 +93,58 @@ def test_one_bad_file_among_good_ones_still_fails( assert code == 1 +class TestVersionRefusal: + """A file from another format version is refused by name, before its + shape is checked: a 0.4 CND failing on "Input should be an object" + never tells its reader that the version is the problem (spec §11).""" + + def test_a_real_0_4_cnd_is_refused_with_its_version(self, capsys) -> None: + # main's minimal.cnd, a 0.3.0 file that parses unchanged as 0.4.0, + # stamped 0.4.0. + code, _, err = _run(capsys, "validate", LEGACY / "minimal-0.4.0.cnd") + + assert code == 1 + assert "cnd-version-unsupported" in err + assert "0.4.0" in err and "0.5.0" in err + assert "schema error" not in err + + @pytest.mark.parametrize("version", ["0.4.0", "9.9.9"]) + def test_a_0_5_shaped_cnd_stamped_with_another_version_is_refused( + self, capsys, tmp_path: Path, version: str, + ) -> None: + data = json.loads((FIXTURES / "minimal.cnd").read_text(encoding="utf-8")) + data["cnd_version"] = version + path = tmp_path / "stamped.cnd" + path.write_text(json.dumps(data), encoding="utf-8") + + code, out, err = _run(capsys, "validate", path) + + assert code == 1 + assert ": ok" not in out + assert "cnd-version-unsupported" in err and version in err + + def test_hash_refuses_another_version_too(self, capsys) -> None: + """A 0.5 hash of a 0.4 document means nothing: every loader + refuses, not only validate.""" + code, out, err = _run(capsys, "hash", LEGACY / "minimal-0.4.0.cnd") + + assert code == 1 + assert out == "" + assert "cnd-version-unsupported" in err + + def test_a_real_0_1_declaration_is_refused_with_its_version( + self, capsys, + ) -> None: + # main's article.decl.yaml, byte for byte. + code, out, err = _run(capsys, "build", LEGACY / "article-0.1.0.decl.yaml") + + assert code == 1 + assert out == "" + assert "declaration-version-unsupported" in err + assert "0.1.0" in err and DECLARATION_VERSION in err + assert "Extra inputs" not in err + + class TestHash: def test_it_reproduces_the_committed_vectors(self, capsys) -> None: """The oracle must agree with the corpus it is the oracle of.""" @@ -157,7 +214,7 @@ def test_plain_output_names_the_pass_that_matched(self, capsys) -> None: _, out, _ = _run(capsys, "diff", *self._pair("01-label-survives-edit-and-move")) assert "via label" in out - assert "matching v1" in out + assert "matching v2" in out def test_an_identical_pair_reports_nothing_changed(self, capsys) -> None: code, out, _ = _run( @@ -184,16 +241,26 @@ def test_builds_a_declaration_to_stdout(self, capsys) -> None: assert code == 0 built = json.loads(out) - assert built["cnd_version"] == "0.4.0" + assert built["cnd_version"] == "0.5.0" assert built["nodes"][0]["type"] == "heading" - # Engine off by default: nothing numbered. - assert built["nodes"][0]["number"] is None + # Engine off by default: nothing numbered, so nothing written. + assert "numbering" not in built["nodes"][0] def test_numbering_flag_runs_the_counter_engine(self, capsys) -> None: code, out, _ = _run(capsys, "build", "--numbering", self.DECL) assert code == 0 - assert json.loads(out)["nodes"][0]["number"] == "1" + assert json.loads(out)["nodes"][0]["numbering"] == {"value": "1"} + + def test_the_output_omits_default_valued_fields(self, capsys) -> None: + """The SDK writer (docs/proposals/0013): no empty list, no null and + no empty object is written, and the CND is the one `build` makes.""" + code, out, _ = _run(capsys, "build", self.DECL) + + assert code == 0 + assert ": []" not in out and ": null" not in out and ": {}" not in out + decl = Declaration.model_validate(yaml.safe_load(self.DECL.read_text())) + assert content_hash(Cnd.model_validate_json(out)) == content_hash(build(decl)) def test_output_flag_writes_a_file(self, capsys, tmp_path: Path) -> None: target = tmp_path / "article.cnd" @@ -202,16 +269,17 @@ def test_output_flag_writes_a_file(self, capsys, tmp_path: Path) -> None: assert code == 0 assert out == "" - assert json.loads(target.read_text())["cnd_version"] == "0.4.0" + assert json.loads(target.read_text())["cnd_version"] == "0.5.0" def test_unbuildable_declaration_exits_nonzero_with_rules( self, capsys, tmp_path: Path, ) -> None: decl = { - "declaration_version": "0.1.0", + "declaration_version": DECLARATION_VERSION, "doc": {"title": "T"}, "nodes": [ - {"type": "paragraph", "text": "a", "refs": [{"label": "ghost"}]}, + {"type": "paragraph", + "text": {"value": "a", "refs": [{"label": "ghost"}]}}, ], } path = tmp_path / "bad.decl.json" @@ -227,7 +295,7 @@ def test_a_stray_key_is_rejected_not_dropped( self, capsys, tmp_path: Path, ) -> None: decl = { - "declaration_version": "0.1.0", + "declaration_version": DECLARATION_VERSION, "doc": {"title": "T"}, "nodes": [{"type": "paragraph", "text": "a", "capton": "typo"}], } @@ -239,6 +307,28 @@ def test_a_stray_key_is_rejected_not_dropped( assert code == 1 assert "capton" in err + def test_authored_numbering_needs_the_flag( + self, capsys, tmp_path: Path, + ) -> None: + decl = { + "declaration_version": DECLARATION_VERSION, + "doc": {"title": "T"}, + "nodes": [{"type": "math", "text": "x", "numbering": {"name": "Eq."}}], + } + path = tmp_path / "eq.decl.json" + path.write_text(json.dumps(decl)) + + code, _, err = _run(capsys, "build", path) + assert code == 1 + assert "numbering-without-engine" in err + + code, out, _ = _run(capsys, "build", "--numbering", path) + assert code == 0 + assert json.loads(out)["nodes"][0]["numbering"] == { + "value": "(1)", + "name": "Eq.", + } + def test_yaml_without_pyyaml_names_the_extra( self, capsys, monkeypatch, ) -> None: @@ -257,3 +347,21 @@ def _no_yaml(name, *args, **kwargs): assert code == 1 assert "cnd-sdk[yaml]" in err + + +class TestInspect: + def test_the_whole_corpus_inspects(self, capsys) -> None: + """Terminal inspection is a conformance verb (ADR 0020): every + 0.5 node type is displayed, none falls through.""" + code, out, _ = _run(capsys, "inspect", *_corpus()) + + assert code == 0 + assert "ADMONITION" in out.upper() and "LAYOUT" in out.upper() + + def test_an_unreadable_file_fails_without_crashing( + self, capsys, tmp_path: Path, + ) -> None: + code, _, err = _run(capsys, "inspect", tmp_path / "absent.cnd") + + assert code == 1 + assert "cannot read" in err diff --git a/tests/test_cnd.py b/tests/test_cnd.py index f5f199b..b7fcf1a 100644 --- a/tests/test_cnd.py +++ b/tests/test_cnd.py @@ -1,6 +1,8 @@ +from datetime import datetime from pathlib import Path -from uuid import UUID +from uuid import UUID, uuid4 +from factories import rt from ids import ( HEADING002_ID, HEADING002_LABEL, @@ -14,8 +16,9 @@ TABLE001_LABEL, ) -from cnd import BibEntry, Cnd, FigureNode, Footnote -from cnd.core.nodes import LinkRef, ParagraphNode +from cnd import BibEntry, Cnd, DocMetadata, FigureNode, Footnote, NodeLocation +from cnd.core.nodes import ParagraphNode +from cnd.core.text import LinkRef def _load(path: Path) -> Cnd: @@ -97,7 +100,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 +112,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 +120,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 +140,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: @@ -154,6 +157,25 @@ def test_paginated_is_derived_not_serialized( assert _load(minimal_cnd_path).paginated is True assert "paginated" not in unpaginated_cnd_path.read_text() + def test_footnote_content_alone_settles_pagination(self) -> None: + note = Footnote( + id=uuid4(), + label="fn", + children=[ + ParagraphNode( + id=uuid4(), type="paragraph", text=rt("n"), location=NodeLocation(page=1) + ) + ], + ) + cnd = Cnd( + cnd_version="0.5.0", + built_at=datetime(2026, 10, 13), + doc=DocMetadata(title="T"), + nodes=[], + footnotes=[note], + ) + assert cnd.paginated is True + def test_no_node_carries_a_location(self, unpaginated_cnd_path: Path) -> None: cnd = _load(unpaginated_cnd_path) @@ -200,17 +222,61 @@ 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"] + + +def test_the_vocabulary_and_slot_helpers_are_exported() -> None: + import cnd + import cnd.core + + for name in ( + "child_slots", + "ChildSlot", + "KNOWN_MARK_KINDS", + "ATOMIC_MARK_KINDS", + "KNOWN_ADMONITION_KINDS", + "KNOWN_TABLE_KINDS", + ): + assert getattr(cnd, name) is getattr(cnd.core, name) + assert name in cnd.__all__ and name in cnd.core.__all__ + assert cnd.__all__ == sorted(cnd.__all__) + assert cnd.core.__all__ == sorted(cnd.core.__all__) + + +def test_expand_headings_is_exported() -> None: + import cnd + import cnd.core + import cnd.core.render + + assert "expand_headings" in cnd.core.render.__all__ + assert cnd.expand_headings is cnd.core.expand_headings is cnd.core.render.expand_headings + assert "expand_headings" in cnd.__all__ and "expand_headings" in cnd.core.__all__ + assert cnd.core.render.__all__ == sorted(cnd.core.render.__all__) diff --git a/tests/test_converter_blocks.py b/tests/test_converter_blocks.py new file mode 100644 index 0000000..10dc6b4 --- /dev/null +++ b/tests/test_converter_blocks.py @@ -0,0 +1,408 @@ +"""The converters on 0.5 documents: blocks rendered once, markers read +from every text of a block, pools holding blocks, document metadata +(docs/proposals/0013).""" + +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest +from markdown_it import MarkdownIt + +from factories import cell, item, para, rt + +from cnd.converters import HtmlConverter, MarkdownConverter, iter_body, resolve_markers +from cnd.converters.markdown import format_bib_entry +from cnd.core.cnd import BibEntry, Cnd, DocMetadata, Footnote +from cnd.core.nodes import ( + AdmonitionNode, + FigureNode, + HeadingNode, + LayoutNode, + ListNode, + QuoteNode, + TableNode, +) +from cnd.core.render import MarkdownRenderer +from cnd.core.text import FootnoteRef, LinkRef, Mark, NodeRef + + +def _cnd(nodes, **kwargs) -> Cnd: + doc = kwargs.pop("doc", DocMetadata(title="T")) + return Cnd( + cnd_version="0.5.0", + built_at=datetime(2026, 10, 13, tzinfo=timezone.utc), + doc=doc, + nodes=nodes, + **kwargs, + ) + + +def _heading(*children) -> HeadingNode: + return HeadingNode( + type="heading", id=uuid4(), level=1, text=rt("H"), heading_path=["H"], + children=list(children), + ) + + +def _containers() -> list: + lst = ListNode(type="list", id=uuid4(), items=[item("in a list")]) + table = TableNode( + type="table", id=uuid4(), kind="content", + cells=[cell(0, 0, "head", is_header=True), cell(1, 0, "in a cell")], + ) + quote = QuoteNode(type="quote", id=uuid4(), children=[para("in a quote")]) + return [lst, table, quote] + + +class TestRenderOnce: + @pytest.mark.parametrize("converter_cls", [MarkdownConverter, HtmlConverter]) + def test_nested_content_renders_once(self, converter_cls) -> None: + text = converter_cls().convert(_cnd([_heading(*_containers())])).text + for phrase in ("in a list", "in a cell", "in a quote"): + assert text.count(phrase) == 1, phrase + + def test_iter_body_descends_only_into_headings(self) -> None: + heading = _heading(*_containers()) + walked = [visit.node for visit in iter_body(_cnd([heading]))] + assert walked == [heading, *heading.children] + + +class TestMarkers: + def test_markers_of_nested_paragraphs_reach_the_block(self) -> None: + target = para("target", label="t") + lst = ListNode( + type="list", id=uuid4(), + items=[item(para(rt("see it", refs=[NodeRef(label="t", text_span=[4, 6])])))], + ) + out = MarkdownConverter().convert(_cnd([target, lst])).text + assert "- see it\n\n[t]" in out + + def test_html_markers_of_nested_paragraphs_reach_the_block(self) -> None: + target = para("target", label="t") + lst = ListNode( + type="list", id=uuid4(), + items=[item(para(rt("see it", refs=[NodeRef(label="t", text_span=[4, 6])])))], + ) + out = HtmlConverter().convert(_cnd([target, lst])).text + assert ( + '
    • see it
    \n' + '[t]' + ) in out + assert out.count('href="#t"') == 1 + + def _parse(self, node, target): + out = MarkdownConverter().convert(_cnd([target, node])).text + body = out.split("\n---\n", 1)[1] + return MarkdownIt("commonmark").enable("table").parse(body) + + @staticmethod + def _marker_paragraph(tokens, marker): + inline = [i for i, t in enumerate(tokens) if t.type == "inline" and t.content == marker] + assert len(inline) == 1 + assert tokens[inline[0] - 1].type == "paragraph_open" + return inline[0] + + @staticmethod + def _inside(tokens, open_type, close_type, marker): + start = max(i for i, t in enumerate(tokens) if t.type == open_type) + end = next(i for i in range(start, len(tokens)) if tokens[i].type == close_type) + return any(t.content == marker for t in tokens[start:end] if t.type == "inline") + + def test_marker_under_a_list_is_its_own_paragraph(self) -> None: + lst = ListNode(type="list", id=uuid4(), + items=[item(para(rt("see", refs=[NodeRef(label="t", text_span=[0, 3])])))]) + tokens = self._parse(lst, para("target", label="t")) + self._marker_paragraph(tokens, "[t]") + assert not self._inside(tokens, "list_item_open", "list_item_close", "[t]") + + def test_marker_under_a_table_is_not_a_row(self) -> None: + link = LinkRef(href="http://x", text_span=[0, 1]) + table = TableNode(type="table", id=uuid4(), kind="content", cells=[ + cell(0, 0, "h", is_header=True), cell(1, 0, None, children=[para(rt("c", links=[link]))]), + ]) + tokens = self._parse(table, para("target", label="t")) + self._marker_paragraph(tokens, "[http://x](http://x)") + assert len([t for t in tokens if t.type in ("td_open", "th_open")]) == 2 + + def test_marker_under_a_quote_is_its_own_paragraph(self) -> None: + quote = QuoteNode(type="quote", id=uuid4(), + children=[para(rt("see", refs=[NodeRef(label="t", text_span=[0, 3])]))]) + tokens = self._parse(quote, para("target", label="t")) + self._marker_paragraph(tokens, "[t]") + assert not self._inside(tokens, "blockquote_open", "blockquote_close", "[t]") + + def test_spans_of_two_texts_are_never_compared(self) -> None: + figure = FigureNode( + type="figure", id=uuid4(), kind="image", + caption=rt("caption with a late ref", refs=[NodeRef(label="b", text_span=[20, 23])]), + description=rt("early", refs=[NodeRef(label="a", text_span=[0, 5])]), + ) + cnd = _cnd([figure, para("a", label="a"), para("b", label="b")]) + labels = [marker.label for marker in resolve_markers(cnd, figure, [])] + assert labels == ["b", "a"] # caption first, then description + + def test_unresolved_label_names_the_node_holding_the_edge(self) -> None: + inner = para(rt("x", refs=[NodeRef(label="nowhere")])) + lst = ListNode(type="list", id=uuid4(), items=[item(inner)]) + warnings: list[str] = [] + resolve_markers(_cnd([lst]), lst, warnings) + assert warnings == [f"unresolved refs label 'nowhere' on node {inner.id}"] + + def test_links_come_back_unresolved_without_warning(self) -> None: + node = para(rt("site", links=[LinkRef(href="https://example.com", text_span=[0, 4])])) + warnings: list[str] = [] + [marker] = resolve_markers(_cnd([node]), node, warnings) + assert marker.family == "links" and marker.target is None and warnings == [] + + +class TestPools: + def _with_footnote(self) -> Cnd: + note = Footnote(id=uuid4(), label="fn", children=[para("First."), para("Second.")]) + body = para(rt("Text.", footnotes=[FootnoteRef(label="fn", text_span=[4, 5])])) + return _cnd([body], footnotes=[note]) + + def test_markdown_footnote_holds_several_blocks(self) -> None: + # Pandoc and GFM footnotes: a further paragraph is indented 4 spaces. + out = MarkdownConverter().convert(self._with_footnote()).text + assert "[^fn]: First.\n\n Second." in out + + def test_formatted_reference_keeps_its_marks(self) -> None: + entry = BibEntry( + id=uuid4(), label="k", + formatted=rt("Knuth. The TeXbook.", marks=[Mark(start=7, end=18, kind="emph")]), + ) + cnd = _cnd([para("x")], bibliography=[entry]) + assert "- **k** — Knuth. *The TeXbook*." in MarkdownConverter().convert(cnd).text + + def test_format_bib_entry_returns_rich_text(self) -> None: + entry = BibEntry(id=uuid4(), label="k", authors=["A"], year=2020) + reference, warning = format_bib_entry(entry) + assert reference.value == "A. (2020)" and warning is not None + + +class TestDocumentMetadata: + def _doc(self) -> DocMetadata: + return DocMetadata( + title="Guide", status="Approved", version="1.2", + extra={"client": "ACME", "reference": "PRJ-042"}, + ) + + def test_markdown_front_matter(self) -> None: + head = MarkdownConverter().convert(_cnd([], doc=self._doc())).text.split("---\n")[1] + assert ( + 'status: "Approved"\nversion: "1.2"\nextra:\n' + ' "client": "ACME"\n "reference": "PRJ-042"\n' + ) in head + + +class TestTextOnly: + def test_text_only_threads_into_the_default_renderer(self) -> None: + converter = MarkdownConverter(text_only=True) + assert converter.renderer.text_only is True + assert MarkdownConverter().renderer.text_only is False + + def test_text_only_with_an_injected_renderer_is_refused(self) -> None: + with pytest.raises(ValueError): + MarkdownConverter(MarkdownRenderer(), text_only=True) + + def test_text_only_document_has_no_html(self, elements_cnd_path) -> None: + cnd = Cnd.model_validate_json(elements_cnd_path.read_text()) + out = MarkdownConverter(text_only=True).convert(cnd).text + kinds = {t.type for t in MarkdownIt("commonmark").enable("table").parse(out)} + assert not kinds & {"html_block", "html_inline"} + inline = [ + child.type + for t in MarkdownIt("commonmark").enable("table").parse(out) + for child in (t.children or []) + ] + assert "html_inline" not in inline + + +def test_explicit_defaults_twin_converts_identically(elements_cnd_path) -> None: + explicit = elements_cnd_path.with_name("elements_explicit.cnd") + a = MarkdownConverter().convert(Cnd.model_validate_json(elements_cnd_path.read_text())) + b = MarkdownConverter().convert(Cnd.model_validate_json(explicit.read_text())) + assert a.text == b.text + + +# -- HTML --------------------------------------------------------------- + + +class TestHtml: + def test_links_are_not_rendered_as_unresolved(self) -> None: + node = para(rt("site", links=[LinkRef(href="https://example.com", text_span=[0, 4])])) + html = HtmlConverter().convert(_cnd([node])).text + assert 'class="cnd-unresolved"' not in html + + def test_footnote_holds_its_blocks(self) -> None: + note = Footnote(id=uuid4(), label="fn", children=[para("First."), para("Second.")]) + out = HtmlConverter().convert(_cnd([para("x")], footnotes=[note])).text + assert '
  • First.

    Second.

  • ' in out + + def test_formatted_reference_keeps_its_marks(self) -> None: + entry = BibEntry( + id=uuid4(), label="k", + formatted=rt("Knuth. The TeXbook.", marks=[Mark(start=7, end=18, kind="emph")]), + ) + out = HtmlConverter().convert(_cnd([para("x")], bibliography=[entry])).text + assert '
  • Knuth. The TeXbook.
  • ' in out + + def test_head_carries_status_version_and_extra(self) -> None: + doc = DocMetadata(title="Guide", status="Approved", version="1.2", extra={"client": "ACME"}) + text = HtmlConverter().convert(_cnd([], doc=doc)).text + assert '' in text + assert '' in text + assert '' in text + + def test_explicit_defaults_twin_converts_identically(self, elements_cnd_path) -> None: + explicit = elements_cnd_path.with_name("elements_explicit.cnd") + a = HtmlConverter().convert(Cnd.model_validate_json(elements_cnd_path.read_text())) + b = HtmlConverter().convert(Cnd.model_validate_json(explicit.read_text())) + assert a.text == b.text + + +# -- headings nested in containers and footnotes -------------------------- + + +def _section_heading() -> HeadingNode: + """A heading whose section holds one paragraph with a ref marker.""" + child = para(rt("SECTION-TEXT", refs=[NodeRef(label="tgt-c1", text_span=[0, 7])])) + return HeadingNode( + type="heading", id=uuid4(), level=3, text=rt("Inner"), heading_path=["Inner"], + children=[child], + ) + + +def _holding(kind: str): + heading = _section_heading() + if kind == "quote": + return QuoteNode(type="quote", id=uuid4(), children=[heading]) + if kind == "list item": + return ListNode(type="list", id=uuid4(), items=[item(heading)]) + if kind == "admonition": + return AdmonitionNode(type="admonition", id=uuid4(), kind="note", children=[heading]) + if kind == "table cell": + return TableNode(type="table", id=uuid4(), kind="content", cells=[ + cell(0, 0, "head", is_header=True), cell(1, 0, None, children=[heading]), + ]) + if kind == "layout cell": + return LayoutNode(type="layout", id=uuid4(), cells=[cell(0, 0, None, children=[heading])]) + if kind == "figure": + return FigureNode(type="figure", id=uuid4(), kind="other", children=[heading]) + raise AssertionError(kind) + + +_CONTAINERS = ["quote", "list item", "admonition", "table cell", "layout cell", "figure"] +_CONVERTERS = { + "markdown": (lambda: MarkdownConverter(), "[tgt-c1]"), + "markdown text_only": (lambda: MarkdownConverter(text_only=True), "[tgt-c1]"), + "html": (lambda: HtmlConverter(), 'href="#tgt-c1"'), +} + + +def _assert_once_with_its_text(out: str, marker: str) -> None: + assert out.count("Inner") == 1 + assert out.count("SECTION-TEXT") == 1 + assert out.count(marker) == 1 + assert out.index("SECTION-TEXT") < out.index(marker) + + +class TestNestedHeadings: + @pytest.mark.parametrize("converter", list(_CONVERTERS)) + @pytest.mark.parametrize("container", _CONTAINERS) + def test_a_heading_in_a_container_keeps_its_section(self, container, converter) -> None: + make, marker = _CONVERTERS[converter] + target = para("target", label="tgt-c1") + out = make().convert(_cnd([target, _holding(container)])).text + _assert_once_with_its_text(out, marker) + + @pytest.mark.parametrize("converter", list(_CONVERTERS)) + def test_a_heading_in_a_footnote_keeps_its_section(self, converter) -> None: + make, marker = _CONVERTERS[converter] + note = Footnote(id=uuid4(), label="fn", children=[para("Intro."), _section_heading()]) + body = para(rt("Text.", footnotes=[FootnoteRef(label="fn", text_span=[4, 5])])) + target = para("target", label="tgt-c1") + out = make().convert(_cnd([body, target], footnotes=[note])).text + _assert_once_with_its_text(out, marker) + notes = out[out.index("Footnotes"):] + assert notes.index("Intro.") < notes.index("Inner") < notes.index("SECTION-TEXT") + + def test_a_heading_in_a_footnote_keeps_the_markdown_structure(self) -> None: + note = Footnote(id=uuid4(), label="fn", children=[para("Intro."), _section_heading()]) + body = para(rt("Text.", footnotes=[FootnoteRef(label="fn", text_span=[4, 5])])) + out = MarkdownConverter().convert(_cnd([body, para("t", label="tgt-c1")], footnotes=[note])).text + assert "[^fn]: Intro.\n\n ### Inner\n\n SECTION-TEXT\n [tgt-c1]" in out + + def test_nested_sections_expand_in_pre_order(self) -> None: + deep = HeadingNode( + type="heading", id=uuid4(), level=4, text=rt("Deep"), heading_path=["Deep"], + children=[para("DEEP-TEXT")], + ) + outer = HeadingNode( + type="heading", id=uuid4(), level=3, text=rt("Outer"), heading_path=["Outer"], + children=[deep, para("AFTER-DEEP")], + ) + quote = QuoteNode(type="quote", id=uuid4(), children=[outer]) + out = MarkdownRenderer().render(quote) + assert out == "> ### Outer\n>\n> #### Deep\n>\n> DEEP-TEXT\n>\n> AFTER-DEEP" + + +class TestFrontMatterScalars: + CONTROLS = ( + [chr(c) for c in range(0x20) if chr(c) not in "\n\r\t"] + + [chr(c) for c in range(0x7F, 0xA0)] + + ["\u2028", "\u2029", "\ud800", "\udfff", "\ufffe", "\uffff"] + ) + + @pytest.mark.parametrize("ch", CONTROLS) + def test_a_control_character_is_escaped(self, ch: str) -> None: + from cnd.converters.markdown import _yaml_scalar + + out = _yaml_scalar(f"a{ch}b") + assert out == f'"a\\u{ord(ch):04x}b"' + assert ch not in out + + def test_front_matter_parses_back(self) -> None: + yaml = pytest.importorskip("yaml") + nasty = "t\x00i\x1bt\x7fl\x85e\u2028x\u2029y \"q\" \\ \n\r\t" + doc = DocMetadata(title=nasty, extra={"k\x01": "v\u2028"}) + text = MarkdownConverter().convert(_cnd([para("x")], doc=doc)).text + front = text.split("---\n")[1] + assert "\u2028" not in front and "\x85" not in front + data = yaml.safe_load(front) + assert data["title"] == nasty + assert data["extra"] == {"k\x01": "v\u2028"} + + +class TestHtmlMetaExtra: + @pytest.mark.parametrize("key", [ + "author", "description", "keywords", "date", "status", "version", "viewport", + "Author", " DESCRIPTION ", + ]) + def test_a_key_the_converter_emits_is_skipped_with_a_warning(self, key: str) -> None: + doc = DocMetadata(title="T", description="real", extra={key: "spoof", "team": "core"}) + result = HtmlConverter().convert(_cnd([para("x")], doc=doc)) + assert "spoof" not in result.text + assert '' in result.text + assert '' in result.text + assert any(repr(key) in w and "extra" in w for w in result.warnings) + + def test_other_keys_do_not_warn(self) -> None: + doc = DocMetadata(title="T", extra={"team": "core"}) + result = HtmlConverter().convert(_cnd([para("x")], doc=doc)) + assert result.warnings == () + + +def test_a_cp1252_style_title_round_trips() -> None: + """Text decoded as Latin-1 instead of cp1252 carries C1 controls + (U+0080-U+009F) where the quotes and dashes were.""" + yaml = pytest.importorskip("yaml") + title = "\x93Quoted\x94 \x96 dash\x85 \x80uro\x9f \ufffe\uffff" + doc = DocMetadata(title=title, authors=["\x8aimon"], extra={"k\x9d": "v\x81"}) + text = MarkdownConverter().convert(_cnd([para("x")], doc=doc)).text + front = text.split("---\n")[1] + assert not any(0x80 <= ord(ch) <= 0x9F or ch in "\ufffe\uffff" for ch in front) + data = yaml.safe_load(front) + assert data["title"] == title + assert data["authors"] == ["\x8aimon"] + assert data["extra"] == {"k\x9d": "v\x81"} diff --git a/tests/test_converters.py b/tests/test_converters.py index 99c7cd5..7b2d8e5 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -11,6 +11,7 @@ from uuid import UUID import pytest +from factories import para, rt from cnd.converters import ( ConversionResult, @@ -42,6 +43,7 @@ "rich_content", "full_coverage", "unpaginated", + "elements", ] # full_coverage exercises both pools; comprehensive has neither; @@ -54,6 +56,8 @@ ("unpaginated", MarkdownConverter), ("unpaginated", HtmlConverter), ("minimal", MarkdownConverter), + ("elements", MarkdownConverter), + ("elements", HtmlConverter), ] @@ -101,7 +105,7 @@ def test_markdown_front_matter_carries_doc_metadata() -> None: assert ' - "Équipe CND"' in head assert "date: 2026-07-15" in head assert 'lang: "fr"' in head - assert 'cnd_version: "0.3.0"' in head + assert 'cnd_version: "0.5.0"' in head def test_figure_children_render_once() -> None: @@ -174,9 +178,11 @@ def test_markdown_renders_a_links_marker_ordered_with_the_other_families() -> No ParagraphNode( type="paragraph", id=UUID("00000000-0000-4000-f000-000000000004"), - text="see the auth spec and eq 1", - links=[LinkRef(href="https://example.com/auth", text_span=[4, 17])], - refs=[NodeRef(label="eq-1", text_span=[22, 26])], + text=rt( + "see the auth spec and eq 1", + links=[LinkRef(href="https://example.com/auth", text_span=[4, 17])], + refs=[NodeRef(label="eq-1", text_span=[22, 26])], + ), ) ] ) @@ -224,8 +230,7 @@ def test_unresolvable_label_warns_and_degrades() -> None: ParagraphNode( type="paragraph", id=UUID("00000000-0000-4000-f000-000000000001"), - text="Dangling.", - refs=[NodeRef(label="nowhere")], + text=rt("Dangling.", refs=[NodeRef(label="nowhere")]), ) ] ) @@ -244,15 +249,14 @@ def test_wrong_family_domain_warns() -> None: ParagraphNode( type="paragraph", id=UUID("00000000-0000-4000-f000-000000000002"), - text="Mis-filed.", - cites=[CiteRef(label="fn-a")], + text=rt("Mis-filed.", cites=[CiteRef(label="fn-a")]), ) ], footnotes=[ Footnote( id=UUID("00000000-0000-4000-f000-000000000003"), label="fn-a", - text="A note.", + children=[para("A note.")], ) ], ) @@ -267,12 +271,12 @@ def test_formatted_is_preferred_verbatim() -> None: entry = BibEntry( id=UUID("00000000-0000-4000-f000-000000000010"), label="smith2024", - formatted="Smith, J. (2024). A styled reference. Journal, 1(1), 1–2.", + formatted=rt("Smith, J. (2024). A styled reference. Journal, 1(1), 1–2."), authors=["Ignored, X."], title="Ignored title", ) reference, warning = format_bib_entry(entry) - assert reference == "Smith, J. (2024). A styled reference. Journal, 1(1), 1–2." + assert reference.value == "Smith, J. (2024). A styled reference. Journal, 1(1), 1–2." assert warning is None @@ -291,7 +295,7 @@ def test_unformatted_entry_falls_back_to_lifted_fields() -> None: url="https://example.org/lee2020", ) reference, warning = format_bib_entry(entry) - assert reference == ( + assert reference.value == ( "Lee, K., Ito, S.. (2020). Structured only. Proceedings of Nothing. " "doi:10.1000/xyz. https://example.org/lee2020" ) @@ -305,8 +309,7 @@ def test_unformatted_entry_warns_through_the_converter() -> None: ParagraphNode( type="paragraph", id=UUID("00000000-0000-4000-f000-000000000012"), - text="Cited.", - cites=[CiteRef(label="lee2020")], + text=rt("Cited.", cites=[CiteRef(label="lee2020")]), ) ], bibliography=[ @@ -342,7 +345,7 @@ def test_entry_with_only_a_fields_blob_degrades_to_its_label() -> None: fields={"type": "book", "title": {"nested": "unread"}}, ) reference, warning = format_bib_entry(entry) - assert reference == "opaque" + assert reference.value == "opaque" assert warning is not None @@ -363,7 +366,7 @@ def test_html_renderer_escapes_and_anchors() -> None: type="paragraph", id=UUID("00000000-0000-4000-f000-000000000020"), label="p-esc", - text="a < b & c > d", + text=rt("a < b & c > d"), ) assert renderer.render(node) == ( '

    a < b & c > d

    ' @@ -408,7 +411,7 @@ def test_conversion_result_str_is_the_document() -> None: def _tiny_cnd(*, nodes, bibliography=(), footnotes=()) -> Cnd: return Cnd( id=UUID("00000000-0000-4000-f000-000000000000"), - cnd_version="0.3.0", + cnd_version="0.5.0", built_at=datetime(2026, 7, 23, tzinfo=timezone.utc), doc=DocMetadata(title="Ad-hoc"), nodes=list(nodes), diff --git a/tests/test_declaration.py b/tests/test_declaration.py index 9762fb7..585978d 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -16,11 +16,17 @@ from jsonschema import Draft202012Validator from pydantic import ValidationError -from cnd.declaration import DECLARATION_VERSION, Declaration +from cnd.declaration import ( + DECLARATION_VERSION, + DeclParagraphNode, + Declaration, + RichText, +) ROOT = Path(__file__).parent.parent SCHEMA_PATH = ROOT / "schema" / "cnd-declaration.schema.json" FIXTURES = ROOT / "fixtures" / "declaration" +LEGACY = ROOT / "tests" / "legacy" def _committed_schema() -> dict: @@ -63,10 +69,10 @@ class TestNothingTheBuilderDerivesIsCarried: merely *allowed* these fields to be absent would not prove it — these assert they cannot be *present*.""" - # `counter_label` is NOT here: it has an authored half the builder - # cannot derive, so the declaration keeps it (Fable review). `number` - # stays — its resolved-ordinal form is dropped; the list-item override - # lives on DeclListItem, not a node. + # The counter word is NOT here: it has an authored half the builder + # cannot derive, so the declaration keeps it as `numbering.name`. + # `number` stays — its resolved-ordinal form is dropped; the list-item + # override lives on DeclListItem, not a node. DERIVED_NODE_FIELDS = ("id", "location", "number", "heading_path") def _node_properties(self) -> set[str]: @@ -94,6 +100,21 @@ def test_pool_entries_carry_no_id(self) -> None: assert "id" not in defs["DeclBibEntry"]["properties"] assert "id" not in defs["DeclFootnote"]["properties"] + def test_the_entries_that_hold_nodes_carry_no_id(self) -> None: + """List items, term items and cells hold nodes; they are declared + fresh so that their children carry no id either.""" + defs = _committed_schema()["$defs"] + + for entry in ("DeclListItem", "DeclTermItem", "DeclCell"): + assert "id" not in defs[entry]["properties"], entry + assert "ParagraphNode" not in defs, "a CND node leaked into the schema" + + def test_numbering_carries_no_value(self) -> None: + """The value is the counter engine's; only the word is authored.""" + numbering = _committed_schema()["$defs"]["DeclNumbering"] + + assert set(numbering["properties"]) == {"name"} + class TestUnknownFieldsAreRejected: """A stray field is a rejection, not a silent drop — the point of an @@ -101,7 +122,7 @@ class TestUnknownFieldsAreRejected: def _decl(self, node: dict) -> dict: return { - "declaration_version": "0.1.0", + "declaration_version": DECLARATION_VERSION, "doc": {"title": "T"}, "nodes": [node], } @@ -128,7 +149,7 @@ def test_a_top_level_typo_is_rejected(self) -> None: with pytest.raises(ValidationError, match="bibliografy"): Declaration.model_validate( { - "declaration_version": "0.1.0", + "declaration_version": DECLARATION_VERSION, "doc": {"title": "T"}, "nodes": [], "bibliografy": [], @@ -152,17 +173,50 @@ def test_the_extension_bag_still_holds_arbitrary_keys(self) -> None: def test_strictness_is_partial_at_the_reused_value_objects(self) -> None: """A documented limit, pinned so it is a known boundary and not a surprise: a stray key *inside* a reused CND value object (here a - table cell) is not caught, because forbidding on it would change + rich text) is not caught, because forbidding on it would change CND parsing (docs: `_Strict`).""" decl = Declaration.model_validate( self._decl( - { - "type": "table", - "cells": [{"row": 0, "col": 0, "text": "x", "txet": "typo"}], - } + {"type": "paragraph", "text": {"value": "x", "marsk": []}} ) ) - assert decl.nodes[0].cells[0].text == "x" + assert decl.nodes[0].text.value == "x" + + def test_a_typo_inside_a_cell_is_now_rejected(self) -> None: + """Cells hold nodes, so the declaration declares its own strict + cell: the boundary moved past it.""" + with pytest.raises(ValidationError, match="txet"): + Declaration.model_validate( + self._decl( + {"type": "table", "cells": [{"row": 0, "col": 0, "txet": "x"}]} + ) + ) + + def test_a_typo_inside_a_list_item_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="chekced"): + Declaration.model_validate( + self._decl({"type": "list", "items": [{"chekced": True}]}) + ) + + def test_a_numbering_value_is_rejected(self) -> None: + """A declaration cannot smuggle in the value the engine resolves.""" + with pytest.raises(ValidationError, match="value"): + Declaration.model_validate( + self._decl( + {"type": "figure", "numbering": {"name": "Listing", "value": "3"}} + ) + ) + + def test_the_0_4_fields_are_rejected(self) -> None: + """`counter_label`, a node-level `refs` and a list item's `text` + were 0.4 shapes; a 0.4 declaration fails loudly, not silently.""" + for node in ( + {"type": "figure", "counter_label": "Listing"}, + {"type": "paragraph", "text": "x", "refs": [{"label": "a"}]}, + {"type": "list", "items": [{"text": "x"}]}, + ): + with pytest.raises(ValidationError): + Declaration.model_validate(self._decl(node)) class TestWhatTheDeclarationKeeps: @@ -180,18 +234,18 @@ def test_a_list_item_keeps_number_as_an_override(self) -> None: assert "number" in item - def test_counter_label_is_kept_on_every_counter_bearing_node(self) -> None: + def test_numbering_is_kept_on_every_counter_bearing_node(self) -> None: """The authored counter word — with no `kind` on a heading or equation it is purely authored, so dropping it while keeping the list-number override would split the same authored/derived line two - opposite ways (Fable review).""" + opposite ways.""" defs = _committed_schema()["$defs"] for node in ("DeclHeadingNode", "DeclMathNode", "DeclFigureNode"): - assert "counter_label" in defs[node]["properties"], node + assert "numbering" in defs[node]["properties"], node def test_the_resolved_number_is_still_stripped_from_those_nodes(self) -> None: - """`counter_label` returns but `number` (the running counter value) + """`numbering.name` is kept but `number` (the running counter value) stays out — it has no authored half on a heading/math/figure.""" defs = _committed_schema()["$defs"] @@ -225,3 +279,100 @@ def test_a_present_version_parses(self) -> None: yaml.safe_load(next(iter(_declarations())).read_text()) ) assert built.declaration_version == DECLARATION_VERSION + + +#: Every RichText slot of the CND, as (declaration model, field). +RICH_TEXT_SLOTS = [ + ("DeclHeadingNode", "text"), + ("DeclParagraphNode", "text"), + ("DeclQuoteNode", "attribution"), + ("DeclFigureNode", "caption"), + ("DeclFigureNode", "description"), + ("DeclAdmonitionNode", "title"), + ("DeclTermItem", "term"), + ("DeclBibEntry", "formatted"), +] + + +class TestShorthand: + """Wherever the CND holds a RichText, a declaration accepts a plain + string or the full object (docs/proposals/0013, "Declaration").""" + + @pytest.mark.parametrize(("model", "field"), RICH_TEXT_SLOTS) + def test_the_schema_accepts_both_forms(self, model: str, field: str) -> None: + prop = _committed_schema()["$defs"][model]["properties"][field] + forms = { + option.get("type") or option.get("$ref") for option in prop["anyOf"] + } + + assert {"string", "#/$defs/RichText"} <= forms + + def test_a_string_and_its_object_build_the_same_text(self) -> None: + short = DeclParagraphNode(type="paragraph", text="Hello") + full = DeclParagraphNode(type="paragraph", text={"value": "Hello"}) + + assert short.text == full.text == RichText(value="Hello") + + def test_the_object_form_carries_marks_and_every_link_family(self) -> None: + """The full form is the CND's own RichText, so hyperlinks are + declarable too (the gap ADR 0024 left open).""" + decl = Declaration.model_validate( + { + "declaration_version": DECLARATION_VERSION, + "doc": {"title": "T"}, + "nodes": [ + { + "type": "paragraph", + "text": { + "value": "See the site.", + "marks": [{"start": 0, "end": 3, "kind": "strong"}], + "links": [ + {"href": "https://example.org", "text_span": [8, 12]} + ], + }, + } + ], + } + ) + text = decl.nodes[0].text + + assert text.marks[0].kind == "strong" + assert text.links[0].href == "https://example.org" + + def test_the_value_is_kept_byte_for_byte(self) -> None: + raw = " two spaces\tand a tab \n trailing " + assert DeclParagraphNode(type="paragraph", text=raw).text.value == raw + + +class TestVersion: + """Another declaration_version is refused by name before the shape is + checked (spec §12): a 0.1 declaration must not surface as a list of + "Extra inputs" errors that never mention its version.""" + + def test_a_real_0_1_declaration_names_its_version(self) -> None: + data = yaml.safe_load( + (LEGACY / "article-0.1.0.decl.yaml").read_text(encoding="utf-8") + ) + with pytest.raises(ValidationError) as excinfo: + Declaration.model_validate(data) + message = str(excinfo.value) + assert excinfo.value.error_count() == 1 + assert "0.1.0" in message and DECLARATION_VERSION in message + assert "Extra inputs" not in message + + @pytest.mark.parametrize("version", ["0.1.0", "9.9.9"]) + def test_another_version_on_a_current_shape_is_refused( + self, version: str, + ) -> None: + with pytest.raises(ValidationError, match=version): + Declaration.model_validate( + {"declaration_version": version, "doc": {"title": "T"}, + "nodes": []} + ) + + def test_a_missing_version_stays_a_shape_error(self) -> None: + with pytest.raises(ValidationError) as excinfo: + Declaration.model_validate({"doc": {"title": "T"}, "nodes": []}) + [error] = excinfo.value.errors() + assert error["loc"] == ("declaration_version",) + assert error["type"] == "missing" diff --git a/tests/test_full_coverage.py b/tests/test_full_coverage.py index 7bf5991..3c27e70 100644 --- a/tests/test_full_coverage.py +++ b/tests/test_full_coverage.py @@ -34,8 +34,17 @@ ) from cnd.core.cnd import Cnd -from cnd.core.nodes import CndNode, FigureNode, ImageNode, ListNode, TableNode +from cnd.core.nodes import ( + CndNode, + FigureNode, + ImageNode, + LayoutNode, + ListNode, + TableNode, + edges_of, +) from cnd.core.render import MarkdownRenderer +from cnd.core.text import CiteRef, LinkRef, NodeRef FIXTURE_PATH = Path(__file__).parent.parent / "fixtures" / "full_coverage.cnd" @@ -44,6 +53,11 @@ def _load() -> Cnd: return Cnd.model_validate_json(FIXTURE_PATH.read_text()) +def _family(node: CndNode, family: type) -> list: + """One link family of a node, read from its texts (docs/adr/0025).""" + return [edge for edge in edges_of(node) if isinstance(edge, family)] + + def _node(cnd: Cnd, node_id) -> CndNode: return next(v.node for v in cnd.iter() if v.node.id == node_id) @@ -70,40 +84,45 @@ def test_covers_every_node_type(self) -> None: "image", "list", "terms", + "layout", } def test_covers_table_and_list_variants(self) -> None: cnd = _load() tables = [v.node for v in cnd.iter() if isinstance(v.node, TableNode)] - assert {t.kind for t in tables} == {"table", "grid"} - assert {t.content_kind for t in tables} == {"content", "data"} + assert {t.kind for t in tables} == {"content"} grid = _node(cnd, FULL_GRID_ID) + assert isinstance(grid, LayoutNode) assert any(c.rowspan > 1 for c in grid.cells) assert any(c.colspan > 1 for c in grid.cells) assert any(c.is_header for c in grid.cells) lists = [v.node for v in cnd.iter() if isinstance(v.node, ListNode)] assert {lst.ordered for lst in lists} == {True, False} - assert {lst.tight for lst in lists} == {True, False} - assert any(item.children for lst in lists for item in lst.items) + assert any( + isinstance(child, ListNode) + for lst in lists + for item in lst.items + for child in item.children + ) def test_covers_cite_form_variants(self) -> None: cnd = _load() forms = { - cite.form for v in cnd.iter() for cite in v.node.cites + cite.form for v in cnd.iter() for cite in _family(v.node, CiteRef) } assert forms == {"normal", "prose", "full", "author", "year", "none"} supplements = [ cite.supplement for v in cnd.iter() - for cite in v.node.cites + for cite in _family(v.node, CiteRef) if cite.supplement ] assert supplements == ["p. 42"] def test_refs_with_and_without_span(self) -> None: cnd = _load() - spans = [ref.text_span for v in cnd.iter() for ref in v.node.refs] + spans = [ref.text_span for v in cnd.iter() for ref in _family(v.node, NodeRef)] assert any(span is not None for span in spans) assert any(span is None for span in spans) @@ -114,7 +133,7 @@ def test_covers_a_populated_links_entry(self) -> None: cnd = _load() para = _node(cnd, FULL_PARA_REFS_ID) - [link] = para.links + [link] = _family(para, LinkRef) assert link.href == "https://example.org/appendix/grid-raw" assert link.text_span == [16, 22] @@ -145,7 +164,7 @@ def test_form_none_cite_has_no_span_and_still_resolves(self) -> None: cnd = _load() para = _node(cnd, FULL_PARA_REFS_ID) - [none_cite] = [c for c in para.cites if c.form == "none"] + [none_cite] = [c for c in _family(para, CiteRef) if c.form == "none"] assert none_cite.text_span is None assert none_cite.label == FULL_BIB_MINIMAL_LABEL assert FULL_PARA_REFS_ID in {n.id for n in cnd.incoming(FULL_BIB_MINIMAL_LABEL)} diff --git a/tests/test_hashing.py b/tests/test_hashing.py index 0ba4196..c5c1482 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 @@ -255,3 +350,33 @@ def test_hashing_is_deterministic_across_reloads(self) -> None: @pytest.mark.parametrize("name", [p.name for p in sorted(FIXTURES.glob("*.cnd"))]) def test_every_fixture_has_a_vector(self, name: str) -> None: assert name in json.loads(VECTORS.read_text()) + + +class TestExplicitDefaultsTwin: + """``elements_explicit.cnd`` is ``elements.cnd`` with every default written + out. Spec section 10 drops a field the schema declares with a default, so + the two must hash alike: this is what lets another implementation prove it + applies the rule rather than copying the SDK's field list.""" + + def _load(self, name: str) -> Cnd: + return Cnd.model_validate_json((FIXTURES / name).read_text()) + + def test_the_twin_spells_out_what_the_original_omits(self) -> None: + plain = (FIXTURES / "elements.cnd").read_text() + explicit = (FIXTURES / "elements_explicit.cnd").read_text() + assert len(explicit) > len(plain) + assert '"marks": []' in explicit + assert json.loads(explicit) != json.loads(plain) + + def test_the_content_hash_is_the_same(self) -> None: + assert content_hash(self._load("elements_explicit.cnd")) == content_hash( + self._load("elements.cnd") + ) + + def test_every_node_hash_matches_in_walk_order(self) -> None: + plain = [node_hash(v.node) for v in self._load("elements.cnd").iter_all()] + explicit = [ + node_hash(v.node) for v in self._load("elements_explicit.cnd").iter_all() + ] + assert explicit == plain + assert len(plain) > 0 diff --git a/tests/test_html_renderer.py b/tests/test_html_renderer.py new file mode 100644 index 0000000..dcc2676 --- /dev/null +++ b/tests/test_html_renderer.py @@ -0,0 +1,193 @@ +"""HtmlNodeRenderer on the 0.5 elements (docs/proposals/0013, "Rendering").""" + +from uuid import uuid4 + +import pytest + +from factories import cell, item, para, rt + +from cnd.converters.html import HtmlNodeRenderer +from cnd.core.nodes import ( + AdmonitionNode, + CodeNode, + FigureNode, + HeadingNode, + ImageNode, + LayoutNode, + ListNode, + MathNode, + QuoteNode, + TableNode, + TermItem, + TermsNode, +) +from cnd.core.render import NodeRenderer +from cnd.core.text import Mark, Numbering, RawSource + +H = HtmlNodeRenderer() + + +def _id() -> dict: + return {"id": uuid4()} + + +def test_every_node_type_has_a_method() -> None: + abstract = NodeRenderer.__abstractmethods__ + assert {"render_admonition", "render_layout", "render_text"} <= abstract + HtmlNodeRenderer() # instantiable: implements them all + + +def test_paragraph_marks_and_math() -> None: + raw = RawSource(format="typst", value="x^2") + text = rt("Bold x2 here", marks=[ + Mark(start=0, end=4, kind="strong"), + Mark(start=5, end=7, kind="math", raw=raw), + ]) + assert H.render(para(text)) == '

    Bold x^2 here

    ' + + +def test_paragraph_lang_comes_from_its_text() -> None: + assert H.render(para(rt("Hallo", lang="de"))) == '

    Hallo

    ' + + +def test_heading_numbering() -> None: + node = HeadingNode( + type="heading", level=2, text=rt("Scope"), heading_path=["Scope"], + numbering=Numbering(value="3", name="Chapter"), **_id(), + ) + assert H.render(node) == "

    Chapter 3 Scope

    " + + +def test_tight_list_with_checkbox_and_nested_list() -> None: + nested = ListNode(type="list", ordered=False, items=[item("b")], **_id()) + node = ListNode(type="list", ordered=True, items=[item("a", nested, checked=True)], **_id()) + assert H.render(node) == ( + '
    1. a
      • b
    ' + ) + + +def test_loose_item_keeps_paragraphs() -> None: + node = ListNode(type="list", items=[item("a", "b")], **_id()) + assert H.render(node) == "
    • a

      b

    " + + +def test_terms_definition_holds_blocks() -> None: + node = TermsNode( + type="terms", + items=[TermItem(term=rt("T", marks=[Mark(start=0, end=1, kind="emph")]), children=[para("d")])], + **_id(), + ) + assert H.render(node) == "
    T
    d
    " + + +def test_table_cells_hold_blocks() -> None: + node = TableNode( + type="table", + kind="content", + cells=[cell(0, 0, "H", is_header=True), cell(1, 0, children=[para("a"), para("b")])], + **_id(), + ) + assert H.render(node) == ( + '' + "
    H

    a

    b

    " + ) + + +def test_layout_is_a_css_grid() -> None: + image = ImageNode(type="image", path="a.png", alt="A", **_id()) + node = LayoutNode(type="layout", cells=[cell(0, 1, "right"), cell(0, 0, children=[image])], **_id()) + assert H.render(node) == ( + '
    ' + '
    A
    ' + '

    right

    ' + ) + + +def test_quote_children_and_attribution() -> None: + node = QuoteNode(type="quote", children=[para("a"), para("b")], attribution=rt("Ada"), **_id()) + assert H.render(node) == "

    a

    b

    Ada
    " + + +def test_admonition_is_an_aside_with_a_kind_class() -> None: + known = AdmonitionNode(type="admonition", kind="warning", title=rt("Careful"), + children=[para("x")], **_id()) + assert H.render(known) == ( + '' + ) + unknown = AdmonitionNode(type="admonition", kind="my example", children=[], **_id()) + assert H.render(unknown) == ( + '' + ) + + +def test_code_title_wraps_the_block() -> None: + node = CodeNode(type="code", text="x", lang="py", title="main.py", label="lst", **_id()) + assert H.render(node) == ( + '

    main.py

    ' + '
    x
    ' + ) + + +def test_block_math_with_numbering() -> None: + node = MathNode(type="math", text="a+b", numbering=Numbering(value="(1)"), **_id()) + assert H.render(node) == ( + '
    a+b' + '(1)
    ' + ) + + +def test_figure_caption_then_description() -> None: + image = ImageNode(type="image", path="a.png", alt="A", **_id()) + node = FigureNode( + type="figure", kind="chart", caption=rt("Rates"), description=rt("Bars rise."), + numbering=Numbering(value="2", name="Figure"), children=[image], **_id(), + ) + assert H.render(node) == ( + '
    A' + '
    Figure 2: Rates

    Bars rise.

    ' + ) + + +class TestImageSource: + @staticmethod + def _image(path: str, alt: str | None = "A cat") -> ImageNode: + return ImageNode(type="image", path=path, alt=alt, **_id()) + + @pytest.mark.parametrize("path", [ + "figures/cat.png", + "https://example.com/cat.png", + "data:image/png;base64,iVBORw0KGgo=", + "DATA:IMAGE/svg+xml;utf8,", + ]) + def test_safe_source_is_emitted(self, path: str) -> None: + out = H.render(self._image(path)) + assert out.startswith("alert(1)", + "vbscript:x", + ]) + def test_unsafe_source_is_the_missing_image_span(self, path: str) -> None: + out = H.render(self._image(path)) + assert out == 'A cat' + + def test_unsafe_source_without_alt(self) -> None: + out = H.render(self._image("javascript:x", alt=None)) + assert out == 'image' + + +@pytest.mark.parametrize("kind", ["", " ", "\t\n"]) +def test_a_blank_admonition_kind_has_no_kind_class(kind: str) -> None: + node = AdmonitionNode(type="admonition", kind=kind, title=rt("T"), children=[], **_id()) + out = H.render(node) + assert out.startswith('