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. Producers (`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`.
21//! - `::lemma`, `::hypograph`, and `::taxis` are properties; bare
22//! `::` (and `::text`) is the flattened prose of the subtree,
23//! lemma first, hypograph last.
24//! - `::::level` on a section is the source heading level;
25//! `::::lang` on a verbatim block is its declared language.
26//! - Sections are derived from the flat heading stream by the
27//! outline rule: a heading closes every open section at its
28//! level or deeper, then opens a section under the nearest
29//! shallower one. Content before the first heading belongs to
30//! the document root. A heading inside an open container
31//! (blockquote, list) is decorative, not sectioning: it lowers
32//! to a paragraph of its text.
33//! - Every kind admits `::lemma`, `::taxis`, and `::hypograph` —
34//! the atrep model, where these are universal affordances of a
35//! block rather than privileges of particular kinds.
36//! - Tables denormalize into nested lists: an `ordered-list`
37//! carrying the `<table>` trait (`::lemma` = the caption), one
38//! `ordered-item` per row (`::taxis` = row number, `<row>`
39//! trait), one `unordered-item` per cell (`<cell>` trait) whose
40//! `::lemma` is the column name — from the header row in grids,
41//! from the row's `th` label otherwise; headerless cells carry
42//! no lemma. A lemma'd item flattens as `lemma: prose`, so a
43//! row exists, the bare cell text otherwise. Empty cells are
44//! skipped.
45
46use quarb::{AstAdapter, NodeId, Value};
47
48pub mod render;
49pub use render::{Render, render_node, render_nodes};
50
51/// A block-level event in the text-level vocabulary — what a format
52/// producer emits. Headings arrive flat; the section tree is
53/// derived here, once, for every producer.
54#[derive(Debug, Clone, PartialEq)]
55pub enum Block {
56 /// A flat heading: `level` is the source level (`h2` → 2, a
57 /// LaTeX `\section` → its depth), `lemma` its text.
58 Heading { level: u8, lemma: String },
59 /// A plain paragraph — the implicit, lemma-less block.
60 Paragraph { text: String },
61 /// Inline content belonging directly to the open container (a
62 /// list item's own text, a bare-text blockquote). With no open
63 /// container it is read as a paragraph.
64 Text { text: String },
65 /// Open a nesting container. Items take their `unordered-` /
66 /// `ordered-` flavor (and taxis) from the enclosing list.
67 Open { kind: Container, lemma: Option<String> },
68 /// Close the innermost open container, optionally with its
69 /// hypograph (footer or attribution).
70 Close { hypograph: Option<String> },
71 /// A verbatim block — code or other preformatted lines, kept
72 /// as authored.
73 Verbatim { lang: Option<String>, text: String },
74 /// A table, denormalized here into nested lists (rows =
75 /// ordered items with the `<row>` trait, cells = unordered
76 /// items with the `<cell>` trait and the column name as
77 /// `::lemma`). Header *detection* is the producer's job; the
78 /// lowering rule lives here. A cell's own `label` (a row's
79 /// `th`) wins over the positional `headers` entry.
80 Table {
81 lemma: Option<String>,
82 headers: Option<Vec<String>>,
83 rows: Vec<Vec<Cell>>,
84 },
85}
86
87/// One table cell as a producer hands it over: the text, plus the
88/// label a row-shaped dialect attaches directly (an infobox row's
89/// `th`). Grid dialects leave `label` empty and let the lowering
90/// zip the header row on by position.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct Cell {
93 pub label: Option<String>,
94 pub text: String,
95}
96
97impl From<&str> for Cell {
98 fn from(text: &str) -> Self {
99 Cell {
100 label: None,
101 text: text.to_string(),
102 }
103 }
104}
105
106impl From<String> for Cell {
107 fn from(text: String) -> Self {
108 Cell { label: None, text }
109 }
110}
111
112/// The nesting containers a producer opens and closes explicitly.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum Container {
115 Blockquote,
116 UnorderedList,
117 /// `start` is the first item's ordinal (Markdown's `3.` lists).
118 OrderedList { start: i64 },
119 /// A list item; flavor and taxis come from the enclosing list.
120 Item,
121}
122
123/// The structural kind of a node — also its name.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125enum Kind {
126 Document,
127 Section,
128 Paragraph,
129 Blockquote,
130 UnorderedList,
131 OrderedList,
132 UnorderedItem,
133 OrderedItem,
134 Verbatim,
135}
136
137impl Kind {
138 fn name(self) -> Option<&'static str> {
139 Some(match self {
140 Kind::Document => return None,
141 Kind::Section => "section",
142 Kind::Paragraph => "paragraph",
143 Kind::Blockquote => "blockquote",
144 Kind::UnorderedList => "unordered-list",
145 Kind::OrderedList => "ordered-list",
146 Kind::UnorderedItem => "unordered-item",
147 Kind::OrderedItem => "ordered-item",
148 Kind::Verbatim => "verbatim",
149 })
150 }
151}
152
153struct Node {
154 kind: Kind,
155 lemma: Option<String>,
156 hypograph: Option<String>,
157 taxis: Option<i64>,
158 /// Source heading level, on sections.
159 level: Option<u8>,
160 /// Declared language, on verbatim blocks.
161 lang: Option<String>,
162 /// First ordinal of an ordered list (not exposed; feeds the
163 /// items' taxis).
164 start: i64,
165 /// The node's own (direct) text, before subtree flattening.
166 text: String,
167 /// The flattened prose of the subtree — the `::` projection.
168 prose: String,
169 /// The node heads a denormalized table (`<table>` trait).
170 table: bool,
171 /// The node is a denormalized table row (`<row>` trait).
172 row: bool,
173 /// The node is a denormalized table cell (`<cell>` trait).
174 cell: bool,
175 parent: Option<NodeId>,
176 children: Vec<NodeId>,
177}
178
179impl Node {
180 fn new(kind: Kind, parent: Option<NodeId>) -> Self {
181 Node {
182 kind,
183 lemma: None,
184 hypograph: None,
185 taxis: None,
186 level: None,
187 lang: None,
188 start: 1,
189 text: String::new(),
190 prose: String::new(),
191 table: false,
192 row: false,
193 cell: false,
194 parent,
195 children: Vec::new(),
196 }
197 }
198}
199
200/// Collapse whitespace runs to single spaces and trim — the prose
201/// normalization producers apply to inline content. Verbatim text
202/// is the exception: it is kept as authored.
203pub fn normalize_ws(s: &str) -> String {
204 s.split_whitespace().collect::<Vec<_>>().join(" ")
205}
206
207/// A Quarb adapter over a text-level document.
208pub struct TextModel {
209 nodes: Vec<Node>,
210 root: NodeId,
211}
212
213impl TextModel {
214 /// Assemble the document tree from a producer's event stream.
215 ///
216 /// Iterative throughout (the stream is flat; prose flattening
217 /// runs over indices), so pathological nesting cannot overflow
218 /// the call stack. Lenient on malformed streams: a stray
219 /// `Close` is ignored, unclosed containers close at the end.
220 pub fn build(blocks: Vec<Block>) -> Self {
221 let mut nodes = vec![Node::new(Kind::Document, None)];
222 let root = NodeId(0);
223 // Innermost-last stack of open *sections* (outline-derived).
224 let mut sections: Vec<NodeId> = Vec::new();
225 // Innermost-last stack of open explicit containers.
226 let mut containers: Vec<NodeId> = Vec::new();
227
228 for block in blocks {
229 match block {
230 Block::Heading { level, lemma } => {
231 let lemma = normalize_ws(&lemma);
232 if !containers.is_empty() {
233 // Decorative heading inside a container:
234 // not sectioning — lower to a paragraph.
235 if !lemma.is_empty() {
236 let parent = *containers.last().unwrap();
237 let id = push(&mut nodes, Kind::Paragraph, parent);
238 nodes[id.0 as usize].text = lemma;
239 }
240 continue;
241 }
242 while let Some(&open) = sections.last() {
243 if nodes[open.0 as usize].level >= Some(level) {
244 sections.pop();
245 } else {
246 break;
247 }
248 }
249 let parent = sections.last().copied().unwrap_or(root);
250 let id = push(&mut nodes, Kind::Section, parent);
251 let n = &mut nodes[id.0 as usize];
252 n.lemma = Some(lemma);
253 n.level = Some(level);
254 sections.push(id);
255 }
256 Block::Paragraph { text } => {
257 let text = normalize_ws(&text);
258 if text.is_empty() {
259 continue;
260 }
261 let parent = cursor(§ions, &containers, root);
262 let id = push(&mut nodes, Kind::Paragraph, parent);
263 nodes[id.0 as usize].text = text;
264 }
265 Block::Text { text } => {
266 let text = normalize_ws(&text);
267 if text.is_empty() {
268 continue;
269 }
270 match containers.last() {
271 Some(&open) => {
272 let own = &mut nodes[open.0 as usize].text;
273 if !own.is_empty() {
274 own.push(' ');
275 }
276 own.push_str(&text);
277 }
278 None => {
279 let parent = sections.last().copied().unwrap_or(root);
280 let id = push(&mut nodes, Kind::Paragraph, parent);
281 nodes[id.0 as usize].text = text;
282 }
283 }
284 }
285 Block::Open { kind, lemma } => {
286 let parent = cursor(§ions, &containers, root);
287 let (nkind, start) = match kind {
288 Container::Blockquote => (Kind::Blockquote, None),
289 Container::UnorderedList => (Kind::UnorderedList, None),
290 Container::OrderedList { start } => (Kind::OrderedList, Some(start)),
291 Container::Item => (
292 match nodes[parent.0 as usize].kind {
293 Kind::OrderedList => Kind::OrderedItem,
294 _ => Kind::UnorderedItem,
295 },
296 None,
297 ),
298 };
299 let id = push(&mut nodes, nkind, parent);
300 nodes[id.0 as usize].lemma =
301 lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
302 if let Some(start) = start {
303 nodes[id.0 as usize].start = start;
304 }
305 if nkind == Kind::OrderedItem {
306 // `push` already appended this item, so the
307 // count includes it.
308 let nth = nodes[parent.0 as usize]
309 .children
310 .iter()
311 .filter(|&&c| nodes[c.0 as usize].kind == Kind::OrderedItem)
312 .count() as i64;
313 let start = nodes[parent.0 as usize].start;
314 nodes[id.0 as usize].taxis = Some(start + nth - 1);
315 }
316 containers.push(id);
317 }
318 Block::Close { hypograph } => {
319 if let Some(open) = containers.pop() {
320 nodes[open.0 as usize].hypograph =
321 hypograph.map(|h| normalize_ws(&h)).filter(|h| !h.is_empty());
322 }
323 }
324 Block::Verbatim { lang, text } => {
325 let parent = cursor(§ions, &containers, root);
326 let id = push(&mut nodes, Kind::Verbatim, parent);
327 let n = &mut nodes[id.0 as usize];
328 n.lang = lang.filter(|l| !l.is_empty());
329 n.text = text;
330 }
331 Block::Table {
332 lemma,
333 headers,
334 rows,
335 } => {
336 let parent = cursor(§ions, &containers, root);
337 lower_table(&mut nodes, parent, lemma, headers, rows);
338 }
339 }
340 }
341
342 flatten_prose(&mut nodes);
343 TextModel { nodes, root }
344 }
345
346 /// Read plain text: blank-line-separated paragraphs, each
347 /// collapsed to one line — the atramento paragraph rule. No
348 /// headings, no markup.
349 pub fn parse_plain(text: &str) -> Self {
350 let mut blocks = Vec::new();
351 let mut para: Vec<&str> = Vec::new();
352 for line in text.lines() {
353 if line.trim().is_empty() {
354 if !para.is_empty() {
355 blocks.push(Block::Paragraph {
356 text: para.join(" "),
357 });
358 para.clear();
359 }
360 } else {
361 para.push(line);
362 }
363 }
364 if !para.is_empty() {
365 blocks.push(Block::Paragraph {
366 text: para.join(" "),
367 });
368 }
369 Self::build(blocks)
370 }
371
372 /// A locator path to `node`, like `/section[2]/paragraph[3]`,
373 /// for rendering. A `[n]` index is added only to disambiguate
374 /// same-name siblings.
375 pub fn locator(&self, node: NodeId) -> String {
376 let mut segments = Vec::new();
377 let mut cur = Some(node);
378 while let Some(id) = cur {
379 let n = &self.nodes[id.0 as usize];
380 if let Some(name) = n.kind.name() {
381 segments.push(self.segment(id, name));
382 }
383 cur = n.parent;
384 }
385 segments.reverse();
386 format!("/{}", segments.join("/"))
387 }
388
389 fn segment(&self, node: NodeId, name: &str) -> String {
390 let Some(parent) = self.nodes[node.0 as usize].parent else {
391 return name.to_string();
392 };
393 let siblings = &self.nodes[parent.0 as usize].children;
394 let same_name: Vec<NodeId> = siblings
395 .iter()
396 .copied()
397 .filter(|&s| self.nodes[s.0 as usize].kind == self.nodes[node.0 as usize].kind)
398 .collect();
399 if same_name.len() > 1 {
400 let n = same_name.iter().position(|&s| s == node).unwrap() + 1;
401 format!("{name}[{n}]")
402 } else {
403 name.to_string()
404 }
405 }
406}
407
408/// Where the next block lands: the innermost open container, else
409/// the innermost open section, else the root.
410fn cursor(sections: &[NodeId], containers: &[NodeId], root: NodeId) -> NodeId {
411 containers
412 .last()
413 .or(sections.last())
414 .copied()
415 .unwrap_or(root)
416}
417
418fn push(nodes: &mut Vec<Node>, kind: Kind, parent: NodeId) -> NodeId {
419 let id = NodeId(nodes.len() as u64);
420 nodes.push(Node::new(kind, Some(parent)));
421 nodes[parent.0 as usize].children.push(id);
422 id
423}
424
425/// Denormalize a table into nested lists (see the module doc).
426/// The column name lands as the cell's `::lemma` — a cell's own
427/// label (a row's `th`) wins over the positional header entry —
428/// and never as folded text: addressing is property projection,
429/// the flattening rule alone spells `lemma: value`.
430fn lower_table(
431 nodes: &mut Vec<Node>,
432 parent: NodeId,
433 lemma: Option<String>,
434 headers: Option<Vec<String>>,
435 rows: Vec<Vec<Cell>>,
436) {
437 let list = push(nodes, Kind::OrderedList, parent);
438 {
439 let n = &mut nodes[list.0 as usize];
440 n.table = true;
441 n.lemma = lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
442 }
443 for (i, row) in rows.into_iter().enumerate() {
444 let item = push(nodes, Kind::OrderedItem, list);
445 nodes[item.0 as usize].taxis = Some(i as i64 + 1);
446 nodes[item.0 as usize].row = true;
447 let cells = push(nodes, Kind::UnorderedList, item);
448 for (j, cell) in row.into_iter().enumerate() {
449 let value = normalize_ws(&cell.text);
450 if value.is_empty() {
451 continue;
452 }
453 let label = cell
454 .label
455 .as_deref()
456 .or_else(|| headers.as_ref().and_then(|h| h.get(j)).map(|h| h.as_str()))
457 .map(normalize_ws)
458 .filter(|h| !h.is_empty());
459 let cell_item = push(nodes, Kind::UnorderedItem, cells);
460 let n = &mut nodes[cell_item.0 as usize];
461 n.cell = true;
462 n.lemma = label;
463 n.text = value;
464 }
465 }
466}
467
468/// Compute every node's flattened prose: lemma first, then the
469/// node's own text, then its children's prose in order, then the
470/// hypograph, block-joined with newlines. On a list *item*, the
471/// lemma joins the rest with `: ` instead — an item's lemma names
472/// its content inline (a table cell reads `Outcome: Emus won`, a
473/// definition reads `term: description`), where a section's lemma
474/// opens its block. Children always carry larger indices than
475/// their parents (nodes are interned in document order), so one
476/// reverse index scan suffices — no recursion.
477fn flatten_prose(nodes: &mut [Node]) {
478 for i in (0..nodes.len()).rev() {
479 let inline_lemma = matches!(
480 nodes[i].kind,
481 Kind::UnorderedItem | Kind::OrderedItem
482 );
483 let mut lemma_part: Option<String> = None;
484 let mut parts: Vec<String> = Vec::new();
485 if let Some(lemma) = &nodes[i].lemma
486 && !lemma.is_empty()
487 {
488 if inline_lemma {
489 lemma_part = Some(lemma.clone());
490 } else {
491 parts.push(lemma.clone());
492 }
493 }
494 if !nodes[i].text.is_empty() {
495 parts.push(nodes[i].text.clone());
496 }
497 for &child in nodes[i].children.clone().iter() {
498 let prose = &nodes[child.0 as usize].prose;
499 if !prose.is_empty() {
500 parts.push(prose.clone());
501 }
502 }
503 if let Some(hypograph) = &nodes[i].hypograph
504 && !hypograph.is_empty()
505 {
506 parts.push(hypograph.clone());
507 }
508 let mut prose = parts.join("\n");
509 if let Some(lemma) = lemma_part {
510 prose = if prose.is_empty() {
511 lemma
512 } else {
513 format!("{lemma}: {prose}")
514 };
515 }
516 nodes[i].prose = prose;
517 }
518}
519
520impl TextModel {
521 /// The body prose: everything between the lemma and the
522 /// hypograph — the simmere anatomy's third member, derived
523 /// from the flattened prose by construction (the lemma joins
524 /// a block on its own line, an item with `: `; the hypograph
525 /// closes on its own line).
526 fn grammata(&self, node: NodeId) -> String {
527 let n = &self.nodes[node.0 as usize];
528 let mut s = n.prose.as_str();
529 if let Some(lemma) = &n.lemma
530 && !lemma.is_empty()
531 && let Some(rest) = s.strip_prefix(lemma.as_str())
532 {
533 s = rest
534 .strip_prefix(": ")
535 .or_else(|| rest.strip_prefix('\n'))
536 .unwrap_or(rest);
537 }
538 if let Some(h) = &n.hypograph
539 && !h.is_empty()
540 && let Some(rest) = s.strip_suffix(h.as_str())
541 {
542 s = rest.strip_suffix('\n').unwrap_or(rest);
543 }
544 s.trim_end().to_string()
545 }
546}
547
548impl AstAdapter for TextModel {
549 fn root(&self) -> NodeId {
550 self.root
551 }
552
553 fn children(&self, node: NodeId) -> Vec<NodeId> {
554 self.nodes[node.0 as usize].children.clone()
555 }
556
557 fn name(&self, node: NodeId) -> Option<String> {
558 self.nodes[node.0 as usize].kind.name().map(str::to_string)
559 }
560
561 fn parent(&self, node: NodeId) -> Option<NodeId> {
562 self.nodes[node.0 as usize].parent
563 }
564
565 /// The `<block>` family on every block node, plus `<table>` on
566 /// a list that denormalizes a table. Kinds are node names, not
567 /// traits.
568 fn traits(&self, node: NodeId) -> Vec<String> {
569 let n = &self.nodes[node.0 as usize];
570 let mut out = Vec::new();
571 if n.kind != Kind::Document {
572 out.push("block".to_string());
573 }
574 if n.table {
575 out.push("table".to_string());
576 }
577 if n.row {
578 out.push("row".to_string());
579 }
580 if n.cell {
581 out.push("cell".to_string());
582 }
583 out
584 }
585
586 /// `::lemma` (title), `::hypograph` (footer or attribution),
587 /// `::taxis` (ordinal), `::text` (the flattened prose, same as
588 /// the bare projection).
589 /// The Greek anatomy — `::lemma`, `::grammata`, `::hypograph`,
590 /// `::taxis` — plus the friendly aliases (`::title`, `::body`,
591 /// `::attribution`, `::ord`), answered here because this
592 /// adapter's property surface IS the vocabulary; on data
593 /// adapters those spellings stay ordinary field names. The
594 /// Greek is canon in docs and reflection preserves whichever
595 /// spelling was written.
596 fn property(&self, node: NodeId, name: &str) -> Option<Value> {
597 let n = &self.nodes[node.0 as usize];
598 match name {
599 "lemma" | "title" => n.lemma.clone().map(Value::Str),
600 "hypograph" | "attribution" => n.hypograph.clone().map(Value::Str),
601 "taxis" | "ord" => n.taxis.map(Value::Int),
602 "grammata" | "body" => {
603 let g = self.grammata(node);
604 if g.is_empty() { None } else { Some(Value::Str(g)) }
605 }
606 "text" => Some(Value::Str(n.prose.clone())),
607 _ => None,
608 }
609 }
610
611 /// The default projection is the flattened prose of the
612 /// subtree — lemma first, hypograph last.
613 fn default_value(&self, node: NodeId) -> Option<Value> {
614 Some(Value::Str(self.nodes[node.0 as usize].prose.clone()))
615 }
616
617 /// Ruling #29: the text level's surface is the vocabulary
618 /// itself — no document can introduce a property name — so
619 /// its two annotations answer at `::` as well.
620 fn aliased_metadata(&self, _node: NodeId) -> &'static [&'static str] {
621 &["level", "lang"]
622 }
623
624 /// `::::level` on sections (the source heading level) and
625 /// `::::lang` on verbatim blocks (the declared language).
626 fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
627 let n = &self.nodes[node.0 as usize];
628 match key {
629 "level" => n.level.map(|l| Value::Int(l as i64)),
630 "lang" => n.lang.clone().map(Value::Str),
631 _ => None,
632 }
633 }
634}