use crate::model::{Align, Block, Cell, DocModel, FieldRole, Paragraph, Run, Table};
pub(crate) fn render(doc: &DocModel) -> String {
let mut out = String::new();
let mut stack: Vec<bool> = Vec::new(); for block in &doc.blocks {
if matches!(block, Block::Paragraph(p) if p.props.page_break_before) && !out.is_empty() {
close_lists(&mut out, &mut stack);
out.push_str("<hr class=\"page-break\">");
}
match block {
Block::Paragraph(p) if p.props.list.is_some() => {
let list = p.props.list.as_ref().expect("list checked");
adjust_lists(&mut out, &mut stack, list.level as usize, list.ordered);
out.push_str("<li>");
out.push_str(&render_runs(&p.runs));
out.push_str("</li>");
}
other => {
close_lists(&mut out, &mut stack);
out.push_str(&render_block(other));
}
}
}
close_lists(&mut out, &mut stack);
out
}
fn adjust_lists(out: &mut String, stack: &mut Vec<bool>, level: usize, ordered: bool) {
while stack.len() > level + 1 {
let o = stack.pop().unwrap_or(false);
out.push_str(if o { "</ol>" } else { "</ul>" });
}
while stack.len() < level + 1 {
stack.push(ordered);
out.push_str(if ordered { "<ol>" } else { "<ul>" });
}
if stack.last().copied() != Some(ordered) {
let o = stack.pop().unwrap_or(false);
out.push_str(if o { "</ol>" } else { "</ul>" });
stack.push(ordered);
out.push_str(if ordered { "<ol>" } else { "<ul>" });
}
}
fn close_lists(out: &mut String, stack: &mut Vec<bool>) {
while let Some(o) = stack.pop() {
out.push_str(if o { "</ol>" } else { "</ul>" });
}
}
fn render_block(block: &Block) -> String {
match block {
Block::Paragraph(p) => render_paragraph(p),
Block::Table(t) => render_table_fragment(t),
Block::Image(img) => format!(
"<img alt=\"{}\">",
escape_attr(img.alt.as_deref().unwrap_or("image"))
),
Block::Chart(chart) => format!(
"<figure class=\"chart\" aria-label=\"{}\"></figure>",
escape_attr(
chart
.alt
.as_deref()
.or(chart.title.as_deref())
.unwrap_or("chart")
)
),
Block::PageBreak => "<hr class=\"page-break\">".to_string(),
Block::SectionBreak(_) => "<hr class=\"section-break\">".to_string(),
}
}
fn render_paragraph(p: &Paragraph) -> String {
let inline = render_runs(&p.runs);
let style = align_style(p.props.align);
if let Some(level) = p.props.heading_level {
let n = level.clamp(1, 6);
return format!("<h{n}{style}>{inline}</h{n}>");
}
format!("<p{style}>{inline}</p>")
}
fn align_style(align: Align) -> &'static str {
match align {
Align::Left => "",
Align::Center => " style=\"text-align:center\"",
Align::Right => " style=\"text-align:right\"",
Align::Justify => " style=\"text-align:justify\"",
}
}
fn render_runs(runs: &[Run]) -> String {
let mut out = String::new();
for run in runs {
if let Some(img) = &run.image {
match crate::image::data_uri(img) {
Some(src) => out.push_str(&format!("<img src=\"{src}\" alt=\"image\">")),
None => out.push_str("<img alt=\"image\">"),
}
continue;
}
if run.props.hidden {
continue;
}
let mut t = escape(&run.text);
if t.is_empty() {
continue;
}
if run.props.strike {
t = format!("<s>{t}</s>");
}
if run.props.underline {
t = format!("<u>{t}</u>");
}
if run.props.italic {
t = format!("<em>{t}</em>");
}
if run.props.bold {
t = format!("<strong>{t}</strong>");
}
match &run.field {
FieldRole::Hyperlink { url } => {
out.push_str(&format!("<a href=\"{}\">{t}</a>", escape_attr(url)))
}
_ => out.push_str(&t),
}
}
out
}
pub(crate) fn render_table_fragment(t: &Table) -> String {
let mut out = String::from("<table>");
for (i, row) in t.rows.iter().enumerate() {
let header = i < t.header_rows;
if i == 0 && t.header_rows > 0 {
out.push_str("<thead>");
}
if i == t.header_rows {
out.push_str(if t.header_rows > 0 {
"</thead><tbody>"
} else {
"<tbody>"
});
}
out.push_str("<tr>");
for cell in &row.cells {
out.push_str(&render_cell(cell, header || cell.is_header));
}
out.push_str("</tr>");
}
if t.rows.len() <= t.header_rows && t.header_rows > 0 {
out.push_str("</thead>");
} else if !t.rows.is_empty() {
out.push_str("</tbody>");
}
out.push_str("</table>");
out
}
fn render_cell(cell: &Cell, header: bool) -> String {
let tag = if header { "th" } else { "td" };
let mut attrs = String::new();
if cell.col_span > 1 {
attrs.push_str(&format!(" colspan=\"{}\"", cell.col_span));
}
if cell.row_span > 1 {
attrs.push_str(&format!(" rowspan=\"{}\"", cell.row_span));
}
let inner: String = cell.blocks.iter().map(render_block).collect();
format!("<{tag}{attrs}>{inner}</{tag}>")
}
fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(ch),
}
}
out
}
fn escape_attr(s: &str) -> String {
escape(s).replace('"', """)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::*;
fn run(text: &str) -> Run {
Run {
text: text.to_string(),
..Default::default()
}
}
#[test]
fn nested_lists() {
let item = |text: &str, level: u8, ordered: bool| {
Block::Paragraph(Paragraph {
props: ParaProps {
list: Some(ListInfo {
level,
ordered,
label: String::new(),
}),
..Default::default()
},
runs: vec![run(text)],
})
};
let doc = DocModel {
blocks: vec![item("a", 0, false), item("b", 1, true), item("c", 0, false)],
meta: DocMeta::default(),
..Default::default()
};
assert_eq!(
render(&doc),
"<ul><li>a</li><ol><li>b</li></ol><li>c</li></ul>"
);
}
#[test]
fn table_with_colspan_and_escaping() {
let mut wide = Cell {
blocks: vec![Block::Paragraph(Paragraph {
props: ParaProps::default(),
runs: vec![run("a<b>")],
})],
..Default::default()
};
wide.col_span = 2;
let t = Table {
rows: vec![Row { cells: vec![wide] }],
header_rows: 0,
..Default::default()
};
assert_eq!(
render_table_fragment(&t),
"<table><tbody><tr><td colspan=\"2\"><p>a<b></p></td></tr></tbody></table>"
);
}
#[test]
fn paragraph_page_break_before_emits_page_break_marker() {
let doc = DocModel {
blocks: vec![
Block::Paragraph(Paragraph {
props: ParaProps::default(),
runs: vec![run("Cover")],
}),
Block::Paragraph(Paragraph {
props: ParaProps {
page_break_before: true,
..Default::default()
},
runs: vec![run("Detail")],
}),
],
meta: DocMeta::default(),
..Default::default()
};
assert_eq!(
render(&doc),
"<p>Cover</p><hr class=\"page-break\"><p>Detail</p>"
);
}
}