use super::inline::escape_inline;
use super::paragraphs::{ParaAccum, is_soft_hyphen_break};
use super::tables::escape_table_cell;
use crate::types::Rect;
#[derive(Debug, Clone, Default)]
pub struct Cell {
pub text: String,
pub bbox: Option<Rect>,
}
impl Cell {
pub fn located(text: impl Into<String>, bbox: Rect) -> Self {
Cell {
text: text.into(),
bbox: Some(bbox),
}
}
pub fn as_str(&self) -> &str {
&self.text
}
}
impl From<&str> for Cell {
fn from(text: &str) -> Self {
Cell {
text: text.to_string(),
bbox: None,
}
}
}
impl From<String> for Cell {
fn from(text: String) -> Self {
Cell { text, bbox: None }
}
}
impl PartialEq for Cell {
fn eq(&self, other: &Self) -> bool {
self.text == other.text
}
}
impl Eq for Cell {}
impl PartialEq<str> for Cell {
fn eq(&self, other: &str) -> bool {
self.text == other
}
}
impl PartialEq<&str> for Cell {
fn eq(&self, other: &&str) -> bool {
self.text == *other
}
}
impl PartialEq<String> for Cell {
fn eq(&self, other: &String) -> bool {
self.text == *other
}
}
#[derive(Debug, Clone)]
pub struct SpanCell {
pub text: String,
pub colspan: u16,
pub rowspan: u16,
pub bbox: Option<Rect>,
}
impl SpanCell {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
colspan: 1,
rowspan: 1,
bbox: None,
}
}
pub fn spanning(text: impl Into<String>, colspan: u16, rowspan: u16) -> Self {
Self {
text: text.into(),
colspan: colspan.max(1),
rowspan: rowspan.max(1),
bbox: None,
}
}
pub fn with_bbox(mut self, bbox: Rect) -> Self {
self.bbox = Some(bbox);
self
}
fn is_plain(&self) -> bool {
self.colspan <= 1 && self.rowspan <= 1
}
}
impl PartialEq for SpanCell {
fn eq(&self, other: &Self) -> bool {
self.text == other.text && self.colspan == other.colspan && self.rowspan == other.rowspan
}
}
impl Eq for SpanCell {}
#[derive(Debug, Clone)]
pub enum Block {
Heading {
level: u8,
text: String,
},
Paragraph {
text: String,
bold: bool,
italic: bool,
},
ListItem {
ordered: bool,
marker: String,
level: u8,
text: String,
bold: bool,
italic: bool,
},
CodeBlock {
lines: Vec<String>,
lang: Option<String>,
},
Table {
header: Option<Vec<Cell>>,
rows: Vec<Vec<Cell>>,
},
MergedTable {
rows: Vec<Vec<SpanCell>>,
header_rows: usize,
},
GridFallback {
lines: Vec<String>,
},
HorizontalRule,
Figure {
id: String,
format: String,
},
}
pub(super) fn paragraph_from_accum(accum: ParaAccum) -> Block {
match accum.uniform {
Some((bold, italic)) if bold || italic => Block::Paragraph {
text: escape_inline(&accum.raw),
bold,
italic,
},
Some(_) => Block::Paragraph {
text: escape_inline(&accum.raw),
bold: false,
italic: false,
},
None => Block::Paragraph {
text: accum.inline,
bold: false,
italic: false,
},
}
}
fn wrap_emphasis(text: &str, bold: bool, italic: bool) -> String {
if text.trim().is_empty() {
return text.to_string();
}
match (bold, italic) {
(true, true) => format!("***{text}***"),
(true, false) => format!("**{text}**"),
(false, true) => format!("*{text}*"),
(false, false) => text.to_string(),
}
}
#[derive(Debug, Clone)]
pub struct PositionedBlock {
pub block: Block,
pub bbox: Option<Rect>,
}
impl PositionedBlock {
pub fn new(block: Block, bbox: Option<Rect>) -> Self {
PositionedBlock { block, bbox }
}
pub fn unlocated(block: Block) -> Self {
PositionedBlock { block, bbox: None }
}
fn absorb(&mut self, other: &Option<Rect>) {
if let Some(r) = other {
Rect::extend(&mut self.bbox, r);
}
}
}
pub fn splice_soft_hyphens(blocks: Vec<PositionedBlock>) -> Vec<PositionedBlock> {
let mut out: Vec<PositionedBlock> = Vec::with_capacity(blocks.len());
for pb in blocks {
let joinable = match (out.last().map(|p| &p.block), &pb.block) {
(
Some(Block::Paragraph {
text: prev,
bold: false,
italic: false,
}),
Block::Paragraph {
text,
bold: false,
italic: false,
},
) => is_soft_hyphen_break(prev, text).then(|| text.clone()),
_ => None,
};
if let Some(tail) = joinable {
let prev = out.last_mut().expect("gate matched on a previous block");
if let Block::Paragraph { text, .. } = &mut prev.block {
while text.ends_with(|c: char| c.is_whitespace()) {
text.pop();
}
text.pop(); text.push_str(&tail);
}
prev.absorb(&pb.bbox);
continue;
}
out.push(pb);
}
out
}
fn render_pipe_table(head: Option<&[Cell]>, body: &[Vec<Cell>], out: &mut String) {
let column_count = head.map(|h| h.len()).unwrap_or(0);
if column_count == 0 {
return;
}
out.push_str("| ");
for (i, cell) in head.unwrap().iter().enumerate() {
if i > 0 {
out.push_str(" | ");
}
out.push_str(&escape_table_cell(&cell.text));
}
out.push_str(" |\n");
out.push('|');
for _ in 0..column_count {
out.push_str("---|");
}
for row in body {
out.push_str("\n| ");
for (i, cell) in row.iter().enumerate() {
if i > 0 {
out.push_str(" | ");
}
out.push_str(&escape_table_cell(&cell.text));
}
out.push_str(" |");
}
}
fn escape_html_cell(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'\n' => out.push(' '),
_ => out.push(c),
}
}
out
}
fn is_plain_grid(rows: &[Vec<SpanCell>], header_rows: usize) -> bool {
if header_rows > 1 {
return false;
}
if !rows.iter().all(|r| r.iter().all(SpanCell::is_plain)) {
return false;
}
let width = rows.first().map(|r| r.len()).unwrap_or(0);
rows.iter().all(|r| r.len() == width)
}
fn render_html_table(rows: &[Vec<SpanCell>], header_rows: usize, out: &mut String) {
out.push_str("<table>");
for (r, row) in rows.iter().enumerate() {
out.push_str("\n<tr>");
let tag = if r < header_rows { "th" } else { "td" };
for cell in row {
out.push_str("\n<");
out.push_str(tag);
if cell.colspan > 1 {
out.push_str(&format!(" colspan=\"{}\"", cell.colspan));
}
if cell.rowspan > 1 {
out.push_str(&format!(" rowspan=\"{}\"", cell.rowspan));
}
out.push('>');
out.push_str(&escape_html_cell(&cell.text));
out.push_str("</");
out.push_str(tag);
out.push('>');
}
out.push_str("\n</tr>");
}
out.push_str("\n</table>");
}
pub fn render_blocks(blocks: &[PositionedBlock]) -> String {
let mut out = String::new();
for (i, positioned) in blocks.iter().enumerate() {
let block = &positioned.block;
if i > 0 {
let tight = matches!(block, Block::ListItem { .. })
&& matches!(blocks[i - 1].block, Block::ListItem { .. });
if tight {
out.push('\n');
} else {
out.push_str("\n\n");
}
}
match block {
Block::Heading { level, text } => {
let level = (*level).clamp(1, 6) as usize;
out.push_str(&"#".repeat(level));
out.push(' ');
out.push_str(text);
}
Block::Paragraph { text, bold, italic } => {
out.push_str(&wrap_emphasis(text, *bold, *italic));
}
Block::ListItem {
ordered,
marker,
level,
text,
bold,
italic,
} => {
let indent = " ".repeat((*level).min(6) as usize);
out.push_str(&indent);
if *ordered {
out.push_str(marker);
out.push(' ');
} else {
out.push_str("- ");
}
out.push_str(&wrap_emphasis(text, *bold, *italic));
}
Block::Table { header, rows } => {
let (head, body): (Option<&[Cell]>, &[Vec<Cell>]) = match header {
Some(h) => (Some(h.as_slice()), rows.as_slice()),
None => match rows.split_first() {
Some((first, rest)) => (Some(first.as_slice()), rest),
None => (None, rows.as_slice()),
},
};
render_pipe_table(head, body, &mut out);
}
Block::MergedTable { rows, header_rows } => {
if rows.is_empty() {
continue;
}
if is_plain_grid(rows, *header_rows) {
let plain: Vec<Vec<Cell>> = rows
.iter()
.map(|r| r.iter().map(|c| Cell::from(c.text.clone())).collect())
.collect();
let (head, body) = match plain.split_first() {
Some((first, rest)) => (Some(first.as_slice()), rest),
None => (None, plain.as_slice()),
};
render_pipe_table(head, body, &mut out);
} else {
render_html_table(rows, *header_rows, &mut out);
}
}
Block::GridFallback { lines } => {
out.push_str("```text\n");
for line in lines {
out.push_str(line);
out.push('\n');
}
out.push_str("```");
}
Block::CodeBlock { lines, lang } => {
let fence = if lines.iter().any(|l| l.contains("```")) {
"~~~"
} else {
"```"
};
out.push_str(fence);
if let Some(lang) = lang {
out.push_str(lang);
}
out.push('\n');
for line in lines {
out.push_str(line);
out.push('\n');
}
out.push_str(fence);
}
Block::HorizontalRule => {
out.push_str("---");
}
Block::Figure { id, format } => {
out.push_str(";
out.push_str(id);
out.push('.');
out.push_str(format);
out.push(')');
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn render(blocks: Vec<Block>) -> String {
let positioned: Vec<PositionedBlock> =
blocks.into_iter().map(PositionedBlock::unlocated).collect();
render_blocks(&positioned)
}
#[test]
fn render_blocks_formats_markdown() {
let blocks = vec![
Block::Heading {
level: 1,
text: "Title".into(),
},
Block::Paragraph {
text: "A paragraph.".into(),
bold: false,
italic: false,
},
Block::Heading {
level: 2,
text: "Sub".into(),
},
];
let s = render(blocks);
assert_eq!(s, "# Title\n\nA paragraph.\n\n## Sub");
}
#[test]
fn render_figure_uses_extracted_format() {
assert_eq!(
render(vec![Block::Figure {
id: "p1_1".into(),
format: "jpg".into(),
}]),
""
);
}
#[test]
fn render_lists_are_tight() {
let blocks = vec![
Block::Paragraph {
text: "Intro.".into(),
bold: false,
italic: false,
},
Block::ListItem {
ordered: false,
marker: "•".into(),
level: 0,
text: "a".into(),
bold: false,
italic: false,
},
Block::ListItem {
ordered: false,
marker: "•".into(),
level: 0,
text: "b".into(),
bold: false,
italic: false,
},
Block::Paragraph {
text: "Outro.".into(),
bold: false,
italic: false,
},
];
let s = render(blocks);
assert_eq!(s, "Intro.\n\n- a\n- b\n\nOutro.");
let s = render(vec![
Block::ListItem {
ordered: true,
marker: "138.".into(),
level: 0,
text: "footnote".into(),
bold: false,
italic: false,
},
Block::ListItem {
ordered: true,
marker: "139.".into(),
level: 0,
text: "next footnote".into(),
bold: false,
italic: false,
},
]);
assert_eq!(s, "138. footnote\n139. next footnote");
}
#[test]
fn render_emphasis_combinations() {
assert_eq!(wrap_emphasis("hi", false, false), "hi");
assert_eq!(wrap_emphasis("hi", true, false), "**hi**");
assert_eq!(wrap_emphasis("hi", false, true), "*hi*");
assert_eq!(wrap_emphasis("hi", true, true), "***hi***");
}
#[test]
fn code_block_escapes_internal_fence() {
let blocks = vec![Block::CodeBlock {
lines: vec!["body containing ``` backticks".into()],
lang: None,
}];
let s = render(blocks);
assert!(s.starts_with("~~~\n"));
assert!(s.ends_with("~~~"));
}
#[test]
fn renders_table_to_pipe_format() {
let blocks = vec![Block::Table {
header: Some(vec!["a".into(), "b".into()]),
rows: vec![vec!["1".into(), "2".into()], vec!["3".into(), "4".into()]],
}];
let s = render(blocks);
assert_eq!(s, "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |");
}
#[test]
fn splices_hyphen_split_across_paragraph_blocks() {
let p = |t: &str| Block::Paragraph {
text: t.into(),
bold: false,
italic: false,
};
let spliced = |a: &str, b: &str| {
splice_soft_hyphens(vec![
PositionedBlock::unlocated(p(a)),
PositionedBlock::unlocated(p(b)),
])
};
let rendered = |a: &str, b: &str| render_blocks(&spliced(a, b));
assert_eq!(
rendered("they dis-", "lodged the part"),
"they dislodged the part"
);
assert_eq!(
rendered("the well-", "Known fact"),
"the well-\n\nKnown fact"
);
assert_eq!(rendered("a -", "dash line"), "a -\n\ndash line");
let r = |y: f32| Rect {
x: 10.0,
y,
width: 100.0,
height: 12.0,
};
let joined = splice_soft_hyphens(vec![
PositionedBlock::new(p("they dis-"), Some(r(50.0))),
PositionedBlock::new(p("lodged the part"), Some(r(70.0))),
]);
assert_eq!(joined.len(), 1);
let bbox = joined[0].bbox.clone().expect("merged block keeps geometry");
assert_eq!((bbox.y, bbox.height), (50.0, 32.0));
}
#[test]
fn merged_table_without_spans_renders_as_pipes() {
let plain = Block::Table {
header: Some(vec!["a".into(), "b".into()]),
rows: vec![vec!["1".into(), "2".into()]],
};
let merged = Block::MergedTable {
header_rows: 1,
rows: vec![
vec![SpanCell::new("a"), SpanCell::new("b")],
vec![SpanCell::new("1"), SpanCell::new("2")],
],
};
assert_eq!(render(vec![plain]), render(vec![merged]));
}
#[test]
fn merged_table_with_spans_renders_as_html() {
let s = render(vec![Block::MergedTable {
header_rows: 1,
rows: vec![
vec![SpanCell::spanning("wide", 2, 1)],
vec![SpanCell::spanning("tall", 1, 2), SpanCell::new("x")],
vec![SpanCell::new("y")],
],
}]);
assert_eq!(
s,
"<table>\n<tr>\n<th colspan=\"2\">wide</th>\n</tr>\n\
<tr>\n<td rowspan=\"2\">tall</td>\n<td>x</td>\n</tr>\n\
<tr>\n<td>y</td>\n</tr>\n</table>"
);
}
#[test]
fn merged_table_escapes_html_not_markdown() {
let s = render(vec![Block::MergedTable {
header_rows: 0,
rows: vec![vec![
SpanCell::new("a|b *c* <d> &e"),
SpanCell::spanning("f", 2, 1),
]],
}]);
assert!(s.contains("<td>a|b *c* <d> &e</td>"), "{s}");
}
#[test]
fn merged_table_multiple_header_rows_force_html() {
let s = render(vec![Block::MergedTable {
header_rows: 2,
rows: vec![
vec![SpanCell::new("a"), SpanCell::new("b")],
vec![SpanCell::new("c"), SpanCell::new("d")],
vec![SpanCell::new("1"), SpanCell::new("2")],
],
}]);
assert!(s.starts_with("<table>"), "{s}");
assert_eq!(s.matches("<th>").count(), 4);
assert_eq!(s.matches("<td>").count(), 2);
}
#[test]
fn merged_table_ragged_rows_force_html() {
let s = render(vec![Block::MergedTable {
header_rows: 1,
rows: vec![
vec![SpanCell::new("a"), SpanCell::new("b")],
vec![SpanCell::new("1")],
],
}]);
assert!(s.starts_with("<table>"), "{s}");
}
#[test]
fn zero_span_is_coerced_to_one() {
let c = SpanCell::spanning("x", 0, 0);
assert_eq!((c.colspan, c.rowspan), (1, 1));
assert!(c.is_plain());
}
#[test]
fn render_table_without_header_promotes_first_row() {
let blocks = vec![Block::Table {
header: None,
rows: vec![vec!["h1".into(), "h2".into()], vec!["1".into(), "2".into()]],
}];
let s = render(blocks);
assert_eq!(s, "| h1 | h2 |\n|---|---|\n| 1 | 2 |");
}
}