use std::sync::LazyLock;
use regex::Regex;
use crate::{
Document, HasSpan,
blocks::{
Block, Break, ColumnStyle, CompoundDelimitedBlock, Frame, Grid, HorizontalAlignment,
IsBlock, ListBlock, ListItem, ListItemMarker, ListType, MediaBlock, Preamble,
RawDelimitedBlock, SectionBlock, SimpleBlock, SimpleBlockStyle, Stripes, TableBlock,
TableCellContent, TableColumn, TableRow, VerticalAlignment,
},
};
fn decode_html_entities(s: &str) -> String {
let s = decode_numeric_entities(s);
s.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
.replace(""", "\"")
.replace("'", "'")
}
fn decode_numeric_entities(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut rest = s;
while let Some(amp) = rest.find("&#") {
result.push_str(&rest[..amp]);
let after = &rest[amp + 2..];
let (digits, radix) = match after.strip_prefix(['x', 'X']) {
Some(hex) => (hex, 16),
None => (after, 10),
};
let end = digits.find(';');
let parsed = end
.map(|e| &digits[..e])
.and_then(|d| u32::from_str_radix(d, radix).ok())
.and_then(char::from_u32);
match (end, parsed) {
(Some(e), Some(ch)) => {
result.push(ch);
rest = &digits[e + 1..];
}
_ => {
result.push_str("&#");
rest = after;
}
}
}
result.push_str(rest);
result
}
fn parse_html_content(text: &str) -> Vec<VirtualNode> {
let mut result = Vec::new();
let mut last_pos = 0;
let mut i = 0;
while i < text.len() {
if text[i..].starts_with('<') {
if let Some((element, new_pos)) = try_parse_element(text, i) {
if i > last_pos {
let text_content = &text[last_pos..i];
if !text_content.is_empty() {
result.push(VirtualNode::new("text").with_text(text_content));
}
}
result.push(element);
i = new_pos;
last_pos = new_pos;
continue;
}
}
i += 1;
}
if last_pos < text.len() {
let remaining = &text[last_pos..];
if !remaining.is_empty() {
result.push(VirtualNode::new("text").with_text(remaining));
}
}
if result.is_empty() && !text.is_empty() {
result.push(VirtualNode::new("text").with_text(text));
}
result
}
fn try_parse_element(text: &str, pos: usize) -> Option<(VirtualNode, usize)> {
if !text[pos..].starts_with('<') {
return None;
}
let tag_end = text[pos + 1..].find('>')?;
let tag_content = &text[pos + 1..pos + 1 + tag_end];
let tag_name = extract_tag_name(tag_content)?;
if tag_content.ends_with('/') {
return None; }
let after_opening = pos + 1 + tag_end + 1;
let closing_tag = format!("</{tag_name}>");
let close_pos = text[after_opening..].find(&closing_tag)?;
let content = &text[after_opening..after_opening + close_pos];
let after_closing = after_opening + close_pos + closing_tag.len();
let element = if content.contains('<') {
VirtualNode::new(tag_name).with_children(parse_html_content(content))
} else {
VirtualNode::new(tag_name).with_text(content)
};
let element = apply_tag_attributes(element, tag_content);
Some((element, after_closing))
}
static HTML_ATTR: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r#"([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*"([^"]*)""#).unwrap()
});
fn apply_tag_attributes(mut node: VirtualNode, tag_content: &str) -> VirtualNode {
let attrs = tag_content
.trim()
.split_once(char::is_whitespace)
.map(|(_, rest)| rest)
.unwrap_or("");
for caps in HTML_ATTR.captures_iter(attrs) {
let name = &caps[1];
let value = caps[2].to_string();
match name {
"id" => node.id = Some(value),
"class" => {
for class in value.split_whitespace() {
node.classes.push(class.to_string());
}
}
_ => {
node.attributes.insert(name.to_string(), value);
}
}
}
node
}
fn extract_tag_name(tag_content: &str) -> Option<String> {
let tag_content = tag_content.trim();
if tag_content.is_empty() || tag_content.starts_with('/') {
return None;
}
let tag_name = tag_content
.split_whitespace()
.next()
.unwrap_or(tag_content)
.trim_end_matches('/');
if tag_name.is_empty() {
None
} else {
Some(tag_name.to_string())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct VirtualNode {
pub tag: String,
pub classes: Vec<String>,
pub id: Option<String>,
pub text: Option<String>,
pub attributes: std::collections::HashMap<String, String>,
pub children: Vec<VirtualNode>,
}
#[allow(dead_code)] impl VirtualNode {
pub fn new(tag: impl Into<String>) -> Self {
Self {
tag: tag.into(),
classes: Vec::new(),
id: None,
text: None,
attributes: std::collections::HashMap::new(),
children: Vec::new(),
}
}
pub fn with_class(mut self, class: impl Into<String>) -> Self {
self.classes.push(class.into());
self
}
pub fn with_classes(mut self, classes: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.classes.extend(classes.into_iter().map(Into::into));
self
}
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
pub fn with_attribute(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.attributes.insert(name.into(), value.into());
self
}
pub fn with_text(mut self, text: impl Into<String>) -> Self {
self.text = Some(decode_html_entities(&text.into()));
self
}
pub fn with_html_content(mut self, text: impl Into<String>) -> Self {
let content = text.into();
if content.contains('<') {
self.children = parse_html_content(&content);
} else {
self.text = Some(decode_html_entities(&content));
}
self
}
pub fn with_child(mut self, child: VirtualNode) -> Self {
self.children.push(child);
self
}
pub fn with_children(mut self, children: impl IntoIterator<Item = VirtualNode>) -> Self {
self.children.extend(children);
self
}
}
pub trait ToVirtualDom {
fn to_virtual_dom(&self) -> VirtualNode;
}
impl ToVirtualDom for Document<'_> {
fn to_virtual_dom(&self) -> VirtualNode {
let mut node = VirtualNode::new("div").with_class("document");
if let Some(id) = self.id() {
node = node.with_id(id);
}
if self.show_doctitle()
&& let Some(title) = self.doctitle()
{
node.children.push(VirtualNode::new("h1").with_text(title));
}
for block in self.nested_blocks() {
add_block_with_title(&mut node, block);
}
node
}
}
fn add_block_with_title<'a>(parent: &mut VirtualNode, block: &'a Block<'a>) {
let handles_title_internally = matches!(block, Block::List(_) | Block::Table(_));
if !handles_title_internally && let Some(title) = block.title() {
let title_node = VirtualNode::new("div").with_class("title").with_text(title);
parent.children.push(title_node);
}
if let Block::Simple(simple) = block
&& simple.declared_style().is_none()
&& simple.style() == SimpleBlockStyle::Paragraph
{
let mut p_node = block.to_virtual_dom();
let mut wrapper = VirtualNode::new("div").with_class("paragraph");
wrapper.classes.append(&mut p_node.classes);
if p_node.id.is_some() {
wrapper.id = p_node.id.take();
}
wrapper.children.push(p_node);
parent.children.push(wrapper);
} else {
parent.children.push(block.to_virtual_dom());
}
}
impl ToVirtualDom for Block<'_> {
fn to_virtual_dom(&self) -> VirtualNode {
match self {
Block::Simple(simple) => {
if simple.declared_style() == Some("comment") {
return VirtualNode::new("comment");
}
let mut node = simple_block_to_node(simple);
if simple.style() == SimpleBlockStyle::Literal
|| simple.declared_style() == Some("literal")
|| simple.declared_style() == Some("verse")
{
let pre_node =
VirtualNode::new("pre").with_text(simple.content().rendered().to_string());
node = node.with_child(pre_node);
}
node
}
Block::List(list) => list_block_to_node(list),
Block::ListItem(item) => list_item_to_node(item),
Block::Section(section) => {
let mut node = section_to_node(section);
let heading_level = (section.level() + 1).min(6);
let heading_tag = format!("h{}", heading_level);
let mut title_node =
VirtualNode::new(heading_tag).with_text(section.section_title());
if let Some(id) = section.id() {
title_node = title_node.with_id(id);
}
node.children.insert(0, title_node);
node
}
Block::Media(media) => media_to_node(media),
Block::RawDelimited(raw) => raw_delimited_to_node(raw),
Block::CompoundDelimited(compound) => compound_delimited_to_node(compound),
Block::Table(table) => table_to_node(table),
Block::Preamble(preamble) => preamble_to_node(preamble),
Block::Break(break_) => break_to_node(break_),
Block::DocumentAttribute(_) => {
VirtualNode::new("comment")
}
}
}
}
fn simple_block_to_node<'a>(block: &'a SimpleBlock<'a>) -> VirtualNode {
let declared_style = block.declared_style();
let block_style = block.style();
let (tag, wrapper_classes) =
if block_style == SimpleBlockStyle::Literal || declared_style == Some("literal") {
("div", vec!["literalblock"])
} else {
match declared_style {
Some("paragraph") | None => ("p", vec![]),
Some("verse") => ("div", vec!["verseblock"]),
Some("quote") => ("div", vec!["quoteblock"]),
Some("sidebar") => ("div", vec!["sidebarblock"]),
Some("example") => ("div", vec!["exampleblock"]),
Some("open") => ("div", vec!["openblock"]),
Some("pass") => ("div", vec!["passblock"]),
_ => ("p", vec![]),
}
};
let mut node = VirtualNode::new(tag);
for class in wrapper_classes {
node = node.with_class(class);
}
for role in block.roles() {
node = node.with_class(role);
}
if let Some(id) = block.id() {
node = node.with_id(id);
}
if tag == "p" {
node = node.with_html_content(block.content().rendered().to_string());
}
node
}
fn list_block_to_node<'a>(list: &'a ListBlock<'a>) -> VirtualNode {
let is_horizontal =
list.type_() == ListType::Description && list.declared_style() == Some("horizontal");
let (list_tag, base_class) = match list.type_() {
ListType::Unordered => ("ul", "ulist"),
ListType::Ordered => ("ol", "olist"),
ListType::Description => {
if is_horizontal {
("table", "hdlist")
} else {
("dl", "dlist")
}
}
};
let mut list_element = VirtualNode::new(list_tag);
if list.type_() == ListType::Ordered
&& list.declared_style().is_none()
&& let Some(style) = list.marker_style()
{
list_element = list_element.with_class(style);
}
if list.type_() == ListType::Ordered
&& let Some(Block::ListItem(first)) = list.nested_blocks().next()
&& let Some(ordinal) = first.list_item_marker().ordinal_value()
&& ordinal != 1
{
list_element = list_element.with_attribute("start", ordinal.to_string());
}
if let Some(attrlist) = list.attrlist() {
for attr in attrlist.attributes() {
if let Some(attr_name) = attr.name() {
list_element = list_element.with_attribute(attr_name, attr.value());
}
}
}
for option in list.options() {
list_element = list_element.with_attribute(option, "");
}
if !is_horizontal && let Some(style) = list.declared_style() {
list_element = list_element.with_class(style);
}
for item in list.nested_blocks() {
if list.type_() == ListType::Description {
if let Block::ListItem(list_item) = item {
if let ListItemMarker::DefinedTerm { term, .. } = list_item.list_item_marker() {
if is_horizontal {
let mut tr_node = VirtualNode::new("tr");
let td_term = VirtualNode::new("td")
.with_class("hdlist1")
.with_html_content(term.rendered().to_string());
tr_node.children.push(td_term);
let mut td_def = VirtualNode::new("td").with_class("hdlist2");
let nested = list_item.nested_blocks().collect::<Vec<_>>();
for child in &nested {
td_def.children.push(child.to_virtual_dom());
}
tr_node.children.push(td_def);
list_element.children.push(tr_node);
} else {
let mut dt_node = VirtualNode::new("dt");
for role in list_item.roles() {
dt_node = dt_node.with_class(role);
}
if let Some(id) = list_item.id() {
dt_node = dt_node.with_id(id);
}
dt_node = dt_node.with_html_content(term.rendered().to_string());
list_element.children.push(dt_node);
let nested = list_item.nested_blocks().collect::<Vec<_>>();
if !nested.is_empty() {
let mut dd_node = VirtualNode::new("dd");
let has_multiple_blocks = nested.len() > 1;
let first_block_from_continuation =
nested.first().is_some_and(|first_block| {
let item_span = list_item.span();
let marker_span = list_item.list_item_marker().span();
let marker_end_offset =
marker_span.byte_offset() + marker_span.data().len();
let first_block_offset = first_block.span().byte_offset();
let item_start = item_span.byte_offset();
if first_block_offset > marker_end_offset
&& marker_end_offset >= item_start
{
let start = marker_end_offset - item_start;
let end = first_block_offset - item_start;
if end <= item_span.data().len() {
let between = &item_span.data()[start..end];
between.lines().any(|line| line.trim() == "+")
} else {
false
}
} else {
false
}
});
for (index, child) in nested.iter().enumerate() {
let child_vdom = child.to_virtual_dom();
let should_wrap = child_vdom.tag == "p"
&& child_vdom.classes.is_empty()
&& ((has_multiple_blocks && index > 0)
|| (index == 0 && first_block_from_continuation));
if should_wrap {
let wrapper = VirtualNode::new("div")
.with_class("paragraph")
.with_child(child_vdom);
dd_node.children.push(wrapper);
} else {
dd_node.children.push(child_vdom);
}
}
list_element.children.push(dd_node);
}
}
}
}
} else {
list_element.children.push(item.to_virtual_dom());
}
}
let mut wrapper = VirtualNode::new("div").with_class(base_class);
if list.type_() == ListType::Ordered
&& list.declared_style().is_none()
&& let Some(style) = list.marker_style()
{
wrapper = wrapper.with_class(style);
}
if !is_horizontal && let Some(style) = list.declared_style() {
wrapper = wrapper.with_class(style);
}
for role in list.roles() {
wrapper = wrapper.with_class(role);
}
if let Some(id) = list.id() {
wrapper = wrapper.with_id(id);
}
if let Some(title) = list.title() {
let title_node = VirtualNode::new("div").with_class("title").with_text(title);
wrapper.children.push(title_node);
}
wrapper.children.push(list_element);
wrapper
}
fn list_item_to_node<'a>(item: &'a ListItem<'a>) -> VirtualNode {
let mut node = VirtualNode::new("li");
for role in item.roles() {
node = node.with_class(role);
}
if let Some(id) = item.id() {
node = node.with_id(id);
}
let nested = item.nested_blocks().collect::<Vec<_>>();
let has_multiple_blocks = nested.len() > 1;
for (index, child) in nested.iter().enumerate() {
let child_vdom = child.to_virtual_dom();
if has_multiple_blocks
&& index > 0
&& child_vdom.tag == "p"
&& child_vdom.classes.is_empty()
{
let wrapper = VirtualNode::new("div")
.with_class("paragraph")
.with_child(child_vdom);
node.children.push(wrapper);
} else {
node.children.push(child_vdom);
}
}
node
}
fn section_to_node<'a>(section: &'a SectionBlock<'a>) -> VirtualNode {
let class = format!("sect{}", section.level());
let mut node = VirtualNode::new("div").with_class(class);
for role in section.roles() {
node = node.with_class(role);
}
if let Some(id) = section.id() {
node = node.with_id(id);
}
for child in section.nested_blocks() {
add_block_with_title(&mut node, child);
}
node
}
fn media_to_node<'a>(media: &'a MediaBlock<'a>) -> VirtualNode {
let context = media.raw_context();
let class = format!("{}block", context.as_ref());
let mut node = VirtualNode::new("div").with_class(class);
for role in media.roles() {
node = node.with_class(role);
}
if let Some(id) = media.id() {
node = node.with_id(id);
}
node
}
fn raw_delimited_to_node<'a>(raw: &'a RawDelimitedBlock<'a>) -> VirtualNode {
let context = raw.raw_context();
let (tag, classes): (&str, Vec<String>) = match context.as_ref() {
"listing" => ("div", vec!["listingblock".to_string()]),
"literal" => ("div", vec!["literalblock".to_string()]),
"comment" => ("comment", vec![]),
_ => ("div", vec![format!("{}block", context.as_ref())]),
};
let mut node = VirtualNode::new(tag);
for class in classes {
node = node.with_class(class);
}
for role in raw.roles() {
node = node.with_class(role);
}
if let Some(id) = raw.id() {
node = node.with_id(id);
}
if let Some(title) = raw.title() {
let title_node = VirtualNode::new("div").with_class("title").with_text(title);
node.children.push(title_node);
}
if tag != "comment" {
let is_source_block = raw
.attrlist()
.and_then(|attrlist| attrlist.attributes().next())
.map(|attr| attr.value() == "source")
.unwrap_or(false);
if is_source_block {
let mut code = VirtualNode::new("code");
if let Some(attrlist) = raw.attrlist() {
let mut attrs = attrlist.attributes();
attrs.next();
if let Some(lang_attr) = attrs.next() {
code = code.with_attribute("data-lang", lang_attr.value());
}
}
if let Some(content) = raw.rendered_content() {
code = code.with_text(content);
}
let pre = VirtualNode::new("pre").with_child(code);
node.children.push(pre);
} else {
let mut pre = VirtualNode::new("pre");
if let Some(content) = raw.rendered_content() {
pre = pre.with_text(content);
}
node.children.push(pre);
}
}
node
}
fn compound_delimited_to_node<'a>(compound: &'a CompoundDelimitedBlock<'a>) -> VirtualNode {
let context = compound.raw_context();
let class = format!("{}block", context.as_ref());
let mut node = VirtualNode::new("div").with_class(class);
for role in compound.roles() {
node = node.with_class(role);
}
if let Some(id) = compound.id() {
node = node.with_id(id);
}
for child in compound.nested_blocks() {
node.children.push(child.to_virtual_dom());
}
node
}
fn table_to_node<'a>(table: &'a TableBlock<'a>) -> VirtualNode {
let mut classes = vec![
"tableblock".to_string(),
frame_class(table.frame()).to_string(),
grid_class(table.grid()).to_string(),
];
let autowidth = table.columns().iter().any(TableColumn::is_autowidth);
if autowidth {
classes.push("fit-content".to_string());
} else if table.width().is_none() {
classes.push("stretch".to_string());
}
if let Some(stripes) = stripes_class(table.stripes()) {
classes.push(stripes.to_string());
}
if let Some(float) = table
.attrlist()
.and_then(|a| a.named_attribute("float"))
.map(|a| a.value())
{
classes.push(float.to_string());
}
let mut node = VirtualNode::new("table").with_classes(classes);
if let Some(id) = table.id() {
node = node.with_id(id);
}
for role in table.roles() {
node = node.with_class(role);
}
if let Some(width) = table.width() {
node = node.with_attribute("width", format!("{width}%"));
}
if let Some(title) = table.title() {
let caption_text = match table.caption() {
Some(caption) => format!("{caption}{title}"),
None => title.to_string(),
};
node.children.push(
VirtualNode::new("caption")
.with_class("title")
.with_text(caption_text),
);
}
if table.header_row().is_none() && table.body_rows().is_empty() && table.footer_row().is_none()
{
return node;
}
let mut colgroup = VirtualNode::new("colgroup");
for (column, pcwidth) in table.columns().iter().zip(column_pcwidths(table.columns())) {
let mut col = VirtualNode::new("col").with_attribute("colpcwidth", pcwidth.clone());
if column.is_autowidth() {
col = col.with_attribute("autowidth-option", "");
} else {
col = col.with_attribute("width", format!("{pcwidth}%"));
}
colgroup.children.push(col);
}
node.children.push(colgroup);
if let Some(header) = table.header_row() {
let mut thead = VirtualNode::new("thead");
thead.children.push(table_row_to_node(header, true, false));
node.children.push(thead);
}
if !table.body_rows().is_empty() {
let mut tbody = VirtualNode::new("tbody");
for row in table.body_rows() {
tbody.children.push(table_row_to_node(row, false, true));
}
node.children.push(tbody);
}
if let Some(footer) = table.footer_row() {
let mut tfoot = VirtualNode::new("tfoot");
tfoot.children.push(table_row_to_node(footer, false, true));
node.children.push(tfoot);
}
node
}
fn table_row_to_node(row: &TableRow<'_>, header_row: bool, wrap_in_paragraph: bool) -> VirtualNode {
let mut tr = VirtualNode::new("tr");
for cell in row.cells() {
let cell_tag = if header_row || cell.style() == ColumnStyle::Header {
"th"
} else {
"td"
};
let mut cell_node = VirtualNode::new(cell_tag).with_classes([
"tableblock".to_string(),
halign_class(cell.h_align()).to_string(),
valign_class(cell.v_align()).to_string(),
]);
if cell.colspan() > 1 {
cell_node = cell_node.with_attribute("colspan", cell.colspan().to_string());
}
if cell.rowspan() > 1 {
cell_node = cell_node.with_attribute("rowspan", cell.rowspan().to_string());
}
match cell.content() {
TableCellContent::Simple(content) => {
let rendered = content.rendered().to_string();
match cell.style() {
ColumnStyle::Literal => {
cell_node.children.push(
VirtualNode::new("div")
.with_class("literal")
.with_child(VirtualNode::new("pre").with_html_content(rendered)),
);
}
_ if !wrap_in_paragraph => {
match style_wrapper(cell.style()) {
Some(tag) => cell_node
.children
.push(VirtualNode::new(tag).with_html_content(rendered)),
None => cell_node = cell_node.with_html_content(rendered),
}
}
_ if rendered.is_empty() => {}
style => match style_wrapper(style) {
Some(tag) => {
cell_node.children.push(
VirtualNode::new("p")
.with_class("tableblock")
.with_child(VirtualNode::new(tag).with_html_content(rendered)),
);
}
None => {
for para in
split_cell_paragraphs(content.original().data(), content.rendered())
{
cell_node.children.push(
VirtualNode::new("p")
.with_class("tableblock")
.with_html_content(para),
);
}
}
},
}
}
TableCellContent::AsciiDoc(cell) => {
let mut content = VirtualNode::new("div").with_class("content");
if let Some(title) = cell.title() {
content
.children
.push(VirtualNode::new("h1").with_text(title));
}
if cell.is_inline() {
for block in cell.blocks() {
match block.rendered_content() {
Some(rendered) => {
content.children.extend(parse_html_content(rendered));
}
None => add_block_with_title(&mut content, block),
}
}
} else {
for block in cell.blocks() {
add_block_with_title(&mut content, block);
}
}
cell_node.children.push(content);
}
}
tr.children.push(cell_node);
}
tr
}
fn column_pcwidths(columns: &[TableColumn]) -> Vec<String> {
let n = columns.len();
if n == 0 {
return vec![];
}
let fixed_total: usize = columns
.iter()
.filter(|c| !c.is_autowidth())
.map(TableColumn::width)
.sum();
let (effective, base): (Vec<f64>, f64) = if columns.iter().any(TableColumn::is_autowidth) {
let autowidth_count = columns.iter().filter(|c| c.is_autowidth()).count();
let (share, base) = if fixed_total > 100 {
(0.0, fixed_total as f64)
} else {
(
truncate4((100.0 - fixed_total as f64) / autowidth_count as f64),
100.0,
)
};
let effective = columns
.iter()
.map(|c| {
if c.is_autowidth() {
share
} else {
c.width() as f64
}
})
.collect();
(effective, base)
} else {
let base = if fixed_total == 0 {
n as f64
} else {
fixed_total as f64
};
(columns.iter().map(|c| c.width() as f64).collect(), base)
};
let mut pct: Vec<f64> = effective
.iter()
.map(|w| truncate4(w * 100.0 / base))
.collect();
let total: f64 = pct.iter().sum();
if (total - 100.0).abs() > 1e-9 {
let last = n - 1;
pct[last] = round4(100.0 - total + pct[last]);
}
pct.iter().map(|w| format_pcwidth(*w)).collect()
}
fn split_cell_paragraphs(source: &str, rendered: &str) -> Vec<String> {
let source_lines: Vec<&str> = source.split('\n').collect();
let rendered_lines: Vec<&str> = rendered.split('\n').collect();
if source_lines.len() != rendered_lines.len() {
return rendered
.split("\n\n")
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect();
}
let mut paragraphs: Vec<String> = vec![];
let mut current: Vec<&str> = vec![];
for (src, rendered) in source_lines.iter().zip(rendered_lines.iter()) {
if src.trim().is_empty() {
if !current.is_empty() {
paragraphs.push(current.join("\n").trim().to_string());
current.clear();
}
} else {
current.push(rendered);
}
}
if !current.is_empty() {
paragraphs.push(current.join("\n").trim().to_string());
}
paragraphs.retain(|p| !p.is_empty());
paragraphs
}
fn truncate4(x: f64) -> f64 {
(x * 10000.0).trunc() / 10000.0
}
fn round4(x: f64) -> f64 {
(x * 10000.0).round() / 10000.0
}
fn format_pcwidth(x: f64) -> String {
let s = format!("{x:.4}");
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
trimmed.to_string()
}
fn style_wrapper(style: ColumnStyle) -> Option<&'static str> {
match style {
ColumnStyle::Strong => Some("strong"),
ColumnStyle::Emphasis => Some("em"),
ColumnStyle::Monospace => Some("code"),
_ => None,
}
}
fn frame_class(frame: Frame) -> &'static str {
match frame {
Frame::All => "frame-all",
Frame::Ends => "frame-ends",
Frame::Sides => "frame-sides",
Frame::None => "frame-none",
}
}
fn grid_class(grid: Grid) -> &'static str {
match grid {
Grid::All => "grid-all",
Grid::Rows => "grid-rows",
Grid::Cols => "grid-cols",
Grid::None => "grid-none",
}
}
fn stripes_class(stripes: Stripes) -> Option<&'static str> {
match stripes {
Stripes::None => None,
Stripes::Even => Some("stripes-even"),
Stripes::Odd => Some("stripes-odd"),
Stripes::All => Some("stripes-all"),
Stripes::Hover => Some("stripes-hover"),
}
}
fn halign_class(align: HorizontalAlignment) -> &'static str {
match align {
HorizontalAlignment::Left => "halign-left",
HorizontalAlignment::Center => "halign-center",
HorizontalAlignment::Right => "halign-right",
}
}
fn valign_class(align: VerticalAlignment) -> &'static str {
match align {
VerticalAlignment::Top => "valign-top",
VerticalAlignment::Middle => "valign-middle",
VerticalAlignment::Bottom => "valign-bottom",
}
}
fn preamble_to_node<'a>(preamble: &'a Preamble<'a>) -> VirtualNode {
let mut node = VirtualNode::new("div").with_id("preamble");
for child in preamble.nested_blocks() {
node.children.push(child.to_virtual_dom());
}
node
}
fn break_to_node<'a>(break_: &'a Break<'a>) -> VirtualNode {
let context = break_.raw_context();
match context.as_ref() {
"thematic_break" => VirtualNode::new("hr"),
"page_break" => VirtualNode::new("div").with_class("page-break"),
_ => VirtualNode::new("hr"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::prelude::*;
#[test]
fn empty_document() {
let doc = Parser::default().parse("");
let vdom = doc.to_virtual_dom();
assert_eq!(vdom.tag, "div");
assert_eq!(vdom.classes, vec!["document"]);
assert_eq!(vdom.children.len(), 0);
}
#[test]
fn single_paragraph() {
let doc = Parser::default().parse("Hello, world!");
let vdom = doc.to_virtual_dom();
assert_eq!(vdom.tag, "div");
assert_eq!(vdom.classes, vec!["document"]);
assert_eq!(vdom.children.len(), 1);
let wrapper = &vdom.children[0];
assert_eq!(wrapper.tag, "div");
assert!(wrapper.classes.contains(&"paragraph".to_string()));
assert_eq!(wrapper.children.len(), 1);
let para = &wrapper.children[0];
assert_eq!(para.tag, "p");
assert_eq!(para.text.as_deref(), Some("Hello, world!"));
}
#[test]
fn unordered_list() {
let doc = Parser::default().parse("* item 1\n* item 2\n* item 3");
let vdom = doc.to_virtual_dom();
assert_eq!(vdom.children.len(), 1);
let wrapper = &vdom.children[0];
assert_eq!(wrapper.tag, "div");
assert!(wrapper.classes.contains(&"ulist".to_string()));
assert_eq!(wrapper.children.len(), 1);
let ul = &wrapper.children[0];
assert_eq!(ul.tag, "ul");
assert_eq!(ul.children.len(), 3);
for li in &ul.children {
assert_eq!(li.tag, "li");
}
}
#[test]
fn section_with_paragraph() {
let doc = Parser::default().parse("== Section Title\n\nSome text.");
let vdom = doc.to_virtual_dom();
assert_eq!(vdom.children.len(), 1);
let section = &vdom.children[0];
assert_eq!(section.tag, "div");
assert!(section.classes.contains(&"sect1".to_string()));
assert_eq!(section.children.len(), 2);
assert_eq!(section.children[0].tag, "h2");
let para_wrapper = §ion.children[1];
assert_eq!(para_wrapper.tag, "div");
assert!(para_wrapper.classes.contains(&"paragraph".to_string()));
assert_eq!(para_wrapper.children.len(), 1);
assert_eq!(para_wrapper.children[0].tag, "p");
}
#[test]
fn ordered_list_has_arabic_class() {
let doc = Parser::default().parse(". item 1\n. item 2\n. item 3");
let vdom = doc.to_virtual_dom();
assert_eq!(vdom.children.len(), 1);
let wrapper = &vdom.children[0];
assert_eq!(wrapper.tag, "div");
assert!(wrapper.classes.contains(&"olist".to_string()));
assert!(wrapper.classes.contains(&"arabic".to_string()));
assert_eq!(wrapper.children.len(), 1);
let ol = &wrapper.children[0];
assert_eq!(ol.tag, "ol");
assert!(ol.classes.contains(&"arabic".to_string()));
assert_eq!(ol.children.len(), 3);
for li in &ol.children {
assert_eq!(li.tag, "li");
}
}
#[test]
fn inline_html_markup_in_paragraph() {
let doc = Parser::default().parse("I am *strong* and _emphasized_ and `code`.");
let vdom = doc.to_virtual_dom();
assert_eq!(vdom.children.len(), 1);
let wrapper = &vdom.children[0];
assert_eq!(wrapper.tag, "div");
assert!(wrapper.classes.contains(&"paragraph".to_string()));
assert_eq!(wrapper.children.len(), 1);
let para = &wrapper.children[0];
assert_eq!(para.tag, "p");
assert!(
!para.children.is_empty(),
"Should have child nodes from parsed HTML"
);
let strong = para.children.iter().find(|c| c.tag == "strong");
assert!(strong.is_some(), "Should have a <strong> element");
assert_eq!(strong.unwrap().text.as_deref(), Some("strong"));
let em = para.children.iter().find(|c| c.tag == "em");
assert!(em.is_some(), "Should have an <em> element");
assert_eq!(em.unwrap().text.as_deref(), Some("emphasized"));
let code = para.children.iter().find(|c| c.tag == "code");
assert!(code.is_some(), "Should have a <code> element");
assert_eq!(code.unwrap().text.as_deref(), Some("code"));
}
#[test]
fn titled_table_renders_captioned_title() {
let doc = Parser::default().parse(".A table with a title\n|===\n|a |b\n|===");
let vdom = doc.to_virtual_dom();
let table = &vdom.children[0];
assert_eq!(table.tag, "table");
let caption = &table.children[0];
assert_eq!(caption.tag, "caption");
assert!(caption.classes.contains(&"title".to_string()));
assert_eq!(
caption.text.as_deref(),
Some("Table 1. A table with a title")
);
}
#[test]
fn description_list_uses_dt_and_dd_tags() {
let doc = Parser::default().parse("term1:: definition1\nterm2:: definition2");
let vdom = doc.to_virtual_dom();
assert_eq!(vdom.children.len(), 1);
let wrapper = &vdom.children[0];
assert_eq!(wrapper.tag, "div");
assert!(wrapper.classes.contains(&"dlist".to_string()));
assert_eq!(wrapper.children.len(), 1);
let dl = &wrapper.children[0];
assert_eq!(dl.tag, "dl");
assert_eq!(dl.children.len(), 4);
assert_eq!(dl.children[0].tag, "dt");
assert_eq!(dl.children[0].text.as_deref(), Some("term1"));
assert_eq!(dl.children[1].tag, "dd");
assert_eq!(dl.children[1].children.len(), 1);
assert_eq!(dl.children[1].children[0].tag, "p");
assert_eq!(
dl.children[1].children[0].text.as_deref(),
Some("definition1")
);
assert_eq!(dl.children[2].tag, "dt");
assert_eq!(dl.children[2].text.as_deref(), Some("term2"));
assert_eq!(dl.children[3].tag, "dd");
assert_eq!(dl.children[3].children.len(), 1);
assert_eq!(dl.children[3].children[0].tag, "p");
assert_eq!(
dl.children[3].children[0].text.as_deref(),
Some("definition2")
);
}
}