use rustyfi_backend::{
AnnotAction, Closing, Color, DecoId, DocExtras, FontKey, GraphicsElem, HorzStringInfo,
InlineMarkKind, Length, ListMarkKind, MathGlyph, OutlineEntry, PageGeometry, Path, PathSeg,
PureHorzBox, Subpath, TabularBox, TabularCellBox, VertBox,
};
fn geometry() -> PageGeometry {
PageGeometry {
paper_width: Length::pt(200.0),
paper_height: Length::pt(300.0),
text_origin: (Length::pt(20.0), Length::pt(20.0)),
text_width: Length::pt(160.0),
text_height: Length::pt(260.0),
}
}
fn text_run(text: &str) -> PureHorzBox {
PureHorzBox::InnerString {
info: HorzStringInfo {
font: FontKey(0),
size: Length::pt(12.0),
rising: Length::ZERO,
color: Color::Gray(0.0),
},
text: text.to_string(),
width: Length::pt(80.0),
height: Length::pt(9.0),
depth: Length::pt(2.0),
}
}
fn line(bx: PureHorzBox) -> VertBox {
VertBox::Line {
height: Length::pt(9.0),
depth: Length::pt(2.0),
leading: Length::pt(12.0),
contents: vec![(Length::ZERO, bx)],
}
}
fn text_line(text: &str) -> VertBox {
line(text_run(text))
}
fn render(vboxes: &[VertBox]) -> String {
render_with_links(vboxes, &[], &[])
}
fn render_with_links(
vboxes: &[VertBox],
links: &[(DecoId, AnnotAction)],
dests: &[(DecoId, String)],
) -> String {
render_with_extras(vboxes, &DocExtras::default(), links, dests)
}
fn render_with_extras(
vboxes: &[VertBox],
extras: &DocExtras,
links: &[(DecoId, AnnotAction)],
dests: &[(DecoId, String)],
) -> String {
rustyfi_html::render_html_reflow(Some(vboxes), &geometry(), &[], extras, links, dests)
.expect("reflow HTML rendering must succeed")
}
#[test]
fn consecutive_lines_coalesce_and_skip_splits_into_two_paragraphs() {
let vboxes = vec![
text_line("Hello,"),
text_line("world!"),
VertBox::Skip(Length::pt(12.0)),
text_line("Second paragraph."),
];
let html = render(&vboxes);
assert!(
html.starts_with("<!doctype html>"),
"missing doctype:\n{html}"
);
let para_count = html.matches("<p class=\"para\"").count();
assert_eq!(para_count, 2, "expected exactly two <p>s:\n{html}");
assert!(
html.contains("Hello,"),
"missing first line's text:\n{html}"
);
assert!(
html.contains("world!"),
"missing second line's text:\n{html}"
);
assert!(
html.contains("Second paragraph."),
"missing the post-Skip paragraph's text:\n{html}"
);
assert!(
html.contains("margin-top:12pt"),
"Skip length did not become a margin-top:\n{html}"
);
}
#[test]
fn frame_start_end_becomes_a_nested_div() {
let vboxes = vec![
VertBox::FrameStart(DecoId(0)),
text_line("inside the frame"),
VertBox::FrameEnd(DecoId(0)),
];
let html = render(&vboxes);
assert!(
html.contains("<div class=\"frame\""),
"missing frame div:\n{html}"
);
assert!(
html.contains("inside the frame"),
"missing frame content:\n{html}"
);
let frame_open = html.find("<div class=\"frame\"").unwrap();
let para_open = html.find("<p class=\"para\"").unwrap();
let para_close = html[para_open..].find("</p>").unwrap() + para_open;
assert!(
html[para_close..].contains("</div>"),
"frame div never closes after its paragraph:\n{html}"
);
assert!(
frame_open < para_open,
"frame should open before its content:\n{html}"
);
}
#[test]
fn embedded_block_becomes_a_nested_div_recursively() {
let inner_vboxes = vec![text_line("nested content")];
let embed_box = PureHorzBox::EmbeddedBlock {
breakable: false,
width: Length::pt(100.0),
height: Length::pt(20.0),
depth: Length::ZERO,
block: inner_vboxes,
anchor_last: false,
};
let vboxes = vec![line(embed_box)];
let html = render(&vboxes);
assert!(
html.contains("<div class=\"embed\""),
"missing embed div:\n{html}"
);
assert!(
html.contains("nested content"),
"missing nested block's text:\n{html}"
);
}
#[test]
fn styled_run_carries_color_and_rising_as_css_not_position() {
let mut bx = text_run("styled");
if let PureHorzBox::InnerString { info, .. } = &mut bx {
info.color = Color::Rgb(1.0, 0.0, 0.0);
info.rising = Length::pt(3.0);
info.size = Length::pt(18.0);
}
let vboxes = vec![line(bx), text_line("plain body text, and more of it")];
let html = render(&vboxes);
assert!(
html.contains("color:rgb(255,0,0)"),
"missing color CSS:\n{html}"
);
assert!(
html.contains("vertical-align:3pt"),
"missing rising-as-vertical-align CSS:\n{html}"
);
assert!(
html.contains("font-size:1.5000em"),
"missing the em-ratio font-size:\n{html}"
);
assert!(
html.contains("font-size: 12pt"),
"the body rule should carry the document's dominant size:\n{html}"
);
}
#[test]
fn body_styled_runs_need_no_span() {
let vboxes = vec![text_line("ordinary prose")];
let html = render(&vboxes);
let body = html.split("<body>").nth(1).expect("a body");
assert!(
body.contains("<p class=\"para\">ordinary prose</p>"),
"body-styled text should be bare, unwrapped text:\n{body}"
);
assert!(
!body.contains("class=\"run\""),
"no run span should be emitted for body-styled text:\n{body}"
);
}
#[test]
fn run_text_is_html_escaped() {
let vboxes = vec![text_line("<a & \"b\">")];
let html = render(&vboxes);
assert!(
html.contains("<a & "b">"),
"text was not HTML-escaped:\n{html}"
);
assert!(
!html.contains("<a & \"b\">"),
"raw unescaped text leaked:\n{html}"
);
}
#[test]
fn math_renders_as_an_inline_svg_with_glyph_text() {
let math_box = PureHorzBox::Math {
width: Length::pt(10.0),
height: Length::pt(8.0),
depth: Length::ZERO,
glyphs: vec![MathGlyph {
text: "x".to_string(),
gid: None,
dx: Length::ZERO,
dy: Length::ZERO,
info: HorzStringInfo {
font: FontKey(0),
size: Length::pt(12.0),
rising: Length::ZERO,
color: Color::Gray(0.0),
},
width: Length::pt(10.0),
height: Length::pt(8.0),
depth: Length::ZERO,
}],
rules: vec![],
};
let vboxes = vec![line(math_box)];
let html = render(&vboxes);
assert!(
!html.contains("class=\"math-placeholder\""),
"S1 placeholder leaked:\n{html}"
);
assert!(
html.contains("<svg"),
"missing inline <svg> for math:\n{html}"
);
assert!(
html.contains("<text"),
"missing SVG <text> glyph element:\n{html}"
);
assert!(
html.contains('x'),
"missing the glyph's literal text:\n{html}"
);
}
#[test]
fn math_rules_render_as_svg_paths() {
let rule_path = Path {
subpaths: vec![Subpath {
start: (Length::pt(0.0), Length::pt(0.0)),
segs: vec![PathSeg::Line((Length::pt(10.0), Length::pt(0.0)))],
closing: Closing::Open,
}],
};
let math_box = PureHorzBox::Math {
width: Length::pt(10.0),
height: Length::pt(8.0),
depth: Length::pt(2.0),
glyphs: vec![],
rules: vec![GraphicsElem::Fill(Color::Gray(0.0), rule_path)],
};
let vboxes = vec![line(math_box)];
let html = render(&vboxes);
assert!(
html.contains("<svg"),
"missing inline <svg> for math rules:\n{html}"
);
assert!(
html.contains("<path"),
"missing SVG <path> for the fraction-bar rule:\n{html}"
);
}
#[test]
fn graphics_renders_as_an_inline_svg() {
let path = Path {
subpaths: vec![Subpath {
start: (Length::pt(0.0), Length::pt(0.0)),
segs: vec![
PathSeg::Line((Length::pt(10.0), Length::pt(0.0))),
PathSeg::Line((Length::pt(10.0), Length::pt(10.0))),
],
closing: Closing::Line,
}],
};
let gfx_box = PureHorzBox::Graphics {
origin_independent: false,
width: Length::pt(10.0),
height: Length::pt(10.0),
depth: Length::ZERO,
elems: vec![GraphicsElem::Fill(Color::Rgb(1.0, 0.0, 0.0), path)],
};
let vboxes = vec![line(gfx_box)];
let html = render(&vboxes);
assert!(
!html.contains("class=\"gfx-placeholder\""),
"S1 placeholder leaked:\n{html}"
);
assert!(
html.contains("<svg"),
"missing inline <svg> for graphics:\n{html}"
);
assert!(
html.contains("<path"),
"missing SVG <path> for the fill:\n{html}"
);
assert!(
html.contains("rgb(255,0,0)"),
"missing the fill color:\n{html}"
);
}
#[test]
fn href_frame_renders_as_an_anchor_link() {
let link_frame = PureHorzBox::Frame {
width: Length::pt(40.0),
height: Length::pt(9.0),
depth: Length::pt(2.0),
deco: DecoId(7),
contents: vec![(Length::ZERO, text_run("click me"))],
};
let vboxes = vec![line(link_frame)];
let links = vec![(
DecoId(7),
AnnotAction::Uri("https://example.com".to_string()),
)];
let html = render_with_links(&vboxes, &links, &[]);
assert!(
html.contains("<a class=\"link\" href=\"https://example.com\">"),
"missing <a href> for the link frame:\n{html}"
);
assert!(
html.contains("click me"),
"missing the link's inline text:\n{html}"
);
assert!(html.contains("</a>"), "missing the closing </a>:\n{html}");
}
#[test]
fn goto_name_link_and_matching_destination_frame_wire_together() {
let link_frame = PureHorzBox::Frame {
width: Length::pt(40.0),
height: Length::pt(9.0),
depth: Length::pt(2.0),
deco: DecoId(1),
contents: vec![(Length::ZERO, text_run("see below"))],
};
let vboxes = vec![
line(link_frame),
VertBox::FrameStart(DecoId(2)),
text_line("the target section"),
VertBox::FrameEnd(DecoId(2)),
];
let links = vec![(DecoId(1), AnnotAction::GotoName("sec1".to_string()))];
let dests = vec![(DecoId(2), "sec1".to_string())];
let html = render_with_links(&vboxes, &links, &dests);
assert!(
html.contains("<a class=\"link\" href=\"#sec1\">"),
"missing the GotoName <a href=\"#…\">:\n{html}"
);
assert!(
html.contains("id=\"sec1\""),
"missing the destination frame's id=\"…\" anchor:\n{html}"
);
}
#[test]
fn reflow_output_never_uses_absolute_positioning() {
let vboxes = vec![
text_line("first"),
VertBox::Skip(Length::pt(6.0)),
VertBox::FrameStart(DecoId(1)),
text_line("second"),
VertBox::FrameEnd(DecoId(1)),
];
let html = render(&vboxes);
let body = body_of(&html);
assert!(
!body.contains("position:absolute") && !body.contains("position: absolute"),
"reflow content must never use position:absolute:\n{body}"
);
let sheet = html.split("<style>").nth(1).expect("a stylesheet");
let sheet = sheet.split("</style>").next().unwrap();
assert_eq!(
sheet.matches("position: absolute").count()
+ sheet.matches("position:absolute").count(),
1,
"unexpected absolute positioning in the stylesheet:\n{sheet}"
);
assert!(
sheet.contains("svg.frame-deco"),
"the one absolute rule should be the decoration layer:\n{sheet}"
);
for prop in ["top:", "left:"] {
for (idx, _) in body.match_indices(prop) {
let before = &body[..idx];
assert!(
["margin-", "border-", "padding-", "-"]
.iter()
.any(|p| before.ends_with(p)),
"found a bare `{prop}` CSS declaration at byte {idx}:\n{body}"
);
}
}
}
#[test]
fn missing_reflow_source_renders_a_placeholder_instead_of_panicking() {
let html =
rustyfi_html::render_html_reflow(None, &geometry(), &[], &DocExtras::default(), &[], &[])
.expect("must not panic/error when reflow_source is None");
assert!(
html.starts_with("<!doctype html>"),
"missing doctype:\n{html}"
);
assert!(
html.contains("reflow-empty"),
"missing the no-source placeholder:\n{html}"
);
}
#[test]
fn outline_matched_frame_promotes_its_paragraph_to_a_heading() {
let heading_frame = PureHorzBox::Frame {
width: Length::pt(80.0),
height: Length::pt(9.0),
depth: Length::pt(2.0),
deco: DecoId(9),
contents: vec![(Length::ZERO, text_run("Introduction"))],
};
let vboxes = vec![line(heading_frame)];
let extras = DocExtras {
outline: vec![OutlineEntry {
level: 0,
text: "Introduction".to_string(),
dest_name: "sec1".to_string(),
is_open: false,
}],
..DocExtras::default()
};
let dests = vec![(DecoId(9), "sec1".to_string())];
let html = render_with_extras(&vboxes, &extras, &[], &dests);
assert!(
html.contains("<h1 class=\"heading\" data-outline-level=\"0\""),
"missing promoted <h1>:\n{html}"
);
assert!(
!html.contains("<p class=\"para\">"),
"the heading's own text must not ALSO render as a plain <p>:\n{html}"
);
assert!(
html.contains("Introduction"),
"missing heading text:\n{html}"
);
assert!(html.contains("</h1>"), "missing closing </h1>:\n{html}");
assert!(
html.contains("id=\"sec1\""),
"missing id anchor inside heading:\n{html}"
);
}
#[test]
fn outline_level_maps_to_the_matching_heading_tag_and_clamps_at_h6() {
for (level, expected_tag) in [(0i64, "h1"), (1, "h2"), (5, "h6"), (9, "h6")] {
let frame = PureHorzBox::Frame {
width: Length::pt(40.0),
height: Length::pt(9.0),
depth: Length::pt(2.0),
deco: DecoId(3),
contents: vec![(Length::ZERO, text_run("T"))],
};
let vboxes = vec![line(frame)];
let extras = DocExtras {
outline: vec![OutlineEntry {
level,
text: "T".to_string(),
dest_name: "d".to_string(),
is_open: false,
}],
..DocExtras::default()
};
let dests = vec![(DecoId(3), "d".to_string())];
let html = render_with_extras(&vboxes, &extras, &[], &dests);
assert!(
html.contains(&format!("<{expected_tag} class=\"heading\"")),
"level {level} should map to <{expected_tag}>:\n{html}"
);
}
}
#[test]
fn no_outline_means_no_nav_and_no_heading_promotion() {
let html = render(&[text_line("just a paragraph")]);
assert!(
!html.contains("<nav"),
"unexpected <nav> with no outline:\n{html}"
);
assert!(
!html.contains("<h1"),
"unexpected heading with no outline:\n{html}"
);
assert!(
html.contains("<p class=\"para\">"),
"missing plain <p>:\n{html}"
);
}
#[test]
fn an_outline_generates_no_table_of_contents() {
let extras = DocExtras {
outline: vec![OutlineEntry {
level: 0,
text: "Chapter One".to_string(),
dest_name: "ch1".to_string(),
is_open: false,
}],
..DocExtras::default()
};
let html = render_with_extras(&[text_line("body")], &extras, &[], &[]);
assert!(!html.contains("<nav"), "generated a TOC nav:\n{html}");
assert!(
!html.contains("Chapter One"),
"the outline's own text must not be emitted as content:\n{html}"
);
assert!(html.contains("body"), "lost the document:\n{html}");
}
#[test]
fn tabular_renders_as_a_real_table_with_rows_and_cells() {
let cell = |x: f64, text: &str| TabularCellBox {
x: Length::pt(x),
baseline_y: Length::ZERO,
contents: vec![(Length::ZERO, text_run(text))],
};
let tab = TabularBox {
width: Length::pt(40.0),
height: Length::pt(20.0),
depth: Length::ZERO,
cells: vec![
cell(0.0, "R0C0"),
cell(20.0, "R0C1"),
cell(0.0, "R1C0"),
cell(20.0, "R1C1"),
],
rules: vec![],
};
let vboxes = vec![line(PureHorzBox::Tabular(tab))];
let html = render(&vboxes);
assert!(
!html.contains("class=\"table-placeholder\""),
"S1/S2 placeholder leaked:\n{html}"
);
assert!(
html.contains("<table class=\"tabular\">"),
"missing <table>:\n{html}"
);
assert_eq!(
html.matches("<tr>").count(),
2,
"expected two rows:\n{html}"
);
assert_eq!(
html.matches("<td>").count(),
4,
"expected four cells:\n{html}"
);
for text in ["R0C0", "R0C1", "R1C0", "R1C1"] {
assert!(html.contains(text), "missing cell text {text}:\n{html}");
}
}
#[test]
fn list_marks_become_a_ul_with_li() {
let vboxes = vec![
VertBox::ListMark(ListMarkKind::ListStart { ordered: false }),
VertBox::ListMark(ListMarkKind::ItemStart),
text_line("one item"),
VertBox::ListMark(ListMarkKind::ItemEnd),
VertBox::ListMark(ListMarkKind::ListEnd),
];
let html = render(&vboxes);
assert_eq!(
html.matches("<ul").count(),
1,
"expected exactly one <ul>:\n{html}"
);
assert_eq!(
html.matches("</ul>").count(),
1,
"expected exactly one </ul>:\n{html}"
);
assert_eq!(
html.matches("<li").count(),
1,
"expected exactly one <li>:\n{html}"
);
assert_eq!(
html.matches("</li>").count(),
1,
"expected exactly one </li>:\n{html}"
);
assert!(
!html.contains("<ol"),
"unordered list must not render <ol>:\n{html}"
);
assert!(html.contains("one item"), "missing item text:\n{html}");
}
#[test]
fn nested_list_marks_nest_li() {
let vboxes = vec![
VertBox::ListMark(ListMarkKind::ListStart { ordered: false }),
VertBox::ListMark(ListMarkKind::ItemStart),
text_line("parent"),
VertBox::ListMark(ListMarkKind::ListStart { ordered: false }),
VertBox::ListMark(ListMarkKind::ItemStart),
text_line("child"),
VertBox::ListMark(ListMarkKind::ItemEnd),
VertBox::ListMark(ListMarkKind::ListEnd),
VertBox::ListMark(ListMarkKind::ItemEnd),
VertBox::ListMark(ListMarkKind::ListEnd),
];
let html = render(&vboxes);
assert_eq!(
html.matches("<ul").count(),
2,
"expected two <ul>s (outer + nested):\n{html}"
);
assert_eq!(
html.matches("</ul>").count(),
2,
"expected two </ul>s:\n{html}"
);
assert_eq!(
html.matches("<li").count(),
2,
"expected two <li>s:\n{html}"
);
assert!(html.contains("parent"), "missing parent item text:\n{html}");
assert!(html.contains("child"), "missing child item text:\n{html}");
let first_li = html.find("<li").expect("missing first <li>");
let first_li_close = html[first_li..].find("</li>").expect("missing first </li>") + first_li;
let parent_text = html.find("parent").expect("missing parent text");
let nested_ul = html[parent_text..]
.find("<ul")
.expect("missing nested <ul>")
+ parent_text;
assert!(
nested_ul < first_li_close,
"nested <ul> must open before the parent's </li> closes (real nesting, not flattening):\n{html}"
);
}
#[test]
fn enumerate_marks_become_ol() {
let vboxes = vec![
VertBox::ListMark(ListMarkKind::ListStart { ordered: true }),
VertBox::ListMark(ListMarkKind::ItemStart),
text_line("first"),
VertBox::ListMark(ListMarkKind::ItemEnd),
VertBox::ListMark(ListMarkKind::ItemStart),
text_line("second"),
VertBox::ListMark(ListMarkKind::ItemEnd),
VertBox::ListMark(ListMarkKind::ListEnd),
];
let html = render(&vboxes);
assert_eq!(
html.matches("<ol").count(),
1,
"expected exactly one <ol>:\n{html}"
);
assert_eq!(
html.matches("</ol>").count(),
1,
"expected exactly one </ol>:\n{html}"
);
assert_eq!(
html.matches("<li").count(),
2,
"expected two <li>s:\n{html}"
);
assert!(
!html.contains("<ul"),
"ordered list must not render <ul>:\n{html}"
);
assert!(
html.contains("first") && html.contains("second"),
"missing item text:\n{html}"
);
}
#[test]
fn bullet_fence_is_suppressed() {
let vboxes = vec![VertBox::Line {
height: Length::pt(9.0),
depth: Length::pt(2.0),
leading: Length::pt(12.0),
contents: vec![
(
Length::ZERO,
PureHorzBox::InlineMark(InlineMarkKind::BulletStart),
),
(Length::ZERO, text_run("BULLETGLYPH")),
(
Length::ZERO,
PureHorzBox::InlineMark(InlineMarkKind::BulletEnd),
),
(Length::ZERO, text_run("realtext")),
],
}];
let html = render(&vboxes);
assert!(
!html.contains("BULLETGLYPH"),
"fenced bullet glyph run must be dropped entirely:\n{html}"
);
assert!(
html.contains("realtext"),
"unfenced content after the bullet fence must still render:\n{html}"
);
}
#[test]
fn emph_marks_wrap_em() {
let vboxes = vec![VertBox::Line {
height: Length::pt(9.0),
depth: Length::pt(2.0),
leading: Length::pt(12.0),
contents: vec![
(
Length::ZERO,
PureHorzBox::InlineMark(InlineMarkKind::EmphStart { strong: false }),
),
(Length::ZERO, text_run("emphasized")),
(
Length::ZERO,
PureHorzBox::InlineMark(InlineMarkKind::EmphEnd),
),
],
}];
let html = render(&vboxes);
assert!(html.contains("<em>"), "missing <em>:\n{html}");
assert!(html.contains("</em>"), "missing </em>:\n{html}");
assert!(
!html.contains("<strong>"),
"\\emph must not render <strong>:\n{html}"
);
let open = html.find("<em>").unwrap();
let close = html.find("</em>").unwrap();
let text = html.find("emphasized").unwrap();
assert!(
open < text && text < close,
"emphasized text must sit between <em> and </em>:\n{html}"
);
}
#[test]
fn bold_marks_wrap_strong() {
let vboxes = vec![VertBox::Line {
height: Length::pt(9.0),
depth: Length::pt(2.0),
leading: Length::pt(12.0),
contents: vec![
(
Length::ZERO,
PureHorzBox::InlineMark(InlineMarkKind::EmphStart { strong: true }),
),
(Length::ZERO, text_run("bolded")),
(
Length::ZERO,
PureHorzBox::InlineMark(InlineMarkKind::EmphEnd),
),
],
}];
let html = render(&vboxes);
assert!(html.contains("<strong>"), "missing <strong>:\n{html}");
assert!(html.contains("</strong>"), "missing </strong>:\n{html}");
assert!(
!html.contains("<em>"),
"\\bold must not render <em>:\n{html}"
);
}
fn iframe_marker(id: usize, end: bool) -> PureHorzBox {
PureHorzBox::InlineFrameMarker {
id: DecoId(id),
end,
height: Length::pt(9.0),
depth: Length::pt(2.0),
}
}
fn line_of(boxes: Vec<PureHorzBox>) -> VertBox {
VertBox::Line {
height: Length::pt(9.0),
depth: Length::pt(2.0),
leading: Length::pt(12.0),
contents: boxes.into_iter().map(|b| (Length::ZERO, b)).collect(),
}
}
#[test]
fn href_through_a_breakable_inline_frame_renders_as_an_anchor_link() {
let vboxes = vec![line_of(vec![
iframe_marker(11, false),
text_run("click me"),
iframe_marker(11, true),
])];
let links = vec![(
DecoId(11),
AnnotAction::Uri("https://example.com".to_string()),
)];
let html = render_with_links(&vboxes, &links, &[]);
let open = html
.find("<a class=\"link\" href=\"https://example.com\">")
.unwrap_or_else(|| panic!("missing <a href> for the marker pair:\n{html}"));
let text = html
.find("click me")
.unwrap_or_else(|| panic!("missing the link text:\n{html}"));
let close = html
.find("</a>")
.unwrap_or_else(|| panic!("missing the closing </a>:\n{html}"));
assert!(
open < text && text < close,
"the anchor must WRAP the text, not merely appear near it:\n{html}"
);
}
#[test]
fn nested_breakable_inline_frames_close_innermost_first() {
let vboxes = vec![line_of(vec![
iframe_marker(21, false),
text_run("outer "),
iframe_marker(22, false),
text_run("inner"),
iframe_marker(22, true),
iframe_marker(21, true),
])];
let links = vec![(
DecoId(21),
AnnotAction::Uri("https://example.org".to_string()),
)];
let html = render_with_links(&vboxes, &links, &[]);
let span_close = html
.find("</span>")
.expect("inner frame must close a <span>");
let a_close = html.find("</a>").expect("outer link must close an </a>");
assert!(
span_close < a_close,
"inner </span> must close before the outer </a>:\n{html}"
);
}
fn sized_run(text: &str, size: f64) -> PureHorzBox {
PureHorzBox::InnerString {
info: HorzStringInfo {
font: FontKey(0),
size: Length::pt(size),
rising: Length::ZERO,
color: Color::Gray(0.0),
},
text: text.to_string(),
width: Length::pt(8.0),
height: Length::pt(9.0),
depth: Length::pt(2.0),
}
}
fn glue(natural: f64) -> PureHorzBox {
PureHorzBox::OuterEmpty {
natural: Length::pt(natural),
shrinkable: Length::pt(1.0),
stretchable: Length::pt(1.0),
}
}
fn body_of(html: &str) -> &str {
html.split("<body>").nth(1).expect("a <body>")
}
#[test]
fn cjk_characters_join_into_one_run_with_no_spaces_between() {
let vboxes = vec![line_of(vec![
sized_run("研", 12.0),
glue(0.0),
sized_run("究", 12.0),
glue(0.0),
sized_run("計", 12.0),
glue(5.28),
sized_run("画", 12.0),
])];
let html = render(&vboxes);
let body = body_of(&html);
assert!(
body.contains("研究計画"),
"CJK characters must join into one uninterrupted string:\n{body}"
);
assert!(
!body.contains("研 究"),
"no space may be inserted between two CJK characters:\n{body}"
);
}
#[test]
fn zero_width_glue_inside_a_word_rejoins_it() {
let vboxes = vec![line_of(vec![
sized_run("Con", 12.0),
glue(0.0),
sized_run("trib", 12.0),
glue(0.0),
sized_run("utors", 12.0),
])];
let html = render(&vboxes);
assert!(
body_of(&html).contains("Contributors"),
"chunks of one word must rejoin:\n{html}"
);
}
#[test]
fn word_spaces_and_script_boundaries_still_take_a_space() {
let vboxes = vec![line_of(vec![
sized_run("hello", 12.0),
glue(3.5),
sized_run("world", 12.0),
glue(2.6),
sized_run("を", 12.0),
])];
let html = render(&vboxes);
let body = body_of(&html);
assert!(body.contains("hello world"), "{body}");
assert!(body.contains("world を"), "{body}");
}
#[test]
fn a_negative_inline_skip_is_a_kern_not_a_space() {
let vboxes = vec![line_of(vec![
sized_run("L", 12.0),
PureHorzBox::FixedEmpty {
width: Length::pt(-4.0),
},
sized_run("A", 12.0),
])];
let html = render(&vboxes);
let body = body_of(&html);
assert!(
body.contains("LA"),
"kerned letters must not split:\n{body}"
);
assert!(!body.contains("L A"), "{body}");
assert!(
!body.contains("hskip"),
"a negative skip is not a strut:\n{body}"
);
}
#[test]
fn a_wide_inline_skip_survives_as_a_sized_strut() {
let vboxes = vec![line_of(vec![
PureHorzBox::FixedEmpty {
width: Length::pt(10.5),
},
sized_run("indented", 12.0),
])];
let html = render(&vboxes);
assert!(
html.contains("<span class=\"hskip\" style=\"width:10.5pt;\">"),
"{html}"
);
}
#[test]
fn only_a_hyphen_bearing_discretionary_becomes_a_soft_hyphen() {
let with_hyphen = PureHorzBox::Discretionary {
penalty: 0,
pre_break: vec![sized_run("-", 12.0)],
post_break: vec![],
no_break: vec![],
};
let bare = PureHorzBox::Discretionary {
penalty: 0,
pre_break: vec![glue(0.0)],
post_break: vec![],
no_break: vec![],
};
let a = render(&[line_of(vec![
sized_run("La", 12.0),
with_hyphen,
sized_run("tex", 12.0),
])]);
assert!(a.contains("­"), "{a}");
let b = render(&[line_of(vec![
sized_run("Con", 12.0),
bare,
sized_run("trib", 12.0),
])]);
let b = body_of(&b);
assert!(
!b.contains("­"),
"a bare chunk boundary is not a hyphen:\n{b}"
);
assert!(b.contains("Contrib"), "{b}");
}
#[test]
fn a_footnote_lands_as_an_aside_right_after_its_paragraph() {
let note = PureHorzBox::Footnote {
block: vec![text_line("the note body")],
};
let vboxes = vec![
line_of(vec![text_run("body text"), note]),
VertBox::Skip(Length::pt(6.0)),
text_line("the next paragraph"),
];
let html = render(&vboxes);
let body = body_of(&html);
let para = body.find("body text").expect("the referencing paragraph");
let aside = body
.find("<aside class=\"footnote\" id=\"fn-1\"")
.unwrap_or_else(|| panic!("missing the footnote aside:\n{body}"));
let next = body
.find("the next paragraph")
.expect("the following paragraph");
assert!(
para < aside && aside < next,
"the aside must sit between its own paragraph and the next:\n{body}"
);
assert!(body.contains("the note body"), "{body}");
assert!(
body.contains("<span class=\"fnref\" id=\"fnref-1\"></span>"),
"{body}"
);
assert!(
body.contains("href=\"#fnref-1\""),
"missing the back-link:\n{body}"
);
assert!(
!body.contains("footnote-placeholder"),
"footnotes must not be dropped:\n{body}"
);
}
fn tiny_image(fill: u8) -> rustyfi_backend::ImageResource {
rustyfi_backend::ImageResource {
samples: vec![fill; 3 * 4],
px_w: 2,
px_h: 2,
jpeg_dct: None,
pdf: None,
}
}
fn image_line(id: usize) -> VertBox {
line(PureHorzBox::Image {
width: Length::pt(40.0),
height: Length::pt(30.0),
image: rustyfi_backend::ImageId(id),
})
}
fn render_with_images(vboxes: &[VertBox], images: &[rustyfi_backend::ImageResource]) -> String {
rustyfi_html::render_html_reflow(
Some(vboxes),
&geometry(),
images,
&DocExtras::default(),
&[],
&[],
)
.expect("reflow HTML rendering must succeed")
}
#[test]
fn an_image_renders_as_a_self_contained_img() {
let html = render_with_images(&[image_line(0)], &[tiny_image(7)]);
assert!(
html.contains("<img class=\"img\" src=\"data:image/"),
"{html}"
);
assert!(html.contains("width:40pt; height:30pt;"), "{html}");
assert!(!html.contains("image-placeholder"), "{html}");
}
#[test]
fn a_repeated_image_is_emitted_once_as_a_shared_rule() {
let images = vec![tiny_image(9), tiny_image(9)];
let vboxes = vec![image_line(0), VertBox::Skip(Length::pt(4.0)), image_line(1)];
let html = render_with_images(&vboxes, &images);
assert_eq!(
html.matches("data:image/").count(),
1,
"the picture's bytes must appear exactly once:\n{html}"
);
assert_eq!(
html.matches("shared-img-0").count(),
3,
"one CSS rule plus one class per placement:\n{html}"
);
}
#[test]
fn distinct_images_are_not_shared_with_each_other() {
let images = vec![tiny_image(1), tiny_image(2)];
let vboxes = vec![image_line(0), VertBox::Skip(Length::pt(4.0)), image_line(1)];
let html = render_with_images(&vboxes, &images);
assert_eq!(
html.matches("data:image/").count(),
2,
"each distinct picture keeps its own bytes:\n{html}"
);
assert!(!html.contains("shared-img-"), "{html}");
}
#[test]
fn leading_and_trailing_fil_recover_centring_and_right_alignment() {
let centred = render(&[line_of(vec![
PureHorzBox::OuterFil,
text_run("centred"),
PureHorzBox::OuterFil,
])]);
assert!(centred.contains("data-align=\"center\""), "{centred}");
let right = render(&[line_of(vec![
PureHorzBox::OuterFil,
text_run("flush right"),
])]);
assert!(right.contains("data-align=\"right\""), "{right}");
let ordinary = render(&[line_of(vec![text_run("ordinary"), PureHorzBox::OuterFil])]);
let ordinary = body_of(&ordinary);
assert!(
!ordinary.contains("data-align"),
"a trailing fil alone is just the last line filling:\n{ordinary}"
);
}
#[test]
fn a_heading_behind_a_breakable_inline_frame_is_promoted() {
let extras = DocExtras {
outline: vec![OutlineEntry {
level: 0,
text: "1. Introduction".to_string(),
dest_name: "sec1".to_string(),
is_open: false,
}],
..DocExtras::default()
};
let vboxes = vec![line_of(vec![
iframe_marker(7, false),
text_run("1. Introduction"),
iframe_marker(7, true),
])];
let dests = vec![(DecoId(7), "sec1".to_string())];
let html = render_with_extras(&vboxes, &extras, &[], &dests);
assert!(
html.contains("<h1 class=\"heading\" data-outline-level=\"0\""),
"the outline-registered title must become a heading:\n{html}"
);
}
#[test]
fn consecutive_skips_collapse_to_the_largest() {
let vboxes = vec![
text_line("first"),
VertBox::Skip(Length::pt(6.0)),
VertBox::ParagTop(Length::pt(9.0)),
VertBox::Skip(Length::pt(4.0)),
text_line("second"),
];
let html = render(&vboxes);
assert!(
html.contains("margin-top:9pt;"),
"three adjacent skips must collapse to the largest, not sum to 19pt:\n{html}"
);
}
#[test]
fn a_whitespace_only_paragraph_emits_nothing() {
let vboxes = vec![
VertBox::Skip(Length::pt(7.0)),
line(PureHorzBox::OuterFil),
text_line("real content"),
];
let html = render(&vboxes);
let body = body_of(&html);
assert_eq!(
body.matches("<p class=\"para\"").count(),
1,
"only the paragraph with content should be emitted:\n{body}"
);
assert!(
body.contains("margin-top:7pt;"),
"the dropped paragraph's margin must carry forward:\n{body}"
);
}
#[test]
fn an_all_empty_phantom_table_renders_nothing() {
let pad = || {
(
Length::ZERO,
PureHorzBox::FixedEmpty {
width: Length::pt(6.0),
},
)
};
let empty = TabularBox {
width: Length::pt(60.0),
height: Length::pt(20.0),
depth: Length::ZERO,
cells: vec![
TabularCellBox {
x: Length::ZERO,
baseline_y: Length::ZERO,
contents: vec![pad(), pad()],
},
TabularCellBox {
x: Length::pt(30.0),
baseline_y: Length::ZERO,
contents: vec![pad(), pad()],
},
],
rules: vec![],
};
let html = render(&[line(PureHorzBox::Tabular(empty))]);
let body = body_of(&html);
assert!(
!body.contains("<table"),
"a phantom rule-carrier table must not be rendered:\n{body}"
);
}
fn assert_balanced_tags(html: &str) {
const VOID: [&str; 4] = ["img", "br", "hr", "meta"];
let mut stack: Vec<&str> = Vec::new();
let mut i = 0;
while let Some(rel) = html[i..].find('<') {
let open = i + rel;
let Some(rel_end) = html[open..].find('>') else {
break;
};
let close = open + rel_end;
let inner = &html[open + 1..close];
i = close + 1;
if inner.starts_with('!') || inner.starts_with('?') || inner.ends_with('/') {
continue;
}
let (is_end, rest) = match inner.strip_prefix('/') {
Some(r) => (true, r),
None => (false, inner),
};
let name = rest.split([' ', '\n', '\t']).next().unwrap_or("").trim();
if name.is_empty() || VOID.contains(&name) {
continue;
}
if is_end {
assert_eq!(
stack.pop(),
Some(name),
"unbalanced </{name}> — open stack {stack:?}"
);
} else {
stack.push(name);
}
}
assert!(stack.is_empty(), "unclosed elements: {stack:?}");
}
#[test]
fn an_inline_frame_split_by_a_paragraph_break_stays_balanced() {
let vboxes = vec![
line_of(vec![iframe_marker(9, false), text_run("before")]),
VertBox::Skip(Length::pt(6.0)),
line_of(vec![text_run("after"), iframe_marker(9, true)]),
];
let dests = vec![(DecoId(9), "anchor".to_string())];
let html = render_with_links(&vboxes, &[], &dests);
assert_eq!(
body_of(&html).matches("id=\"anchor\"").count(),
1,
"the anchor id must appear exactly once:\n{html}"
);
assert_balanced_tags(&html);
}
#[test]
fn a_document_touching_every_boundary_stays_balanced() {
let extras = DocExtras {
outline: vec![OutlineEntry {
level: 0,
text: "T".to_string(),
dest_name: "d".to_string(),
is_open: false,
}],
..DocExtras::default()
};
let vboxes = vec![
line_of(vec![
iframe_marker(1, false),
text_run("title"),
iframe_marker(1, true),
]),
VertBox::Skip(Length::pt(5.0)),
line_of(vec![
PureHorzBox::FixedEmpty {
width: Length::pt(10.0),
},
sized_run("prose", 18.0),
glue(3.0),
sized_run("more", 18.0),
PureHorzBox::Footnote {
block: vec![text_line("note")],
},
PureHorzBox::Graphics {
width: Length::pt(10.0),
height: Length::pt(10.0),
depth: Length::ZERO,
elems: vec![GraphicsElem::Fill(
Color::Gray(0.0),
Path {
subpaths: vec![Subpath {
start: (Length::ZERO, Length::ZERO),
segs: vec![PathSeg::Line((Length::pt(5.0), Length::ZERO))],
closing: Closing::Line,
}],
},
)],
origin_independent: false,
},
text_run("tail"),
]),
VertBox::FrameStart(DecoId(3)),
text_line("framed"),
VertBox::FrameEnd(DecoId(3)),
];
let html = render_with_extras(&vboxes, &extras, &[], &[(DecoId(1), "d".to_string())]);
assert_balanced_tags(&html);
}
#[test]
fn a_nested_block_inside_an_open_inline_frame_stays_balanced() {
let vboxes = vec![line_of(vec![
iframe_marker(4, false),
text_run("linked "),
PureHorzBox::Footnote {
block: vec![text_line("note inside a link")],
},
PureHorzBox::EmbeddedBlock {
width: Length::pt(50.0),
height: Length::pt(10.0),
depth: Length::ZERO,
block: vec![text_line("embedded inside a link")],
anchor_last: false,
breakable: false,
},
text_run("tail"),
iframe_marker(4, true),
])];
let links = vec![(
DecoId(4),
AnnotAction::Uri("https://example.net".to_string()),
)];
let html = render_with_links(&vboxes, &links, &[]);
let body = body_of(&html);
assert_eq!(
body.matches("<a class=\"link\"").count(),
2,
"the link should be closed around the nested blocks and re-opened:\n{body}"
);
assert!(
!body
.split("<div class=\"embed\">")
.nth(1)
.unwrap()
.starts_with("</a>"),
"a nested block must not close its enclosing paragraph's wrapper:\n{body}"
);
assert_balanced_tags(&html);
}
#[test]
fn a_breaker_hyphen_is_undone_and_an_authored_one_is_not() {
let hyphenated = VertBox::Line {
height: Length::pt(9.0),
depth: Length::pt(2.0),
leading: Length::pt(12.0),
contents: vec![
(Length::ZERO, sized_run("vestibu", 12.0)),
(
Length::pt(40.0),
PureHorzBox::InlineMark(InlineMarkKind::BreakHyphen),
),
(Length::pt(40.0), sized_run("-", 12.0)),
],
};
let out = render(&[hyphenated, line(sized_run("lum sed", 12.0))]);
let body = body_of(&out);
assert!(body.contains("vestibulum"), "not rejoined:\n{body}");
assert!(!body.contains("vestibu-"), "the breaker's hyphen survived:\n{body}");
let authored = render(&[
line(sized_run("code-", 12.0)),
line(sized_run("printer runs", 12.0)),
]);
let authored = body_of(&authored);
assert!(
authored.contains("code-printer"),
"an authored hyphen was mangled:\n{authored}"
);
assert!(
!authored.contains("code- printer") && !authored.contains("codeprinter"),
"{authored}"
);
let kept = render(&[
line(sized_run("well-", 12.0)),
line(sized_run("Known name", 12.0)),
]);
let kept = body_of(&kept);
assert!(kept.contains("well-Known"), "{kept}");
}
#[test]
fn output_fetches_nothing_and_runs_nothing() {
let html = render(&[text_line("hello")]);
for forbidden in ["<script", "@import", "http://", "https://", "url(/"] {
assert!(
!html.contains(forbidden),
"output must be self-contained, found {forbidden}:\n{html}"
);
}
}
fn rule_line(x0: f64, y0: f64, x1: f64, y1: f64, width: f64) -> GraphicsElem {
GraphicsElem::Stroke(
Length::pt(width),
Color::Gray(0.0),
Path {
subpaths: vec![Subpath {
start: (Length::pt(x0), Length::pt(y0)),
segs: vec![PathSeg::Line((Length::pt(x1), Length::pt(y1)))],
closing: Closing::Open,
}],
},
)
}
fn grid_cell(x: f64, y: f64, text: &str) -> TabularCellBox {
TabularCellBox {
x: Length::pt(x),
baseline_y: Length::pt(y),
contents: vec![(Length::ZERO, text_run(text))],
}
}
fn two_by_two(rules: Vec<GraphicsElem>) -> TabularBox {
TabularBox {
width: Length::pt(40.0),
height: Length::pt(20.0),
depth: Length::ZERO,
cells: vec![
grid_cell(0.0, 14.0, "R0C0"),
grid_cell(20.0, 14.0, "R0C1"),
grid_cell(0.0, 4.0, "R1C0"),
grid_cell(20.0, 4.0, "R1C1"),
],
rules,
}
}
fn indented_line(x: f64, text: &str) -> VertBox {
VertBox::Line {
height: Length::pt(9.0),
depth: Length::pt(2.0),
leading: Length::pt(12.0),
contents: vec![(Length::pt(x), text_run(text))],
}
}
#[test]
fn frame_left_padding_survives_as_a_margin_left() {
let out = render(&[indented_line(36.0, "nested item")]);
let html = body_of(&out);
assert!(
html.contains("margin-left:36pt;"),
"indentation lost:\n{html}"
);
}
#[test]
fn an_unindented_paragraph_gets_no_margin_left() {
let flush = render(&[text_line("flush left")]);
let kerned = render(&[indented_line(0.4, "still flush left")]);
for html in [body_of(&flush), body_of(&kerned)] {
assert!(
!html.contains("margin-left"),
"spurious indent:\n{html}"
);
}
}
#[test]
fn a_centred_line_reports_no_indent() {
let vboxes = vec![VertBox::Line {
height: Length::pt(9.0),
depth: Length::pt(2.0),
leading: Length::pt(12.0),
contents: vec![
(Length::ZERO, PureHorzBox::OuterFil),
(Length::pt(60.0), text_run("centred")),
(Length::pt(100.0), PureHorzBox::OuterFil),
],
}];
let out = render(&vboxes);
let html = body_of(&out);
assert!(
html.contains("data-align=\"center\""),
"lost the centring:\n{html}"
);
assert!(
!html.contains("margin-left"),
"alignment offset misread as an indent:\n{html}"
);
}
#[test]
fn table_rules_become_per_cell_borders_and_nothing_else_does() {
let tab = two_by_two(vec![rule_line(0.0, 20.0, 40.0, 20.0, 1.0)]);
let out = render(&[line(PureHorzBox::Tabular(tab))]);
let html = body_of(&out);
assert_eq!(
html.matches("border-top:1pt solid").count(),
2,
"expected the rule on both cells of row 0 only:\n{html}"
);
assert!(
!html.contains("border-left") && !html.contains("border-right"),
"invented a vertical rule the document never drew:\n{html}"
);
assert!(
!html.contains("border-bottom"),
"invented a bottom rule:\n{html}"
);
}
#[test]
fn a_vertical_rule_becomes_a_column_border() {
let tab = two_by_two(vec![rule_line(20.0, 0.0, 20.0, 20.0, 0.5)]);
let out = render(&[line(PureHorzBox::Tabular(tab))]);
let html = body_of(&out);
assert_eq!(
html.matches("border-left:0.5pt solid").count(),
2,
"expected the rule left of column 1, on both rows:\n{html}"
);
assert!(!html.contains("border-top"), "misread as horizontal:\n{html}");
}
#[test]
fn a_ruleless_table_draws_no_borders() {
let out = render(&[line(PureHorzBox::Tabular(two_by_two(vec![])))]);
let html = body_of(&out);
assert!(
!html.contains("border-"),
"a table the document left unruled gained borders:\n{html}"
);
}
#[test]
fn overlaid_rule_and_content_tabulars_are_paired() {
let phantom = TabularBox {
cells: two_by_two(vec![])
.cells
.into_iter()
.map(|c| TabularCellBox {
contents: vec![],
..c
})
.collect(),
..two_by_two(vec![rule_line(0.0, 20.0, 40.0, 20.0, 1.0)])
};
let overlay = PureHorzBox::Graphics {
width: Length::pt(40.0),
height: Length::pt(20.0),
depth: Length::ZERO,
origin_independent: false,
elems: vec![
draw_text(PureHorzBox::Tabular(phantom)),
draw_text(PureHorzBox::Tabular(two_by_two(vec![]))),
],
};
let out = render(&[line(overlay)]);
let html = body_of(&out);
assert_eq!(
html.matches("<table").count(),
1,
"the phantom half must not render as a table of its own:\n{html}"
);
assert!(html.contains("R0C0"), "lost the content half:\n{html}");
assert_eq!(
html.matches("border-top:1pt solid").count(),
2,
"the rules did not reach the visible table:\n{html}"
);
}
#[test]
fn a_text_only_graphics_box_emits_no_sized_wrapper() {
let overlay = PureHorzBox::Graphics {
width: Length::pt(40.0),
height: Length::pt(20.0),
depth: Length::ZERO,
origin_independent: false,
elems: vec![draw_text(text_run("drawn text"))],
};
let out = render(&[line(overlay)]);
let html = body_of(&out);
assert!(html.contains("drawn text"), "lost the content:\n{html}");
assert!(
!html.contains("class=\"gfx\""),
"emitted a wrapper for a box that draws nothing:\n{html}"
);
let drawn = PureHorzBox::Graphics {
width: Length::pt(40.0),
height: Length::pt(20.0),
depth: Length::ZERO,
origin_independent: false,
elems: vec![rule_line(0.0, 0.0, 40.0, 0.0, 1.0)],
};
assert!(
render(&[line(drawn)]).contains("class=\"gfx\""),
"a real drawing lost its wrapper"
);
}
fn draw_text(bx: PureHorzBox) -> GraphicsElem {
GraphicsElem::Text {
pt: (Length::ZERO, Length::ZERO),
contents: vec![(Length::ZERO, bx)],
width: Length::pt(40.0),
height: Length::pt(20.0),
depth: Length::ZERO,
transform: None,
}
}
#[test]
fn a_monospace_paragraph_keeps_its_line_breaks_and_says_so() {
let path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../lib-rustyfi/dist/fonts/lmmono10-regular.otf");
let Ok(store) = rustyfi_pdf::TtfFontStore::load(&path, None, None) else {
eprintln!("skipping: {} did not load", path.display());
return;
};
let vboxes = vec![text_line("line one"), text_line("line two")];
let html = rustyfi_html::render_html_reflow_ttf_with(
Some(&vboxes),
&geometry(),
&store,
&[],
&DocExtras::default(),
&[],
&[],
)
.expect("reflow HTML rendering must succeed");
let body = body_of(&html);
assert!(body.contains("<br>"), "line break collapsed:\n{body}");
assert!(
body.contains("class=\"para code\""),
"not marked as code, so the stylesheet still justifies it:\n{body}"
);
assert!(
html.contains("monospace"),
"no monospace fallback in the stack:\n{html}"
);
let prose = render(&vboxes);
assert!(
!body_of(&prose).contains("<br>"),
"a proportional paragraph must still rejoin its lines:\n{prose}"
);
}
#[test]
fn a_frames_own_decoration_is_drawn_over_it() {
let deco = DecoId(7);
let decos = vec![(
deco,
rustyfi_backend::FrameDecoration {
width: Length::pt(100.0),
height: Length::pt(40.0),
pads: (
Length::pt(6.0),
Length::pt(8.0),
Length::pt(4.0),
Length::pt(4.0),
),
elems: vec![rule_line(0.0, 0.0, 100.0, 0.0, 1.0)],
},
)];
let vboxes = vec![
VertBox::FrameStart(deco),
text_line("inside"),
VertBox::FrameEnd(deco),
];
let out = rustyfi_html::render_html_reflow_with_decos(
Some(&vboxes),
&geometry(),
&[],
&DocExtras::default(),
&[],
&[],
&decos,
)
.expect("reflow HTML rendering must succeed");
let html = body_of(&out);
assert!(
html.contains("class=\"frame framed\""),
"the frame is not marked as decorated:\n{html}"
);
assert!(
html.contains("<svg class=\"frame-deco\""),
"the decoration was not drawn:\n{html}"
);
assert!(
html.contains("preserveAspectRatio=\"none\""),
"the decoration must stretch with the box:\n{html}"
);
assert!(html.contains("padding-right:8pt;"), "{html}");
assert!(!html.contains("padding-left"), "{html}");
assert!(html.contains("inside"), "lost the frame's content:\n{html}");
assert_balanced_tags(&out);
}
#[test]
fn an_undecorated_frame_is_left_alone() {
let vboxes = vec![
VertBox::FrameStart(DecoId(1)),
text_line("plain grouping"),
VertBox::FrameEnd(DecoId(1)),
];
let out = render(&vboxes);
let html = body_of(&out);
assert!(html.contains("class=\"frame\""), "{html}");
assert!(!html.contains("framed"), "invented a decoration:\n{html}");
assert!(!html.contains("frame-deco"), "{html}");
}
#[test]
fn a_plain_filled_panel_becomes_a_background_not_an_svg() {
let deco = DecoId(3);
let panel = Path {
subpaths: vec![Subpath {
start: (Length::ZERO, Length::ZERO),
segs: vec![
PathSeg::Line((Length::pt(100.0), Length::ZERO)),
PathSeg::Line((Length::pt(100.0), Length::pt(40.0))),
PathSeg::Line((Length::ZERO, Length::pt(40.0))),
],
closing: Closing::Line,
}],
};
let decos = vec![(
deco,
rustyfi_backend::FrameDecoration {
width: Length::pt(100.0),
height: Length::pt(40.0),
pads: (Length::ZERO, Length::ZERO, Length::ZERO, Length::ZERO),
elems: vec![GraphicsElem::Fill(Color::Gray(0.9), panel)],
},
)];
let vboxes = vec![
VertBox::FrameStart(deco),
text_line("code"),
VertBox::FrameEnd(deco),
];
let out = rustyfi_html::render_html_reflow_with_decos(
Some(&vboxes),
&geometry(),
&[],
&DocExtras::default(),
&[],
&[],
&decos,
)
.expect("reflow HTML rendering must succeed");
let html = body_of(&out);
assert!(html.contains("background:"), "no background panel:\n{html}");
assert!(
!html.contains("frame-deco"),
"a flat panel should need no SVG:\n{html}"
);
}