use quarb::{AstAdapter, NodeId, Value};
pub mod render;
pub use render::{Render, render_node, render_nodes};
#[derive(Debug, Clone, PartialEq)]
pub enum Block {
Heading { level: u8, lemma: String },
Paragraph { text: String },
Text { text: String },
Open { kind: Container, lemma: Option<String> },
Close { hypograph: Option<String> },
Verbatim { lang: Option<String>, text: String },
NoteRef {
onym: String,
family: Option<NoteFamily>,
margin: bool,
},
IndexMark { term: String },
Verse {
lemma: Option<String>,
strophes: Vec<Vec<String>>,
hypograph: Option<String>,
},
Table {
lemma: Option<String>,
headers: Option<Vec<String>>,
rows: Vec<Vec<Cell>>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cell {
pub label: Option<String>,
pub text: String,
}
impl From<&str> for Cell {
fn from(text: &str) -> Self {
Cell {
label: None,
text: text.to_string(),
}
}
}
impl From<String> for Cell {
fn from(text: String) -> Self {
Cell { label: None, text }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Container {
Blockquote,
UnorderedList,
OrderedList { start: i64 },
Item,
Note {
onym: String,
family: NoteFamily,
margin: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NoteFamily {
Footnote,
Endnote,
Aside,
}
impl NoteFamily {
fn kind(self) -> Kind {
match self {
NoteFamily::Footnote => Kind::Footnote,
NoteFamily::Endnote => Kind::Endnote,
NoteFamily::Aside => Kind::Aside,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Kind {
Document,
Section,
Paragraph,
Blockquote,
UnorderedList,
OrderedList,
UnorderedItem,
OrderedItem,
Verbatim,
Footnote,
Endnote,
Aside,
IndexMark,
Verse,
Strophe,
Stichos,
}
impl Kind {
fn name(self) -> Option<&'static str> {
Some(match self {
Kind::Document => return None,
Kind::Section => "section",
Kind::Paragraph => "paragraph",
Kind::Blockquote => "blockquote",
Kind::Footnote => "footnote",
Kind::Endnote => "endnote",
Kind::Aside => "aside",
Kind::IndexMark => "index-mark",
Kind::Verse => "verse",
Kind::Strophe => "strophe",
Kind::Stichos => "stichos",
Kind::UnorderedList => "unordered-list",
Kind::OrderedList => "ordered-list",
Kind::UnorderedItem => "unordered-item",
Kind::OrderedItem => "ordered-item",
Kind::Verbatim => "verbatim",
})
}
}
struct Node {
kind: Kind,
lemma: Option<String>,
hypograph: Option<String>,
taxis: Option<i64>,
level: Option<u8>,
lang: Option<String>,
start: i64,
text: String,
prose: String,
table: bool,
row: bool,
cell: bool,
onym: Option<String>,
deixis: bool,
dangling: bool,
family_open: bool,
margin: bool,
note_edge: Option<NodeId>,
cites: Vec<NodeId>,
parent: Option<NodeId>,
children: Vec<NodeId>,
}
impl Node {
fn new(kind: Kind, parent: Option<NodeId>) -> Self {
Node {
kind,
lemma: None,
hypograph: None,
taxis: None,
level: None,
lang: None,
start: 1,
text: String::new(),
prose: String::new(),
table: false,
row: false,
cell: false,
onym: None,
deixis: false,
dangling: false,
family_open: false,
margin: false,
note_edge: None,
cites: Vec::new(),
parent,
children: Vec::new(),
}
}
}
pub fn normalize_ws(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub struct TextModel {
nodes: Vec<Node>,
root: NodeId,
}
impl TextModel {
pub fn build(blocks: Vec<Block>) -> Self {
let mut nodes = vec![Node::new(Kind::Document, None)];
let root = NodeId(0);
let mut sections: Vec<NodeId> = Vec::new();
let mut containers: Vec<NodeId> = Vec::new();
let mut last_flow: Option<NodeId> = None;
for block in blocks {
match block {
Block::Heading { level, lemma } => {
let lemma = normalize_ws(&lemma);
if !containers.is_empty() {
if !lemma.is_empty() {
let parent = *containers.last().unwrap();
let id = push(&mut nodes, Kind::Paragraph, parent);
nodes[id.0 as usize].text = lemma;
}
continue;
}
while let Some(&open) = sections.last() {
if nodes[open.0 as usize].level >= Some(level) {
sections.pop();
} else {
break;
}
}
let parent = sections.last().copied().unwrap_or(root);
let id = push(&mut nodes, Kind::Section, parent);
let n = &mut nodes[id.0 as usize];
n.lemma = Some(lemma);
n.level = Some(level);
sections.push(id);
last_flow = Some(id);
}
Block::Paragraph { text } => {
let text = normalize_ws(&text);
if text.is_empty() {
continue;
}
let parent = cursor(§ions, &containers, root);
let id = push(&mut nodes, Kind::Paragraph, parent);
nodes[id.0 as usize].text = text;
last_flow = Some(id);
}
Block::Text { text } => {
let text = normalize_ws(&text);
if text.is_empty() {
continue;
}
match containers.last() {
Some(&open) => {
let own = &mut nodes[open.0 as usize].text;
if !own.is_empty() {
own.push(' ');
}
own.push_str(&text);
last_flow = Some(open);
}
None => {
let parent = sections.last().copied().unwrap_or(root);
let id = push(&mut nodes, Kind::Paragraph, parent);
nodes[id.0 as usize].text = text;
last_flow = Some(id);
}
}
}
Block::Open { kind, lemma } => {
let kind = match kind {
Container::Note { onym, family, margin } => {
let id = push(&mut nodes, family.kind(), root);
let n = &mut nodes[id.0 as usize];
n.onym = Some(onym.trim().to_string()).filter(|o| !o.is_empty());
n.margin = margin;
containers.push(id);
last_flow = Some(id);
continue;
}
other => other,
};
let parent = cursor(§ions, &containers, root);
let (nkind, start) = match kind {
Container::Blockquote => (Kind::Blockquote, None),
Container::UnorderedList => (Kind::UnorderedList, None),
Container::OrderedList { start } => (Kind::OrderedList, Some(start)),
Container::Item => (
match nodes[parent.0 as usize].kind {
Kind::OrderedList => Kind::OrderedItem,
_ => Kind::UnorderedItem,
},
None,
),
Container::Note { .. } => unreachable!("handled above"),
};
let id = push(&mut nodes, nkind, parent);
nodes[id.0 as usize].lemma =
lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
if let Some(start) = start {
nodes[id.0 as usize].start = start;
}
if nkind == Kind::OrderedItem {
let nth = nodes[parent.0 as usize]
.children
.iter()
.filter(|&&c| nodes[c.0 as usize].kind == Kind::OrderedItem)
.count() as i64;
let start = nodes[parent.0 as usize].start;
nodes[id.0 as usize].taxis = Some(start + nth - 1);
}
containers.push(id);
}
Block::Close { hypograph } => {
if let Some(open) = containers.pop() {
nodes[open.0 as usize].hypograph =
hypograph.map(|h| normalize_ws(&h)).filter(|h| !h.is_empty());
}
}
Block::NoteRef { onym, family, margin } => {
let parent = last_flow.unwrap_or(root);
let kind = family.map(NoteFamily::kind).unwrap_or(Kind::Footnote);
let id = push(&mut nodes, kind, parent);
let n = &mut nodes[id.0 as usize];
n.deixis = true;
n.family_open = family.is_none();
n.margin = margin;
n.onym = Some(onym.trim().to_string()).filter(|o| !o.is_empty());
}
Block::IndexMark { term } => {
let parent = last_flow.unwrap_or(root);
let id = push(&mut nodes, Kind::IndexMark, parent);
nodes[id.0 as usize].onym =
Some(quarb_term(&term)).filter(|t| !t.is_empty());
}
Block::Verbatim { lang, text } => {
let parent = cursor(§ions, &containers, root);
let id = push(&mut nodes, Kind::Verbatim, parent);
let n = &mut nodes[id.0 as usize];
n.lang = lang.filter(|l| !l.is_empty());
n.text = text;
}
Block::Table {
lemma,
headers,
rows,
} => {
let parent = cursor(§ions, &containers, root);
lower_table(&mut nodes, parent, lemma, headers, rows);
}
Block::Verse {
lemma,
strophes,
hypograph,
} => {
let parent = cursor(§ions, &containers, root);
let id = lower_verse(&mut nodes, parent, lemma, strophes, hypograph);
last_flow = Some(id);
}
}
}
resolve_notes(&mut nodes);
flatten_prose(&mut nodes);
TextModel { nodes, root }
}
pub fn parse_plain(text: &str) -> Self {
let mut blocks = Vec::new();
let mut para: Vec<&str> = Vec::new();
for line in text.lines() {
if line.trim().is_empty() {
if !para.is_empty() {
blocks.push(Block::Paragraph {
text: para.join(" "),
});
para.clear();
}
} else {
para.push(line);
}
}
if !para.is_empty() {
blocks.push(Block::Paragraph {
text: para.join(" "),
});
}
Self::build(blocks)
}
pub fn locator(&self, node: NodeId) -> String {
let mut segments = Vec::new();
let mut cur = Some(node);
while let Some(id) = cur {
let n = &self.nodes[id.0 as usize];
if let Some(name) = n.kind.name() {
segments.push(self.segment(id, name));
}
cur = n.parent;
}
segments.reverse();
format!("/{}", segments.join("/"))
}
fn segment(&self, node: NodeId, name: &str) -> String {
let Some(parent) = self.nodes[node.0 as usize].parent else {
return name.to_string();
};
let siblings = &self.nodes[parent.0 as usize].children;
let same_name: Vec<NodeId> = siblings
.iter()
.copied()
.filter(|&s| self.nodes[s.0 as usize].kind == self.nodes[node.0 as usize].kind)
.collect();
if same_name.len() > 1 {
let n = same_name.iter().position(|&s| s == node).unwrap() + 1;
format!("{name}[{n}]")
} else {
name.to_string()
}
}
}
fn resolve_notes(nodes: &mut [Node]) {
let mut bodies: std::collections::HashMap<(Kind, String), NodeId> = Default::default();
for (i, n) in nodes.iter().enumerate() {
if matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside)
&& !n.deixis
&& let Some(o) = &n.onym
{
bodies.entry((n.kind, o.clone())).or_insert(NodeId(i as u64));
}
}
for i in 0..nodes.len() {
if !matches!(nodes[i].kind, Kind::Footnote | Kind::Endnote | Kind::Aside)
|| !nodes[i].deixis
{
continue;
}
let Some(onym) = nodes[i].onym.clone() else {
nodes[i].dangling = true;
continue;
};
let hit = if nodes[i].family_open {
[Kind::Footnote, Kind::Endnote, Kind::Aside]
.into_iter()
.find_map(|k| bodies.get(&(k, onym.clone())).map(|b| (k, *b)))
} else {
bodies.get(&(nodes[i].kind, onym)).map(|b| (nodes[i].kind, *b))
};
match hit {
Some((family, body)) => {
nodes[i].kind = family;
nodes[i].note_edge = Some(body);
let callout = NodeId(i as u64);
nodes[body.0 as usize].cites.push(callout);
}
None => nodes[i].dangling = true,
}
}
}
fn quarb_term(term: &str) -> String {
normalize_ws(term.split('|').next().unwrap_or(term))
}
fn cursor(sections: &[NodeId], containers: &[NodeId], root: NodeId) -> NodeId {
containers
.last()
.or(sections.last())
.copied()
.unwrap_or(root)
}
fn push(nodes: &mut Vec<Node>, kind: Kind, parent: NodeId) -> NodeId {
let id = NodeId(nodes.len() as u64);
nodes.push(Node::new(kind, Some(parent)));
nodes[parent.0 as usize].children.push(id);
id
}
fn lower_table(
nodes: &mut Vec<Node>,
parent: NodeId,
lemma: Option<String>,
headers: Option<Vec<String>>,
rows: Vec<Vec<Cell>>,
) {
let list = push(nodes, Kind::OrderedList, parent);
{
let n = &mut nodes[list.0 as usize];
n.table = true;
n.lemma = lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
}
for (i, row) in rows.into_iter().enumerate() {
let item = push(nodes, Kind::OrderedItem, list);
nodes[item.0 as usize].taxis = Some(i as i64 + 1);
nodes[item.0 as usize].row = true;
let cells = push(nodes, Kind::UnorderedList, item);
for (j, cell) in row.into_iter().enumerate() {
let value = normalize_ws(&cell.text);
if value.is_empty() {
continue;
}
let label = cell
.label
.as_deref()
.or_else(|| headers.as_ref().and_then(|h| h.get(j)).map(|h| h.as_str()))
.map(normalize_ws)
.filter(|h| !h.is_empty());
let cell_item = push(nodes, Kind::UnorderedItem, cells);
let n = &mut nodes[cell_item.0 as usize];
n.cell = true;
n.lemma = label;
n.text = value;
}
}
}
fn lower_verse(
nodes: &mut Vec<Node>,
parent: NodeId,
lemma: Option<String>,
strophes: Vec<Vec<String>>,
hypograph: Option<String>,
) -> NodeId {
let verse = push(nodes, Kind::Verse, parent);
{
let n = &mut nodes[verse.0 as usize];
n.lemma = lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
n.hypograph = hypograph.map(|h| normalize_ws(&h)).filter(|h| !h.is_empty());
}
let mut line_no = 0i64;
for (i, strophe) in strophes.into_iter().enumerate() {
let sid = push(nodes, Kind::Strophe, verse);
nodes[sid.0 as usize].taxis = Some(i as i64 + 1);
for line in strophe {
let line = line.trim_end().to_string();
if line.trim().is_empty() {
continue;
}
line_no += 1;
let lid = push(nodes, Kind::Stichos, sid);
let n = &mut nodes[lid.0 as usize];
n.taxis = Some(line_no);
n.text = line;
}
}
verse
}
fn flatten_prose(nodes: &mut [Node]) {
for i in (0..nodes.len()).rev() {
let inline_lemma = matches!(
nodes[i].kind,
Kind::UnorderedItem | Kind::OrderedItem
);
let mut lemma_part: Option<String> = None;
let mut parts: Vec<String> = Vec::new();
if let Some(lemma) = &nodes[i].lemma
&& !lemma.is_empty()
{
if inline_lemma {
lemma_part = Some(lemma.clone());
} else {
parts.push(lemma.clone());
}
}
if !nodes[i].text.is_empty() {
parts.push(nodes[i].text.clone());
}
let mut child_parts: Vec<String> = Vec::new();
for &child in nodes[i].children.clone().iter() {
let prose = &nodes[child.0 as usize].prose;
if !prose.is_empty() {
child_parts.push(prose.clone());
}
}
if nodes[i].kind == Kind::Verse {
let joined = child_parts.join("\n\n");
if !joined.is_empty() {
parts.push(joined);
}
} else {
parts.extend(child_parts);
}
if let Some(hypograph) = &nodes[i].hypograph
&& !hypograph.is_empty()
{
parts.push(hypograph.clone());
}
let mut prose = parts.join("\n");
if let Some(lemma) = lemma_part {
prose = if prose.is_empty() {
lemma
} else {
format!("{lemma}: {prose}")
};
}
nodes[i].prose = prose;
}
}
impl TextModel {
fn grammata(&self, node: NodeId) -> String {
let n = &self.nodes[node.0 as usize];
let mut s = n.prose.as_str();
if let Some(lemma) = &n.lemma
&& !lemma.is_empty()
&& let Some(rest) = s.strip_prefix(lemma.as_str())
{
s = rest
.strip_prefix(": ")
.or_else(|| rest.strip_prefix('\n'))
.unwrap_or(rest);
}
if let Some(h) = &n.hypograph
&& !h.is_empty()
&& let Some(rest) = s.strip_suffix(h.as_str())
{
s = rest.strip_suffix('\n').unwrap_or(rest);
}
s.trim_end().to_string()
}
}
impl AstAdapter for TextModel {
fn root(&self) -> NodeId {
self.root
}
fn children(&self, node: NodeId) -> Vec<NodeId> {
self.nodes[node.0 as usize].children.clone()
}
fn name(&self, node: NodeId) -> Option<String> {
self.nodes[node.0 as usize].kind.name().map(str::to_string)
}
fn parent(&self, node: NodeId) -> Option<NodeId> {
self.nodes[node.0 as usize].parent
}
fn traits(&self, node: NodeId) -> Vec<String> {
let n = &self.nodes[node.0 as usize];
let mut out = Vec::new();
if n.kind != Kind::Document
&& !n.deixis
&& !matches!(n.kind, Kind::IndexMark | Kind::Strophe | Kind::Stichos)
{
out.push("block".to_string());
}
if matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside) {
if n.deixis {
out.push("deixis".to_string());
} else if n.kind != Kind::Aside {
out.push("note".to_string());
}
if n.dangling {
out.push("dangling".to_string());
}
}
if n.table {
out.push("table".to_string());
}
if n.row {
out.push("row".to_string());
}
if n.cell {
out.push("cell".to_string());
}
out
}
fn property(&self, node: NodeId, name: &str) -> Option<Value> {
let n = &self.nodes[node.0 as usize];
match name {
"lemma" | "title" => n.lemma.clone().map(Value::Str),
"onym" => n.onym.clone().map(Value::Str),
"term" if n.kind == Kind::IndexMark => n.onym.clone().map(Value::Str),
"hypograph" | "attribution" => n.hypograph.clone().map(Value::Str),
"taxis" | "ord" => n.taxis.map(Value::Int),
"grammata" | "body" => {
let g = self.grammata(node);
if g.is_empty() { None } else { Some(Value::Str(g)) }
}
"text" => Some(Value::Str(n.prose.clone())),
_ => None,
}
}
fn default_value(&self, node: NodeId) -> Option<Value> {
let n = &self.nodes[node.0 as usize];
if n.kind == Kind::IndexMark {
return Some(Value::Str(n.onym.clone().unwrap_or_default()));
}
if n.deixis {
return Some(Value::Str(match n.note_edge {
Some(body) => self.nodes[body.0 as usize].prose.clone(),
None => n.onym.clone().unwrap_or_default(),
}));
}
Some(Value::Str(n.prose.clone()))
}
fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
let n = &self.nodes[node.0 as usize];
match n.note_edge {
Some(body) => vec![(n.kind.name().unwrap_or("footnote").to_string(), body)],
None => Vec::new(),
}
}
fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
let n = &self.nodes[node.0 as usize];
let label = n.kind.name().unwrap_or("footnote");
n.cites.iter().map(|&c| (label.to_string(), c)).collect()
}
fn aliased_metadata(&self, _node: NodeId) -> &'static [&'static str] {
&["level", "lang", "form"]
}
fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
let n = &self.nodes[node.0 as usize];
match key {
"level" => n.level.map(|l| Value::Int(l as i64)),
"lang" => n.lang.clone().map(Value::Str),
"resolved" if n.deixis => Some(Value::Bool(!n.dangling)),
"form" if n.margin => Some(Value::Str("margin".to_string())),
_ => None,
}
}
}