use printpdf::{
BuiltinFont, Color, Line, LinePoint, Op, PdfFontHandle, PdfPage, Point, Pt, Rgb, TextItem,
};
use super::html::Node;
use super::metrics::{char_width_1000em, text_width_pt};
const MAX_DEPTH: u32 = 512;
const PAGE_WIDTH_PT: f32 = 595.28;
const PAGE_HEIGHT_PT: f32 = 841.89;
const MARGIN_PT: f32 = 50.0;
const BLACK: Color = Color::Rgb(Rgb {
r: 0.0,
g: 0.0,
b: 0.0,
icc_profile: None,
});
#[derive(Debug, Clone, PartialEq)]
enum Span {
Run {
text: String,
bold: bool,
italic: bool,
},
Break,
}
#[derive(Debug, Clone, PartialEq)]
enum Word {
Text {
text: String,
bold: bool,
italic: bool,
glue: bool,
unbreakable: bool,
},
Break,
}
#[derive(Debug, Clone, PartialEq)]
struct TableRow {
cells: Vec<(Vec<Span>, bool)>,
}
#[derive(Debug, Clone, PartialEq)]
enum Block {
Heading(u8, Vec<Span>),
Paragraph(Vec<Span>),
ListItem { marker: String, spans: Vec<Span> },
Rule,
Table(Vec<TableRow>),
}
fn heading_level(tag: &str) -> Option<u8> {
match tag {
"h1" => Some(1),
"h2" => Some(2),
"h3" => Some(3),
"h4" => Some(4),
"h5" => Some(5),
"h6" => Some(6),
_ => None,
}
}
fn is_non_rendered(tag: &str) -> bool {
matches!(
tag,
"script" | "style" | "noscript" | "template" | "head" | "title"
)
}
fn is_block_boundary_in_inline_context(tag: &str) -> bool {
heading_level(tag).is_some()
|| matches!(
tag,
"p" | "div"
| "blockquote"
| "li"
| "dl"
| "dt"
| "dd"
| "section"
| "article"
| "main"
| "header"
| "footer"
| "nav"
| "aside"
| "table"
| "thead"
| "tbody"
| "tfoot"
| "tr"
| "td"
| "th"
| "hr"
)
}
fn push_block_break(out: &mut Vec<Span>) {
if !matches!(out.last(), None | Some(Span::Break)) {
out.push(Span::Break);
}
}
fn trim_trailing_break(spans: &mut Vec<Span>) {
if matches!(spans.last(), Some(Span::Break)) {
spans.pop();
}
}
fn inline_spans(nodes: &[Node], bold: bool, italic: bool, depth: u32, out: &mut Vec<Span>) {
if depth > MAX_DEPTH {
return;
}
for node in nodes {
match node {
Node::Text(text) => {
if !text.is_empty() {
out.push(Span::Run {
text: text.clone(),
bold,
italic,
});
}
}
Node::Element { tag, children } => match tag.as_str() {
"br" => out.push(Span::Break),
"strong" | "b" => inline_spans(children, true, italic, depth + 1, out),
"em" | "i" => inline_spans(children, bold, true, depth + 1, out),
_ if is_non_rendered(tag) => {}
"ul" => {
push_block_break(out);
inline_list_items(children, false, bold, italic, depth + 1, out);
push_block_break(out);
}
"ol" => {
push_block_break(out);
inline_list_items(children, true, bold, italic, depth + 1, out);
push_block_break(out);
}
_ if is_block_boundary_in_inline_context(tag) => {
push_block_break(out);
inline_spans(children, bold, italic, depth + 1, out);
push_block_break(out);
}
_ => inline_spans(children, bold, italic, depth + 1, out),
},
}
}
}
fn inline_list_items(
nodes: &[Node],
ordered: bool,
bold: bool,
italic: bool,
depth: u32,
out: &mut Vec<Span>,
) {
if depth > MAX_DEPTH {
return;
}
let mut index = 0u32;
for node in nodes {
let Node::Element { tag, children } = node else {
continue;
};
if tag != "li" {
continue;
}
index += 1;
if index > 1 {
push_block_break(out);
}
let marker = if ordered {
format!("{index}. ")
} else {
"\u{2022} ".to_owned()
};
out.push(Span::Run {
text: marker,
bold,
italic,
});
let content_start = out.len();
inline_spans(children, bold, italic, depth + 1, out);
if out.get(content_start) == Some(&Span::Break) {
out.remove(content_start);
}
}
}
fn extract_table_rows(nodes: &[Node], depth: u32, out: &mut Vec<TableRow>) {
if depth > MAX_DEPTH {
return;
}
for node in nodes {
let Node::Element { tag, children } = node else {
continue;
};
match tag.as_str() {
"tr" => {
let mut cells = Vec::new();
for cell in children {
let Node::Element {
tag: cell_tag,
children: cell_children,
} = cell
else {
continue;
};
let is_header = cell_tag == "th";
if is_header || cell_tag == "td" {
let mut spans = Vec::new();
inline_spans(cell_children, is_header, false, depth + 2, &mut spans);
trim_trailing_break(&mut spans);
cells.push((spans, is_header));
}
}
out.push(TableRow { cells });
}
"thead" | "tbody" | "tfoot" => extract_table_rows(children, depth + 1, out),
_ if is_non_rendered(tag) => {}
_ => {
let mut spans = Vec::new();
inline_spans(children, false, false, depth + 1, &mut spans);
trim_trailing_break(&mut spans);
if !spans.is_empty() {
out.push(TableRow {
cells: vec![(spans, false)],
});
}
}
}
}
}
fn extract_list_items(nodes: &[Node], ordered: bool, depth: u32, out: &mut Vec<Block>) {
if depth > MAX_DEPTH {
return;
}
let mut index = 0u32;
for node in nodes {
let Node::Element { tag, children } = node else {
continue;
};
if tag != "li" {
continue;
}
index += 1;
let marker = if ordered {
format!("{index}.")
} else {
"\u{2022}".to_owned()
};
let mut spans = Vec::new();
inline_spans(children, false, false, depth + 1, &mut spans);
trim_trailing_break(&mut spans);
out.push(Block::ListItem { marker, spans });
}
}
fn flatten_blocks(nodes: &[Node], depth: u32, out: &mut Vec<Block>) {
if depth > MAX_DEPTH {
return;
}
let mut pending: Vec<Span> = Vec::new();
let flush = |pending: &mut Vec<Span>, out: &mut Vec<Block>| {
if !pending.is_empty() {
out.push(Block::Paragraph(std::mem::take(pending)));
}
};
for node in nodes {
match node {
Node::Text(text) => {
if !text.is_empty() {
pending.push(Span::Run {
text: text.clone(),
bold: false,
italic: false,
});
}
}
Node::Element { tag, children } => {
if let Some(level) = heading_level(tag) {
flush(&mut pending, out);
let mut spans = Vec::new();
inline_spans(children, true, false, depth + 1, &mut spans);
trim_trailing_break(&mut spans);
out.push(Block::Heading(level, spans));
continue;
}
match tag.as_str() {
"p" | "li" | "dt" | "dd" => {
flush(&mut pending, out);
let mut spans = Vec::new();
inline_spans(children, false, false, depth + 1, &mut spans);
trim_trailing_break(&mut spans);
out.push(Block::Paragraph(spans));
}
"div" | "blockquote" | "dl" | "section" | "article" | "main" | "header"
| "footer" | "nav" | "aside" => {
flush(&mut pending, out);
flatten_blocks(children, depth + 1, out);
}
"hr" => {
flush(&mut pending, out);
out.push(Block::Rule);
}
"table" => {
flush(&mut pending, out);
let mut rows = Vec::new();
extract_table_rows(children, depth + 1, &mut rows);
out.push(Block::Table(rows));
}
"ul" => {
flush(&mut pending, out);
extract_list_items(children, false, depth + 1, out);
}
"ol" => {
flush(&mut pending, out);
extract_list_items(children, true, depth + 1, out);
}
"br" => pending.push(Span::Break),
"strong" | "b" => inline_spans(children, true, false, depth + 1, &mut pending),
"em" | "i" => inline_spans(children, false, true, depth + 1, &mut pending),
_ if is_non_rendered(tag) => {}
_ => flatten_into_pending(children, depth + 1, &mut pending, out),
}
}
}
}
flush(&mut pending, out);
}
fn flatten_into_pending(nodes: &[Node], depth: u32, pending: &mut Vec<Span>, out: &mut Vec<Block>) {
if depth > MAX_DEPTH {
return;
}
for node in nodes {
match node {
Node::Text(text) => {
if !text.is_empty() {
pending.push(Span::Run {
text: text.clone(),
bold: false,
italic: false,
});
}
}
Node::Element { tag, children } => {
if heading_level(tag).is_some()
|| matches!(
tag.as_str(),
"p" | "div"
| "li"
| "blockquote"
| "hr"
| "table"
| "ul"
| "ol"
| "dl"
| "dt"
| "dd"
| "section"
| "article"
| "main"
| "header"
| "footer"
| "nav"
| "aside"
)
{
if !pending.is_empty() {
out.push(Block::Paragraph(std::mem::take(pending)));
}
flatten_blocks(std::slice::from_ref(node), depth, out);
} else {
match tag.as_str() {
"br" => pending.push(Span::Break),
"strong" | "b" => inline_spans(children, true, false, depth + 1, pending),
"em" | "i" => inline_spans(children, false, true, depth + 1, pending),
_ if is_non_rendered(tag) => {}
_ => flatten_into_pending(children, depth + 1, pending, out),
}
}
}
}
}
}
const fn is_non_breaking_space(c: char) -> bool {
matches!(c, '\u{00A0}' | '\u{2007}' | '\u{202F}')
}
fn words_of(spans: &[Span]) -> Vec<Word> {
let mut words = Vec::new();
let mut glue_next = false;
let mut glue_next_unbreakable = false;
for span in spans {
match span {
Span::Break => {
words.push(Word::Break);
glue_next = false;
glue_next_unbreakable = false;
}
Span::Run { text, bold, italic } => {
let is_breakable_ws = |c: char| c.is_whitespace() && !is_non_breaking_space(c);
let starts_with_ws = text.starts_with(is_breakable_ws);
let ends_with_ws = text.ends_with(is_breakable_ws);
let starts_with_nbsp = text.starts_with(is_non_breaking_space);
let ends_with_nbsp = text.ends_with(is_non_breaking_space);
let mut emitted_any = false;
for (i, w) in text
.split(is_breakable_ws)
.filter(|w| !w.is_empty())
.enumerate()
{
let glue = i == 0 && glue_next && !starts_with_ws;
words.push(Word::Text {
text: w.to_owned(),
bold: *bold,
italic: *italic,
glue,
unbreakable: glue && (glue_next_unbreakable || starts_with_nbsp),
});
emitted_any = true;
}
glue_next = emitted_any && !ends_with_ws;
glue_next_unbreakable = emitted_any && ends_with_nbsp;
}
}
}
words
}
type StyledWord = (String, bool, bool, bool);
fn split_into_fitting_chunks(
text: &str,
font_size_pt: f32,
bold: bool,
first_chunk_max_width_pt: f32,
max_width_pt: f32,
) -> Vec<String> {
let mut chunks = Vec::new();
let mut current = String::new();
let mut current_width = 0.0f32;
let mut run_start = 0usize;
let mut run_width = 0.0f32;
for ch in text.chars() {
let ch_width = f32::from(char_width_1000em(ch, bold)) / 1000.0 * font_size_pt;
let connected = is_non_breaking_space(ch) || current.ends_with(is_non_breaking_space);
let limit = if chunks.is_empty() {
first_chunk_max_width_pt
} else {
max_width_pt
};
if !current.is_empty() && current_width + ch_width > limit {
if connected {
if run_start > 0 {
let tail = current.split_off(run_start);
chunks.push(std::mem::take(&mut current));
current = tail;
current_width = run_width;
run_start = 0;
}
} else {
chunks.push(std::mem::take(&mut current));
current_width = 0.0;
run_start = 0;
run_width = 0.0;
}
}
if connected {
run_width += ch_width;
} else {
run_start = current.len();
run_width = ch_width;
}
current.push(ch);
current_width += ch_width;
}
if !current.is_empty() {
chunks.push(current);
}
chunks
}
#[allow(clippy::too_many_arguments)]
fn split_oversized_glued_word(
text: &str,
bold: bool,
italic: bool,
w: f32,
font_size_pt: f32,
max_width_pt: f32,
current: &mut Vec<StyledWord>,
current_width: &mut f32,
lines: &mut Vec<Vec<StyledWord>>,
) -> bool {
let existing_width = (*current_width - w).max(0.0);
let mut chunks = split_into_fitting_chunks(
text,
font_size_pt,
bold,
(max_width_pt - existing_width).max(0.0),
max_width_pt,
)
.into_iter();
let first = chunks
.next()
.expect("split_into_fitting_chunks never returns empty chunks for non-empty text");
let rest: Vec<String> = chunks.collect();
if rest.is_empty() {
return false;
}
let first_w = text_width_pt(&first, font_size_pt, bold);
*current_width = *current_width - w + first_w;
current.push((first, bold, italic, true));
lines.push(std::mem::take(current));
let last = rest.len() - 1;
for (i, chunk) in rest.into_iter().enumerate() {
let chunk_w = text_width_pt(&chunk, font_size_pt, bold);
if i == last {
*current_width = chunk_w;
*current = vec![(chunk, bold, italic, false)];
} else {
lines.push(vec![(chunk, bold, italic, false)]);
}
}
true
}
#[allow(clippy::too_many_arguments)]
fn handle_unbreakable_word(
text: &str,
bold: bool,
italic: bool,
w: f32,
font_size_pt: f32,
max_width_pt: f32,
current: &mut Vec<StyledWord>,
current_width: &mut f32,
run_start: &mut usize,
run_width: &mut f32,
lines: &mut Vec<Vec<StyledWord>>,
) {
let new_run_width = *run_width + w;
let prefix_width = *current_width - *run_width;
if *run_start > 0 && prefix_width + new_run_width > max_width_pt {
let tail = current.split_off(*run_start);
lines.push(std::mem::take(current));
*current = tail;
*current_width = new_run_width;
*run_start = 0;
} else {
*current_width = prefix_width + new_run_width;
}
if w > max_width_pt
&& !text.is_empty()
&& split_oversized_glued_word(
text,
bold,
italic,
w,
font_size_pt,
max_width_pt,
current,
current_width,
lines,
)
{
*run_start = 0;
*run_width = *current_width;
return;
}
current.push((text.to_owned(), bold, italic, true));
*run_width = new_run_width;
}
fn wrap(words: &[Word], max_width_pt: f32, font_size_pt: f32) -> Vec<Vec<StyledWord>> {
let space_w = text_width_pt(" ", font_size_pt, false);
let mut lines = Vec::new();
let mut current: Vec<StyledWord> = Vec::new();
let mut current_width = 0.0f32;
let mut run_start = 0usize;
let mut run_width = 0.0f32;
for word in words {
match word {
Word::Break => {
lines.push(std::mem::take(&mut current));
current_width = 0.0;
run_start = 0;
run_width = 0.0;
}
Word::Text {
text,
bold,
italic,
glue,
unbreakable,
} => {
let w = text_width_pt(text, font_size_pt, *bold);
if *unbreakable && !current.is_empty() {
handle_unbreakable_word(
text,
*bold,
*italic,
w,
font_size_pt,
max_width_pt,
&mut current,
&mut current_width,
&mut run_start,
&mut run_width,
&mut lines,
);
continue;
}
if w > max_width_pt && !text.is_empty() {
if !current.is_empty() {
lines.push(std::mem::take(&mut current));
current_width = 0.0;
}
let chunks = split_into_fitting_chunks(
text,
font_size_pt,
*bold,
max_width_pt,
max_width_pt,
);
let last = chunks.len().saturating_sub(1);
for (i, chunk) in chunks.into_iter().enumerate() {
let chunk_w = text_width_pt(&chunk, font_size_pt, *bold);
if i == last {
current_width = chunk_w;
current = vec![(chunk, *bold, *italic, false)];
} else {
lines.push(vec![(chunk, *bold, *italic, false)]);
}
}
run_start = 0;
run_width = current_width;
continue;
}
let mut glued = *glue && !current.is_empty();
let needed = if current.is_empty() || glued {
w
} else {
w + space_w
};
if !current.is_empty() && current_width + needed > max_width_pt {
lines.push(std::mem::take(&mut current));
current_width = 0.0;
glued = false;
}
current_width += if current.is_empty() || glued {
w
} else {
w + space_w
};
run_start = current.len();
run_width = w;
current.push((text.clone(), *bold, *italic, glued));
}
}
}
if !current.is_empty() {
lines.push(current);
}
lines
}
const fn builtin_font(bold: bool, italic: bool) -> BuiltinFont {
match (bold, italic) {
(false, false) => BuiltinFont::Helvetica,
(true, false) => BuiltinFont::HelveticaBold,
(false, true) => BuiltinFont::HelveticaOblique,
(true, true) => BuiltinFont::HelveticaBoldOblique,
}
}
struct Writer {
pages: Vec<PdfPage>,
ops: Vec<Op>,
y_from_top: f32,
content_width: f32,
}
impl Writer {
fn new() -> Self {
Self {
pages: Vec::new(),
ops: Vec::new(),
y_from_top: 0.0,
content_width: (-2.0f32).mul_add(MARGIN_PT, PAGE_WIDTH_PT),
}
}
fn cursor_y_pt(&self) -> f32 {
PAGE_HEIGHT_PT - MARGIN_PT - self.y_from_top
}
fn ensure_space(&mut self, height_needed: f32) {
let max_y = (-2.0f32).mul_add(MARGIN_PT, PAGE_HEIGHT_PT);
if self.y_from_top + height_needed > max_y && self.y_from_top > 0.0 {
self.new_page();
}
}
fn new_page(&mut self) {
let ops = std::mem::take(&mut self.ops);
self.pages.push(PdfPage::new(
Pt(PAGE_WIDTH_PT).into(),
Pt(PAGE_HEIGHT_PT).into(),
ops,
));
self.y_from_top = 0.0;
}
fn draw_word(&mut self, x_from_left: f32, text: &str, bold: bool, italic: bool, size: f32) {
self.ops.push(Op::StartTextSection);
self.ops.push(Op::SetFont {
font: PdfFontHandle::Builtin(builtin_font(bold, italic)),
size: Pt(size),
});
self.ops.push(Op::SetFillColor { col: BLACK });
self.ops.push(Op::SetTextCursor {
pos: Point {
x: Pt(MARGIN_PT + x_from_left),
y: Pt(self.cursor_y_pt()),
},
});
self.ops.push(Op::ShowText {
items: vec![TextItem::Text(text.to_owned())],
});
self.ops.push(Op::EndTextSection);
}
fn draw_lines(
&mut self,
lines: &[Vec<StyledWord>],
x_offset: f32,
font_size: f32,
line_height: f32,
break_pages: bool,
) {
let space_w = text_width_pt(" ", font_size, false);
for line in lines {
if break_pages {
self.ensure_space(line_height);
}
let mut x = x_offset;
let mut first = true;
for (text, bold, italic, glue) in line {
if !first && !glue {
x += space_w;
}
self.draw_word(x, text, *bold, *italic, font_size);
x += text_width_pt(text, font_size, *bold);
first = false;
}
self.y_from_top += line_height;
}
}
fn draw_spans(&mut self, spans: &[Span], font_size: f32, line_height: f32, space_after: f32) {
let words = words_of(spans);
if words.is_empty() {
return;
}
let lines = wrap(&words, self.content_width, font_size);
self.draw_lines(&lines, 0.0, font_size, line_height, true);
self.y_from_top += space_after;
}
fn draw_rule(&mut self) {
self.ensure_space(14.0);
let y = self.cursor_y_pt() - 4.0;
self.ops.push(Op::SetOutlineColor { col: BLACK });
self.ops.push(Op::SetOutlineThickness { pt: Pt(0.75) });
self.ops.push(Op::DrawLine {
line: Line {
points: vec![
LinePoint {
p: Point {
x: Pt(MARGIN_PT),
y: Pt(y),
},
bezier: false,
},
LinePoint {
p: Point {
x: Pt(MARGIN_PT + self.content_width),
y: Pt(y),
},
bezier: false,
},
],
is_closed: false,
},
});
self.y_from_top += 14.0;
}
#[allow(clippy::cast_precision_loss)]
fn draw_table(&mut self, rows: &[TableRow]) {
const FONT_SIZE: f32 = 10.5;
const LINE_HEIGHT: f32 = 14.0;
const CELL_PADDING: f32 = 4.0;
let n_cols = rows.iter().map(|r| r.cells.len()).max().unwrap_or(0);
if n_cols == 0 {
return;
}
let col_width = self.content_width / n_cols as f32;
for row in rows {
let wrapped: Vec<Vec<Vec<StyledWord>>> = row
.cells
.iter()
.map(|(spans, _)| wrap(&words_of(spans), col_width - CELL_PADDING, FONT_SIZE))
.collect();
let row_lines = wrapped.iter().map(Vec::len).max().unwrap_or(1).max(1);
let row_height = row_lines as f32 * LINE_HEIGHT;
self.ensure_space(row_height);
for (col, lines) in wrapped.iter().enumerate() {
let x_offset = col as f32 * col_width;
let saved_y = self.y_from_top;
self.draw_lines(lines, x_offset, FONT_SIZE, LINE_HEIGHT, false);
self.y_from_top = saved_y;
}
self.y_from_top += row_height;
}
self.y_from_top += 6.0;
}
fn draw_block(&mut self, block: &Block) {
match block {
Block::Heading(level, spans) => {
let size = match level {
1 => 22.0,
2 => 18.0,
3 => 16.0,
4 => 14.0,
5 => 12.5,
_ => 11.5,
};
self.draw_spans(spans, size, size * 1.3, size * 0.5);
}
Block::Paragraph(spans) => {
self.draw_spans(spans, 11.0, 14.5, 10.0);
}
Block::ListItem { marker, spans } => {
const MIN_INDENT: f32 = 16.0;
const MARKER_GAP: f32 = 4.0;
const LINE_HEIGHT: f32 = 14.5;
self.ensure_space(LINE_HEIGHT);
self.draw_word(0.0, marker, false, false, 11.0);
let indent = (text_width_pt(marker, 11.0, false) + MARKER_GAP).max(MIN_INDENT);
let words = words_of(spans);
let lines = wrap(&words, self.content_width - indent, 11.0);
if lines.is_empty() {
self.y_from_top += LINE_HEIGHT;
} else {
self.draw_lines(&lines, indent, 11.0, LINE_HEIGHT, true);
}
self.y_from_top += 4.0;
}
Block::Rule => self.draw_rule(),
Block::Table(rows) => self.draw_table(rows),
}
}
fn finish(mut self) -> Vec<PdfPage> {
if self.pages.is_empty() || self.y_from_top > 0.0 || !self.ops.is_empty() {
self.new_page();
}
self.pages
}
}
pub(super) fn render_pages(html: &str) -> Vec<PdfPage> {
let nodes = super::html::parse(html);
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
let mut writer = Writer::new();
for block in &blocks {
writer.draw_block(block);
}
writer.finish()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrap_breaks_long_text_into_multiple_lines() {
let words = words_of(&[Span::Run {
text: "the quick brown fox jumps over the lazy dog".to_owned(),
bold: false,
italic: false,
}]);
let lines = wrap(&words, 80.0, 12.0);
assert!(lines.len() > 1, "expected wrapping at a narrow width");
for line in &lines {
let width: f32 = line
.iter()
.map(|(t, b, _, _)| text_width_pt(t, 12.0, *b))
.sum();
assert!(width <= 80.0 + 1.0, "line exceeds max width: {width}");
}
}
#[test]
fn oversized_single_token_is_character_wrapped_not_overflowed() {
let words = words_of(&[Span::Run {
text: "https://example.com/a/very/long/path/that/has/no/spaces/anywhere/at/all"
.to_owned(),
bold: false,
italic: false,
}]);
let lines = wrap(&words, 80.0, 12.0);
assert!(
lines.len() > 1,
"expected the token to be split across lines"
);
for line in &lines {
let width: f32 = line
.iter()
.map(|(t, b, _, _)| text_width_pt(t, 12.0, *b))
.sum();
assert!(width <= 80.0 + 1.0, "line exceeds max width: {width}");
}
let reassembled: String = lines
.iter()
.flat_map(|line| line.iter().map(|(t, ..)| t.as_str()))
.collect();
assert_eq!(
reassembled, "https://example.com/a/very/long/path/that/has/no/spaces/anywhere/at/all",
"splitting must not drop or reorder any characters"
);
}
#[test]
fn oversized_token_does_not_split_immediately_adjacent_to_an_embedded_nbsp() {
let text = format!("{}\u{00A0}B", "A".repeat(67));
let font_size_pt = 11.0;
let max_width_pt = text_width_pt(&"A".repeat(67), font_size_pt, false)
+ text_width_pt("\u{00A0}", font_size_pt, false)
+ 0.5;
let chunks =
split_into_fitting_chunks(&text, font_size_pt, false, max_width_pt, max_width_pt);
assert!(
chunks
.iter()
.all(|c| !c.starts_with('\u{00A0}') && !c.ends_with('\u{00A0}')),
"no chunk boundary may sit immediately before or after an NBSP, got {chunks:?}"
);
let reassembled: String = chunks.concat();
assert_eq!(
reassembled, text,
"splitting must not drop or reorder any characters"
);
}
#[test]
fn oversized_token_does_not_split_around_other_unicode_non_breaking_space_variants() {
for nbsp in ['\u{2007}', '\u{202F}'] {
let text = format!("{}{nbsp}B", "A".repeat(67));
let font_size_pt = 11.0;
let max_width_pt = text_width_pt(&"A".repeat(67), font_size_pt, false)
+ text_width_pt( .to_string(), font_size_pt, false)
+ 0.5;
let chunks =
split_into_fitting_chunks(&text, font_size_pt, false, max_width_pt, max_width_pt);
assert!(
chunks
.iter()
.all(|c| !c.starts_with(nbsp) && !c.ends_with(nbsp)),
"U+{:04X}: no chunk boundary may sit immediately before or after it, got \
{chunks:?}",
nbsp as u32
);
let reassembled: String = chunks.concat();
assert_eq!(
reassembled, text,
"U+{:04X}: splitting must not drop or reorder any characters",
nbsp as u32
);
}
}
#[test]
fn oversized_token_does_not_split_when_the_incoming_character_is_the_nbsp() {
let text = format!("{}i\u{00A0}B", "A".repeat(67));
let font_size_pt = 11.0;
let max_width_pt = text_width_pt(&"A".repeat(67), font_size_pt, false)
+ text_width_pt("i", font_size_pt, false)
+ 0.5;
let chunks =
split_into_fitting_chunks(&text, font_size_pt, false, max_width_pt, max_width_pt);
assert!(
chunks
.iter()
.all(|c| !c.starts_with('\u{00A0}') && !c.ends_with('\u{00A0}')),
"no chunk boundary may sit immediately before or after an NBSP, got {chunks:?}"
);
let reassembled: String = chunks.concat();
assert_eq!(
reassembled, text,
"splitting must not drop or reorder any characters"
);
}
#[test]
fn oversized_token_moves_the_entire_nbsp_connected_chain_not_just_one_neighbor() {
let text = format!("{}\u{00A0}B\u{00A0}C", "A".repeat(66));
let font_size_pt = 11.0;
let max_width_pt = text_width_pt(&"A".repeat(66), font_size_pt, false)
+ text_width_pt("\u{00A0}B", font_size_pt, false)
+ 0.5;
let chunks =
split_into_fitting_chunks(&text, font_size_pt, false, max_width_pt, max_width_pt);
assert!(
chunks
.iter()
.all(|c| !c.starts_with('\u{00A0}') && !c.ends_with('\u{00A0}')),
"no chunk boundary may sit immediately before or after an NBSP, got {chunks:?}"
);
let reassembled: String = chunks.concat();
assert_eq!(
reassembled, text,
"splitting must not drop or reorder any characters"
);
}
#[test]
fn oversized_token_narrower_than_max_width_is_left_whole() {
let words = words_of(&[Span::Run {
text: "short".to_owned(),
bold: false,
italic: false,
}]);
let lines = wrap(&words, 80.0, 12.0);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].len(), 1);
assert_eq!(lines[0][0].0, "short");
}
#[test]
fn wrap_honors_explicit_break() {
let words = vec![
Word::Text {
text: "a".to_owned(),
bold: false,
italic: false,
glue: false,
unbreakable: false,
},
Word::Break,
Word::Text {
text: "b".to_owned(),
bold: false,
italic: false,
glue: false,
unbreakable: false,
},
];
let lines = wrap(&words, 1000.0, 12.0);
assert_eq!(lines.len(), 2);
}
#[test]
fn non_breaking_space_keeps_its_words_on_one_line() {
let words = words_of(&[Span::Run {
text: "Invoice\u{00A0}#42".to_owned(),
bold: false,
italic: false,
}]);
assert_eq!(
words,
vec![Word::Text {
text: "Invoice\u{00A0}#42".to_owned(),
bold: false,
italic: false,
glue: false,
unbreakable: false,
}],
"NBSP must not split the run into two breakable words"
);
let narrow_width = text_width_pt("Invoice\u{00A0}#42", 12.0, false) + 1.0;
let lines = wrap(&words, narrow_width, 12.0);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].len(), 1);
assert_eq!(lines[0][0].0, "Invoice\u{00A0}#42");
}
#[test]
fn other_unicode_non_breaking_space_variants_also_keep_their_words_on_one_line() {
for nbsp in ['\u{2007}', '\u{202F}'] {
let text = format!("10{nbsp}000");
let words = words_of(&[Span::Run {
text: text.clone(),
bold: false,
italic: false,
}]);
assert_eq!(
words,
vec![Word::Text {
text: text.clone(),
bold: false,
italic: false,
glue: false,
unbreakable: false,
}],
"U+{:04X} must not split the run into two breakable words, got {words:?}",
nbsp as u32
);
let narrow_width = text_width_pt(&text, 12.0, false) + 1.0;
let lines = wrap(&words, narrow_width, 12.0);
assert_eq!(
lines.len(),
1,
"U+{:04X}: the number must stay on one line, got {lines:?}",
nbsp as u32
);
}
}
#[test]
fn non_breaking_space_leading_a_styled_span_still_glues_to_the_previous_word() {
let words = words_of(&[
Span::Run {
text: "Hello".to_owned(),
bold: false,
italic: false,
},
Span::Run {
text: "\u{00A0}world".to_owned(),
bold: true,
italic: false,
},
]);
assert_eq!(
words,
vec![
Word::Text {
text: "Hello".to_owned(),
bold: false,
italic: false,
glue: false,
unbreakable: false,
},
Word::Text {
text: "\u{00A0}world".to_owned(),
bold: true,
italic: false,
glue: true,
unbreakable: true,
},
],
"the NBSP-led word must glue to the previous word, not add a second separator"
);
}
#[test]
fn glued_run_that_cannot_fit_still_breaks_the_line() {
let words = vec![
Word::Text {
text: "WWWW".to_owned(),
bold: false,
italic: false,
glue: false,
unbreakable: false,
},
Word::Text {
text: "WWWW".to_owned(),
bold: false,
italic: false,
glue: true,
unbreakable: false,
},
];
let max_width_pt = 50.0;
let word_width = text_width_pt("WWWW", 12.0, false);
assert!(
word_width <= max_width_pt,
"fixture word must fit alone on a line"
);
assert!(
word_width * 2.0 > max_width_pt,
"fixture pair must not fit together on one line"
);
let lines = wrap(&words, max_width_pt, 12.0);
assert_eq!(
lines.len(),
2,
"the glued word must move to its own line rather than overflow"
);
assert_eq!(lines[0], vec![("WWWW".to_owned(), false, false, false)]);
assert_eq!(
lines[1],
vec![("WWWW".to_owned(), false, false, false)],
"the word that moved to a new line is no longer glued to anything on it"
);
}
#[test]
fn unbreakable_nbsp_pair_moves_together_when_it_does_not_fit() {
let words = vec![
Word::Text {
text: "WWWW".to_owned(), bold: false,
italic: false,
glue: false,
unbreakable: false,
},
Word::Text {
text: "WWWW".to_owned(), bold: false,
italic: false,
glue: false,
unbreakable: false,
},
Word::Text {
text: "WWWW".to_owned(), bold: false,
italic: false,
glue: true,
unbreakable: true,
},
];
let word_width = text_width_pt("WWWW", 12.0, false);
let space_w = text_width_pt(" ", 12.0, false);
let max_width_pt = 2.0f32.mul_add(word_width, space_w) + 0.5;
let lines = wrap(&words, max_width_pt, 12.0);
assert_eq!(
lines.len(),
2,
"the NBSP pair must move to a new line rather than splitting across two"
);
assert_eq!(
lines[0],
vec![("WWWW".to_owned(), false, false, false)],
"only the unrelated prefix word stays on the first line"
);
assert_eq!(
lines[1],
vec![
("WWWW".to_owned(), false, false, false),
("WWWW".to_owned(), false, false, true),
],
"the NBSP-glued pair must move together onto the second line"
);
}
#[test]
fn unbreakable_word_that_is_individually_oversized_stays_glued_to_its_predecessor() {
let words = vec![
Word::Text {
text: "Hello".to_owned(),
bold: false,
italic: false,
glue: false,
unbreakable: false,
},
Word::Text {
text: format!("\u{00A0}{}", "A".repeat(100)),
bold: true,
italic: false,
glue: true,
unbreakable: true,
},
];
let max_width_pt = 495.0; let word_width = text_width_pt(&format!("\u{00A0}{}", "A".repeat(100)), 11.0, true);
assert!(
word_width > max_width_pt,
"fixture word must be individually oversized"
);
let lines = wrap(&words, max_width_pt, 11.0);
assert!(
lines.len() > 1,
"the oversized NBSP-led word must still be character-split across multiple \
lines instead of left whole, got {lines:?}"
);
assert_eq!(
lines[0][0],
("Hello".to_owned(), false, false, false),
"\"Hello\" must not be flushed onto its own line ahead of the glued word"
);
assert!(
lines[0][1].0.starts_with('\u{00A0}'),
"the first chunk of the NBSP-led word must stay glued (with its NBSP intact) \
right after \"Hello\", got {:?}",
lines[0][1]
);
assert!(
lines[0][1].3,
"the first chunk of the NBSP-led word must still render glued (no rendered \
space before it)"
);
for (i, line) in lines.iter().enumerate() {
let line_width: f32 = line
.iter()
.map(|(text, bold, _, _)| text_width_pt(text, 11.0, *bold))
.sum();
assert!(
line_width <= max_width_pt,
"line {i} exceeds max_width_pt ({line_width} > {max_width_pt}): {line:?}"
);
}
let rejoined: String = lines
.iter()
.flat_map(|line| line.iter().map(|(text, ..)| text.as_str()))
.collect();
assert_eq!(
rejoined,
format!("Hello\u{00A0}{}", "A".repeat(100)),
"splitting into chunks must not drop or duplicate any characters"
);
}
#[test]
fn oversized_glued_word_first_chunk_is_sized_to_the_remaining_line_width() {
let words = vec![
Word::Text {
text: "A".repeat(40),
bold: false,
italic: false,
glue: false,
unbreakable: false,
},
Word::Text {
text: format!("\u{00A0}{}", "A".repeat(100)),
bold: true,
italic: false,
glue: true,
unbreakable: true,
},
];
let max_width_pt = 495.0;
let lines = wrap(&words, max_width_pt, 11.0);
for (i, line) in lines.iter().enumerate() {
let line_width: f32 = line
.iter()
.map(|(text, bold, _, _)| text_width_pt(text, 11.0, *bold))
.sum();
assert!(
line_width <= max_width_pt,
"line {i} exceeds max_width_pt ({line_width} > {max_width_pt}): {line:?}"
);
}
let rejoined: String = lines
.iter()
.flat_map(|line| line.iter().map(|(text, ..)| text.as_str()))
.collect();
assert_eq!(
rejoined,
format!("{}\u{00A0}{}", "A".repeat(40), "A".repeat(100)),
"splitting into chunks must not drop or duplicate any characters"
);
}
#[test]
fn adjacent_spans_with_no_whitespace_render_with_no_space_between() {
let words = words_of(&[
Span::Run {
text: "$".to_owned(),
bold: false,
italic: false,
},
Span::Run {
text: "42.00".to_owned(),
bold: true,
italic: false,
},
]);
let lines = wrap(&words, 1000.0, 12.0);
assert_eq!(lines.len(), 1);
assert_eq!(
lines[0],
vec![
("$".to_owned(), false, false, false),
("42.00".to_owned(), true, false, true),
]
);
}
#[test]
fn spans_separated_by_whitespace_still_get_a_space() {
let words = words_of(&[
Span::Run {
text: "Total:".to_owned(),
bold: false,
italic: false,
},
Span::Run {
text: " ".to_owned(),
bold: false,
italic: false,
},
Span::Run {
text: "$42.00".to_owned(),
bold: true,
italic: false,
},
]);
let lines = wrap(&words, 1000.0, 12.0);
assert_eq!(
lines[0],
vec![
("Total:".to_owned(), false, false, false),
("$42.00".to_owned(), true, false, false),
]
);
}
#[test]
fn flatten_blocks_groups_bare_text_as_implicit_paragraph() {
let nodes = super::super::html::parse("hello <strong>world</strong>");
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
assert_eq!(blocks.len(), 1);
assert!(matches!(&blocks[0], Block::Paragraph(_)));
}
#[test]
fn flatten_blocks_recognizes_headings_paragraphs_and_tables() {
let nodes = super::super::html::parse(
"<h1>Invoice</h1><p>Hello</p><table><tr><th>A</th></tr><tr><td>1</td></tr></table>",
);
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
assert_eq!(blocks.len(), 3);
assert!(matches!(&blocks[0], Block::Heading(1, _)));
assert!(matches!(&blocks[1], Block::Paragraph(_)));
assert!(matches!(&blocks[2], Block::Table(rows) if rows.len() == 2));
}
#[test]
fn table_caption_text_is_not_silently_dropped() {
let nodes = super::super::html::parse(
"<table><caption>Grand Total</caption><tr><td>1</td></tr></table>",
);
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
let Block::Table(rows) = &blocks[0] else {
panic!("expected a table block")
};
assert_eq!(rows.len(), 2, "caption becomes an extra row, not lost");
let caption_text: String = rows[0]
.cells
.iter()
.flat_map(|(spans, _)| spans)
.map(|s| match s {
Span::Run { text, .. } => text.clone(),
Span::Break => String::new(),
})
.collect();
assert_eq!(caption_text, "Grand Total");
}
#[test]
fn unknown_wrapper_tags_pass_through_transparently() {
let nodes = super::super::html::parse(r#"<div class="card"><span>hi</span></div>"#);
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
assert_eq!(blocks.len(), 1);
assert!(
matches!(&blocks[0], Block::Paragraph(spans) if spans == &[Span::Run {
text: "hi".to_owned(), bold: false, italic: false,
}])
);
}
#[test]
fn div_wrapper_preserves_nested_block_structure() {
let nodes = super::super::html::parse("<div><h1>Title</h1><p>First</p><p>Second</p></div>");
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
assert_eq!(
blocks.len(),
3,
"expected 3 separate blocks, got {blocks:?}"
);
assert!(
matches!(&blocks[0], Block::Heading(1, spans) if spans == &[Span::Run {
text: "Title".to_owned(), bold: true, italic: false,
}])
);
assert!(
matches!(&blocks[1], Block::Paragraph(spans) if spans == &[Span::Run {
text: "First".to_owned(), bold: false, italic: false,
}])
);
assert!(
matches!(&blocks[2], Block::Paragraph(spans) if spans == &[Span::Run {
text: "Second".to_owned(), bold: false, italic: false,
}])
);
}
#[test]
fn blockquote_wrapper_preserves_nested_paragraph() {
let nodes = super::super::html::parse("<blockquote><p>Quote text</p></blockquote>");
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
assert_eq!(blocks.len(), 1);
assert!(
matches!(&blocks[0], Block::Paragraph(spans) if spans == &[Span::Run {
text: "Quote text".to_owned(), bold: false, italic: false,
}])
);
}
#[test]
fn semantic_sectioning_elements_keep_adjacent_blocks_separate() {
let nodes = super::super::html::parse(
"<main><section>Summary</section><section>Details</section></main>",
);
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
assert_eq!(
blocks.len(),
2,
"expected 2 separate paragraphs, got {blocks:?}"
);
assert!(
matches!(&blocks[0], Block::Paragraph(spans) if spans == &[Span::Run {
text: "Summary".to_owned(), bold: false, italic: false,
}])
);
assert!(
matches!(&blocks[1], Block::Paragraph(spans) if spans == &[Span::Run {
text: "Details".to_owned(), bold: false, italic: false,
}])
);
}
#[test]
fn nav_and_aside_keep_adjacent_blocks_separate() {
let nodes = super::super::html::parse("<aside>Summary</aside><aside>Details</aside>");
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
assert_eq!(
blocks.len(),
2,
"expected 2 separate paragraphs, got {blocks:?}"
);
assert!(
matches!(&blocks[0], Block::Paragraph(spans) if spans == &[Span::Run {
text: "Summary".to_owned(), bold: false, italic: false,
}])
);
assert!(
matches!(&blocks[1], Block::Paragraph(spans) if spans == &[Span::Run {
text: "Details".to_owned(), bold: false, italic: false,
}])
);
}
#[test]
fn list_item_with_nested_paragraphs_keeps_them_separate() {
let nodes = super::super::html::parse("<ul><li><p>First</p><p>Second</p></li></ul>");
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
assert_eq!(blocks.len(), 1);
let Block::ListItem { spans, .. } = &blocks[0] else {
panic!("expected a list item block");
};
assert_eq!(
spans,
&[
Span::Run {
text: "First".to_owned(),
bold: false,
italic: false,
},
Span::Break,
Span::Run {
text: "Second".to_owned(),
bold: false,
italic: false,
},
],
"nested paragraphs must be line-break separated, with no trailing break"
);
}
#[test]
fn hr_inside_a_list_item_still_separates_adjacent_text() {
let nodes = super::super::html::parse("<ul><li>Before<hr>After</li></ul>");
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
assert_eq!(blocks.len(), 1);
let Block::ListItem { spans, .. } = &blocks[0] else {
panic!("expected a list item block");
};
assert_eq!(
spans,
&[
Span::Run {
text: "Before".to_owned(),
bold: false,
italic: false,
},
Span::Break,
Span::Run {
text: "After".to_owned(),
bold: false,
italic: false,
},
],
"hr must still separate the text around it, not vanish and glue them together"
);
}
#[test]
fn nested_list_inside_a_list_item_keeps_its_markers() {
let nodes = super::super::html::parse("<ul><li>Parent<ul><li>Child</li></ul></li></ul>");
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
assert_eq!(blocks.len(), 1);
let Block::ListItem { marker, spans } = &blocks[0] else {
panic!("expected a list item block");
};
assert_eq!(marker, "\u{2022}");
assert_eq!(
spans,
&[
Span::Run {
text: "Parent".to_owned(),
bold: false,
italic: false,
},
Span::Break,
Span::Run {
text: "\u{2022} ".to_owned(),
bold: false,
italic: false,
},
Span::Run {
text: "Child".to_owned(),
bold: false,
italic: false,
},
],
"the nested item must keep its own bullet marker instead of losing all list semantics"
);
}
#[test]
fn list_nested_inside_a_table_header_cell_stays_bold() {
let nodes =
super::super::html::parse("<table><tr><th><ul><li>Header</li></ul></th></tr></table>");
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
assert_eq!(blocks.len(), 1);
let Block::Table(rows) = &blocks[0] else {
panic!("expected a table block");
};
assert_eq!(rows.len(), 1);
let (spans, is_header) = &rows[0].cells[0];
assert!(is_header);
assert_eq!(
spans,
&[
Span::Run {
text: "\u{2022} ".to_owned(),
bold: true,
italic: false,
},
Span::Run {
text: "Header".to_owned(),
bold: true,
italic: false,
},
],
"both the list marker and its item content must stay bold inside a <th>, got {spans:?}"
);
}
#[test]
fn nested_list_item_marker_stays_beside_paragraph_wrapped_content() {
let nodes =
super::super::html::parse("<ul><li>Parent<ul><li><p>Child</p></li></ul></li></ul>");
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
assert_eq!(blocks.len(), 1);
let Block::ListItem { marker, spans } = &blocks[0] else {
panic!("expected a list item block");
};
assert_eq!(marker, "\u{2022}");
assert_eq!(
spans,
&[
Span::Run {
text: "Parent".to_owned(),
bold: false,
italic: false,
},
Span::Break,
Span::Run {
text: "\u{2022} ".to_owned(),
bold: false,
italic: false,
},
Span::Run {
text: "Child".to_owned(),
bold: false,
italic: false,
},
],
"the nested marker must stay on the same line as its paragraph-wrapped content"
);
}
#[test]
fn table_cell_with_nested_paragraphs_keeps_them_separate() {
let nodes = super::super::html::parse("<table><tr><td><p>A</p><p>B</p></td></tr></table>");
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
assert_eq!(blocks.len(), 1);
let Block::Table(rows) = &blocks[0] else {
panic!("expected a table block");
};
assert_eq!(rows.len(), 1);
let (spans, is_header) = &rows[0].cells[0];
assert!(!is_header);
assert_eq!(
spans,
&[
Span::Run {
text: "A".to_owned(),
bold: false,
italic: false,
},
Span::Break,
Span::Run {
text: "B".to_owned(),
bold: false,
italic: false,
},
],
"nested paragraphs inside a cell must be line-break separated, with no trailing break"
);
}
#[test]
fn nested_table_inside_a_cell_keeps_its_rows_and_cells_separate() {
let nodes = super::super::html::parse(
"<table><tr><td><table><tr><td>A</td><td>B</td></tr></table></td></tr></table>",
);
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
assert_eq!(blocks.len(), 1);
let Block::Table(rows) = &blocks[0] else {
panic!("expected a table block");
};
assert_eq!(rows.len(), 1);
let (spans, is_header) = &rows[0].cells[0];
assert!(!is_header);
assert_eq!(
spans,
&[
Span::Run {
text: "A".to_owned(),
bold: false,
italic: false,
},
Span::Break,
Span::Run {
text: "B".to_owned(),
bold: false,
italic: false,
},
],
"the nested table's cells must be line-break separated, not glued into \"AB\""
);
}
#[test]
fn omitted_p_close_before_a_table_still_produces_a_real_table_block() {
let nodes = super::super::html::parse(
"<p>Intro</p><table><tr><td>A</td><td>B</td></tr></table><p>After</p>",
);
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
assert_eq!(
blocks.len(),
3,
"expected 3 separate blocks (p, table, p), got {blocks:?}"
);
assert!(
matches!(&blocks[0], Block::Paragraph(spans) if spans == &[Span::Run {
text: "Intro".to_owned(), bold: false, italic: false,
}])
);
let Block::Table(rows) = &blocks[1] else {
panic!("expected a real table block, got {:?}", blocks[1]);
};
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].cells.len(), 2, "expected two separate cells");
assert!(
matches!(&blocks[2], Block::Paragraph(spans) if spans == &[Span::Run {
text: "After".to_owned(), bold: false, italic: false,
}])
);
}
#[test]
fn omitted_head_close_before_body_does_not_discard_the_whole_document() {
let nodes = super::super::html::parse(
"<html><head><title>X</title><body><p>Visible</p></body></html>",
);
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
assert_eq!(
blocks.len(),
1,
"expected the <body>'s <p> to survive, got {blocks:?}"
);
assert!(
matches!(&blocks[0], Block::Paragraph(spans) if spans == &[Span::Run {
text: "Visible".to_owned(), bold: false, italic: false,
}])
);
}
#[test]
fn description_list_terms_and_values_keep_their_own_blocks() {
let nodes = super::super::html::parse(
"<dl><dt>Title</dt><dd>My Post</dd><dt>Published</dt><dd>true</dd></dl>",
);
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
let texts: Vec<String> = blocks
.iter()
.map(|block| {
let Block::Paragraph(spans) = block else {
panic!("expected a paragraph block, got {block:?}")
};
spans
.iter()
.map(|span| match span {
Span::Run { text, .. } => text.as_str(),
Span::Break => "",
})
.collect()
})
.collect();
assert_eq!(texts, vec!["Title", "My Post", "Published", "true"]);
}
#[test]
fn description_list_inside_a_transparent_wrapper_still_keeps_blocks_separate() {
let nodes =
super::super::html::parse("<span><dl><dt>Title</dt><dd>My Post</dd></dl></span>");
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
let texts: Vec<String> = blocks
.iter()
.map(|block| {
let Block::Paragraph(spans) = block else {
panic!("expected a paragraph block, got {block:?}")
};
spans
.iter()
.map(|span| match span {
Span::Run { text, .. } => text.as_str(),
Span::Break => "",
})
.collect()
})
.collect();
assert_eq!(texts, vec!["Title", "My Post"]);
}
#[test]
fn whitespace_between_loose_inline_elements_is_not_dropped() {
let nodes = super::super::html::parse("<span>Hello</span> <span>world</span>");
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
assert_eq!(blocks.len(), 1);
let Block::Paragraph(spans) = &blocks[0] else {
panic!("expected a paragraph block")
};
let words = words_of(spans);
assert_eq!(
words,
vec![
Word::Text {
text: "Hello".to_owned(),
bold: false,
italic: false,
glue: false,
unbreakable: false,
},
Word::Text {
text: "world".to_owned(),
bold: false,
italic: false,
glue: false,
unbreakable: false,
},
],
"the space between the two spans must survive as a real word boundary"
);
}
#[test]
fn script_and_style_content_is_never_rendered() {
let nodes = super::super::html::parse(
"<head><title>Ignored</title><style>body { color: red; }</style></head>\
<script>alert('hi');</script><p>Visible</p>",
);
let mut blocks = Vec::new();
flatten_blocks(&nodes, 0, &mut blocks);
assert_eq!(blocks.len(), 1);
assert!(
matches!(&blocks[0], Block::Paragraph(spans) if spans == &[Span::Run {
text: "Visible".to_owned(), bold: false, italic: false,
}])
);
}
#[test]
fn render_pages_produces_at_least_one_page_for_empty_input() {
let pages = render_pages("");
assert_eq!(pages.len(), 1);
}
#[test]
fn deeply_nested_wrapper_tags_do_not_overflow_the_stack() {
let mut html = String::new();
for _ in 0..50_000 {
html.push_str("<span>");
}
html.push_str("hi");
for _ in 0..50_000 {
html.push_str("</span>");
}
let pages = render_pages(&html);
assert!(!pages.is_empty());
}
#[test]
fn ordered_list_marker_wide_enough_to_overlap_the_fixed_indent_gets_more_room() {
let mut writer = Writer::new();
let marker = "100.".to_owned();
writer.draw_block(&Block::ListItem {
marker: marker.clone(),
spans: vec![Span::Run {
text: "Item".to_owned(),
bold: false,
italic: false,
}],
});
let cursor_xs: Vec<f32> = writer
.ops
.iter()
.filter_map(|op| match op {
Op::SetTextCursor { pos } => Some(pos.x.0),
_ => None,
})
.collect();
assert_eq!(
cursor_xs.len(),
2,
"expected one cursor position for the marker and one for the item's text"
);
let (marker_x, content_x) = (cursor_xs[0], cursor_xs[1]);
let marker_width = text_width_pt(&marker, 11.0, false);
assert!(
content_x - marker_x >= marker_width,
"content (x={content_x}) must start at or past the end of the marker \
(x={marker_x} + width={marker_width}), not overlap it"
);
}
#[test]
fn empty_list_item_still_reserves_a_full_line() {
let mut writer = Writer::new();
writer.draw_block(&Block::ListItem {
marker: "\u{2022}".to_owned(),
spans: vec![],
});
let advance = writer.y_from_top;
assert!(
advance >= 14.5,
"an empty list item must still advance a full line's height, got {advance}"
);
}
#[test]
fn render_pages_paginates_long_content() {
use std::fmt::Write as _;
let mut html = String::new();
for i in 0..200 {
let _ = write!(html, "<p>Line number {i}</p>");
}
let pages = render_pages(&html);
assert!(pages.len() > 1, "expected multiple pages for long content");
}
}