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 && !n.deixis && let Some(o) = &n.onym {
902 return Some((o.clone(), exact));
903 }
904 exact = false;
905 cur = n.parent;
906 }
907 None
908 }
909
910 /// A link into `node`, as far as the served form can narrow
911 /// it. The base is the document's URL (its canonical link,
912 /// else the mount's path). An HTML page — a base ending in
913 /// `.html` / `.htm` / `/` or without an extension — narrows
914 /// with the nearest anchor and, for a block below the anchored
915 /// one, a text fragment (`#:~:text=start,end`) built from the
916 /// node's own prose. A PDF narrows to nothing yet; any other
917 /// form links the document alone, since no browser takes a
918 /// fragment into it. The narrowing stays available as data
919 /// beside the link (`::anchor`, the prose) for a client that
920 /// renders the document itself.
921 fn href(&self, node: NodeId) -> Option<String> {
922 let base = self.href_base()?;
923 let n = &self.nodes[node.0 as usize];
924 if n.kind == Kind::Document {
925 return Some(base);
926 }
927 let served = base.split(['?', '#']).next().unwrap_or(&base);
928 let last = served.rsplit('/').next().unwrap_or(served);
929 let html = served.ends_with('/')
930 || !last.contains('.')
931 || last.ends_with(".html")
932 || last.ends_with(".htm");
933 if !html {
934 return Some(base);
935 }
936 let mut out = base;
937 let (anchor, exact) = match self.nearest_onym(node) {
938 Some((a, exact)) => (Some(a), exact),
939 None => (None, false),
940 };
941 if let Some(a) = &anchor {
942 out.push('#');
943 out.push_str(a);
944 }
945 if !exact {
946 let words: Vec<&str> = n.prose.split_whitespace().collect();
947 if !words.is_empty() {
948 use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
949 let enc = |ws: &[&str]| utf8_percent_encode(&ws.join(" "), NON_ALPHANUMERIC).to_string();
950 if anchor.is_none() {
951 out.push('#');
952 }
953 out.push_str(":~:text=");
954 if words.len() <= 12 {
955 out.push_str(&enc(&words));
956 } else {
957 out.push_str(&enc(&words[..6]));
958 out.push(',');
959 out.push_str(&enc(&words[words.len() - 6..]));
960 }
961 }
962 }
963 Some(out)
964 }
965
966 /// The document's title for a link: what its head declares,
967 /// else its first top-level section's lemma.
968 fn document_title(&self) -> Option<String> {
969 if let Some(t) = &self.head.title {
970 return Some(t.clone());
971 }
972 self.nodes[self.root.0 as usize]
973 .children
974 .iter()
975 .map(|c| &self.nodes[c.0 as usize])
976 .find(|n| n.kind == Kind::Section)
977 .and_then(|n| n.lemma.clone())
978 }
979
980 /// Attach what the document declares about itself; the format
981 /// adapter reads it from the source's own metadata vocabulary.
982 pub fn set_head_meta(&mut self, head: HeadMeta) {
983 self.head = head;
984 }
985
986 /// What the document declares about itself.
987 pub fn head_meta(&self) -> &HeadMeta {
988 &self.head
989 }
990
991 /// The document's text as one string: every section's lemma
992 /// and every block's prose, one per line, in document order —
993 /// the lowering a substrate stores beside the document so a
994 /// substring predicate inside the graft can be prefiltered on
995 /// it. The invariant a prefilter relies on: every grafted
996 /// node's prose (and every lemma) is a substring of this.
997 pub fn plain_text(&self) -> String {
998 let mut out = String::new();
999 let mut stack = vec![self.root];
1000 while let Some(id) = stack.pop() {
1001 let n = &self.nodes[id.0 as usize];
1002 if let Some(l) = &n.lemma
1003 && !l.is_empty()
1004 {
1005 out.push_str(l);
1006 out.push('\n');
1007 }
1008 // Every node's own prose, leaf or not: a paragraph that
1009 // holds a ref or an anchor still has prose of its own,
1010 // and the invariant covers it too.
1011 if !n.prose.is_empty() && n.kind != Kind::Document {
1012 out.push_str(&n.prose);
1013 out.push('\n');
1014 }
1015 for c in n.children.iter().rev() {
1016 stack.push(*c);
1017 }
1018 }
1019 out
1020 }
1021
1022 /// How many nodes the document holds (the root included) — the
1023 /// bound a host needs to pack this document's node ids into a
1024 /// wider id space.
1025 pub fn node_count(&self) -> usize {
1026 self.nodes.len()
1027 }
1028
1029 /// Read plain text: blank-line-separated paragraphs, each
1030 /// collapsed to one line — the atramento paragraph rule. No
1031 /// headings, no markup.
1032 pub fn parse_plain(text: &str) -> Self {
1033 let mut blocks = Vec::new();
1034 let mut para: Vec<&str> = Vec::new();
1035 for line in text.lines() {
1036 if line.trim().is_empty() {
1037 if !para.is_empty() {
1038 blocks.push(Block::Paragraph {
1039 text: para.join(" "),
1040 });
1041 para.clear();
1042 }
1043 } else {
1044 para.push(line);
1045 }
1046 }
1047 if !para.is_empty() {
1048 blocks.push(Block::Paragraph {
1049 text: para.join(" "),
1050 });
1051 }
1052 Self::build(blocks)
1053 }
1054
1055 /// A locator path to `node`, like `/section[2]/paragraph[3]`,
1056 /// for rendering. A `[n]` index is added only to disambiguate
1057 /// same-name siblings.
1058 pub fn locator(&self, node: NodeId) -> String {
1059 let mut segments = Vec::new();
1060 let mut cur = Some(node);
1061 while let Some(id) = cur {
1062 let n = &self.nodes[id.0 as usize];
1063 if let Some(name) = n.kind.name() {
1064 segments.push(self.segment(id, name));
1065 }
1066 cur = n.parent;
1067 }
1068 segments.reverse();
1069 format!("/{}", segments.join("/"))
1070 }
1071
1072 fn segment(&self, node: NodeId, name: &str) -> String {
1073 let Some(parent) = self.nodes[node.0 as usize].parent else {
1074 return name.to_string();
1075 };
1076 let siblings = &self.nodes[parent.0 as usize].children;
1077 let same_name: Vec<NodeId> = siblings
1078 .iter()
1079 .copied()
1080 .filter(|&s| self.nodes[s.0 as usize].kind == self.nodes[node.0 as usize].kind)
1081 .collect();
1082 if same_name.len() > 1 {
1083 let n = same_name.iter().position(|&s| s == node).unwrap() + 1;
1084 format!("{name}[{n}]")
1085 } else {
1086 name.to_string()
1087 }
1088 }
1089}
1090
1091/// Where the next block lands: the innermost open container, else
1092/// the innermost open section, else the root.
1093/// Ruling #35: wire each callout to its body by (family, onym) —
1094/// first body wins on a duplicate — build the reverse index, and
1095/// mark the dangling: a callout with no body keeps its node,
1096/// carries `<dangling>` and `::::resolved = false`, and emits no
1097/// edge.
1098fn resolve_notes(nodes: &mut [Node]) {
1099 let mut bodies: std::collections::HashMap<(Kind, String), NodeId> = Default::default();
1100 for (i, n) in nodes.iter().enumerate() {
1101 if matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside)
1102 && !n.deixis
1103 && let Some(o) = &n.onym
1104 {
1105 bodies
1106 .entry((n.kind, o.clone()))
1107 .or_insert(NodeId(i as u64));
1108 }
1109 }
1110 for i in 0..nodes.len() {
1111 if !matches!(nodes[i].kind, Kind::Footnote | Kind::Endnote | Kind::Aside)
1112 || !nodes[i].deixis
1113 {
1114 continue;
1115 }
1116 let Some(onym) = nodes[i].onym.clone() else {
1117 nodes[i].dangling = true;
1118 continue;
1119 };
1120 let hit = if nodes[i].family_open {
1121 // No declared family (an HTML noteref): the body's
1122 // family is the callout's; footnote when dangling.
1123 [Kind::Footnote, Kind::Endnote, Kind::Aside]
1124 .into_iter()
1125 .find_map(|k| bodies.get(&(k, onym.clone())).map(|b| (k, *b)))
1126 } else {
1127 bodies
1128 .get(&(nodes[i].kind, onym))
1129 .map(|b| (nodes[i].kind, *b))
1130 };
1131 match hit {
1132 Some((family, body)) => {
1133 nodes[i].kind = family;
1134 nodes[i].note_edge = Some(body);
1135 let callout = NodeId(i as u64);
1136 nodes[body.0 as usize].cites.push(callout);
1137 }
1138 None => nodes[i].dangling = true,
1139 }
1140 }
1141}
1142
1143/// An index term as written, with the `|...` formatting and range
1144/// directives stripped (v1 of ruling #36; ranges and
1145/// see-references are recorded follow-ups).
1146fn quarb_term(term: &str) -> String {
1147 normalize_ws(term.split('|').next().unwrap_or(term))
1148}
1149
1150fn cursor(sections: &[NodeId], containers: &[NodeId], root: NodeId) -> NodeId {
1151 containers
1152 .last()
1153 .or(sections.last())
1154 .copied()
1155 .unwrap_or(root)
1156}
1157
1158fn push(nodes: &mut Vec<Node>, kind: Kind, parent: NodeId) -> NodeId {
1159 let id = NodeId(nodes.len() as u64);
1160 nodes.push(Node::new(kind, Some(parent)));
1161 nodes[parent.0 as usize].children.push(id);
1162 id
1163}
1164
1165/// Denormalize a table into nested lists (see the module doc).
1166/// The column name lands as the cell's `::lemma` — a cell's own
1167/// label (a row's `th`) wins over the positional header entry —
1168/// and never as folded text: addressing is property projection,
1169/// the flattening rule alone spells `lemma: value`.
1170fn lower_table(
1171 nodes: &mut Vec<Node>,
1172 parent: NodeId,
1173 lemma: Option<String>,
1174 headers: Option<Vec<String>>,
1175 rows: Vec<Vec<Cell>>,
1176) {
1177 let list = push(nodes, Kind::OrderedList, parent);
1178 {
1179 let n = &mut nodes[list.0 as usize];
1180 n.table = true;
1181 n.lemma = lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
1182 }
1183 for (i, row) in rows.into_iter().enumerate() {
1184 let item = push(nodes, Kind::OrderedItem, list);
1185 nodes[item.0 as usize].taxis = Some(i as i64 + 1);
1186 nodes[item.0 as usize].row = true;
1187 let cells = push(nodes, Kind::UnorderedList, item);
1188 for (j, cell) in row.into_iter().enumerate() {
1189 let value = normalize_ws(&cell.text);
1190 if value.is_empty() {
1191 continue;
1192 }
1193 let label = cell
1194 .label
1195 .as_deref()
1196 .or_else(|| headers.as_ref().and_then(|h| h.get(j)).map(|h| h.as_str()))
1197 .map(normalize_ws)
1198 .filter(|h| !h.is_empty());
1199 let cell_item = push(nodes, Kind::UnorderedItem, cells);
1200 let n = &mut nodes[cell_item.0 as usize];
1201 n.cell = true;
1202 n.lemma = label;
1203 n.text = value;
1204 }
1205 }
1206}
1207
1208/// Lower a verse block (ruling #37): `verse` → `strophe`s →
1209/// `stichos` lines. The stichos taxis numbers lines 1-based,
1210/// continuously across strophes — Iliad 1.34 is a taxis; the
1211/// strophe taxis is its ordinal. Empty lines are dropped (the
1212/// strophe boundary carries the separation).
1213fn lower_verse(
1214 nodes: &mut Vec<Node>,
1215 parent: NodeId,
1216 lemma: Option<String>,
1217 strophes: Vec<Vec<String>>,
1218 hypograph: Option<String>,
1219) -> NodeId {
1220 let verse = push(nodes, Kind::Verse, parent);
1221 {
1222 let n = &mut nodes[verse.0 as usize];
1223 n.lemma = lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
1224 n.hypograph = hypograph
1225 .map(|h| normalize_ws(&h))
1226 .filter(|h| !h.is_empty());
1227 }
1228 let mut line_no = 0i64;
1229 for (i, strophe) in strophes.into_iter().enumerate() {
1230 let sid = push(nodes, Kind::Strophe, verse);
1231 nodes[sid.0 as usize].taxis = Some(i as i64 + 1);
1232 for line in strophe {
1233 let line = line.trim_end().to_string();
1234 if line.trim().is_empty() {
1235 continue;
1236 }
1237 line_no += 1;
1238 let lid = push(nodes, Kind::Stichos, sid);
1239 let n = &mut nodes[lid.0 as usize];
1240 n.taxis = Some(line_no);
1241 n.text = line;
1242 }
1243 }
1244 verse
1245}
1246
1247/// Compute every node's flattened prose: lemma first, then the
1248/// node's own text, then its children's prose in order, then the
1249/// hypograph, block-joined with newlines. On a list *item*, the
1250/// lemma joins the rest with `: ` instead — an item's lemma names
1251/// its content inline (a table cell reads `Outcome: Emus won`, a
1252/// definition reads `term: description`), where a section's lemma
1253/// opens its block. Children always carry larger indices than
1254/// their parents (nodes are interned in document order), so one
1255/// reverse index scan suffices — no recursion.
1256fn flatten_prose(nodes: &mut [Node]) {
1257 for i in (0..nodes.len()).rev() {
1258 let inline_lemma = matches!(nodes[i].kind, Kind::UnorderedItem | Kind::OrderedItem);
1259 let mut lemma_part: Option<String> = None;
1260 let mut parts: Vec<String> = Vec::new();
1261 if let Some(lemma) = &nodes[i].lemma
1262 && !lemma.is_empty()
1263 {
1264 if inline_lemma {
1265 lemma_part = Some(lemma.clone());
1266 } else {
1267 parts.push(lemma.clone());
1268 }
1269 }
1270 if !nodes[i].text.is_empty() {
1271 parts.push(nodes[i].text.clone());
1272 }
1273 let mut child_parts: Vec<String> = Vec::new();
1274 for &child in nodes[i].children.clone().iter() {
1275 // A ref's recorded link text is already part of the
1276 // flow it was met in; an anchor is invisible. Neither
1277 // adds prose (the deixis/index-mark rule).
1278 if matches!(
1279 nodes[child.0 as usize].kind,
1280 Kind::Ref | Kind::Cit | Kind::Anchor
1281 ) {
1282 continue;
1283 }
1284 let prose = &nodes[child.0 as usize].prose;
1285 if !prose.is_empty() {
1286 child_parts.push(prose.clone());
1287 }
1288 }
1289 // Ruling #37: a verse block separates its strophes by a
1290 // blank line (lemma and hypograph still join normally) —
1291 // byte-compatible with the old verbatim lowering.
1292 if nodes[i].kind == Kind::Verse {
1293 let joined = child_parts.join("\n\n");
1294 if !joined.is_empty() {
1295 parts.push(joined);
1296 }
1297 } else {
1298 parts.extend(child_parts);
1299 }
1300 if let Some(hypograph) = &nodes[i].hypograph
1301 && !hypograph.is_empty()
1302 {
1303 parts.push(hypograph.clone());
1304 }
1305 let mut prose = parts.join("\n");
1306 if let Some(lemma) = lemma_part {
1307 prose = if prose.is_empty() {
1308 lemma
1309 } else {
1310 format!("{lemma}: {prose}")
1311 };
1312 }
1313 nodes[i].prose = prose;
1314 }
1315}
1316
1317impl TextModel {
1318 /// The body prose: everything between the lemma and the
1319 /// hypograph — the simmere anatomy's third member, derived
1320 /// from the flattened prose by construction (the lemma joins
1321 /// a block on its own line, an item with `: `; the hypograph
1322 /// closes on its own line).
1323 fn grammata(&self, node: NodeId) -> String {
1324 let n = &self.nodes[node.0 as usize];
1325 let mut s = n.prose.as_str();
1326 if let Some(lemma) = &n.lemma
1327 && !lemma.is_empty()
1328 && let Some(rest) = s.strip_prefix(lemma.as_str())
1329 {
1330 s = rest
1331 .strip_prefix(": ")
1332 .or_else(|| rest.strip_prefix('\n'))
1333 .unwrap_or(rest);
1334 }
1335 if let Some(h) = &n.hypograph
1336 && !h.is_empty()
1337 && let Some(rest) = s.strip_suffix(h.as_str())
1338 {
1339 s = rest.strip_suffix('\n').unwrap_or(rest);
1340 }
1341 s.trim_end().to_string()
1342 }
1343}
1344
1345impl AstAdapter for TextModel {
1346 fn root(&self) -> NodeId {
1347 self.root
1348 }
1349
1350 fn children(&self, node: NodeId) -> Vec<NodeId> {
1351 self.nodes[node.0 as usize].children.clone()
1352 }
1353
1354 fn name(&self, node: NodeId) -> Option<String> {
1355 self.nodes[node.0 as usize].kind.name().map(str::to_string)
1356 }
1357
1358 fn parent(&self, node: NodeId) -> Option<NodeId> {
1359 self.nodes[node.0 as usize].parent
1360 }
1361
1362 /// The `<block>` family on every block node, plus `<table>` on
1363 /// a list that denormalizes a table. Kinds are node names, not
1364 /// traits.
1365 fn traits(&self, node: NodeId) -> Vec<String> {
1366 let n = &self.nodes[node.0 as usize];
1367 let mut out = Vec::new();
1368 // The document wears its declared classification: one
1369 // trait per tag and one for the category — the LDAP rule,
1370 // where a multi-valued classifying attribute is the node's
1371 // traits — each in its own namespace (`<tag:pandas>`,
1372 // `<category:Guides>`), so a tag can never collide with a
1373 // structural trait like `<table>` or `<note>`, and spelled
1374 // as declared, case kept.
1375 if n.kind == Kind::Document {
1376 for (ns, t) in self
1377 .head
1378 .tags
1379 .iter()
1380 .map(|t| ("tag", t))
1381 .chain(self.head.category.iter().map(|c| ("category", c)))
1382 {
1383 let name = HeadMeta::trait_name(t);
1384 if name.is_empty() {
1385 continue;
1386 }
1387 let name = format!("{ns}:{name}");
1388 if !out.contains(&name) {
1389 out.push(name);
1390 }
1391 }
1392 }
1393 // A callout is inline apparatus, not a block (ruling #35);
1394 // so is an index mark (ruling #36). Strophes and stichos
1395 // lines are sub-block structure (ruling #37, litogramma's
1396 // own family rule) — the verse block carries the trait.
1397 if n.kind != Kind::Document
1398 && !n.deixis
1399 && !matches!(
1400 n.kind,
1401 Kind::IndexMark
1402 | Kind::Ref
1403 | Kind::Cit
1404 | Kind::Anchor
1405 | Kind::Strophe
1406 | Kind::Stichos
1407 )
1408 {
1409 out.push("block".to_string());
1410 }
1411 // The reference vocabulary (atrep's semantic traits): a
1412 // dangling mention keeps its node and the linter finds it;
1413 // `<target>` marks what bears a referable name — anchors
1414 // and labeled blocks, never the apparatus families (their
1415 // onyms pair within the family, not in this namespace).
1416 if matches!(n.kind, Kind::Ref | Kind::Cit) && n.dangling {
1417 out.push("dangling".to_string());
1418 }
1419 // A structured entry's genus (liber, commentarius, …)
1420 // surfaces as its trait, the genos rule.
1421 if n.kind == Kind::Bib
1422 && let Some(g) = &n.genus
1423 {
1424 out.push(g.clone());
1425 }
1426 if n.kind == Kind::Anchor
1427 || (n.onym.is_some()
1428 && !n.deixis
1429 && !matches!(
1430 n.kind,
1431 Kind::Footnote
1432 | Kind::Endnote
1433 | Kind::Aside
1434 | Kind::IndexMark
1435 | Kind::Ref
1436 | Kind::Cit
1437 | Kind::Bib
1438 ))
1439 {
1440 out.push("target".to_string());
1441 }
1442 if matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside) {
1443 // The body IS the note — `<note>` marks it, whichever
1444 // note family; the callout carries `<deixis>` alone.
1445 // An aside body is content, not apparatus: no
1446 // `<note>`. No both-ends trait: `//footnote` already
1447 // gathers a family whole, `//*<deixis>` the callouts
1448 // across families, `//*<note>` the note bodies.
1449 if n.deixis {
1450 out.push("deixis".to_string());
1451 } else if n.kind != Kind::Aside {
1452 out.push("note".to_string());
1453 }
1454 if n.dangling {
1455 out.push("dangling".to_string());
1456 }
1457 }
1458 if n.table {
1459 out.push("table".to_string());
1460 }
1461 if n.row {
1462 out.push("row".to_string());
1463 }
1464 if n.cell {
1465 out.push("cell".to_string());
1466 }
1467 out
1468 }
1469
1470 /// `::lemma` (title), `::hypograph` (footer or attribution),
1471 /// `::taxis` (ordinal), `::text` (the flattened prose, same as
1472 /// the bare projection).
1473 /// The Greek anatomy — `::lemma`, `::grammata`, `::hypograph`,
1474 /// `::taxis` — plus the friendly aliases (`::title`, `::body`,
1475 /// `::attribution`, `::ord`), answered here because this
1476 /// adapter's property surface IS the vocabulary; on data
1477 /// adapters those spellings stay ordinary field names. The
1478 /// Greek is canon in docs and reflection preserves whichever
1479 /// spelling was written.
1480 fn property(&self, node: NodeId, name: &str) -> Option<Value> {
1481 let n = &self.nodes[node.0 as usize];
1482 // The document node answers its declared identity: the
1483 // curated core of what the head says.
1484 if n.kind == Kind::Document {
1485 let h = &self.head;
1486 match name {
1487 "title" | "lemma" => return h.title.clone().map(Value::Str),
1488 "description" => return h.description.clone().map(Value::Str),
1489 "author" => return h.author.clone().map(Value::Str),
1490 "category" => return h.category.clone().map(Value::Str),
1491 "tags" => {
1492 return (!h.tags.is_empty())
1493 .then(|| Value::List(h.tags.iter().cloned().map(Value::Str).collect()));
1494 }
1495 "published" => return h.published.clone(),
1496 "modified" => return h.modified.clone(),
1497 _ => {}
1498 }
1499 }
1500 // A structured entry answers its campi first — under the
1501 // canonical Latin name or any census alias (BibLaTeX's
1502 // field names included), case-insensitively — ahead of
1503 // the general vocabulary, so `::title` on a bib means
1504 // titulus, not the lemma alias.
1505 if n.kind == Kind::Bib && !n.fields.is_empty() {
1506 let canon = self
1507 .bib_aliases
1508 .get(&name.to_lowercase())
1509 .map(String::as_str)
1510 .unwrap_or(name);
1511 if let Some((_, v)) = n.fields.iter().find(|(k, _)| k == canon) {
1512 return Some(Value::Str(v.clone()));
1513 }
1514 }
1515 match name {
1516 // A link into the node, as far as the served form can
1517 // narrow it; the anchor it lands on; and the record a
1518 // client renders as a clickable result.
1519 "href" => return self.href(node).map(Value::Str),
1520 "anchor" => return self.nearest_onym(node).map(|(a, _)| Value::Str(a)),
1521 "link" => {
1522 let href = self.href(node)?;
1523 let text = match self.default_value(node) {
1524 Some(Value::Str(t)) => t,
1525 _ => String::new(),
1526 };
1527 return Some(Value::Record(vec![
1528 ("title".to_string(), self.document_title().map(Value::Str).unwrap_or(Value::Null)),
1529 ("href".to_string(), Value::Str(href)),
1530 ("text".to_string(), Value::Str(text)),
1531 ]));
1532 }
1533 _ => {}
1534 }
1535 match name {
1536 "lemma" | "title" => n.lemma.clone().map(Value::Str),
1537 "onym" => n.onym.clone().map(Value::Str),
1538 // On an index mark the onym IS the term (ruling #36).
1539 "term" if n.kind == Kind::IndexMark => n.onym.clone().map(Value::Str),
1540 // A ref's word: the identifier as written. Never the
1541 // resolved content — that is what `-->` is for.
1542 "target" if matches!(n.kind, Kind::Ref | Kind::Cit) => n.target.clone().map(Value::Str),
1543 // A structured entry answers its campi (auctor,
1544 // titulus, annus, …) — the bibliogramma vocabulary,
1545 // canonicalized upstream.
1546 _ if n.kind == Kind::Bib => {
1547 let canon = self
1548 .bib_aliases
1549 .get(&name.to_lowercase())
1550 .map(String::as_str)
1551 .unwrap_or(name);
1552 n.fields
1553 .iter()
1554 .find(|(k, _)| k == canon)
1555 .map(|(_, v)| Value::Str(v.clone()))
1556 }
1557 "hypograph" | "attribution" => n.hypograph.clone().map(Value::Str),
1558 "taxis" | "ord" => n.taxis.map(Value::Int),
1559 "grammata" | "body" => {
1560 let g = self.grammata(node);
1561 if g.is_empty() {
1562 None
1563 } else {
1564 Some(Value::Str(g))
1565 }
1566 }
1567 "text" => Some(Value::Str(n.prose.clone())),
1568 _ => None,
1569 }
1570 }
1571
1572 /// The default projection is the flattened prose of the
1573 /// subtree — lemma first, hypograph last.
1574 fn default_value(&self, node: NodeId) -> Option<Value> {
1575 let n = &self.nodes[node.0 as usize];
1576 if n.kind == Kind::IndexMark {
1577 // Invisible in the surrounding prose; its own
1578 // projection is the term it declares.
1579 return Some(Value::Str(n.onym.clone().unwrap_or_default()));
1580 }
1581 if n.deixis {
1582 // The atrep rule: a resolved reference projects as its
1583 // target's rendered form, degrading to the raw onym
1584 // when dangling.
1585 return Some(Value::Str(match n.note_edge {
1586 Some(body) => self.nodes[body.0 as usize].prose.clone(),
1587 None => n.onym.clone().unwrap_or_default(),
1588 }));
1589 }
1590 if n.kind == Kind::Ref {
1591 // The authored link text; LaTeX \ref has none, so the
1592 // atrep projection rule applies — the target's
1593 // rendered form (its lemma, else its prose), degrading
1594 // to the raw target when dangling.
1595 if !n.text.is_empty() {
1596 return Some(Value::Str(n.text.clone()));
1597 }
1598 return Some(Value::Str(match n.ref_edge {
1599 Some(t) => {
1600 let b = &self.nodes[t.0 as usize];
1601 b.lemma.clone().unwrap_or_else(|| b.prose.clone())
1602 }
1603 None => n.target.clone().unwrap_or_default(),
1604 }));
1605 }
1606 if n.kind == Kind::Bib && n.prose.is_empty() && !n.fields.is_empty() {
1607 // A structured entry's plain form: its field values in
1608 // source order — the full data, no citation styling.
1609 return Some(Value::Str(
1610 n.fields
1611 .iter()
1612 .map(|(_, v)| v.as_str())
1613 .collect::<Vec<_>>()
1614 .join(". "),
1615 ));
1616 }
1617 if n.kind == Kind::Cit {
1618 // The mark's own identity: the raw key. The entry is
1619 // one arrow away (`//cit--> ::`); rendered citation
1620 // styles are presentation, not structure.
1621 return Some(Value::Str(n.target.clone().unwrap_or_default()));
1622 }
1623 if n.kind == Kind::Anchor {
1624 // Invisible in the prose; its own projection is the
1625 // name it bears.
1626 return Some(Value::Str(n.onym.clone().unwrap_or_default()));
1627 }
1628 Some(Value::Str(n.prose.clone()))
1629 }
1630
1631 /// The edge label is the family name both ends share —
1632 /// `->footnote` / `->endnote` — the node's own kind, since
1633 /// resolution re-kinds an open-family callout to its body.
1634 fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
1635 let n = &self.nodes[node.0 as usize];
1636 if let Some(t) = n.ref_edge {
1637 // The atrep rule: a resolved mention emits its typed
1638 // crosslink — `->ref`, or `->cit` for a citation.
1639 let label = if n.kind == Kind::Cit { "cit" } else { "ref" };
1640 return vec![(label.to_string(), t)];
1641 }
1642 match n.note_edge {
1643 Some(body) => vec![(n.kind.name().unwrap_or("footnote").to_string(), body)],
1644 None => Vec::new(),
1645 }
1646 }
1647
1648 fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
1649 let n = &self.nodes[node.0 as usize];
1650 let mut out: Vec<(String, NodeId)> = n
1651 .ref_cites
1652 .iter()
1653 .map(|&c| {
1654 let label = if self.nodes[c.0 as usize].kind == Kind::Cit {
1655 "cit"
1656 } else {
1657 "ref"
1658 };
1659 (label.to_string(), c)
1660 })
1661 .collect();
1662 let label = n.kind.name().unwrap_or("footnote");
1663 out.extend(n.cites.iter().map(|&c| (label.to_string(), c)));
1664 out
1665 }
1666
1667 /// `//ref-->`: land on the bearer — the block if a block
1668 /// bears the name, the point anchor if a point does. The
1669 /// text-level hints choose the reading: `-->block` normalizes
1670 /// a point landing to its enclosing block, `-->point` selects
1671 /// point targets only; bare is the honest exact landing.
1672 fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
1673 let n = &self.nodes[node.0 as usize];
1674 if !matches!(n.kind, Kind::Ref | Kind::Cit) || property != "target" {
1675 return None;
1676 }
1677 let landed = n.ref_edge?;
1678 let is_point = self.nodes[landed.0 as usize].kind == Kind::Anchor;
1679 match hint {
1680 None | Some("*") => Some(landed),
1681 Some("point") => is_point.then_some(landed),
1682 Some("block") => {
1683 if !is_point {
1684 return Some(landed);
1685 }
1686 // The anchor's enclosing block.
1687 let mut at = self.nodes[landed.0 as usize].parent;
1688 while let Some(b) = at {
1689 let bn = &self.nodes[b.0 as usize];
1690 if bn.kind != Kind::Document
1691 && !matches!(bn.kind, Kind::Ref | Kind::Anchor | Kind::IndexMark)
1692 {
1693 return Some(b);
1694 }
1695 at = bn.parent;
1696 }
1697 None
1698 }
1699 Some(_) => None,
1700 }
1701 }
1702
1703 /// The bare arrow's property: a ref resolves its target.
1704 fn ref_property(&self, node: NodeId) -> Option<String> {
1705 matches!(self.nodes[node.0 as usize].kind, Kind::Ref | Kind::Cit)
1706 .then(|| "target".to_string())
1707 }
1708
1709 /// A ref the producer declared external: the absolute URL,
1710 /// relative targets joined against the document's own URL —
1711 /// the same acquisition rung the html DOM reading speaks.
1712 fn external_ref(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<String> {
1713 if !matches!(hint, None | Some("*")) {
1714 return None;
1715 }
1716 let n = &self.nodes[node.0 as usize];
1717 if n.kind != Kind::Ref || property != "target" || n.internal {
1718 return None;
1719 }
1720 let t = n.target.as_deref()?.trim();
1721 if t.is_empty() || t.starts_with('#') {
1722 return None;
1723 }
1724 let u = match url::Url::parse(t) {
1725 Ok(u) => u,
1726 Err(url::ParseError::RelativeUrlWithoutBase) => self
1727 .link_base
1728 .as_ref()
1729 .or(self.document_url.as_ref())?
1730 .join(t)
1731 .ok()?,
1732 Err(_) => return None,
1733 };
1734 if u.scheme() != "http" && u.scheme() != "https" {
1735 return None;
1736 }
1737 Some(u.into())
1738 }
1739
1740 /// A sibling document's `#fragment` lands here: the bearer of
1741 /// that name — a labeled block, or a point anchor.
1742 fn resolve_fragment(&self, _node: NodeId, fragment: &str) -> Option<NodeId> {
1743 self.onyms.get(fragment).copied()
1744 }
1745
1746 /// Ruling #29: the text level's surface is the vocabulary
1747 /// itself — no document can introduce a property name — so
1748 /// its two annotations answer at `::` as well.
1749 fn aliased_metadata(&self, _node: NodeId) -> &'static [&'static str] {
1750 &["level", "lang", "form"]
1751 }
1752
1753 /// `::::level` on sections (the source heading level) and
1754 /// `::::lang` on verbatim blocks (the declared language).
1755 fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
1756 let n = &self.nodes[node.0 as usize];
1757 // The lossless layer: on the document node, every
1758 // declaration under the name it was declared with.
1759 if n.kind == Kind::Document
1760 && let Some(v) = self.head.declared_value(key)
1761 {
1762 return Some(v);
1763 }
1764 match key {
1765 "level" => n.level.map(|l| Value::Int(l as i64)),
1766 "lang" => n.lang.clone().map(Value::Str),
1767 // `::::resolved` on a callout: the broken-apparatus
1768 // linter's fact (ruling #35).
1769 "resolved" if n.deixis => Some(Value::Bool(!n.dangling)),
1770 // …and on an internal ref: the broken-cross-reference
1771 // linter's fact. External refs resolve at query time,
1772 // so the fact is not theirs to answer.
1773 "resolved" if n.kind == Kind::Cit || (n.kind == Kind::Ref && n.internal) => {
1774 Some(Value::Bool(n.ref_edge.is_some()))
1775 }
1776 // The declared spelling was a margin form (a Tufte
1777 // sidenote): family footnote, placement preserved.
1778 "form" if n.margin => Some(Value::Str("margin".to_string())),
1779 // The entry's genus, also queryable as an annotation.
1780 "genus" if n.kind == Kind::Bib => n.genus.clone().map(Value::Str),
1781 _ => None,
1782 }
1783 }
1784}