use rustyfi_backend::{
Closing, Color, DecoId, DocExtras, FontKey, GraphicsElem, HorzStringInfo, ImageId,
ImageResource, Length, MathGlyph, Page, PageGeometry, Path, PathSeg, PlacedLine, 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 page_with_run(bx: PureHorzBox) -> Page {
Page {
body_lines: usize::MAX,
lines: vec![PlacedLine {
x: Length::pt(50.0),
baseline_y: Length::pt(100.0),
contents: vec![(Length::ZERO, bx)],
}],
}
}
fn render(page: &Page) -> String {
rustyfi_html::render_html_fixed(
&geometry(),
std::slice::from_ref(page),
&[],
&DocExtras::default(),
)
.expect("HTML rendering must succeed")
}
#[test]
fn text_run_renders_as_a_positioned_span_with_expected_text() {
let page = page_with_run(text_run("Hello, world!"));
let html = render(&page);
assert!(
html.starts_with("<!doctype html>"),
"missing doctype:\n{html}"
);
assert!(
html.contains("<div class=\"page\""),
"missing page div:\n{html}"
);
assert!(html.contains("width:200pt"), "missing page width:\n{html}");
assert!(
html.contains("height:300pt"),
"missing page height:\n{html}"
);
assert!(html.contains("left:50pt"), "missing left offset:\n{html}");
assert!(html.contains("top:91pt"), "missing top offset:\n{html}");
assert!(
html.contains("font-size:12pt"),
"missing font-size:\n{html}"
);
assert!(
html.contains("<span class=\"run\""),
"missing run span:\n{html}"
);
assert!(html.contains("Hello, world!"), "missing run text:\n{html}");
}
#[test]
fn run_text_is_html_escaped() {
let page = page_with_run(text_run("<a & \"b\">"));
let html = render(&page);
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 rising_shifts_the_run_up_the_page() {
let mut bx = text_run("raised");
if let PureHorzBox::InnerString { info, .. } = &mut bx {
info.rising = Length::pt(3.0);
}
let page = page_with_run(bx);
let html = render(&page);
assert!(
html.contains("top:88pt"),
"rising did not shift the span up:\n{html}"
);
}
#[test]
fn glue_boxes_render_no_extra_span() {
let page = Page {
body_lines: usize::MAX,
lines: vec![PlacedLine {
x: Length::pt(50.0),
baseline_y: Length::pt(100.0),
contents: vec![
(
Length::ZERO,
PureHorzBox::FixedEmpty {
width: Length::pt(5.0),
},
),
(
Length::pt(5.0),
PureHorzBox::OuterEmpty {
natural: Length::pt(4.0),
shrinkable: Length::ZERO,
stretchable: Length::ZERO,
},
),
(Length::pt(9.0), text_run("word")),
],
}],
};
let html = render(&page);
assert_eq!(
html.matches("<span").count(),
1,
"glue boxes must not emit their own span:\n{html}"
);
assert!(
html.contains("left:59pt"),
"missing glue-adjusted left offset:\n{html}"
);
}
#[test]
fn unhandled_box_variants_render_nothing() {
let page = Page {
body_lines: usize::MAX,
lines: vec![PlacedLine {
x: Length::ZERO,
baseline_y: Length::pt(100.0),
contents: vec![(Length::ZERO, PureHorzBox::OuterFil)],
}],
};
let html = render(&page);
assert!(
!html.contains("<span"),
"OuterFil must not render a span:\n{html}"
);
assert!(
html.contains("<div class=\"page\""),
"page div must still render:\n{html}"
);
}
fn rectangle_path() -> Path {
Path {
subpaths: vec![Subpath {
start: (Length::pt(0.0), Length::pt(0.0)),
segs: vec![
PathSeg::Line((Length::pt(20.0), Length::pt(0.0))),
PathSeg::Line((Length::pt(20.0), Length::pt(20.0))),
PathSeg::Line((Length::pt(0.0), Length::pt(20.0))),
],
closing: Closing::Line,
}],
}
}
fn graphics_box() -> PureHorzBox {
let path = rectangle_path();
PureHorzBox::Graphics {
origin_independent: false,
width: Length::pt(20.0),
height: Length::pt(20.0),
depth: Length::pt(0.0),
elems: vec![
GraphicsElem::Fill(Color::Rgb(1.0, 0.0, 0.0), path.clone()),
GraphicsElem::Stroke(Length::pt(1.0), Color::Gray(0.0), path),
],
}
}
#[test]
fn graphics_box_renders_svg_path_with_fill_and_stroke() {
let page = page_with_run(graphics_box());
let html = render(&page);
assert!(html.contains("<svg"), "missing <svg> element:\n{html}");
assert!(
html.contains("left:50pt"),
"missing svg left offset:\n{html}"
);
assert!(html.contains("top:80pt"), "missing svg top offset:\n{html}");
assert!(
html.contains("viewBox=\"0 0 20 20\""),
"missing matching viewBox:\n{html}"
);
assert!(
html.contains("<g transform=\"translate(0,20) scale(1,-1)\">"),
"missing the y-flip <g>:\n{html}"
);
assert!(
html.contains("d=\"M0 0 L20 0 L20 20 L0 20 Z\""),
"missing rectangle path d attribute:\n{html}"
);
assert!(
html.contains("fill=\"rgb(255,0,0)\""),
"missing red fill:\n{html}"
);
assert!(
html.contains("fill-rule=\"evenodd\""),
"missing even-odd fill rule:\n{html}"
);
assert!(
html.contains("stroke=\"rgb(0,0,0)\""),
"missing black stroke:\n{html}"
);
assert!(
html.contains("stroke-width=\"1\""),
"missing stroke width:\n{html}"
);
}
#[test]
fn cmyk_fill_converts_to_rgb() {
let bx = PureHorzBox::Graphics {
origin_independent: false,
width: Length::pt(20.0),
height: Length::pt(20.0),
depth: Length::pt(0.0),
elems: vec![GraphicsElem::Fill(
Color::Cmyk(1.0, 0.0, 0.0, 0.0),
rectangle_path(),
)],
};
let html = render(&page_with_run(bx));
assert!(
html.contains("fill=\"rgb(0,255,255)\""),
"CMYK cyan did not convert to the expected sRGB fill:\n{html}"
);
}
#[test]
fn frame_recurses_into_its_contents_on_the_frame_baseline() {
let bx = PureHorzBox::Frame {
width: Length::pt(50.0),
height: Length::pt(9.0),
depth: Length::pt(2.0),
deco: DecoId(0),
contents: vec![(Length::pt(3.0), text_run("framed"))],
};
let html = render(&page_with_run(bx));
assert!(
html.contains("left:53pt"),
"missing frame-content left offset:\n{html}"
);
assert!(
html.contains("top:91pt"),
"missing frame-content top offset:\n{html}"
);
assert!(
html.contains("framed"),
"missing frame-content text:\n{html}"
);
}
#[test]
fn tabular_recurses_into_cell_contents_and_renders_rules() {
let tab = TabularBox {
width: Length::pt(100.0),
height: Length::pt(30.0),
depth: Length::ZERO,
cells: vec![TabularCellBox {
x: Length::pt(5.0),
baseline_y: Length::pt(10.0),
contents: vec![(Length::ZERO, text_run("cell"))],
}],
rules: vec![GraphicsElem::Stroke(
Length::pt(0.5),
Color::Gray(0.0),
rectangle_path(),
)],
};
let html = render(&page_with_run(PureHorzBox::Tabular(tab)));
assert!(
html.contains("left:55pt"),
"missing cell content left offset:\n{html}"
);
assert!(
html.contains("top:81pt"),
"missing cell content top offset:\n{html}"
);
assert!(html.contains("cell"), "missing cell content text:\n{html}");
assert!(html.contains("<svg"), "missing rules <svg>:\n{html}");
assert!(
html.contains("d=\"M0 0 L20 0 L20 20 L0 20 Z\""),
"missing rule path d attribute:\n{html}"
);
}
#[test]
fn embedded_block_stacks_lines_from_the_placed_anchor() {
let line = |text: &str| VertBox::Line {
height: Length::pt(9.0),
depth: Length::pt(2.0),
leading: Length::pt(15.0),
contents: vec![(Length::ZERO, text_run(text))],
};
let bx = PureHorzBox::EmbeddedBlock {
breakable: false,
width: Length::pt(80.0),
height: Length::pt(9.0),
depth: Length::pt(26.0),
block: vec![line("first"), line("second")],
anchor_last: false,
};
let html = render(&page_with_run(bx));
assert_eq!(
html.matches("<span").count(),
2,
"expected exactly two lines:\n{html}"
);
assert!(html.contains("first"), "missing first line text:\n{html}");
assert!(html.contains("second"), "missing second line text:\n{html}");
assert!(
html.contains("top:91pt"),
"missing first line's top offset:\n{html}"
);
assert!(
html.contains("top:106pt"),
"missing second line's top offset:\n{html}"
);
}
use std::path::{Path as FsPath, PathBuf};
use std::process::Command;
use rustyfi_backend::FontMetrics;
use rustyfi_pdf::TtfFontStore;
fn find_regular_font() -> Option<PathBuf> {
if let Ok(output) = Command::new("fc-match")
.args(["--format=%{file}", "DejaVuSans"])
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() && FsPath::new(&path).is_file() {
return Some(PathBuf::from(path));
}
}
}
for candidate in [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/run/current-system/sw/share/fonts/truetype/DejaVuSans.ttf",
"/run/current-system/sw/share/X11/fonts/DejaVuSans.ttf",
] {
if FsPath::new(candidate).is_file() {
return Some(PathBuf::from(candidate));
}
}
None
}
macro_rules! need_font {
() => {
match find_regular_font() {
Some(path) => path,
None => {
eprintln!(
"skipping: no DejaVuSans-like TrueType font found on this system \
(tried `fc-match DejaVuSans` and common nix/distro paths)"
);
return;
}
}
};
}
#[test]
fn ttf_render_emits_font_face_with_embedded_ttf_and_spans_reference_it() {
let path = need_font!();
let store = TtfFontStore::load(&path, None, None).expect("load font");
let page = page_with_run(text_run("Hello"));
let html = rustyfi_html::render_html_fixed_ttf_with(
&geometry(),
std::slice::from_ref(&page),
&store,
&[],
&DocExtras::default(),
)
.expect("HTML rendering must succeed");
assert!(
html.contains("@font-face"),
"missing @font-face rule:\n{html}"
);
assert!(
html.contains("data:font/ttf;base64,"),
"missing embedded TTF data URI:\n{html}"
);
let needle = "font-family: \"";
let start = html
.find(needle)
.expect("missing font-family in @font-face")
+ needle.len();
let end = start
+ html[start..]
.find('"')
.expect("unterminated font-family value");
let family = &html[start..end];
assert!(
html.contains(&format!("font-family:\"{family}\"")),
"span did not reference the @font-face family {family:?}:\n{html}"
);
}
#[test]
fn ttf_render_with_no_runs_emits_no_font_face() {
let path = need_font!();
let store = TtfFontStore::load(&path, None, None).expect("load font");
let html =
rustyfi_html::render_html_fixed_ttf_with(&geometry(), &[], &store, &[], &DocExtras::default())
.expect("HTML rendering must succeed");
assert!(
!html.contains("@font-face"),
"an empty document must not emit @font-face:\n{html}"
);
}
#[test]
fn base14_render_still_emits_no_font_face() {
let html = render(&page_with_run(text_run("plain")));
assert!(
!html.contains("@font-face"),
"base-14 mode must not emit @font-face:\n{html}"
);
assert!(
!html.contains("font-family:\"rustyfi-html-font"),
"base-14 mode must not override font-family per run:\n{html}"
);
}
fn small_image() -> ImageResource {
ImageResource {
samples: vec![
255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255,
],
px_w: 2,
px_h: 2,
jpeg_dct: None,
pdf: None,
}
}
#[test]
fn image_box_renders_as_an_img_data_uri() {
let bx = PureHorzBox::Image {
width: Length::pt(40.0),
height: Length::pt(20.0),
image: ImageId(0),
};
let page = page_with_run(bx);
let html = rustyfi_html::render_html_fixed(
&geometry(),
std::slice::from_ref(&page),
&[small_image()],
&DocExtras::default(),
)
.expect("HTML rendering must succeed");
assert!(html.contains("<img"), "missing <img> element:\n{html}");
assert!(
html.contains("src=\"data:image/bmp;base64,"),
"missing base64 image data URI with the expected MIME:\n{html}"
);
assert!(
html.contains("left:50pt"),
"missing image left offset:\n{html}"
);
assert!(
html.contains("top:80pt"),
"missing image top offset:\n{html}"
);
assert!(html.contains("width:40pt"), "missing image width:\n{html}");
assert!(
html.contains("height:20pt"),
"missing image height:\n{html}"
);
}
#[test]
fn image_with_out_of_range_id_renders_nothing() {
let bx = PureHorzBox::Image {
width: Length::pt(40.0),
height: Length::pt(20.0),
image: ImageId(5),
};
let html = render(&page_with_run(bx)); assert!(
!html.contains("<img"),
"out-of-range ImageId must render nothing:\n{html}"
);
}
#[test]
fn math_box_renders_glyph_spans_and_fraction_rule() {
let path = need_font!();
let store = TtfFontStore::load(&path, None, None).expect("load font");
let size = Length::pt(18.0);
let font = FontKey(0);
let make_glyph = |c: char, dx: f64, dy: f64| {
let advance = store
.advance(font, c, size)
.expect("DejaVu must measure ASCII");
MathGlyph {
info: HorzStringInfo {
font,
size,
rising: Length::ZERO,
color: Color::Gray(0.0),
},
text: c.to_string(),
gid: None,
dx: Length::pt(dx),
dy: Length::pt(dy),
width: advance,
height: store.ascender(font, size),
depth: store.descender(font, size),
}
};
let numerator = make_glyph('1', 0.0, 10.0);
let denominator = make_glyph('x', 0.0, -8.0);
let bar = GraphicsElem::Fill(Color::Gray(0.0), rectangle_path());
let math = PureHorzBox::Math {
width: Length::pt(20.0),
height: Length::pt(20.0),
depth: Length::pt(5.0),
glyphs: vec![numerator, denominator],
rules: vec![bar],
};
let page = page_with_run(math);
let html = rustyfi_html::render_html_fixed_ttf_with(
&geometry(),
std::slice::from_ref(&page),
&store,
&[],
&DocExtras::default(),
)
.expect("HTML rendering must succeed");
assert_eq!(
html.matches("<span").count(),
2,
"expected exactly two glyph spans (numerator, denominator):\n{html}"
);
assert!(
html.contains(">1<"),
"missing numerator glyph text:\n{html}"
);
assert!(
html.contains(">x<"),
"missing denominator glyph text:\n{html}"
);
assert!(html.contains("<svg"), "missing fraction-bar <svg>:\n{html}");
assert!(
html.contains("@font-face"),
"math glyphs should register the SAME @font-face mechanism as text:\n{html}"
);
}
fn pages_with_runs(n: usize) -> Vec<Page> {
(0..n)
.map(|i| page_with_run(text_run(&format!("page{i}"))))
.collect()
}
#[test]
fn two_page_document_renders_two_page_divs() {
let pages = pages_with_runs(2);
let html = rustyfi_html::render_html_fixed(&geometry(), &pages, &[], &DocExtras::default())
.expect("HTML rendering must succeed");
assert_eq!(
html.matches("<div class=\"page\"").count(),
2,
"expected exactly two page divs:\n{html}"
);
assert!(
html.contains("page0"),
"missing first page's run text:\n{html}"
);
assert!(
html.contains("page1"),
"missing second page's run text:\n{html}"
);
}
#[test]
fn print_css_sizes_the_page_and_breaks_between_pages() {
let pages = pages_with_runs(2);
let html = rustyfi_html::render_html_fixed(&geometry(), &pages, &[], &DocExtras::default())
.expect("HTML rendering must succeed");
assert!(
html.contains("@page { size: 200pt 300pt; margin: 0; }"),
"missing @page print rule:\n{html}"
);
assert!(
html.contains(".page:not(:last-child)")
&& html.contains("page-break-after: always")
&& html.contains("break-after: page"),
"missing page-break CSS:\n{html}"
);
}
#[test]
fn single_page_document_has_no_page_break_after_rule_match() {
let pages = pages_with_runs(1);
let html = rustyfi_html::render_html_fixed(&geometry(), &pages, &[], &DocExtras::default())
.expect("HTML rendering must succeed");
assert_eq!(
html.matches("<div class=\"page\"").count(),
1,
"expected exactly one page div:\n{html}"
);
}
#[test]
fn page_graphics_underlay_renders_as_a_flipped_svg_underneath_the_text() {
let overlay_path = rectangle_path();
let mut extras = DocExtras::default();
extras.page_graphics = vec![vec![GraphicsElem::Fill(
Color::Rgb(0.0, 1.0, 0.0),
overlay_path,
)]];
let page = page_with_run(text_run("on top"));
let html = rustyfi_html::render_html_fixed(&geometry(), std::slice::from_ref(&page), &[], &extras)
.expect("HTML rendering must succeed");
assert!(html.contains("<svg"), "missing underlay <svg>:\n{html}");
assert!(
html.contains("left:0pt; top:0pt;"),
"underlay svg must be anchored at the page origin:\n{html}"
);
assert!(
html.contains("width=\"200pt\" height=\"300pt\""),
"underlay svg must cover the full page:\n{html}"
);
assert!(
html.contains("viewBox=\"0 0 200 300\""),
"underlay svg viewBox must match the full page:\n{html}"
);
assert!(
html.contains("<g transform=\"translate(0,300) scale(1,-1)\">"),
"missing the full-page y-flip <g>:\n{html}"
);
assert!(
html.contains("d=\"M0 0 L20 0 L20 20 L0 20 Z\""),
"missing underlay rectangle path:\n{html}"
);
assert!(
html.contains("fill=\"rgb(0,255,0)\""),
"missing green underlay fill:\n{html}"
);
let svg_pos = html.find("<svg").expect("svg present");
let span_pos = html.find("<span class=\"run\"").expect("span present");
assert!(
svg_pos < span_pos,
"underlay svg must come before the page text in document order:\n{html}"
);
}
#[test]
fn no_page_graphics_renders_no_underlay_svg() {
let page = page_with_run(text_run("plain"));
let html = render(&page); assert!(
!html.contains("<svg"),
"a page with no page_graphics must not render any <svg>:\n{html}"
);
}