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 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>>,
},
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
}
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()),
},
};
let column_count = head.map(|h| h.len()).unwrap_or(0);
if column_count == 0 {
continue;
}
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(" |");
}
}
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 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 |");
}
}