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