Skip to main content

quarb_text/
lib.rs

1//! The text level: a shared, source-independent semantics for
2//! written documents — sections, paragraphs, quotes, lists, and
3//! verbatim blocks — produced by format crates and served by this
4//! crate's single adapter.
5//!
6//! The block model follows the atrep markup language (litogramma's
7//! koine core): every block is `(kind, taxis?, lemma?, body,
8//! hypograph?)` — the lemma is the head or title, the hypograph the
9//! footer or attribution, and a paragraph is the degenerate
10//! lemma-less, hypograph-less block. The format adapters (`quarb-text-html`,
11//! `quarb-text-markdown`, the built-in plain-text reader) lower
12//! their format into the [`Block`] event stream; this crate derives
13//! the section tree and implements the adapter once, so
14//! `//section[::lemma ...]`, `//paragraph`, and `//blockquote` read
15//! identically over any text substrate — including an atrep
16//! document mounted by `quarb-atrep`.
17//!
18//! - Node names are the structural kinds: `section`, `paragraph`,
19//!   `blockquote`, `unordered-list`, `ordered-list`,
20//!   `unordered-item`, `ordered-item`, `verbatim` — plus the
21//!   apparatus: `footnote` and `endnote` (callout and body share
22//!   the family name, ruling #35), `aside` (litogramma's fourth
23//!   deixis-attached family — anchored content, not apparatus),
24//!   `index-mark` (ruling #36), and the verse vocabulary
25//!   (ruling #37): `verse` holding `strophe`s holding `stichos`
26//!   lines, the stichos `::taxis` the citation coordinate.
27//! - `::lemma`, `::hypograph`, and `::taxis` are properties; bare
28//!   `::` (and `::text`) is the flattened prose of the subtree,
29//!   lemma first, hypograph last.
30//! - `::::level` on a section is the source heading level;
31//!   `::::lang` on a verbatim block is its declared language.
32//! - Sections are derived from the flat heading stream by the
33//!   outline rule: a heading closes every open section at its
34//!   level or deeper, then opens a section under the nearest
35//!   shallower one. Content before the first heading belongs to
36//!   the document root. A heading inside an open container
37//!   (blockquote, list) is decorative, not sectioning: it lowers
38//!   to a paragraph of its text.
39//! - Every kind admits `::lemma`, `::taxis`, and `::hypograph` —
40//!   the atrep model, where these are universal affordances of a
41//!   block rather than privileges of particular kinds.
42//! - Tables denormalize into nested lists: an `ordered-list`
43//!   carrying the `<table>` trait (`::lemma` = the caption), one
44//!   `ordered-item` per row (`::taxis` = row number, `<row>`
45//!   trait), one `unordered-item` per cell (`<cell>` trait) whose
46//!   `::lemma` is the column name — from the header row in grids,
47//!   from the row's `th` label otherwise; headerless cells carry
48//!   no lemma. A lemma'd item flattens as `lemma: prose`, so a
49//!   row exists, the bare cell text otherwise. Empty cells are
50//!   skipped.
51
52use quarb::{AstAdapter, NodeId, Value};
53
54pub mod render;
55pub use render::{Render, render_node, render_nodes};
56
57/// A block-level event in the text-level vocabulary — what a format
58/// adapter emits. Headings arrive flat; the section tree is
59/// derived here, once, for every adapter.
60#[derive(Debug, Clone, PartialEq)]
61pub enum Block {
62    /// A flat heading: `level` is the source level (`h2` → 2, a
63    /// LaTeX `\section` → its depth), `lemma` its text.
64    Heading { level: u8, lemma: String },
65    /// A plain paragraph — the implicit, lemma-less block.
66    Paragraph { text: String },
67    /// Inline content belonging directly to the open container (a
68    /// list item's own text, a bare-text blockquote). With no open
69    /// container it is read as a paragraph.
70    Text { text: String },
71    /// Open a nesting container. Items take their `unordered-` /
72    /// `ordered-` flavor (and taxis) from the enclosing list.
73    Open {
74        kind: Container,
75        lemma: Option<String>,
76    },
77    /// Close the innermost open container, optionally with its
78    /// hypograph (footer or attribution).
79    Close { hypograph: Option<String> },
80    /// A verbatim block — code or other preformatted lines, kept
81    /// as authored.
82    Verbatim { lang: Option<String>, text: String },
83    /// An in-text note callout (the deixis, ruling #35): `onym`
84    /// names the body it cites within its family. Emitted after
85    /// the flow block it sits in; it becomes that block's child,
86    /// in order, and the block's own text stays clean of markers.
87    /// `family: None` means the source declares none (an HTML
88    /// noteref): the callout takes its resolved body's family,
89    /// and a dangling one defaults to footnote.
90    NoteRef {
91        onym: String,
92        family: Option<NoteFamily>,
93        /// The declared spelling placed this pair in the margin
94        /// (a Tufte-style sidenote): surfaced as `::::form =
95        /// "margin"` on both ends — the family stays footnote,
96        /// placement is presentation.
97        margin: bool,
98    },
99    /// An index mark (ruling #36): an invisible anchor declaring
100    /// this place concerns `term`. The back-of-book index is a
101    /// query over these, never a stored structure.
102    IndexMark { term: String },
103    /// A reference mark — the text-level mention (html's in-prose
104    /// `<a href>`, markdown's `[text](url)`, LaTeX's `\ref`):
105    /// `target` is the identifier as written, `text` the authored
106    /// link text (LaTeX `\ref` has none), and `internal` is the
107    /// producer's declaration that the target names a label in
108    /// *this* document (`#fragment`, a `\ref` key) rather than
109    /// another document. A mention, not an attachment: refs do
110    /// not carry `<deixis>`.
111    Ref {
112        target: String,
113        text: Option<String>,
114        internal: bool,
115    },
116    /// A point anchor — an invisible in-flow node bearing an
117    /// `onym` at this position (LaTeX's mid-flow `\label`, an
118    /// inline html `id=`). The IndexMark construction: child of
119    /// its flow block, document order.
120    Anchor { onym: String },
121    /// A citation mark — the bibliography's mention (LaTeX's
122    /// `\cite`, koine's cite monosim): `target` is the authored
123    /// key, resolved against the `bib` bearers — a namespace of
124    /// its own, separate from labels (LaTeX precedent).
125    Cite { target: String },
126    /// One bibliographic entry: a block bearing its authored
127    /// `key`. Unstructured sources (\bibitem) carry the full
128    /// reference as `text`; structured ones (bibliogramma,
129    /// BibTeX/BibLaTeX through it) carry `fields` — canonical
130    /// campus name → value, Latin per the bibliogramma
131    /// vocabulary — and the entry's `genus` (liber,
132    /// commentarius, …).
133    Bib {
134        key: String,
135        text: String,
136        fields: Vec<(String, String)>,
137        genus: Option<String>,
138    },
139    /// A block label: the innermost open section takes `onym` as
140    /// the name it bears (a heading's `id`; a `\label` attached
141    /// to a sectioning command — the promotion rule). With no
142    /// open section it degrades to a point [`Block::Anchor`].
143    Label { onym: String },
144    /// A verse block (ruling #37 — litogramma's stichoi model):
145    /// strophes of lines, denormalized like [`Block::Table`].
146    /// Lines arrive flattened by the adapter and are kept as
147    /// authored; the stichos `::taxis` numbers lines 1-based,
148    /// CONTINUOUSLY across strophes — the citation coordinate.
149    Verse {
150        lemma: Option<String>,
151        strophes: Vec<Vec<String>>,
152        hypograph: Option<String>,
153    },
154    /// A table, denormalized here into nested lists (rows =
155    /// ordered items with the `<row>` trait, cells = unordered
156    /// items with the `<cell>` trait and the column name as
157    /// `::lemma`). Header *detection* is the adapter's job; the
158    /// lowering rule lives here. A cell's own `label` (a row's
159    /// `th`) wins over the positional `headers` entry.
160    Table {
161        lemma: Option<String>,
162        headers: Option<Vec<String>>,
163        rows: Vec<Vec<Cell>>,
164    },
165}
166
167/// One table cell as an adapter hands it over: the text, plus the
168/// label a row-shaped dialect attaches directly (an infobox row's
169/// `th`). Grid dialects leave `label` empty and let the lowering
170/// zip the header row on by position.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct Cell {
173    pub label: Option<String>,
174    pub text: String,
175}
176
177impl From<&str> for Cell {
178    fn from(text: &str) -> Self {
179        Cell {
180            label: None,
181            text: text.to_string(),
182        }
183    }
184}
185
186impl From<String> for Cell {
187    fn from(text: String) -> Self {
188        Cell { label: None, text }
189    }
190}
191
192/// The nesting containers an adapter opens and closes explicitly.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub enum Container {
195    Blockquote,
196    UnorderedList,
197    /// `start` is the first item's ordinal (Markdown's `3.` lists).
198    OrderedList {
199        start: i64,
200    },
201    /// A list item; flavor and taxis come from the enclosing list.
202    Item,
203    /// A note body (the noted, ruling #35): opens a note node of
204    /// its family at the document root — litogramma's canonical
205    /// document-end placement — holding its own blocks. `margin`
206    /// as on [`Block::NoteRef`].
207    Note {
208        onym: String,
209        family: NoteFamily,
210        margin: bool,
211    },
212}
213
214/// The deixis-attached families litogramma's canon names
215/// (ruling #35, as amended): each family's callout and body
216/// share its name. `Aside` rides the same construction —
217/// koine's `aside` sim, an anchored body whose insertion point
218/// is a deixis — but is content, not apparatus: its bodies do
219/// not carry `<note>`.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
221pub enum NoteFamily {
222    Footnote,
223    Endnote,
224    Aside,
225}
226
227impl NoteFamily {
228    fn kind(self) -> Kind {
229        match self {
230            NoteFamily::Footnote => Kind::Footnote,
231            NoteFamily::Endnote => Kind::Endnote,
232            NoteFamily::Aside => Kind::Aside,
233        }
234    }
235}
236
237/// The structural kind of a node — also its name.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
239enum Kind {
240    Document,
241    Section,
242    Paragraph,
243    Blockquote,
244    UnorderedList,
245    OrderedList,
246    UnorderedItem,
247    OrderedItem,
248    Verbatim,
249    /// Callout and body both — they share the name by
250    /// construction (litogramma F5): `//footnote` gathers the
251    /// whole apparatus, `<deixis>`/`<note>` tell them apart.
252    Footnote,
253    /// The second note family, same construction.
254    Endnote,
255    /// The anchored-content family, same construction
256    /// (litogramma's aside): body + insertion-point deixis. Content, not
257    /// apparatus — no `<note>` on its bodies.
258    Aside,
259    /// An index mark (ruling #36): `::term`, in flow position.
260    IndexMark,
261    /// A reference mark: `::target`, `-->` resolves it.
262    Ref,
263    /// A citation mark: `::target` the authored key, resolved
264    /// against the `bib` namespace.
265    Cit,
266    /// One bibliographic entry: `::onym` the key it bears, its
267    /// projection the full reference text.
268    Bib,
269    /// A point anchor: `::onym`, an invisible in-flow bearer.
270    Anchor,
271    /// The verse vocabulary (ruling #37): the stichoi container…
272    Verse,
273    /// …its strophes…
274    Strophe,
275    /// …and its lines, `::taxis` the citation coordinate.
276    Stichos,
277}
278
279impl Kind {
280    fn name(self) -> Option<&'static str> {
281        Some(match self {
282            Kind::Document => return None,
283            Kind::Section => "section",
284            Kind::Paragraph => "paragraph",
285            Kind::Blockquote => "blockquote",
286            Kind::Footnote => "footnote",
287            Kind::Endnote => "endnote",
288            Kind::Aside => "aside",
289            Kind::IndexMark => "index-mark",
290            Kind::Ref => "ref",
291            Kind::Cit => "cit",
292            Kind::Bib => "bib",
293            Kind::Anchor => "anchor",
294            Kind::Verse => "verse",
295            Kind::Strophe => "strophe",
296            Kind::Stichos => "stichos",
297            Kind::UnorderedList => "unordered-list",
298            Kind::OrderedList => "ordered-list",
299            Kind::UnorderedItem => "unordered-item",
300            Kind::OrderedItem => "ordered-item",
301            Kind::Verbatim => "verbatim",
302        })
303    }
304}
305
306struct Node {
307    kind: Kind,
308    lemma: Option<String>,
309    hypograph: Option<String>,
310    taxis: Option<i64>,
311    /// Source heading level, on sections.
312    level: Option<u8>,
313    /// Declared language, on verbatim blocks.
314    lang: Option<String>,
315    /// First ordinal of an ordered list (not exposed; feeds the
316    /// items' taxis).
317    start: i64,
318    /// The node's own (direct) text, before subtree flattening.
319    text: String,
320    /// The flattened prose of the subtree — the `::` projection.
321    prose: String,
322    /// The node heads a denormalized table (`<table>` trait).
323    table: bool,
324    /// The node is a denormalized table row (`<row>` trait).
325    row: bool,
326    /// The node is a denormalized table cell (`<cell>` trait).
327    cell: bool,
328    /// The apparatus (ruling #35): the note name as written —
329    /// and, on an index mark (ruling #36), the term.
330    onym: Option<String>,
331    /// A footnote callout (`<deixis>`) rather than a body.
332    deixis: bool,
333    /// A callout whose body is missing (`<dangling>`).
334    dangling: bool,
335    /// A callout whose source declared no family: resolution may
336    /// re-kind it from the body it reaches.
337    family_open: bool,
338    /// The declared spelling was a margin form (`::::form`).
339    margin: bool,
340    /// Callout -> body edge, once resolved.
341    note_edge: Option<NodeId>,
342    /// Body <- callouts (the reverse index).
343    cites: Vec<NodeId>,
344    /// A ref's target identifier, as written.
345    target: Option<String>,
346    /// The producer declared the target in-document.
347    internal: bool,
348    /// A structured bib entry's fields (campus → value) and its
349    /// genus, per the bibliogramma vocabulary.
350    fields: Vec<(String, String)>,
351    genus: Option<String>,
352    /// Ref -> bearer edge, once resolved (in-document).
353    ref_edge: Option<NodeId>,
354    /// Bearer <- refs (the reverse index).
355    ref_cites: Vec<NodeId>,
356    parent: Option<NodeId>,
357    children: Vec<NodeId>,
358}
359
360impl Node {
361    fn new(kind: Kind, parent: Option<NodeId>) -> Self {
362        Node {
363            kind,
364            lemma: None,
365            hypograph: None,
366            taxis: None,
367            level: None,
368            lang: None,
369            start: 1,
370            text: String::new(),
371            prose: String::new(),
372            table: false,
373            row: false,
374            cell: false,
375            onym: None,
376            deixis: false,
377            dangling: false,
378            family_open: false,
379            margin: false,
380            note_edge: None,
381            cites: Vec::new(),
382            target: None,
383            internal: false,
384            fields: Vec::new(),
385            genus: None,
386            ref_edge: None,
387            ref_cites: Vec::new(),
388            parent,
389            children: Vec::new(),
390        }
391    }
392}
393
394/// The reference pass: collect every borne name (block labels
395/// and point anchors — deixis callouts pair within their own
396/// families and stay out of this namespace), then land each
397/// internal ref on its bearer, `<dangling>` when nothing bears
398/// the name. External refs resolve at query time, through the
399/// engine's reference machinery.
400fn resolve_refs(nodes: &mut [Node]) -> std::collections::HashMap<String, NodeId> {
401    let mut onyms: std::collections::HashMap<String, NodeId> = std::collections::HashMap::new();
402    // Citation keys are a namespace of their own, separate from
403    // labels (LaTeX's \bibcite vs \newlabel precedent): `cit`
404    // resolves against `bib` bearers only.
405    let mut bibs: std::collections::HashMap<String, NodeId> = std::collections::HashMap::new();
406    for (i, n) in nodes.iter().enumerate() {
407        if n.deixis || matches!(n.kind, Kind::IndexMark | Kind::Ref | Kind::Cit) {
408            continue;
409        }
410        if n.kind == Kind::Bib {
411            if let Some(k) = &n.onym {
412                bibs.entry(k.clone()).or_insert(NodeId(i as u64));
413            }
414            continue;
415        }
416        let bearer = n.kind == Kind::Anchor
417            || (!matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside)
418                && n.onym.is_some());
419        if bearer && let Some(o) = &n.onym {
420            onyms.entry(o.clone()).or_insert(NodeId(i as u64));
421        }
422    }
423    for i in 0..nodes.len() {
424        let (map, key) = match nodes[i].kind {
425            Kind::Ref if nodes[i].internal => (
426                &onyms,
427                nodes[i]
428                    .target
429                    .as_deref()
430                    .map(|t| t.trim_start_matches('#').to_string())
431                    .unwrap_or_default(),
432            ),
433            Kind::Cit => (&bibs, nodes[i].target.clone().unwrap_or_default()),
434            _ => continue,
435        };
436        match map.get(&key) {
437            Some(&bearer) => {
438                nodes[i].ref_edge = Some(bearer);
439                let me = NodeId(i as u64);
440                nodes[bearer.0 as usize].ref_cites.push(me);
441            }
442            None => nodes[i].dangling = true,
443        }
444    }
445    onyms
446}
447
448/// Collapse whitespace runs to single spaces and trim — the prose
449/// normalization adapters apply to inline content. Verbatim text
450/// is the exception: it is kept as authored.
451pub fn normalize_ws(s: &str) -> String {
452    s.split_whitespace().collect::<Vec<_>>().join(" ")
453}
454
455/// What a document declares about itself, in a standard
456/// vocabulary its format provides — HTML's `<head>` (the `meta`
457/// name/property pairs, `title`, the `lang` attribute, the
458/// canonical link, `rel="tag"` links) and a schema.org JSON-LD
459/// block. Two layers: every declaration lands verbatim under
460/// `::::name` (`::::description`, `::::og:type`,
461/// `::::article:section`, `::::schema:@type`), so nothing a page
462/// says is lost; and the standard vocabularies feed a curated
463/// core — the tags (`keywords`, `article:tag`, `rel=tag`, JSON-LD
464/// `keywords`) become traits on the document and `::tags`, the
465/// one classification (`article:section` / `articleSection`)
466/// becomes `::category` and a trait too, `description`, `author`,
467/// and the `published` / `modified` instants. Declared identity
468/// only: a format adapter reads a cited vocabulary, never a class
469/// name or a layout.
470#[derive(Debug, Clone, Default)]
471pub struct HeadMeta {
472    /// Every declaration, name → values, in document order; a
473    /// repeated name accumulates.
474    pub declared: Vec<(String, Vec<String>)>,
475    pub title: Option<String>,
476    pub description: Option<String>,
477    pub author: Option<String>,
478    /// The one classification (a section of the site, a category).
479    pub category: Option<String>,
480    /// The tags, as written, first appearance wins.
481    pub tags: Vec<String>,
482    pub published: Option<Value>,
483    pub modified: Option<Value>,
484}
485
486impl HeadMeta {
487    /// Record a declaration verbatim (the lossless layer).
488    pub fn declare(&mut self, name: &str, value: &str) {
489        let value = normalize_ws(value);
490        if name.is_empty() || value.is_empty() {
491            return;
492        }
493        match self.declared.iter_mut().find(|(k, _)| k == name) {
494            Some((_, vs)) => vs.push(value),
495            None => self.declared.push((name.to_string(), vec![value])),
496        }
497    }
498
499    /// Add a tag unless already present (case-insensitively).
500    pub fn tag(&mut self, tag: &str) {
501        let tag = normalize_ws(tag);
502        if tag.is_empty() || self.tags.iter().any(|t| t.eq_ignore_ascii_case(&tag)) {
503            return;
504        }
505        self.tags.push(tag);
506    }
507
508    /// A declaration's value: the string when declared once, the
509    /// list when repeated.
510    pub fn declared_value(&self, name: &str) -> Option<Value> {
511        let (_, vs) = self.declared.iter().find(|(k, _)| k == name)?;
512        Some(match vs.as_slice() {
513            [one] => Value::Str(one.clone()),
514            many => Value::List(many.iter().cloned().map(Value::Str).collect()),
515        })
516    }
517
518    /// The trait spelling of a tag or category: as declared, case
519    /// kept, with runs of anything a name cannot carry (spaces,
520    /// slashes, parentheses) collapsed to one dash — `Execution
521    /// Model` is `<tag:Execution-Model>`, `now()` is `<tag:now>`.
522    pub fn trait_name(s: &str) -> String {
523        let mut out = String::new();
524        let mut dash = false;
525        for c in s.chars() {
526            if c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '+') {
527                out.push(c);
528                dash = c == '-';
529            } else if !dash && !out.is_empty() {
530                out.push('-');
531                dash = true;
532            }
533        }
534        while out.ends_with('-') {
535            out.pop();
536        }
537        out
538    }
539}
540
541/// A Quarb adapter over a text-level document.
542pub struct TextModel {
543    nodes: Vec<Node>,
544    root: NodeId,
545    /// Every borne name → its bearer (block labels and point
546    /// anchors, one namespace, first bearer in document order
547    /// wins) — the landing map for `-->` and for a sibling
548    /// document's `#fragment`.
549    onyms: std::collections::HashMap<String, NodeId>,
550    /// The document's own URL, when the mount knows it.
551    document_url: Option<url::Url>,
552    /// The base relative reference targets join against when the
553    /// document declares one (html's `<base href>`); else the
554    /// document's own URL.
555    link_base: Option<url::Url>,
556    /// Where the document is served from when no absolute URL is
557    /// known — the mount's path, an archive member's path — the
558    /// base `::href` links against.
559    document_path: Option<String>,
560    /// Alias → canonical campus, for bib field lookup: the
561    /// bibliogramma vocabulary's census (BibLaTeX's field names
562    /// are its English rows), so `::author` answers as
563    /// `::auctor` — in any covered language. Set by the koine
564    /// route, which holds the dialektos.
565    bib_aliases: std::collections::HashMap<String, String>,
566    /// What the document declares about itself (see [`HeadMeta`]);
567    /// answered on the document node.
568    head: HeadMeta,
569}
570
571impl TextModel {
572    /// Assemble the document tree from a adapter's event stream.
573    ///
574    /// Iterative throughout (the stream is flat; prose flattening
575    /// runs over indices), so pathological nesting cannot overflow
576    /// the call stack. Lenient on malformed streams: a stray
577    /// `Close` is ignored, unclosed containers close at the end.
578    pub fn build(blocks: Vec<Block>) -> Self {
579        let mut nodes = vec![Node::new(Kind::Document, None)];
580        let root = NodeId(0);
581        // Innermost-last stack of open *sections* (outline-derived).
582        let mut sections: Vec<NodeId> = Vec::new();
583        // Innermost-last stack of open explicit containers.
584        let mut containers: Vec<NodeId> = Vec::new();
585        // The block a NoteRef callout attaches to: the last flow
586        // node created or appended to (ruling #35).
587        let mut last_flow: Option<NodeId> = None;
588        // The block a Label names: the most recently opened block
589        // of any kind — a heading's section, a paragraph, a
590        // container, a verbatim — the html-id / attached-\label
591        // parity rule.
592        let mut last_block: Option<NodeId> = None;
593
594        for block in blocks {
595            match block {
596                Block::Heading { level, lemma } => {
597                    let lemma = normalize_ws(&lemma);
598                    if !containers.is_empty() {
599                        // Decorative heading inside a container:
600                        // not sectioning — lower to a paragraph.
601                        if !lemma.is_empty() {
602                            let parent = *containers.last().unwrap();
603                            let id = push(&mut nodes, Kind::Paragraph, parent);
604                            nodes[id.0 as usize].text = lemma;
605                        }
606                        continue;
607                    }
608                    while let Some(&open) = sections.last() {
609                        if nodes[open.0 as usize].level >= Some(level) {
610                            sections.pop();
611                        } else {
612                            break;
613                        }
614                    }
615                    let parent = sections.last().copied().unwrap_or(root);
616                    let id = push(&mut nodes, Kind::Section, parent);
617                    let n = &mut nodes[id.0 as usize];
618                    n.lemma = Some(lemma);
619                    n.level = Some(level);
620                    sections.push(id);
621                    last_flow = Some(id);
622                    last_block = Some(id);
623                }
624                Block::Paragraph { text } => {
625                    let text = normalize_ws(&text);
626                    if text.is_empty() {
627                        continue;
628                    }
629                    let parent = cursor(&sections, &containers, root);
630                    let id = push(&mut nodes, Kind::Paragraph, parent);
631                    nodes[id.0 as usize].text = text;
632                    last_flow = Some(id);
633                    last_block = Some(id);
634                }
635                Block::Text { text } => {
636                    let text = normalize_ws(&text);
637                    if text.is_empty() {
638                        continue;
639                    }
640                    match containers.last() {
641                        Some(&open) => {
642                            let own = &mut nodes[open.0 as usize].text;
643                            if !own.is_empty() {
644                                own.push(' ');
645                            }
646                            own.push_str(&text);
647                            last_flow = Some(open);
648                        }
649                        None => {
650                            let parent = sections.last().copied().unwrap_or(root);
651                            let id = push(&mut nodes, Kind::Paragraph, parent);
652                            nodes[id.0 as usize].text = text;
653                            last_flow = Some(id);
654                        }
655                    }
656                }
657                Block::Open { kind, lemma } => {
658                    // A note body opens at the document root —
659                    // litogramma's canonical document-end
660                    // placement — whatever else is open.
661                    let kind = match kind {
662                        Container::Note {
663                            onym,
664                            family,
665                            margin,
666                        } => {
667                            let id = push(&mut nodes, family.kind(), root);
668                            let n = &mut nodes[id.0 as usize];
669                            n.onym = Some(onym.trim().to_string()).filter(|o| !o.is_empty());
670                            n.margin = margin;
671                            containers.push(id);
672                            last_flow = Some(id);
673                            continue;
674                        }
675                        other => other,
676                    };
677                    let parent = cursor(&sections, &containers, root);
678                    let (nkind, start) = match kind {
679                        Container::Blockquote => (Kind::Blockquote, None),
680                        Container::UnorderedList => (Kind::UnorderedList, None),
681                        Container::OrderedList { start } => (Kind::OrderedList, Some(start)),
682                        Container::Item => (
683                            match nodes[parent.0 as usize].kind {
684                                Kind::OrderedList => Kind::OrderedItem,
685                                _ => Kind::UnorderedItem,
686                            },
687                            None,
688                        ),
689                        Container::Note { .. } => unreachable!("handled above"),
690                    };
691                    let id = push(&mut nodes, nkind, parent);
692                    last_block = Some(id);
693                    nodes[id.0 as usize].lemma =
694                        lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
695                    if let Some(start) = start {
696                        nodes[id.0 as usize].start = start;
697                    }
698                    if nkind == Kind::OrderedItem {
699                        // `push` already appended this item, so the
700                        // count includes it.
701                        let nth = nodes[parent.0 as usize]
702                            .children
703                            .iter()
704                            .filter(|&&c| nodes[c.0 as usize].kind == Kind::OrderedItem)
705                            .count() as i64;
706                        let start = nodes[parent.0 as usize].start;
707                        nodes[id.0 as usize].taxis = Some(start + nth - 1);
708                    }
709                    containers.push(id);
710                }
711                Block::Close { hypograph } => {
712                    if let Some(open) = containers.pop() {
713                        nodes[open.0 as usize].hypograph = hypograph
714                            .map(|h| normalize_ws(&h))
715                            .filter(|h| !h.is_empty());
716                    }
717                }
718                // The callout: a `footnote` child of the flow
719                // block it sits in, `<deixis>`-traited; the edge
720                // to its body resolves after the stream.
721                Block::NoteRef {
722                    onym,
723                    family,
724                    margin,
725                } => {
726                    let parent = last_flow.unwrap_or(root);
727                    // A declared family names the callout now; an
728                    // undeclared one is settled at resolution from
729                    // the body it reaches (footnote when dangling).
730                    let kind = family.map(NoteFamily::kind).unwrap_or(Kind::Footnote);
731                    let id = push(&mut nodes, kind, parent);
732                    let n = &mut nodes[id.0 as usize];
733                    n.deixis = true;
734                    n.family_open = family.is_none();
735                    n.margin = margin;
736                    n.onym = Some(onym.trim().to_string()).filter(|o| !o.is_empty());
737                }
738                Block::Ref {
739                    target,
740                    text,
741                    internal,
742                } => {
743                    let parent = last_flow.unwrap_or(root);
744                    let id = push(&mut nodes, Kind::Ref, parent);
745                    let n = &mut nodes[id.0 as usize];
746                    n.internal = internal;
747                    n.target = Some(target.trim().to_string()).filter(|t| !t.is_empty());
748                    n.text = text.map(|t| normalize_ws(&t)).unwrap_or_default();
749                }
750                Block::Cite { target } => {
751                    let parent = last_flow.unwrap_or(root);
752                    let id = push(&mut nodes, Kind::Cit, parent);
753                    let n = &mut nodes[id.0 as usize];
754                    n.internal = true;
755                    n.target = Some(target.trim().to_string()).filter(|t| !t.is_empty());
756                }
757                Block::Bib {
758                    key,
759                    text,
760                    fields,
761                    genus,
762                } => {
763                    let parent = cursor(&sections, &containers, root);
764                    let id = push(&mut nodes, Kind::Bib, parent);
765                    last_block = Some(id);
766                    let n = &mut nodes[id.0 as usize];
767                    n.onym = Some(key.trim().to_string()).filter(|k| !k.is_empty());
768                    n.text = normalize_ws(&text);
769                    n.fields = fields
770                        .into_iter()
771                        .map(|(k, v)| (k.trim().to_string(), normalize_ws(&v)))
772                        .filter(|(k, v)| !k.is_empty() && !v.is_empty())
773                        .collect();
774                    n.genus = genus
775                        .map(|g| g.trim().to_string())
776                        .filter(|g| !g.is_empty());
777                }
778                Block::Anchor { onym } => {
779                    let parent = last_flow.unwrap_or(root);
780                    let id = push(&mut nodes, Kind::Anchor, parent);
781                    nodes[id.0 as usize].onym =
782                        Some(onym.trim().to_string()).filter(|o| !o.is_empty());
783                }
784                Block::Label { onym } => {
785                    let onym = onym.trim().to_string();
786                    if onym.is_empty() {
787                        continue;
788                    }
789                    // The most recently opened block bears the
790                    // name — a heading's section, a paragraph, a
791                    // blockquote: the html-id / attached-\label
792                    // parity rule. A label before any block, or on
793                    // a block already named, degrades to a point.
794                    let free = last_block
795                        .map(|b| nodes[b.0 as usize].onym.is_none())
796                        .unwrap_or(false);
797                    match (last_block, free) {
798                        (Some(b), true) => {
799                            nodes[b.0 as usize].onym = Some(onym);
800                        }
801                        _ => {
802                            let parent = last_flow.unwrap_or(root);
803                            let id = push(&mut nodes, Kind::Anchor, parent);
804                            nodes[id.0 as usize].onym = Some(onym);
805                        }
806                    }
807                }
808                Block::IndexMark { term } => {
809                    let parent = last_flow.unwrap_or(root);
810                    let id = push(&mut nodes, Kind::IndexMark, parent);
811                    nodes[id.0 as usize].onym = Some(quarb_term(&term)).filter(|t| !t.is_empty());
812                }
813                Block::Verbatim { lang, text } => {
814                    let parent = cursor(&sections, &containers, root);
815                    let id = push(&mut nodes, Kind::Verbatim, parent);
816                    last_block = Some(id);
817                    let n = &mut nodes[id.0 as usize];
818                    n.lang = lang.filter(|l| !l.is_empty());
819                    n.text = text;
820                }
821                Block::Table {
822                    lemma,
823                    headers,
824                    rows,
825                } => {
826                    let parent = cursor(&sections, &containers, root);
827                    lower_table(&mut nodes, parent, lemma, headers, rows);
828                }
829                Block::Verse {
830                    lemma,
831                    strophes,
832                    hypograph,
833                } => {
834                    let parent = cursor(&sections, &containers, root);
835                    let id = lower_verse(&mut nodes, parent, lemma, strophes, hypograph);
836                    last_flow = Some(id);
837                    last_block = Some(id);
838                }
839            }
840        }
841
842        resolve_notes(&mut nodes);
843        let onyms = resolve_refs(&mut nodes);
844        flatten_prose(&mut nodes);
845        TextModel {
846            nodes,
847            root,
848            onyms,
849            document_url: None,
850            link_base: None,
851            document_path: None,
852            bib_aliases: Default::default(),
853            head: HeadMeta::default(),
854        }
855    }
856
857    /// Register the bib-field alias census (alias → canonical
858    /// campus), lowercased keys — the friction remover: a field
859    /// authored or queried under any covered name answers.
860    pub fn set_bib_aliases(&mut self, map: std::collections::HashMap<String, String>) {
861        self.bib_aliases = map;
862    }
863
864    /// Declare the document's own URL — the base a relative ref
865    /// target joins against when `-->` reaches across documents.
866    pub fn set_document_url(&mut self, url: &str) {
867        self.document_url = url::Url::parse(url).ok();
868    }
869
870    /// Declare the base relative reference targets join against
871    /// — html's `<base href>` — when it differs from the
872    /// document's own URL.
873    pub fn set_link_base(&mut self, url: &str) {
874        self.link_base = url::Url::parse(url).ok();
875    }
876
877    /// Declare where the document is served from when it declares
878    /// no absolute URL of its own: the base `::href` links against
879    /// (a mount path, an archive member's path).
880    pub fn set_document_path(&mut self, path: &str) {
881        self.document_path = Some(path.to_string());
882    }
883
884    /// The base `::href` links against: the document's URL, else
885    /// its declared path.
886    fn href_base(&self) -> Option<String> {
887        if let Some(u) = &self.document_url {
888            return Some(u.to_string());
889        }
890        self.document_path.clone()
891    }
892
893    /// The nearest bearer of a name, the node itself first, then
894    /// its ancestors — the anchor a link into this node lands on.
895    /// `exact` says the node bears it itself.
896    fn nearest_onym(&self, node: NodeId) -> Option<(String, bool)> {
897        let mut cur = Some(node);
898        let mut exact = true;
899        while let Some(id) = cur {
900            let n = &self.nodes[id.0 as usize];
901            if n.kind != Kind::Document
902                && !n.deixis
903                && let Some(o) = &n.onym
904            {
905                return Some((o.clone(), exact));
906            }
907            exact = false;
908            cur = n.parent;
909        }
910        None
911    }
912
913    /// A link into `node`, as far as the served form can narrow
914    /// it. The base is the document's URL (its canonical link,
915    /// else the mount's path). An HTML page — a base ending in
916    /// `.html` / `.htm` / `/` or without an extension — narrows
917    /// with the nearest anchor and, for a block below the anchored
918    /// one, a text fragment (`#:~:text=start,end`) built from the
919    /// node's own prose. A PDF narrows to nothing yet; any other
920    /// form links the document alone, since no browser takes a
921    /// fragment into it. The narrowing stays available as data
922    /// beside the link (`::anchor`, the prose) for a client that
923    /// renders the document itself.
924    fn href(&self, node: NodeId) -> Option<String> {
925        let base = self.href_base()?;
926        let n = &self.nodes[node.0 as usize];
927        if n.kind == Kind::Document {
928            return Some(base);
929        }
930        let served = base.split(['?', '#']).next().unwrap_or(&base);
931        let last = served.rsplit('/').next().unwrap_or(served);
932        let html = served.ends_with('/')
933            || !last.contains('.')
934            || last.ends_with(".html")
935            || last.ends_with(".htm");
936        if !html {
937            return Some(base);
938        }
939        let mut out = base;
940        let (anchor, exact) = match self.nearest_onym(node) {
941            Some((a, exact)) => (Some(a), exact),
942            None => (None, false),
943        };
944        if let Some(a) = &anchor {
945            out.push('#');
946            out.push_str(a);
947        }
948        if !exact {
949            let words: Vec<&str> = n.prose.split_whitespace().collect();
950            if !words.is_empty() {
951                use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
952                let enc =
953                    |ws: &[&str]| utf8_percent_encode(&ws.join(" "), NON_ALPHANUMERIC).to_string();
954                if anchor.is_none() {
955                    out.push('#');
956                }
957                out.push_str(":~:text=");
958                if words.len() <= 12 {
959                    out.push_str(&enc(&words));
960                } else {
961                    out.push_str(&enc(&words[..6]));
962                    out.push(',');
963                    out.push_str(&enc(&words[words.len() - 6..]));
964                }
965            }
966        }
967        Some(out)
968    }
969
970    /// The document's title for a link: what its head declares,
971    /// else its first top-level section's lemma.
972    fn document_title(&self) -> Option<String> {
973        if let Some(t) = &self.head.title {
974            return Some(t.clone());
975        }
976        self.nodes[self.root.0 as usize]
977            .children
978            .iter()
979            .map(|c| &self.nodes[c.0 as usize])
980            .find(|n| n.kind == Kind::Section)
981            .and_then(|n| n.lemma.clone())
982    }
983
984    /// Attach what the document declares about itself; the format
985    /// adapter reads it from the source's own metadata vocabulary.
986    pub fn set_head_meta(&mut self, head: HeadMeta) {
987        self.head = head;
988    }
989
990    /// What the document declares about itself.
991    pub fn head_meta(&self) -> &HeadMeta {
992        &self.head
993    }
994
995    /// The document's text as one string: every section's lemma
996    /// and every block's prose, one per line, in document order —
997    /// the lowering a substrate stores beside the document so a
998    /// substring predicate inside the graft can be prefiltered on
999    /// it. The invariant a prefilter relies on: every grafted
1000    /// node's prose (and every lemma) is a substring of this.
1001    pub fn plain_text(&self) -> String {
1002        let mut out = String::new();
1003        let mut stack = vec![self.root];
1004        while let Some(id) = stack.pop() {
1005            let n = &self.nodes[id.0 as usize];
1006            if let Some(l) = &n.lemma
1007                && !l.is_empty()
1008            {
1009                out.push_str(l);
1010                out.push('\n');
1011            }
1012            // Every node's own prose, leaf or not: a paragraph that
1013            // holds a ref or an anchor still has prose of its own,
1014            // and the invariant covers it too.
1015            if !n.prose.is_empty() && n.kind != Kind::Document {
1016                out.push_str(&n.prose);
1017                out.push('\n');
1018            }
1019            for c in n.children.iter().rev() {
1020                stack.push(*c);
1021            }
1022        }
1023        out
1024    }
1025
1026    /// How many nodes the document holds (the root included) — the
1027    /// bound a host needs to pack this document's node ids into a
1028    /// wider id space.
1029    pub fn node_count(&self) -> usize {
1030        self.nodes.len()
1031    }
1032
1033    /// Read plain text: blank-line-separated paragraphs, each
1034    /// collapsed to one line — the atramento paragraph rule. No
1035    /// headings, no markup.
1036    pub fn parse_plain(text: &str) -> Self {
1037        let mut blocks = Vec::new();
1038        let mut para: Vec<&str> = Vec::new();
1039        for line in text.lines() {
1040            if line.trim().is_empty() {
1041                if !para.is_empty() {
1042                    blocks.push(Block::Paragraph {
1043                        text: para.join(" "),
1044                    });
1045                    para.clear();
1046                }
1047            } else {
1048                para.push(line);
1049            }
1050        }
1051        if !para.is_empty() {
1052            blocks.push(Block::Paragraph {
1053                text: para.join(" "),
1054            });
1055        }
1056        Self::build(blocks)
1057    }
1058
1059    /// A locator path to `node`, like `/section[2]/paragraph[3]`,
1060    /// for rendering. A `[n]` index is added only to disambiguate
1061    /// same-name siblings.
1062    pub fn locator(&self, node: NodeId) -> String {
1063        let mut segments = Vec::new();
1064        let mut cur = Some(node);
1065        while let Some(id) = cur {
1066            let n = &self.nodes[id.0 as usize];
1067            if let Some(name) = n.kind.name() {
1068                segments.push(self.segment(id, name));
1069            }
1070            cur = n.parent;
1071        }
1072        segments.reverse();
1073        format!("/{}", segments.join("/"))
1074    }
1075
1076    fn segment(&self, node: NodeId, name: &str) -> String {
1077        let Some(parent) = self.nodes[node.0 as usize].parent else {
1078            return name.to_string();
1079        };
1080        let siblings = &self.nodes[parent.0 as usize].children;
1081        let same_name: Vec<NodeId> = siblings
1082            .iter()
1083            .copied()
1084            .filter(|&s| self.nodes[s.0 as usize].kind == self.nodes[node.0 as usize].kind)
1085            .collect();
1086        if same_name.len() > 1 {
1087            let n = same_name.iter().position(|&s| s == node).unwrap() + 1;
1088            format!("{name}[{n}]")
1089        } else {
1090            name.to_string()
1091        }
1092    }
1093}
1094
1095/// Where the next block lands: the innermost open container, else
1096/// the innermost open section, else the root.
1097/// Ruling #35: wire each callout to its body by (family, onym) —
1098/// first body wins on a duplicate — build the reverse index, and
1099/// mark the dangling: a callout with no body keeps its node,
1100/// carries `<dangling>` and `::::resolved = false`, and emits no
1101/// edge.
1102fn resolve_notes(nodes: &mut [Node]) {
1103    let mut bodies: std::collections::HashMap<(Kind, String), NodeId> = Default::default();
1104    for (i, n) in nodes.iter().enumerate() {
1105        if matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside)
1106            && !n.deixis
1107            && let Some(o) = &n.onym
1108        {
1109            bodies
1110                .entry((n.kind, o.clone()))
1111                .or_insert(NodeId(i as u64));
1112        }
1113    }
1114    for i in 0..nodes.len() {
1115        if !matches!(nodes[i].kind, Kind::Footnote | Kind::Endnote | Kind::Aside)
1116            || !nodes[i].deixis
1117        {
1118            continue;
1119        }
1120        let Some(onym) = nodes[i].onym.clone() else {
1121            nodes[i].dangling = true;
1122            continue;
1123        };
1124        let hit = if nodes[i].family_open {
1125            // No declared family (an HTML noteref): the body's
1126            // family is the callout's; footnote when dangling.
1127            [Kind::Footnote, Kind::Endnote, Kind::Aside]
1128                .into_iter()
1129                .find_map(|k| bodies.get(&(k, onym.clone())).map(|b| (k, *b)))
1130        } else {
1131            bodies
1132                .get(&(nodes[i].kind, onym))
1133                .map(|b| (nodes[i].kind, *b))
1134        };
1135        match hit {
1136            Some((family, body)) => {
1137                nodes[i].kind = family;
1138                nodes[i].note_edge = Some(body);
1139                let callout = NodeId(i as u64);
1140                nodes[body.0 as usize].cites.push(callout);
1141            }
1142            None => nodes[i].dangling = true,
1143        }
1144    }
1145}
1146
1147/// An index term as written, with the `|...` formatting and range
1148/// directives stripped (v1 of ruling #36; ranges and
1149/// see-references are recorded follow-ups).
1150fn quarb_term(term: &str) -> String {
1151    normalize_ws(term.split('|').next().unwrap_or(term))
1152}
1153
1154fn cursor(sections: &[NodeId], containers: &[NodeId], root: NodeId) -> NodeId {
1155    containers
1156        .last()
1157        .or(sections.last())
1158        .copied()
1159        .unwrap_or(root)
1160}
1161
1162fn push(nodes: &mut Vec<Node>, kind: Kind, parent: NodeId) -> NodeId {
1163    let id = NodeId(nodes.len() as u64);
1164    nodes.push(Node::new(kind, Some(parent)));
1165    nodes[parent.0 as usize].children.push(id);
1166    id
1167}
1168
1169/// Denormalize a table into nested lists (see the module doc).
1170/// The column name lands as the cell's `::lemma` — a cell's own
1171/// label (a row's `th`) wins over the positional header entry —
1172/// and never as folded text: addressing is property projection,
1173/// the flattening rule alone spells `lemma: value`.
1174fn lower_table(
1175    nodes: &mut Vec<Node>,
1176    parent: NodeId,
1177    lemma: Option<String>,
1178    headers: Option<Vec<String>>,
1179    rows: Vec<Vec<Cell>>,
1180) {
1181    let list = push(nodes, Kind::OrderedList, parent);
1182    {
1183        let n = &mut nodes[list.0 as usize];
1184        n.table = true;
1185        n.lemma = lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
1186    }
1187    for (i, row) in rows.into_iter().enumerate() {
1188        let item = push(nodes, Kind::OrderedItem, list);
1189        nodes[item.0 as usize].taxis = Some(i as i64 + 1);
1190        nodes[item.0 as usize].row = true;
1191        let cells = push(nodes, Kind::UnorderedList, item);
1192        for (j, cell) in row.into_iter().enumerate() {
1193            let value = normalize_ws(&cell.text);
1194            if value.is_empty() {
1195                continue;
1196            }
1197            let label = cell
1198                .label
1199                .as_deref()
1200                .or_else(|| headers.as_ref().and_then(|h| h.get(j)).map(|h| h.as_str()))
1201                .map(normalize_ws)
1202                .filter(|h| !h.is_empty());
1203            let cell_item = push(nodes, Kind::UnorderedItem, cells);
1204            let n = &mut nodes[cell_item.0 as usize];
1205            n.cell = true;
1206            n.lemma = label;
1207            n.text = value;
1208        }
1209    }
1210}
1211
1212/// Lower a verse block (ruling #37): `verse` → `strophe`s →
1213/// `stichos` lines. The stichos taxis numbers lines 1-based,
1214/// continuously across strophes — Iliad 1.34 is a taxis; the
1215/// strophe taxis is its ordinal. Empty lines are dropped (the
1216/// strophe boundary carries the separation).
1217fn lower_verse(
1218    nodes: &mut Vec<Node>,
1219    parent: NodeId,
1220    lemma: Option<String>,
1221    strophes: Vec<Vec<String>>,
1222    hypograph: Option<String>,
1223) -> NodeId {
1224    let verse = push(nodes, Kind::Verse, parent);
1225    {
1226        let n = &mut nodes[verse.0 as usize];
1227        n.lemma = lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
1228        n.hypograph = hypograph
1229            .map(|h| normalize_ws(&h))
1230            .filter(|h| !h.is_empty());
1231    }
1232    let mut line_no = 0i64;
1233    for (i, strophe) in strophes.into_iter().enumerate() {
1234        let sid = push(nodes, Kind::Strophe, verse);
1235        nodes[sid.0 as usize].taxis = Some(i as i64 + 1);
1236        for line in strophe {
1237            let line = line.trim_end().to_string();
1238            if line.trim().is_empty() {
1239                continue;
1240            }
1241            line_no += 1;
1242            let lid = push(nodes, Kind::Stichos, sid);
1243            let n = &mut nodes[lid.0 as usize];
1244            n.taxis = Some(line_no);
1245            n.text = line;
1246        }
1247    }
1248    verse
1249}
1250
1251/// Compute every node's flattened prose: lemma first, then the
1252/// node's own text, then its children's prose in order, then the
1253/// hypograph, block-joined with newlines. On a list *item*, the
1254/// lemma joins the rest with `: ` instead — an item's lemma names
1255/// its content inline (a table cell reads `Outcome: Emus won`, a
1256/// definition reads `term: description`), where a section's lemma
1257/// opens its block. Children always carry larger indices than
1258/// their parents (nodes are interned in document order), so one
1259/// reverse index scan suffices — no recursion.
1260fn flatten_prose(nodes: &mut [Node]) {
1261    for i in (0..nodes.len()).rev() {
1262        let inline_lemma = matches!(nodes[i].kind, Kind::UnorderedItem | Kind::OrderedItem);
1263        let mut lemma_part: Option<String> = None;
1264        let mut parts: Vec<String> = Vec::new();
1265        if let Some(lemma) = &nodes[i].lemma
1266            && !lemma.is_empty()
1267        {
1268            if inline_lemma {
1269                lemma_part = Some(lemma.clone());
1270            } else {
1271                parts.push(lemma.clone());
1272            }
1273        }
1274        if !nodes[i].text.is_empty() {
1275            parts.push(nodes[i].text.clone());
1276        }
1277        let mut child_parts: Vec<String> = Vec::new();
1278        for &child in nodes[i].children.clone().iter() {
1279            // A ref's recorded link text is already part of the
1280            // flow it was met in; an anchor is invisible. Neither
1281            // adds prose (the deixis/index-mark rule).
1282            if matches!(
1283                nodes[child.0 as usize].kind,
1284                Kind::Ref | Kind::Cit | Kind::Anchor
1285            ) {
1286                continue;
1287            }
1288            let prose = &nodes[child.0 as usize].prose;
1289            if !prose.is_empty() {
1290                child_parts.push(prose.clone());
1291            }
1292        }
1293        // Ruling #37: a verse block separates its strophes by a
1294        // blank line (lemma and hypograph still join normally) —
1295        // byte-compatible with the old verbatim lowering.
1296        if nodes[i].kind == Kind::Verse {
1297            let joined = child_parts.join("\n\n");
1298            if !joined.is_empty() {
1299                parts.push(joined);
1300            }
1301        } else {
1302            parts.extend(child_parts);
1303        }
1304        if let Some(hypograph) = &nodes[i].hypograph
1305            && !hypograph.is_empty()
1306        {
1307            parts.push(hypograph.clone());
1308        }
1309        let mut prose = parts.join("\n");
1310        if let Some(lemma) = lemma_part {
1311            prose = if prose.is_empty() {
1312                lemma
1313            } else {
1314                format!("{lemma}: {prose}")
1315            };
1316        }
1317        nodes[i].prose = prose;
1318    }
1319}
1320
1321impl TextModel {
1322    /// The body prose: everything between the lemma and the
1323    /// hypograph — the simmere anatomy's third member, derived
1324    /// from the flattened prose by construction (the lemma joins
1325    /// a block on its own line, an item with `: `; the hypograph
1326    /// closes on its own line).
1327    fn grammata(&self, node: NodeId) -> String {
1328        let n = &self.nodes[node.0 as usize];
1329        let mut s = n.prose.as_str();
1330        if let Some(lemma) = &n.lemma
1331            && !lemma.is_empty()
1332            && let Some(rest) = s.strip_prefix(lemma.as_str())
1333        {
1334            s = rest
1335                .strip_prefix(": ")
1336                .or_else(|| rest.strip_prefix('\n'))
1337                .unwrap_or(rest);
1338        }
1339        if let Some(h) = &n.hypograph
1340            && !h.is_empty()
1341            && let Some(rest) = s.strip_suffix(h.as_str())
1342        {
1343            s = rest.strip_suffix('\n').unwrap_or(rest);
1344        }
1345        s.trim_end().to_string()
1346    }
1347}
1348
1349impl AstAdapter for TextModel {
1350    fn root(&self) -> NodeId {
1351        self.root
1352    }
1353
1354    fn children(&self, node: NodeId) -> Vec<NodeId> {
1355        self.nodes[node.0 as usize].children.clone()
1356    }
1357
1358    fn name(&self, node: NodeId) -> Option<String> {
1359        self.nodes[node.0 as usize].kind.name().map(str::to_string)
1360    }
1361
1362    fn parent(&self, node: NodeId) -> Option<NodeId> {
1363        self.nodes[node.0 as usize].parent
1364    }
1365
1366    /// The `<block>` family on every block node, plus `<table>` on
1367    /// a list that denormalizes a table. Kinds are node names, not
1368    /// traits.
1369    fn traits(&self, node: NodeId) -> Vec<String> {
1370        let n = &self.nodes[node.0 as usize];
1371        let mut out = Vec::new();
1372        // The document wears its declared classification: one
1373        // trait per tag and one for the category — the LDAP rule,
1374        // where a multi-valued classifying attribute is the node's
1375        // traits — each in its own namespace (`<tag:pandas>`,
1376        // `<category:Guides>`), so a tag can never collide with a
1377        // structural trait like `<table>` or `<note>`, and spelled
1378        // as declared, case kept.
1379        if n.kind == Kind::Document {
1380            for (ns, t) in self
1381                .head
1382                .tags
1383                .iter()
1384                .map(|t| ("tag", t))
1385                .chain(self.head.category.iter().map(|c| ("category", c)))
1386            {
1387                let name = HeadMeta::trait_name(t);
1388                if name.is_empty() {
1389                    continue;
1390                }
1391                let name = format!("{ns}:{name}");
1392                if !out.contains(&name) {
1393                    out.push(name);
1394                }
1395            }
1396        }
1397        // A callout is inline apparatus, not a block (ruling #35);
1398        // so is an index mark (ruling #36). Strophes and stichos
1399        // lines are sub-block structure (ruling #37, litogramma's
1400        // own family rule) — the verse block carries the trait.
1401        if n.kind != Kind::Document
1402            && !n.deixis
1403            && !matches!(
1404                n.kind,
1405                Kind::IndexMark
1406                    | Kind::Ref
1407                    | Kind::Cit
1408                    | Kind::Anchor
1409                    | Kind::Strophe
1410                    | Kind::Stichos
1411            )
1412        {
1413            out.push("block".to_string());
1414        }
1415        // The reference vocabulary (atrep's semantic traits): a
1416        // dangling mention keeps its node and the linter finds it;
1417        // `<target>` marks what bears a referable name — anchors
1418        // and labeled blocks, never the apparatus families (their
1419        // onyms pair within the family, not in this namespace).
1420        if matches!(n.kind, Kind::Ref | Kind::Cit) && n.dangling {
1421            out.push("dangling".to_string());
1422        }
1423        // A structured entry's genus (liber, commentarius, …)
1424        // surfaces as its trait, the genos rule.
1425        if n.kind == Kind::Bib
1426            && let Some(g) = &n.genus
1427        {
1428            out.push(g.clone());
1429        }
1430        if n.kind == Kind::Anchor
1431            || (n.onym.is_some()
1432                && !n.deixis
1433                && !matches!(
1434                    n.kind,
1435                    Kind::Footnote
1436                        | Kind::Endnote
1437                        | Kind::Aside
1438                        | Kind::IndexMark
1439                        | Kind::Ref
1440                        | Kind::Cit
1441                        | Kind::Bib
1442                ))
1443        {
1444            out.push("target".to_string());
1445        }
1446        if matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside) {
1447            // The body IS the note — `<note>` marks it, whichever
1448            // note family; the callout carries `<deixis>` alone.
1449            // An aside body is content, not apparatus: no
1450            // `<note>`. No both-ends trait: `//footnote` already
1451            // gathers a family whole, `//*<deixis>` the callouts
1452            // across families, `//*<note>` the note bodies.
1453            if n.deixis {
1454                out.push("deixis".to_string());
1455            } else if n.kind != Kind::Aside {
1456                out.push("note".to_string());
1457            }
1458            if n.dangling {
1459                out.push("dangling".to_string());
1460            }
1461        }
1462        if n.table {
1463            out.push("table".to_string());
1464        }
1465        if n.row {
1466            out.push("row".to_string());
1467        }
1468        if n.cell {
1469            out.push("cell".to_string());
1470        }
1471        out
1472    }
1473
1474    /// `::lemma` (title), `::hypograph` (footer or attribution),
1475    /// `::taxis` (ordinal), `::text` (the flattened prose, same as
1476    /// the bare projection).
1477    /// The Greek anatomy — `::lemma`, `::grammata`, `::hypograph`,
1478    /// `::taxis` — plus the friendly aliases (`::title`, `::body`,
1479    /// `::attribution`, `::ord`), answered here because this
1480    /// adapter's property surface IS the vocabulary; on data
1481    /// adapters those spellings stay ordinary field names. The
1482    /// Greek is canon in docs and reflection preserves whichever
1483    /// spelling was written.
1484    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
1485        let n = &self.nodes[node.0 as usize];
1486        // The document node answers its declared identity: the
1487        // curated core of what the head says.
1488        if n.kind == Kind::Document {
1489            let h = &self.head;
1490            match name {
1491                "title" | "lemma" => return h.title.clone().map(Value::Str),
1492                "description" => return h.description.clone().map(Value::Str),
1493                "author" => return h.author.clone().map(Value::Str),
1494                "category" => return h.category.clone().map(Value::Str),
1495                "tags" => {
1496                    return (!h.tags.is_empty())
1497                        .then(|| Value::List(h.tags.iter().cloned().map(Value::Str).collect()));
1498                }
1499                "published" => return h.published.clone(),
1500                "modified" => return h.modified.clone(),
1501                _ => {}
1502            }
1503        }
1504        // A structured entry answers its campi first — under the
1505        // canonical Latin name or any census alias (BibLaTeX's
1506        // field names included), case-insensitively — ahead of
1507        // the general vocabulary, so `::title` on a bib means
1508        // titulus, not the lemma alias.
1509        if n.kind == Kind::Bib && !n.fields.is_empty() {
1510            let canon = self
1511                .bib_aliases
1512                .get(&name.to_lowercase())
1513                .map(String::as_str)
1514                .unwrap_or(name);
1515            if let Some((_, v)) = n.fields.iter().find(|(k, _)| k == canon) {
1516                return Some(Value::Str(v.clone()));
1517            }
1518        }
1519        match name {
1520            // A link into the node, as far as the served form can
1521            // narrow it; the anchor it lands on; and the record a
1522            // client renders as a clickable result.
1523            "href" => return self.href(node).map(Value::Str),
1524            "anchor" => return self.nearest_onym(node).map(|(a, _)| Value::Str(a)),
1525            "link" => {
1526                let href = self.href(node)?;
1527                let text = match self.default_value(node) {
1528                    Some(Value::Str(t)) => t,
1529                    _ => String::new(),
1530                };
1531                return Some(Value::Record(vec![
1532                    (
1533                        "title".to_string(),
1534                        self.document_title().map(Value::Str).unwrap_or(Value::Null),
1535                    ),
1536                    ("href".to_string(), Value::Str(href)),
1537                    ("text".to_string(), Value::Str(text)),
1538                ]));
1539            }
1540            _ => {}
1541        }
1542        match name {
1543            "lemma" | "title" => n.lemma.clone().map(Value::Str),
1544            "onym" => n.onym.clone().map(Value::Str),
1545            // On an index mark the onym IS the term (ruling #36).
1546            "term" if n.kind == Kind::IndexMark => n.onym.clone().map(Value::Str),
1547            // A ref's word: the identifier as written. Never the
1548            // resolved content — that is what `-->` is for.
1549            "target" if matches!(n.kind, Kind::Ref | Kind::Cit) => n.target.clone().map(Value::Str),
1550            // A structured entry answers its campi (auctor,
1551            // titulus, annus, …) — the bibliogramma vocabulary,
1552            // canonicalized upstream.
1553            _ if n.kind == Kind::Bib => {
1554                let canon = self
1555                    .bib_aliases
1556                    .get(&name.to_lowercase())
1557                    .map(String::as_str)
1558                    .unwrap_or(name);
1559                n.fields
1560                    .iter()
1561                    .find(|(k, _)| k == canon)
1562                    .map(|(_, v)| Value::Str(v.clone()))
1563            }
1564            "hypograph" | "attribution" => n.hypograph.clone().map(Value::Str),
1565            "taxis" | "ord" => n.taxis.map(Value::Int),
1566            "grammata" | "body" => {
1567                let g = self.grammata(node);
1568                if g.is_empty() {
1569                    None
1570                } else {
1571                    Some(Value::Str(g))
1572                }
1573            }
1574            "text" => Some(Value::Str(n.prose.clone())),
1575            _ => None,
1576        }
1577    }
1578
1579    /// The default projection is the flattened prose of the
1580    /// subtree — lemma first, hypograph last.
1581    fn default_value(&self, node: NodeId) -> Option<Value> {
1582        let n = &self.nodes[node.0 as usize];
1583        if n.kind == Kind::IndexMark {
1584            // Invisible in the surrounding prose; its own
1585            // projection is the term it declares.
1586            return Some(Value::Str(n.onym.clone().unwrap_or_default()));
1587        }
1588        if n.deixis {
1589            // The atrep rule: a resolved reference projects as its
1590            // target's rendered form, degrading to the raw onym
1591            // when dangling.
1592            return Some(Value::Str(match n.note_edge {
1593                Some(body) => self.nodes[body.0 as usize].prose.clone(),
1594                None => n.onym.clone().unwrap_or_default(),
1595            }));
1596        }
1597        if n.kind == Kind::Ref {
1598            // The authored link text; LaTeX \ref has none, so the
1599            // atrep projection rule applies — the target's
1600            // rendered form (its lemma, else its prose), degrading
1601            // to the raw target when dangling.
1602            if !n.text.is_empty() {
1603                return Some(Value::Str(n.text.clone()));
1604            }
1605            return Some(Value::Str(match n.ref_edge {
1606                Some(t) => {
1607                    let b = &self.nodes[t.0 as usize];
1608                    b.lemma.clone().unwrap_or_else(|| b.prose.clone())
1609                }
1610                None => n.target.clone().unwrap_or_default(),
1611            }));
1612        }
1613        if n.kind == Kind::Bib && n.prose.is_empty() && !n.fields.is_empty() {
1614            // A structured entry's plain form: its field values in
1615            // source order — the full data, no citation styling.
1616            return Some(Value::Str(
1617                n.fields
1618                    .iter()
1619                    .map(|(_, v)| v.as_str())
1620                    .collect::<Vec<_>>()
1621                    .join(". "),
1622            ));
1623        }
1624        if n.kind == Kind::Cit {
1625            // The mark's own identity: the raw key. The entry is
1626            // one arrow away (`//cit--> ::`); rendered citation
1627            // styles are presentation, not structure.
1628            return Some(Value::Str(n.target.clone().unwrap_or_default()));
1629        }
1630        if n.kind == Kind::Anchor {
1631            // Invisible in the prose; its own projection is the
1632            // name it bears.
1633            return Some(Value::Str(n.onym.clone().unwrap_or_default()));
1634        }
1635        Some(Value::Str(n.prose.clone()))
1636    }
1637
1638    /// The edge label is the family name both ends share —
1639    /// `->footnote` / `->endnote` — the node's own kind, since
1640    /// resolution re-kinds an open-family callout to its body.
1641    fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
1642        let n = &self.nodes[node.0 as usize];
1643        if let Some(t) = n.ref_edge {
1644            // The atrep rule: a resolved mention emits its typed
1645            // crosslink — `->ref`, or `->cit` for a citation.
1646            let label = if n.kind == Kind::Cit { "cit" } else { "ref" };
1647            return vec![(label.to_string(), t)];
1648        }
1649        match n.note_edge {
1650            Some(body) => vec![(n.kind.name().unwrap_or("footnote").to_string(), body)],
1651            None => Vec::new(),
1652        }
1653    }
1654
1655    fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
1656        let n = &self.nodes[node.0 as usize];
1657        let mut out: Vec<(String, NodeId)> = n
1658            .ref_cites
1659            .iter()
1660            .map(|&c| {
1661                let label = if self.nodes[c.0 as usize].kind == Kind::Cit {
1662                    "cit"
1663                } else {
1664                    "ref"
1665                };
1666                (label.to_string(), c)
1667            })
1668            .collect();
1669        let label = n.kind.name().unwrap_or("footnote");
1670        out.extend(n.cites.iter().map(|&c| (label.to_string(), c)));
1671        out
1672    }
1673
1674    /// `//ref-->`: land on the bearer — the block if a block
1675    /// bears the name, the point anchor if a point does. The
1676    /// text-level hints choose the reading: `-->block` normalizes
1677    /// a point landing to its enclosing block, `-->point` selects
1678    /// point targets only; bare is the honest exact landing.
1679    fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
1680        let n = &self.nodes[node.0 as usize];
1681        if !matches!(n.kind, Kind::Ref | Kind::Cit) || property != "target" {
1682            return None;
1683        }
1684        let landed = n.ref_edge?;
1685        let is_point = self.nodes[landed.0 as usize].kind == Kind::Anchor;
1686        match hint {
1687            None | Some("*") => Some(landed),
1688            Some("point") => is_point.then_some(landed),
1689            Some("block") => {
1690                if !is_point {
1691                    return Some(landed);
1692                }
1693                // The anchor's enclosing block.
1694                let mut at = self.nodes[landed.0 as usize].parent;
1695                while let Some(b) = at {
1696                    let bn = &self.nodes[b.0 as usize];
1697                    if bn.kind != Kind::Document
1698                        && !matches!(bn.kind, Kind::Ref | Kind::Anchor | Kind::IndexMark)
1699                    {
1700                        return Some(b);
1701                    }
1702                    at = bn.parent;
1703                }
1704                None
1705            }
1706            Some(_) => None,
1707        }
1708    }
1709
1710    /// The bare arrow's property: a ref resolves its target.
1711    fn ref_property(&self, node: NodeId) -> Option<String> {
1712        matches!(self.nodes[node.0 as usize].kind, Kind::Ref | Kind::Cit)
1713            .then(|| "target".to_string())
1714    }
1715
1716    /// A ref the producer declared external: the absolute URL,
1717    /// relative targets joined against the document's own URL —
1718    /// the same acquisition rung the html DOM reading speaks.
1719    fn external_ref(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<String> {
1720        if !matches!(hint, None | Some("*")) {
1721            return None;
1722        }
1723        let n = &self.nodes[node.0 as usize];
1724        if n.kind != Kind::Ref || property != "target" || n.internal {
1725            return None;
1726        }
1727        let t = n.target.as_deref()?.trim();
1728        if t.is_empty() || t.starts_with('#') {
1729            return None;
1730        }
1731        let u = match url::Url::parse(t) {
1732            Ok(u) => u,
1733            Err(url::ParseError::RelativeUrlWithoutBase) => self
1734                .link_base
1735                .as_ref()
1736                .or(self.document_url.as_ref())?
1737                .join(t)
1738                .ok()?,
1739            Err(_) => return None,
1740        };
1741        if u.scheme() != "http" && u.scheme() != "https" {
1742            return None;
1743        }
1744        Some(u.into())
1745    }
1746
1747    /// A sibling document's `#fragment` lands here: the bearer of
1748    /// that name — a labeled block, or a point anchor.
1749    fn resolve_fragment(&self, _node: NodeId, fragment: &str) -> Option<NodeId> {
1750        self.onyms.get(fragment).copied()
1751    }
1752
1753    /// Ruling #29: the text level's surface is the vocabulary
1754    /// itself — no document can introduce a property name — so
1755    /// its two annotations answer at `::` as well.
1756    fn aliased_metadata(&self, _node: NodeId) -> &'static [&'static str] {
1757        &["level", "lang", "form"]
1758    }
1759
1760    /// `::::level` on sections (the source heading level) and
1761    /// `::::lang` on verbatim blocks (the declared language).
1762    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
1763        let n = &self.nodes[node.0 as usize];
1764        // The lossless layer: on the document node, every
1765        // declaration under the name it was declared with.
1766        if n.kind == Kind::Document
1767            && let Some(v) = self.head.declared_value(key)
1768        {
1769            return Some(v);
1770        }
1771        match key {
1772            "level" => n.level.map(|l| Value::Int(l as i64)),
1773            "lang" => n.lang.clone().map(Value::Str),
1774            // `::::resolved` on a callout: the broken-apparatus
1775            // linter's fact (ruling #35).
1776            "resolved" if n.deixis => Some(Value::Bool(!n.dangling)),
1777            // …and on an internal ref: the broken-cross-reference
1778            // linter's fact. External refs resolve at query time,
1779            // so the fact is not theirs to answer.
1780            "resolved" if n.kind == Kind::Cit || (n.kind == Kind::Ref && n.internal) => {
1781                Some(Value::Bool(n.ref_edge.is_some()))
1782            }
1783            // The declared spelling was a margin form (a Tufte
1784            // sidenote): family footnote, placement preserved.
1785            "form" if n.margin => Some(Value::Str("margin".to_string())),
1786            // The entry's genus, also queryable as an annotation.
1787            "genus" if n.kind == Kind::Bib => n.genus.clone().map(Value::Str),
1788            _ => None,
1789        }
1790    }
1791}