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(§ions, &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(§ions, &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(§ions, &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(§ions, &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(§ions, &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}