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 { kind: Container, lemma: Option<String> },
74    /// Close the innermost open container, optionally with its
75    /// hypograph (footer or attribution).
76    Close { hypograph: Option<String> },
77    /// A verbatim block — code or other preformatted lines, kept
78    /// as authored.
79    Verbatim { lang: Option<String>, text: String },
80    /// An in-text note callout (the deixis, ruling #35): `onym`
81    /// names the body it cites within its family. Emitted after
82    /// the flow block it sits in; it becomes that block's child,
83    /// in order, and the block's own text stays clean of markers.
84    /// `family: None` means the source declares none (an HTML
85    /// noteref): the callout takes its resolved body's family,
86    /// and a dangling one defaults to footnote.
87    NoteRef {
88        onym: String,
89        family: Option<NoteFamily>,
90        /// The declared spelling placed this pair in the margin
91        /// (a Tufte-style sidenote): surfaced as `::::form =
92        /// "margin"` on both ends — the family stays footnote,
93        /// placement is presentation.
94        margin: bool,
95    },
96    /// An index mark (ruling #36): an invisible anchor declaring
97    /// this place concerns `term`. The back-of-book index is a
98    /// query over these, never a stored structure.
99    IndexMark { term: String },
100    /// A verse block (ruling #37 — litogramma's stichoi model):
101    /// strophes of lines, denormalized like [`Block::Table`].
102    /// Lines arrive flattened by the adapter and are kept as
103    /// authored; the stichos `::taxis` numbers lines 1-based,
104    /// CONTINUOUSLY across strophes — the citation coordinate.
105    Verse {
106        lemma: Option<String>,
107        strophes: Vec<Vec<String>>,
108        hypograph: Option<String>,
109    },
110    /// A table, denormalized here into nested lists (rows =
111    /// ordered items with the `<row>` trait, cells = unordered
112    /// items with the `<cell>` trait and the column name as
113    /// `::lemma`). Header *detection* is the adapter's job; the
114    /// lowering rule lives here. A cell's own `label` (a row's
115    /// `th`) wins over the positional `headers` entry.
116    Table {
117        lemma: Option<String>,
118        headers: Option<Vec<String>>,
119        rows: Vec<Vec<Cell>>,
120    },
121}
122
123/// One table cell as an adapter hands it over: the text, plus the
124/// label a row-shaped dialect attaches directly (an infobox row's
125/// `th`). Grid dialects leave `label` empty and let the lowering
126/// zip the header row on by position.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct Cell {
129    pub label: Option<String>,
130    pub text: String,
131}
132
133impl From<&str> for Cell {
134    fn from(text: &str) -> Self {
135        Cell {
136            label: None,
137            text: text.to_string(),
138        }
139    }
140}
141
142impl From<String> for Cell {
143    fn from(text: String) -> Self {
144        Cell { label: None, text }
145    }
146}
147
148/// The nesting containers an adapter opens and closes explicitly.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum Container {
151    Blockquote,
152    UnorderedList,
153    /// `start` is the first item's ordinal (Markdown's `3.` lists).
154    OrderedList { start: i64 },
155    /// A list item; flavor and taxis come from the enclosing list.
156    Item,
157    /// A note body (the noted, ruling #35): opens a note node of
158    /// its family at the document root — litogramma's canonical
159    /// document-end placement — holding its own blocks. `margin`
160    /// as on [`Block::NoteRef`].
161    Note {
162        onym: String,
163        family: NoteFamily,
164        margin: bool,
165    },
166}
167
168/// The deixis-attached families litogramma's canon names
169/// (ruling #35, as amended): each family's callout and body
170/// share its name. `Aside` rides the same construction —
171/// koine's `aside` sim, an anchored body whose insertion point
172/// is a deixis — but is content, not apparatus: its bodies do
173/// not carry `<note>`.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
175pub enum NoteFamily {
176    Footnote,
177    Endnote,
178    Aside,
179}
180
181impl NoteFamily {
182    fn kind(self) -> Kind {
183        match self {
184            NoteFamily::Footnote => Kind::Footnote,
185            NoteFamily::Endnote => Kind::Endnote,
186            NoteFamily::Aside => Kind::Aside,
187        }
188    }
189}
190
191/// The structural kind of a node — also its name.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
193enum Kind {
194    Document,
195    Section,
196    Paragraph,
197    Blockquote,
198    UnorderedList,
199    OrderedList,
200    UnorderedItem,
201    OrderedItem,
202    Verbatim,
203    /// Callout and body both — they share the name by
204    /// construction (litogramma F5): `//footnote` gathers the
205    /// whole apparatus, `<deixis>`/`<note>` tell them apart.
206    Footnote,
207    /// The second note family, same construction.
208    Endnote,
209    /// The anchored-content family, same construction
210    /// (litogramma's aside): body + insertion-point deixis. Content, not
211    /// apparatus — no `<note>` on its bodies.
212    Aside,
213    /// An index mark (ruling #36): `::term`, in flow position.
214    IndexMark,
215    /// The verse vocabulary (ruling #37): the stichoi container…
216    Verse,
217    /// …its strophes…
218    Strophe,
219    /// …and its lines, `::taxis` the citation coordinate.
220    Stichos,
221}
222
223impl Kind {
224    fn name(self) -> Option<&'static str> {
225        Some(match self {
226            Kind::Document => return None,
227            Kind::Section => "section",
228            Kind::Paragraph => "paragraph",
229            Kind::Blockquote => "blockquote",
230            Kind::Footnote => "footnote",
231            Kind::Endnote => "endnote",
232            Kind::Aside => "aside",
233            Kind::IndexMark => "index-mark",
234            Kind::Verse => "verse",
235            Kind::Strophe => "strophe",
236            Kind::Stichos => "stichos",
237            Kind::UnorderedList => "unordered-list",
238            Kind::OrderedList => "ordered-list",
239            Kind::UnorderedItem => "unordered-item",
240            Kind::OrderedItem => "ordered-item",
241            Kind::Verbatim => "verbatim",
242        })
243    }
244}
245
246struct Node {
247    kind: Kind,
248    lemma: Option<String>,
249    hypograph: Option<String>,
250    taxis: Option<i64>,
251    /// Source heading level, on sections.
252    level: Option<u8>,
253    /// Declared language, on verbatim blocks.
254    lang: Option<String>,
255    /// First ordinal of an ordered list (not exposed; feeds the
256    /// items' taxis).
257    start: i64,
258    /// The node's own (direct) text, before subtree flattening.
259    text: String,
260    /// The flattened prose of the subtree — the `::` projection.
261    prose: String,
262    /// The node heads a denormalized table (`<table>` trait).
263    table: bool,
264    /// The node is a denormalized table row (`<row>` trait).
265    row: bool,
266    /// The node is a denormalized table cell (`<cell>` trait).
267    cell: bool,
268    /// The apparatus (ruling #35): the note name as written —
269    /// and, on an index mark (ruling #36), the term.
270    onym: Option<String>,
271    /// A footnote callout (`<deixis>`) rather than a body.
272    deixis: bool,
273    /// A callout whose body is missing (`<dangling>`).
274    dangling: bool,
275    /// A callout whose source declared no family: resolution may
276    /// re-kind it from the body it reaches.
277    family_open: bool,
278    /// The declared spelling was a margin form (`::::form`).
279    margin: bool,
280    /// Callout -> body edge, once resolved.
281    note_edge: Option<NodeId>,
282    /// Body <- callouts (the reverse index).
283    cites: Vec<NodeId>,
284    parent: Option<NodeId>,
285    children: Vec<NodeId>,
286}
287
288impl Node {
289    fn new(kind: Kind, parent: Option<NodeId>) -> Self {
290        Node {
291            kind,
292            lemma: None,
293            hypograph: None,
294            taxis: None,
295            level: None,
296            lang: None,
297            start: 1,
298            text: String::new(),
299            prose: String::new(),
300            table: false,
301            row: false,
302            cell: false,
303            onym: None,
304            deixis: false,
305            dangling: false,
306            family_open: false,
307            margin: false,
308            note_edge: None,
309            cites: Vec::new(),
310            parent,
311            children: Vec::new(),
312        }
313    }
314}
315
316/// Collapse whitespace runs to single spaces and trim — the prose
317/// normalization adapters apply to inline content. Verbatim text
318/// is the exception: it is kept as authored.
319pub fn normalize_ws(s: &str) -> String {
320    s.split_whitespace().collect::<Vec<_>>().join(" ")
321}
322
323/// A Quarb adapter over a text-level document.
324pub struct TextModel {
325    nodes: Vec<Node>,
326    root: NodeId,
327}
328
329impl TextModel {
330    /// Assemble the document tree from a adapter's event stream.
331    ///
332    /// Iterative throughout (the stream is flat; prose flattening
333    /// runs over indices), so pathological nesting cannot overflow
334    /// the call stack. Lenient on malformed streams: a stray
335    /// `Close` is ignored, unclosed containers close at the end.
336    pub fn build(blocks: Vec<Block>) -> Self {
337        let mut nodes = vec![Node::new(Kind::Document, None)];
338        let root = NodeId(0);
339        // Innermost-last stack of open *sections* (outline-derived).
340        let mut sections: Vec<NodeId> = Vec::new();
341        // Innermost-last stack of open explicit containers.
342        let mut containers: Vec<NodeId> = Vec::new();
343        // The block a NoteRef callout attaches to: the last flow
344        // node created or appended to (ruling #35).
345        let mut last_flow: Option<NodeId> = None;
346
347        for block in blocks {
348            match block {
349                Block::Heading { level, lemma } => {
350                    let lemma = normalize_ws(&lemma);
351                    if !containers.is_empty() {
352                        // Decorative heading inside a container:
353                        // not sectioning — lower to a paragraph.
354                        if !lemma.is_empty() {
355                            let parent = *containers.last().unwrap();
356                            let id = push(&mut nodes, Kind::Paragraph, parent);
357                            nodes[id.0 as usize].text = lemma;
358                        }
359                        continue;
360                    }
361                    while let Some(&open) = sections.last() {
362                        if nodes[open.0 as usize].level >= Some(level) {
363                            sections.pop();
364                        } else {
365                            break;
366                        }
367                    }
368                    let parent = sections.last().copied().unwrap_or(root);
369                    let id = push(&mut nodes, Kind::Section, parent);
370                    let n = &mut nodes[id.0 as usize];
371                    n.lemma = Some(lemma);
372                    n.level = Some(level);
373                    sections.push(id);
374                    last_flow = Some(id);
375                }
376                Block::Paragraph { text } => {
377                    let text = normalize_ws(&text);
378                    if text.is_empty() {
379                        continue;
380                    }
381                    let parent = cursor(&sections, &containers, root);
382                    let id = push(&mut nodes, Kind::Paragraph, parent);
383                    nodes[id.0 as usize].text = text;
384                    last_flow = Some(id);
385                }
386                Block::Text { text } => {
387                    let text = normalize_ws(&text);
388                    if text.is_empty() {
389                        continue;
390                    }
391                    match containers.last() {
392                        Some(&open) => {
393                            let own = &mut nodes[open.0 as usize].text;
394                            if !own.is_empty() {
395                                own.push(' ');
396                            }
397                            own.push_str(&text);
398                            last_flow = Some(open);
399                        }
400                        None => {
401                            let parent = sections.last().copied().unwrap_or(root);
402                            let id = push(&mut nodes, Kind::Paragraph, parent);
403                            nodes[id.0 as usize].text = text;
404                            last_flow = Some(id);
405                        }
406                    }
407                }
408                Block::Open { kind, lemma } => {
409                    // A note body opens at the document root —
410                    // litogramma's canonical document-end
411                    // placement — whatever else is open.
412                    let kind = match kind {
413                        Container::Note { onym, family, margin } => {
414                            let id = push(&mut nodes, family.kind(), root);
415                            let n = &mut nodes[id.0 as usize];
416                            n.onym = Some(onym.trim().to_string()).filter(|o| !o.is_empty());
417                            n.margin = margin;
418                            containers.push(id);
419                            last_flow = Some(id);
420                            continue;
421                        }
422                        other => other,
423                    };
424                    let parent = cursor(&sections, &containers, root);
425                    let (nkind, start) = match kind {
426                        Container::Blockquote => (Kind::Blockquote, None),
427                        Container::UnorderedList => (Kind::UnorderedList, None),
428                        Container::OrderedList { start } => (Kind::OrderedList, Some(start)),
429                        Container::Item => (
430                            match nodes[parent.0 as usize].kind {
431                                Kind::OrderedList => Kind::OrderedItem,
432                                _ => Kind::UnorderedItem,
433                            },
434                            None,
435                        ),
436                        Container::Note { .. } => unreachable!("handled above"),
437                    };
438                    let id = push(&mut nodes, nkind, parent);
439                    nodes[id.0 as usize].lemma =
440                        lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
441                    if let Some(start) = start {
442                        nodes[id.0 as usize].start = start;
443                    }
444                    if nkind == Kind::OrderedItem {
445                        // `push` already appended this item, so the
446                        // count includes it.
447                        let nth = nodes[parent.0 as usize]
448                            .children
449                            .iter()
450                            .filter(|&&c| nodes[c.0 as usize].kind == Kind::OrderedItem)
451                            .count() as i64;
452                        let start = nodes[parent.0 as usize].start;
453                        nodes[id.0 as usize].taxis = Some(start + nth - 1);
454                    }
455                    containers.push(id);
456                }
457                Block::Close { hypograph } => {
458                    if let Some(open) = containers.pop() {
459                        nodes[open.0 as usize].hypograph =
460                            hypograph.map(|h| normalize_ws(&h)).filter(|h| !h.is_empty());
461                    }
462                }
463                // The callout: a `footnote` child of the flow
464                // block it sits in, `<deixis>`-traited; the edge
465                // to its body resolves after the stream.
466                Block::NoteRef { onym, family, margin } => {
467                    let parent = last_flow.unwrap_or(root);
468                    // A declared family names the callout now; an
469                    // undeclared one is settled at resolution from
470                    // the body it reaches (footnote when dangling).
471                    let kind = family.map(NoteFamily::kind).unwrap_or(Kind::Footnote);
472                    let id = push(&mut nodes, kind, parent);
473                    let n = &mut nodes[id.0 as usize];
474                    n.deixis = true;
475                    n.family_open = family.is_none();
476                    n.margin = margin;
477                    n.onym = Some(onym.trim().to_string()).filter(|o| !o.is_empty());
478                }
479                Block::IndexMark { term } => {
480                    let parent = last_flow.unwrap_or(root);
481                    let id = push(&mut nodes, Kind::IndexMark, parent);
482                    nodes[id.0 as usize].onym =
483                        Some(quarb_term(&term)).filter(|t| !t.is_empty());
484                }
485                Block::Verbatim { lang, text } => {
486                    let parent = cursor(&sections, &containers, root);
487                    let id = push(&mut nodes, Kind::Verbatim, parent);
488                    let n = &mut nodes[id.0 as usize];
489                    n.lang = lang.filter(|l| !l.is_empty());
490                    n.text = text;
491                }
492                Block::Table {
493                    lemma,
494                    headers,
495                    rows,
496                } => {
497                    let parent = cursor(&sections, &containers, root);
498                    lower_table(&mut nodes, parent, lemma, headers, rows);
499                }
500                Block::Verse {
501                    lemma,
502                    strophes,
503                    hypograph,
504                } => {
505                    let parent = cursor(&sections, &containers, root);
506                    let id = lower_verse(&mut nodes, parent, lemma, strophes, hypograph);
507                    last_flow = Some(id);
508                }
509            }
510        }
511
512        resolve_notes(&mut nodes);
513        flatten_prose(&mut nodes);
514        TextModel { nodes, root }
515    }
516
517    /// Read plain text: blank-line-separated paragraphs, each
518    /// collapsed to one line — the atramento paragraph rule. No
519    /// headings, no markup.
520    pub fn parse_plain(text: &str) -> Self {
521        let mut blocks = Vec::new();
522        let mut para: Vec<&str> = Vec::new();
523        for line in text.lines() {
524            if line.trim().is_empty() {
525                if !para.is_empty() {
526                    blocks.push(Block::Paragraph {
527                        text: para.join(" "),
528                    });
529                    para.clear();
530                }
531            } else {
532                para.push(line);
533            }
534        }
535        if !para.is_empty() {
536            blocks.push(Block::Paragraph {
537                text: para.join(" "),
538            });
539        }
540        Self::build(blocks)
541    }
542
543    /// A locator path to `node`, like `/section[2]/paragraph[3]`,
544    /// for rendering. A `[n]` index is added only to disambiguate
545    /// same-name siblings.
546    pub fn locator(&self, node: NodeId) -> String {
547        let mut segments = Vec::new();
548        let mut cur = Some(node);
549        while let Some(id) = cur {
550            let n = &self.nodes[id.0 as usize];
551            if let Some(name) = n.kind.name() {
552                segments.push(self.segment(id, name));
553            }
554            cur = n.parent;
555        }
556        segments.reverse();
557        format!("/{}", segments.join("/"))
558    }
559
560    fn segment(&self, node: NodeId, name: &str) -> String {
561        let Some(parent) = self.nodes[node.0 as usize].parent else {
562            return name.to_string();
563        };
564        let siblings = &self.nodes[parent.0 as usize].children;
565        let same_name: Vec<NodeId> = siblings
566            .iter()
567            .copied()
568            .filter(|&s| self.nodes[s.0 as usize].kind == self.nodes[node.0 as usize].kind)
569            .collect();
570        if same_name.len() > 1 {
571            let n = same_name.iter().position(|&s| s == node).unwrap() + 1;
572            format!("{name}[{n}]")
573        } else {
574            name.to_string()
575        }
576    }
577}
578
579/// Where the next block lands: the innermost open container, else
580/// the innermost open section, else the root.
581/// Ruling #35: wire each callout to its body by (family, onym) —
582/// first body wins on a duplicate — build the reverse index, and
583/// mark the dangling: a callout with no body keeps its node,
584/// carries `<dangling>` and `::::resolved = false`, and emits no
585/// edge.
586fn resolve_notes(nodes: &mut [Node]) {
587    let mut bodies: std::collections::HashMap<(Kind, String), NodeId> = Default::default();
588    for (i, n) in nodes.iter().enumerate() {
589        if matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside)
590            && !n.deixis
591            && let Some(o) = &n.onym
592        {
593            bodies.entry((n.kind, o.clone())).or_insert(NodeId(i as u64));
594        }
595    }
596    for i in 0..nodes.len() {
597        if !matches!(nodes[i].kind, Kind::Footnote | Kind::Endnote | Kind::Aside)
598            || !nodes[i].deixis
599        {
600            continue;
601        }
602        let Some(onym) = nodes[i].onym.clone() else {
603            nodes[i].dangling = true;
604            continue;
605        };
606        let hit = if nodes[i].family_open {
607            // No declared family (an HTML noteref): the body's
608            // family is the callout's; footnote when dangling.
609            [Kind::Footnote, Kind::Endnote, Kind::Aside]
610                .into_iter()
611                .find_map(|k| bodies.get(&(k, onym.clone())).map(|b| (k, *b)))
612        } else {
613            bodies.get(&(nodes[i].kind, onym)).map(|b| (nodes[i].kind, *b))
614        };
615        match hit {
616            Some((family, body)) => {
617                nodes[i].kind = family;
618                nodes[i].note_edge = Some(body);
619                let callout = NodeId(i as u64);
620                nodes[body.0 as usize].cites.push(callout);
621            }
622            None => nodes[i].dangling = true,
623        }
624    }
625}
626
627/// An index term as written, with the `|...` formatting and range
628/// directives stripped (v1 of ruling #36; ranges and
629/// see-references are recorded follow-ups).
630fn quarb_term(term: &str) -> String {
631    normalize_ws(term.split('|').next().unwrap_or(term))
632}
633
634fn cursor(sections: &[NodeId], containers: &[NodeId], root: NodeId) -> NodeId {
635    containers
636        .last()
637        .or(sections.last())
638        .copied()
639        .unwrap_or(root)
640}
641
642fn push(nodes: &mut Vec<Node>, kind: Kind, parent: NodeId) -> NodeId {
643    let id = NodeId(nodes.len() as u64);
644    nodes.push(Node::new(kind, Some(parent)));
645    nodes[parent.0 as usize].children.push(id);
646    id
647}
648
649/// Denormalize a table into nested lists (see the module doc).
650/// The column name lands as the cell's `::lemma` — a cell's own
651/// label (a row's `th`) wins over the positional header entry —
652/// and never as folded text: addressing is property projection,
653/// the flattening rule alone spells `lemma: value`.
654fn lower_table(
655    nodes: &mut Vec<Node>,
656    parent: NodeId,
657    lemma: Option<String>,
658    headers: Option<Vec<String>>,
659    rows: Vec<Vec<Cell>>,
660) {
661    let list = push(nodes, Kind::OrderedList, parent);
662    {
663        let n = &mut nodes[list.0 as usize];
664        n.table = true;
665        n.lemma = lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
666    }
667    for (i, row) in rows.into_iter().enumerate() {
668        let item = push(nodes, Kind::OrderedItem, list);
669        nodes[item.0 as usize].taxis = Some(i as i64 + 1);
670        nodes[item.0 as usize].row = true;
671        let cells = push(nodes, Kind::UnorderedList, item);
672        for (j, cell) in row.into_iter().enumerate() {
673            let value = normalize_ws(&cell.text);
674            if value.is_empty() {
675                continue;
676            }
677            let label = cell
678                .label
679                .as_deref()
680                .or_else(|| headers.as_ref().and_then(|h| h.get(j)).map(|h| h.as_str()))
681                .map(normalize_ws)
682                .filter(|h| !h.is_empty());
683            let cell_item = push(nodes, Kind::UnorderedItem, cells);
684            let n = &mut nodes[cell_item.0 as usize];
685            n.cell = true;
686            n.lemma = label;
687            n.text = value;
688        }
689    }
690}
691
692/// Lower a verse block (ruling #37): `verse` → `strophe`s →
693/// `stichos` lines. The stichos taxis numbers lines 1-based,
694/// continuously across strophes — Iliad 1.34 is a taxis; the
695/// strophe taxis is its ordinal. Empty lines are dropped (the
696/// strophe boundary carries the separation).
697fn lower_verse(
698    nodes: &mut Vec<Node>,
699    parent: NodeId,
700    lemma: Option<String>,
701    strophes: Vec<Vec<String>>,
702    hypograph: Option<String>,
703) -> NodeId {
704    let verse = push(nodes, Kind::Verse, parent);
705    {
706        let n = &mut nodes[verse.0 as usize];
707        n.lemma = lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
708        n.hypograph = hypograph.map(|h| normalize_ws(&h)).filter(|h| !h.is_empty());
709    }
710    let mut line_no = 0i64;
711    for (i, strophe) in strophes.into_iter().enumerate() {
712        let sid = push(nodes, Kind::Strophe, verse);
713        nodes[sid.0 as usize].taxis = Some(i as i64 + 1);
714        for line in strophe {
715            let line = line.trim_end().to_string();
716            if line.trim().is_empty() {
717                continue;
718            }
719            line_no += 1;
720            let lid = push(nodes, Kind::Stichos, sid);
721            let n = &mut nodes[lid.0 as usize];
722            n.taxis = Some(line_no);
723            n.text = line;
724        }
725    }
726    verse
727}
728
729/// Compute every node's flattened prose: lemma first, then the
730/// node's own text, then its children's prose in order, then the
731/// hypograph, block-joined with newlines. On a list *item*, the
732/// lemma joins the rest with `: ` instead — an item's lemma names
733/// its content inline (a table cell reads `Outcome: Emus won`, a
734/// definition reads `term: description`), where a section's lemma
735/// opens its block. Children always carry larger indices than
736/// their parents (nodes are interned in document order), so one
737/// reverse index scan suffices — no recursion.
738fn flatten_prose(nodes: &mut [Node]) {
739    for i in (0..nodes.len()).rev() {
740        let inline_lemma = matches!(
741            nodes[i].kind,
742            Kind::UnorderedItem | Kind::OrderedItem
743        );
744        let mut lemma_part: Option<String> = None;
745        let mut parts: Vec<String> = Vec::new();
746        if let Some(lemma) = &nodes[i].lemma
747            && !lemma.is_empty()
748        {
749            if inline_lemma {
750                lemma_part = Some(lemma.clone());
751            } else {
752                parts.push(lemma.clone());
753            }
754        }
755        if !nodes[i].text.is_empty() {
756            parts.push(nodes[i].text.clone());
757        }
758        let mut child_parts: Vec<String> = Vec::new();
759        for &child in nodes[i].children.clone().iter() {
760            let prose = &nodes[child.0 as usize].prose;
761            if !prose.is_empty() {
762                child_parts.push(prose.clone());
763            }
764        }
765        // Ruling #37: a verse block separates its strophes by a
766        // blank line (lemma and hypograph still join normally) —
767        // byte-compatible with the old verbatim lowering.
768        if nodes[i].kind == Kind::Verse {
769            let joined = child_parts.join("\n\n");
770            if !joined.is_empty() {
771                parts.push(joined);
772            }
773        } else {
774            parts.extend(child_parts);
775        }
776        if let Some(hypograph) = &nodes[i].hypograph
777            && !hypograph.is_empty()
778        {
779            parts.push(hypograph.clone());
780        }
781        let mut prose = parts.join("\n");
782        if let Some(lemma) = lemma_part {
783            prose = if prose.is_empty() {
784                lemma
785            } else {
786                format!("{lemma}: {prose}")
787            };
788        }
789        nodes[i].prose = prose;
790    }
791}
792
793impl TextModel {
794    /// The body prose: everything between the lemma and the
795    /// hypograph — the simmere anatomy's third member, derived
796    /// from the flattened prose by construction (the lemma joins
797    /// a block on its own line, an item with `: `; the hypograph
798    /// closes on its own line).
799    fn grammata(&self, node: NodeId) -> String {
800        let n = &self.nodes[node.0 as usize];
801        let mut s = n.prose.as_str();
802        if let Some(lemma) = &n.lemma
803            && !lemma.is_empty()
804            && let Some(rest) = s.strip_prefix(lemma.as_str())
805        {
806            s = rest
807                .strip_prefix(": ")
808                .or_else(|| rest.strip_prefix('\n'))
809                .unwrap_or(rest);
810        }
811        if let Some(h) = &n.hypograph
812            && !h.is_empty()
813            && let Some(rest) = s.strip_suffix(h.as_str())
814        {
815            s = rest.strip_suffix('\n').unwrap_or(rest);
816        }
817        s.trim_end().to_string()
818    }
819}
820
821impl AstAdapter for TextModel {
822    fn root(&self) -> NodeId {
823        self.root
824    }
825
826    fn children(&self, node: NodeId) -> Vec<NodeId> {
827        self.nodes[node.0 as usize].children.clone()
828    }
829
830    fn name(&self, node: NodeId) -> Option<String> {
831        self.nodes[node.0 as usize].kind.name().map(str::to_string)
832    }
833
834    fn parent(&self, node: NodeId) -> Option<NodeId> {
835        self.nodes[node.0 as usize].parent
836    }
837
838    /// The `<block>` family on every block node, plus `<table>` on
839    /// a list that denormalizes a table. Kinds are node names, not
840    /// traits.
841    fn traits(&self, node: NodeId) -> Vec<String> {
842        let n = &self.nodes[node.0 as usize];
843        let mut out = Vec::new();
844        // A callout is inline apparatus, not a block (ruling #35);
845        // so is an index mark (ruling #36). Strophes and stichos
846        // lines are sub-block structure (ruling #37, litogramma's
847        // own family rule) — the verse block carries the trait.
848        if n.kind != Kind::Document
849            && !n.deixis
850            && !matches!(n.kind, Kind::IndexMark | Kind::Strophe | Kind::Stichos)
851        {
852            out.push("block".to_string());
853        }
854        if matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside) {
855            // The body IS the note — `<note>` marks it, whichever
856            // note family; the callout carries `<deixis>` alone.
857            // An aside body is content, not apparatus: no
858            // `<note>`. No both-ends trait: `//footnote` already
859            // gathers a family whole, `//*<deixis>` the callouts
860            // across families, `//*<note>` the note bodies.
861            if n.deixis {
862                out.push("deixis".to_string());
863            } else if n.kind != Kind::Aside {
864                out.push("note".to_string());
865            }
866            if n.dangling {
867                out.push("dangling".to_string());
868            }
869        }
870        if n.table {
871            out.push("table".to_string());
872        }
873        if n.row {
874            out.push("row".to_string());
875        }
876        if n.cell {
877            out.push("cell".to_string());
878        }
879        out
880    }
881
882    /// `::lemma` (title), `::hypograph` (footer or attribution),
883    /// `::taxis` (ordinal), `::text` (the flattened prose, same as
884    /// the bare projection).
885    /// The Greek anatomy — `::lemma`, `::grammata`, `::hypograph`,
886    /// `::taxis` — plus the friendly aliases (`::title`, `::body`,
887    /// `::attribution`, `::ord`), answered here because this
888    /// adapter's property surface IS the vocabulary; on data
889    /// adapters those spellings stay ordinary field names. The
890    /// Greek is canon in docs and reflection preserves whichever
891    /// spelling was written.
892    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
893        let n = &self.nodes[node.0 as usize];
894        match name {
895            "lemma" | "title" => n.lemma.clone().map(Value::Str),
896            "onym" => n.onym.clone().map(Value::Str),
897            // On an index mark the onym IS the term (ruling #36).
898            "term" if n.kind == Kind::IndexMark => n.onym.clone().map(Value::Str),
899            "hypograph" | "attribution" => n.hypograph.clone().map(Value::Str),
900            "taxis" | "ord" => n.taxis.map(Value::Int),
901            "grammata" | "body" => {
902                let g = self.grammata(node);
903                if g.is_empty() { None } else { Some(Value::Str(g)) }
904            }
905            "text" => Some(Value::Str(n.prose.clone())),
906            _ => None,
907        }
908    }
909
910    /// The default projection is the flattened prose of the
911    /// subtree — lemma first, hypograph last.
912    fn default_value(&self, node: NodeId) -> Option<Value> {
913        let n = &self.nodes[node.0 as usize];
914        if n.kind == Kind::IndexMark {
915            // Invisible in the surrounding prose; its own
916            // projection is the term it declares.
917            return Some(Value::Str(n.onym.clone().unwrap_or_default()));
918        }
919        if n.deixis {
920            // The atrep rule: a resolved reference projects as its
921            // target's rendered form, degrading to the raw onym
922            // when dangling.
923            return Some(Value::Str(match n.note_edge {
924                Some(body) => self.nodes[body.0 as usize].prose.clone(),
925                None => n.onym.clone().unwrap_or_default(),
926            }));
927        }
928        Some(Value::Str(n.prose.clone()))
929    }
930
931    /// The edge label is the family name both ends share —
932    /// `->footnote` / `->endnote` — the node's own kind, since
933    /// resolution re-kinds an open-family callout to its body.
934    fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
935        let n = &self.nodes[node.0 as usize];
936        match n.note_edge {
937            Some(body) => vec![(n.kind.name().unwrap_or("footnote").to_string(), body)],
938            None => Vec::new(),
939        }
940    }
941
942    fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
943        let n = &self.nodes[node.0 as usize];
944        let label = n.kind.name().unwrap_or("footnote");
945        n.cites.iter().map(|&c| (label.to_string(), c)).collect()
946    }
947
948    /// Ruling #29: the text level's surface is the vocabulary
949    /// itself — no document can introduce a property name — so
950    /// its two annotations answer at `::` as well.
951    fn aliased_metadata(&self, _node: NodeId) -> &'static [&'static str] {
952        &["level", "lang", "form"]
953    }
954
955    /// `::::level` on sections (the source heading level) and
956    /// `::::lang` on verbatim blocks (the declared language).
957    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
958        let n = &self.nodes[node.0 as usize];
959        match key {
960            "level" => n.level.map(|l| Value::Int(l as i64)),
961            "lang" => n.lang.clone().map(Value::Str),
962            // `::::resolved` on a callout: the broken-apparatus
963            // linter's fact (ruling #35).
964            "resolved" if n.deixis => Some(Value::Bool(!n.dangling)),
965            // The declared spelling was a margin form (a Tufte
966            // sidenote): family footnote, placement preserved.
967            "form" if n.margin => Some(Value::Str("margin".to_string())),
968            _ => None,
969        }
970    }
971}