use forme::font::FontContext;
use forme::layout::LayoutEngine;
use forme::model::*;
use forme::style::*;
fn make_text(content: &str, font_size: f64) -> Node {
Node {
kind: NodeKind::Text {
content: content.to_string(),
href: None,
runs: vec![],
},
style: Style {
font_size: Some(font_size),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
fn make_view(children: Vec<Node>) -> Node {
Node {
kind: NodeKind::View,
style: Style::default(),
children,
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
fn make_styled_view(style: Style, children: Vec<Node>) -> Node {
Node {
kind: NodeKind::View,
style,
children,
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
fn make_page_break() -> Node {
Node {
kind: NodeKind::PageBreak,
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
fn make_table_row(is_header: bool, cells: Vec<Node>) -> Node {
Node {
kind: NodeKind::TableRow { is_header },
style: Style::default(),
children: cells,
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
fn make_table_cell(children: Vec<Node>) -> Node {
Node {
kind: NodeKind::TableCell {
col_span: 1,
row_span: 1,
},
style: Style {
padding: Some(Edges::uniform(4.0)),
..Default::default()
},
children,
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
fn default_doc(children: Vec<Node>) -> Document {
Document {
children,
metadata: Metadata::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
}
}
fn layout_doc(doc: &Document) -> Vec<forme::layout::LayoutPage> {
let font_context = FontContext::new();
let engine = LayoutEngine::new();
engine.layout(doc, &font_context)
}
fn render_to_pdf(doc: &Document) -> Vec<u8> {
forme::render(doc).unwrap()
}
fn assert_valid_pdf(bytes: &[u8]) {
assert!(bytes.len() > 50, "PDF too small to be valid");
assert!(bytes.starts_with(b"%PDF-1.7"), "Missing PDF header");
assert!(
bytes.windows(5).any(|w| w == b"%%EOF"),
"Missing %%EOF marker"
);
assert!(bytes.windows(4).any(|w| w == b"xref"), "Missing xref table");
assert!(bytes.windows(7).any(|w| w == b"trailer"), "Missing trailer");
}
#[test]
fn test_empty_document() {
let doc = default_doc(vec![]);
let pages = layout_doc(&doc);
assert!(pages.is_empty(), "Empty document should produce no pages");
}
#[test]
fn test_single_text_node() {
let doc = default_doc(vec![make_text("Hello, World!", 12.0)]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1, "Single text should fit on one page");
assert!(!pages[0].elements.is_empty(), "Page should have elements");
}
#[test]
fn test_single_text_produces_valid_pdf() {
let doc = default_doc(vec![make_text("Hello, World!", 12.0)]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_explicit_page_break() {
let doc = default_doc(vec![
make_text("Page 1", 12.0),
make_page_break(),
make_text("Page 2", 12.0),
]);
let pages = layout_doc(&doc);
assert_eq!(
pages.len(),
2,
"Should have exactly 2 pages after a page break"
);
}
#[test]
fn test_multiple_page_breaks() {
let doc = default_doc(vec![
make_text("Page 1", 12.0),
make_page_break(),
make_text("Page 2", 12.0),
make_page_break(),
make_text("Page 3", 12.0),
]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 3);
}
#[test]
fn test_content_overflow_creates_new_page() {
let mut children = Vec::new();
for i in 0..100 {
children.push(make_text(&format!("Line {}", i), 12.0));
}
let doc = default_doc(children);
let pages = layout_doc(&doc);
assert!(
pages.len() >= 2,
"100 lines should overflow to multiple pages, got {} pages",
pages.len()
);
}
#[test]
fn test_large_font_overflows_faster() {
let mut children = Vec::new();
for i in 0..30 {
children.push(make_text(&format!("Line {}", i), 24.0));
}
let doc = default_doc(children);
let pages = layout_doc(&doc);
assert!(
pages.len() >= 2,
"30 lines at 24pt should overflow, got {} pages",
pages.len()
);
}
#[test]
fn test_flex_row_layout() {
let row = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
..Default::default()
},
vec![make_text("Left", 12.0), make_text("Right", 12.0)],
);
let doc = default_doc(vec![row]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
assert!(!pages[0].elements.is_empty());
}
#[test]
fn test_flex_column_is_default() {
let container = make_view(vec![make_text("First", 12.0), make_text("Second", 12.0)]);
let doc = default_doc(vec![container]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
assert!(!pages[0].elements.is_empty());
let container = &pages[0].elements[0];
assert!(
container.children.len() >= 2,
"Container should have at least 2 child elements, got {}",
container.children.len()
);
}
fn make_simple_table(header_cells: Vec<&str>, rows: Vec<Vec<&str>>) -> Node {
let mut children = Vec::new();
let header_row = make_table_row(
true,
header_cells
.into_iter()
.map(|text| make_table_cell(vec![make_text(text, 10.0)]))
.collect(),
);
children.push(header_row);
for row_data in rows {
let body_row = make_table_row(
false,
row_data
.into_iter()
.map(|text| make_table_cell(vec![make_text(text, 10.0)]))
.collect(),
);
children.push(body_row);
}
Node {
kind: NodeKind::Table { columns: vec![] },
style: Style::default(),
children,
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
#[test]
fn test_simple_table() {
let table = make_simple_table(
vec!["Name", "Age"],
vec![vec!["Alice", "30"], vec!["Bob", "25"]],
);
let doc = default_doc(vec![table]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
assert!(!pages[0].elements.is_empty());
}
#[test]
fn test_table_page_break_with_many_rows() {
let rows: Vec<Vec<&str>> = (0..80)
.map(|i| {
vec![
Box::leak(format!("Item {}", i).into_boxed_str()) as &str,
"Value",
]
})
.collect();
let table = make_simple_table(vec!["Name", "Value"], rows);
let doc = default_doc(vec![table]);
let pages = layout_doc(&doc);
assert!(
pages.len() >= 2,
"80-row table should span multiple pages, got {}",
pages.len()
);
}
#[test]
fn test_minimal_json() {
let json = r#"{
"children": [
{
"kind": { "type": "Text", "content": "Hello from JSON" },
"style": { "fontSize": 14 }
}
]
}"#;
let bytes = forme::render_json(json).expect("Should parse minimal JSON");
assert_valid_pdf(&bytes);
}
#[test]
fn test_view_container_json() {
let json = r#"{
"children": [
{
"kind": { "type": "View" },
"style": { "flexDirection": "Row", "gap": 12 },
"children": [
{ "kind": { "type": "Text", "content": "Left" }, "style": {} },
{ "kind": { "type": "Text", "content": "Right" }, "style": {} }
]
}
]
}"#;
let bytes = forme::render_json(json).expect("Should parse view JSON");
assert_valid_pdf(&bytes);
}
#[test]
fn test_table_json() {
let json = r#"{
"children": [
{
"kind": { "type": "Table", "columns": [] },
"style": {},
"children": [
{
"kind": { "type": "TableRow", "is_header": true },
"style": {},
"children": [
{
"kind": { "type": "TableCell" },
"style": {},
"children": [
{ "kind": { "type": "Text", "content": "Header" }, "style": {} }
]
}
]
},
{
"kind": { "type": "TableRow", "is_header": false },
"style": {},
"children": [
{
"kind": { "type": "TableCell" },
"style": {},
"children": [
{ "kind": { "type": "Text", "content": "Cell" }, "style": {} }
]
}
]
}
]
}
]
}"#;
let bytes = forme::render_json(json).expect("Should parse table JSON");
assert_valid_pdf(&bytes);
}
#[test]
fn test_camel_case_deserialization() {
let json = r#"{
"defaultPage": {
"size": "Letter",
"margin": { "top": 72, "right": 72, "bottom": 72, "left": 72 }
},
"children": [
{
"kind": { "type": "Text", "content": "Test" },
"style": {
"fontSize": 16,
"fontWeight": 700,
"lineHeight": 1.5,
"textAlign": "Center",
"backgroundColor": { "r": 0.9, "g": 0.9, "b": 0.95, "a": 1.0 }
}
}
]
}"#;
let doc: Document = serde_json::from_str(json).expect("Should deserialize camelCase JSON");
assert!(matches!(doc.default_page.size, PageSize::Letter));
assert_eq!(doc.default_page.margin.top, 72.0);
let bytes = forme::render(&doc).unwrap();
assert_valid_pdf(&bytes);
}
#[test]
fn test_style_inheritance() {
let json = r#"{
"children": [
{
"kind": { "type": "View" },
"style": { "fontSize": 20 },
"children": [
{
"kind": { "type": "Text", "content": "Should be 20pt" },
"style": {}
}
]
}
]
}"#;
let bytes = forme::render_json(json).expect("Should handle style inheritance");
assert_valid_pdf(&bytes);
}
#[test]
fn test_page_sizes() {
for (size, expected_w, expected_h) in &[
(PageSize::A4, 595.28, 841.89),
(PageSize::Letter, 612.0, 792.0),
(PageSize::Legal, 612.0, 1008.0),
(PageSize::A3, 841.89, 1190.55),
(PageSize::A5, 419.53, 595.28),
] {
let (w, h) = size.dimensions();
assert!(
(w - expected_w).abs() < 0.01 && (h - expected_h).abs() < 0.01,
"Page size {:?} dimensions wrong: ({}, {}) vs ({}, {})",
size,
w,
h,
expected_w,
expected_h
);
}
}
#[test]
fn test_custom_page_size() {
let size = PageSize::Custom {
width: 400.0,
height: 600.0,
};
let (w, h) = size.dimensions();
assert_eq!(w, 400.0);
assert_eq!(h, 600.0);
}
#[test]
fn test_empty_text_node() {
let doc = default_doc(vec![make_text("", 12.0)]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
}
#[test]
fn test_deeply_nested_views() {
let mut node = make_text("Deep", 12.0);
for _ in 0..10 {
node = make_view(vec![node]);
}
let doc = default_doc(vec![node]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_metadata_in_output() {
let doc = Document {
children: vec![make_text("Content", 12.0)],
metadata: Metadata {
title: Some("Test Title".to_string()),
author: Some("Test Author".to_string()),
subject: Some("Testing".to_string()),
creator: None,
lang: None,
},
default_page: PageConfig::default(),
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/Title (Test Title)"));
assert!(text.contains("/Author (Test Author)"));
}
#[test]
fn test_unbreakable_node_moves_to_next_page() {
let mut children = Vec::new();
for i in 0..40 {
children.push(make_text(&format!("Line {}", i), 12.0));
}
let mut page_children = Vec::new();
for i in 0..45 {
page_children.push(make_text(&format!("Filler {}", i), 12.0));
}
let unbreakable = Node {
kind: NodeKind::View,
style: Style {
wrap: Some(false), ..Default::default()
},
children: vec![
make_text("Must stay together line 1", 12.0),
make_text("Must stay together line 2", 12.0),
make_text("Must stay together line 3", 12.0),
make_text("Must stay together line 4", 12.0),
make_text("Must stay together line 5", 12.0),
],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
page_children.push(unbreakable);
let doc = default_doc(page_children);
let pages = layout_doc(&doc);
assert!(
pages.len() >= 2,
"Unbreakable block should push to next page"
);
}
#[test]
fn test_hex_color_parsing() {
let c = Color::hex("#ff0000");
assert!((c.r - 1.0).abs() < 0.01);
assert!((c.g - 0.0).abs() < 0.01);
assert!((c.b - 0.0).abs() < 0.01);
let c = Color::hex("00ff00");
assert!((c.g - 1.0).abs() < 0.01);
let c = Color::hex("#abc");
assert!((c.r - 0xAA as f64 / 255.0).abs() < 0.01);
assert!((c.g - 0xBB as f64 / 255.0).abs() < 0.01);
assert!((c.b - 0xCC as f64 / 255.0).abs() < 0.01);
}
#[test]
fn test_dimension_resolve() {
assert_eq!(Dimension::Pt(100.0).resolve(500.0), Some(100.0));
assert_eq!(Dimension::Percent(50.0).resolve(500.0), Some(250.0));
assert_eq!(Dimension::Auto.resolve(500.0), None);
}
use forme::pdf::PdfWriter;
fn load_test_font() -> Option<Vec<u8>> {
let paths = [
"/System/Library/Fonts/Supplemental/Andale Mono.ttf",
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
"/System/Library/Fonts/Supplemental/Verdana.ttf",
"/System/Library/Fonts/Apple Braille.ttf",
];
for path in &paths {
if let Ok(data) = std::fs::read(path) {
if ttf_parser::Face::parse(&data, 0).is_ok() {
return Some(data);
}
}
}
None
}
fn render_with_custom_font(font_data: &[u8], text: &str) -> Vec<u8> {
let mut font_context = FontContext::new();
font_context
.registry_mut()
.register("TestFont", 400, false, font_data.to_vec());
let doc = Document {
children: vec![Node {
kind: NodeKind::Text {
content: text.to_string(),
href: None,
runs: vec![],
},
style: Style {
font_family: Some("TestFont".to_string()),
font_size: Some(14.0),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
metadata: Metadata::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let engine = LayoutEngine::new();
let pages = engine.layout(&doc, &font_context);
let writer = PdfWriter::new();
writer
.write(
&pages,
&doc.metadata,
&font_context,
doc.tagged,
doc.pdfa.as_ref(),
doc.embedded_data.as_deref(),
)
.unwrap()
}
#[test]
fn test_custom_font_produces_valid_pdf() {
let font_data = match load_test_font() {
Some(data) => data,
None => {
eprintln!("Skipping: no test TTF font found");
return;
}
};
let bytes = render_with_custom_font(&font_data, "Hello Custom Font");
assert_valid_pdf(&bytes);
}
#[test]
fn test_custom_font_has_cidfont_objects() {
let font_data = match load_test_font() {
Some(data) => data,
None => {
eprintln!("Skipping: no test TTF font found");
return;
}
};
let bytes = render_with_custom_font(&font_data, "ABC");
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("CIDFontType2"),
"Should contain CIDFontType2 subtype"
);
assert!(
text.contains("/FontFile2"),
"Should contain FontFile2 reference"
);
assert!(
text.contains("/Type0"),
"Should contain Type0 font dictionary"
);
assert!(
text.contains("/Identity-H"),
"Should use Identity-H encoding"
);
assert!(
text.contains("/DescendantFonts"),
"Should have DescendantFonts array"
);
}
#[test]
fn test_custom_font_has_tounicode() {
let font_data = match load_test_font() {
Some(data) => data,
None => {
eprintln!("Skipping: no test TTF font found");
return;
}
};
let bytes = render_with_custom_font(&font_data, "Test");
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/ToUnicode"),
"Should have ToUnicode CMap for text extraction"
);
}
#[test]
fn test_mixed_standard_and_custom_fonts() {
let font_data = match load_test_font() {
Some(data) => data,
None => {
eprintln!("Skipping: no test TTF font found");
return;
}
};
let mut font_context = FontContext::new();
font_context
.registry_mut()
.register("CustomFont", 400, false, font_data);
let doc = Document {
children: vec![
Node {
kind: NodeKind::Text {
content: "Standard Helvetica".to_string(),
href: None,
runs: vec![],
},
style: Style {
font_family: Some("Helvetica".to_string()),
font_size: Some(12.0),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
},
Node {
kind: NodeKind::Text {
content: "Custom Font Text".to_string(),
href: None,
runs: vec![],
},
style: Style {
font_family: Some("CustomFont".to_string()),
font_size: Some(12.0),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
},
],
metadata: Metadata::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let engine = LayoutEngine::new();
let pages = engine.layout(&doc, &font_context);
let writer = PdfWriter::new();
let bytes = writer
.write(&pages, &doc.metadata, &font_context, false, None, None)
.unwrap();
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/Type1"),
"Should have Type1 for standard font"
);
assert!(
text.contains("CIDFontType2"),
"Should have CIDFontType2 for custom font"
);
}
#[test]
fn test_custom_font_subset_smaller_than_full() {
let font_data = match load_test_font() {
Some(data) => data,
None => {
eprintln!("Skipping: no test TTF font found");
return;
}
};
let bytes = render_with_custom_font(&font_data, "A");
let pdf_text = String::from_utf8_lossy(&bytes);
assert!(pdf_text.contains("/FontFile2"), "Should embed font data");
assert!(
bytes.len() < font_data.len(),
"PDF ({} bytes) should be smaller than full font ({} bytes)",
bytes.len(),
font_data.len()
);
}
fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
let img = image::RgbImage::from_fn(width, height, |_, _| image::Rgb([0, 128, 255]));
let mut buf = Vec::new();
let encoder = image::codecs::jpeg::JpegEncoder::new(&mut buf);
image::ImageEncoder::write_image(encoder, img.as_raw(), width, height, image::ColorType::Rgb8)
.unwrap();
buf
}
fn make_test_png(width: u32, height: u32) -> Vec<u8> {
let mut img = image::RgbaImage::new(width, height);
for pixel in img.pixels_mut() {
*pixel = image::Rgba([255, 0, 0, 255]);
}
let mut buf = Vec::new();
let encoder = image::codecs::png::PngEncoder::new(&mut buf);
image::ImageEncoder::write_image(
encoder,
img.as_raw(),
width,
height,
image::ColorType::Rgba8,
)
.unwrap();
buf
}
fn make_test_png_with_alpha(width: u32, height: u32) -> Vec<u8> {
let mut img = image::RgbaImage::new(width, height);
for (x, _y, pixel) in img.enumerate_pixels_mut() {
let alpha = if x % 2 == 0 { 128 } else { 255 };
*pixel = image::Rgba([0, 255, 0, alpha]);
}
let mut buf = Vec::new();
let encoder = image::codecs::png::PngEncoder::new(&mut buf);
image::ImageEncoder::write_image(
encoder,
img.as_raw(),
width,
height,
image::ColorType::Rgba8,
)
.unwrap();
buf
}
fn to_data_uri(data: &[u8], mime: &str) -> String {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(data);
format!("data:{};base64,{}", mime, b64)
}
fn make_image_node(src: &str, width: Option<f64>, height: Option<f64>) -> Node {
Node {
kind: NodeKind::Image {
src: src.to_string(),
width,
height,
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
#[test]
fn test_jpeg_image_produces_valid_pdf() {
let jpeg_data = make_test_jpeg(4, 4);
let src = to_data_uri(&jpeg_data, "image/jpeg");
let doc = default_doc(vec![make_image_node(&src, Some(100.0), Some(100.0))]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/DCTDecode"),
"JPEG should use DCTDecode filter"
);
assert!(text.contains("/XObject"), "Page should reference XObject");
assert!(text.contains("/Im0"), "Should reference /Im0");
}
#[test]
fn test_png_image_produces_valid_pdf() {
let png_data = make_test_png(4, 4);
let src = to_data_uri(&png_data, "image/png");
let doc = default_doc(vec![make_image_node(&src, Some(80.0), Some(80.0))]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/FlateDecode"),
"PNG should use FlateDecode filter"
);
assert!(text.contains("/XObject"), "Page should reference XObject");
}
#[test]
fn test_png_with_alpha_has_smask() {
let png_data = make_test_png_with_alpha(4, 4);
let src = to_data_uri(&png_data, "image/png");
let doc = default_doc(vec![make_image_node(&src, Some(60.0), Some(60.0))]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/SMask"),
"Alpha PNG should have SMask reference"
);
assert!(text.contains("/DeviceGray"), "SMask should use DeviceGray");
}
fn make_test_webp(width: u32, height: u32) -> Vec<u8> {
let img = image::RgbaImage::from_fn(width, height, |_, _| image::Rgba([0, 0, 255, 255]));
let mut buf = Vec::new();
let encoder = image::codecs::webp::WebPEncoder::new_lossless(&mut buf);
image::ImageEncoder::write_image(
encoder,
img.as_raw(),
width,
height,
image::ColorType::Rgba8,
)
.unwrap();
buf
}
#[test]
fn test_webp_image_produces_valid_pdf() {
let webp_data = make_test_webp(4, 4);
let src = to_data_uri(&webp_data, "image/webp");
let doc = default_doc(vec![make_image_node(&src, Some(80.0), Some(80.0))]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/FlateDecode"),
"WebP should use FlateDecode filter (decoded to RGB)"
);
assert!(text.contains("/XObject"), "Page should reference XObject");
}
#[test]
fn test_image_aspect_ratio() {
let png_data = make_test_png(8, 4);
let src = to_data_uri(&png_data, "image/png");
let doc = default_doc(vec![make_image_node(&src, Some(100.0), None)]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
let img_elem = pages[0]
.elements
.iter()
.find(|e| matches!(e.draw, forme::layout::DrawCommand::Image { .. }))
.expect("Should have an image element");
assert!((img_elem.width - 100.0).abs() < 0.1, "Width should be 100");
assert!(
(img_elem.height - 50.0).abs() < 0.1,
"Height should be 50 (100 * 4/8), got {}",
img_elem.height
);
}
#[test]
fn test_base64_image_src() {
let png_data = make_test_png(2, 2);
use base64::Engine;
let raw_b64 = base64::engine::general_purpose::STANDARD.encode(&png_data);
let doc = default_doc(vec![make_image_node(&raw_b64, Some(50.0), Some(50.0))]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/XObject"),
"Raw base64 image should produce XObject"
);
}
#[test]
fn test_missing_image_falls_back() {
let doc = default_doc(vec![make_image_node(
"nonexistent_file.png",
Some(100.0),
Some(75.0),
)]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
!text.contains("/XObject"),
"Missing image should render as placeholder, not XObject"
);
}
#[test]
fn test_multiple_images_on_same_page() {
let jpeg_data = make_test_jpeg(4, 4);
let png_data = make_test_png(4, 4);
let jpeg_src = to_data_uri(&jpeg_data, "image/jpeg");
let png_src = to_data_uri(&png_data, "image/png");
let doc = default_doc(vec![
make_image_node(&jpeg_src, Some(100.0), Some(100.0)),
make_image_node(&png_src, Some(100.0), Some(100.0)),
]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/Im0"), "Should have first image reference");
assert!(text.contains("/Im1"), "Should have second image reference");
}
#[test]
fn test_image_json_deserialization() {
let png_data = make_test_png(2, 2);
let src = to_data_uri(&png_data, "image/png");
let json = format!(
r#"{{
"children": [
{{
"kind": {{ "type": "Image", "src": "{}", "width": 100.0, "height": 100.0 }},
"style": {{}}
}}
]
}}"#,
src
);
let bytes = forme::render_json(&json).expect("Should parse image JSON");
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/XObject"),
"Image from JSON should produce XObject"
);
}
fn make_fixed_header(text: &str) -> Node {
Node {
kind: NodeKind::Fixed {
position: FixedPosition::Header,
},
style: Style {
padding: Some(Edges::uniform(8.0)),
background_color: Some(Color::rgb(0.9, 0.9, 0.95)),
..Default::default()
},
children: vec![make_text(text, 10.0)],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
fn make_fixed_footer(text: &str) -> Node {
Node {
kind: NodeKind::Fixed {
position: FixedPosition::Footer,
},
style: Style {
padding: Some(Edges::uniform(8.0)),
background_color: Some(Color::rgb(0.95, 0.95, 0.95)),
..Default::default()
},
children: vec![make_text(text, 10.0)],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
#[test]
fn test_fixed_header_single_page() {
let doc = default_doc(vec![
make_fixed_header("Header Text"),
make_text("Body content", 12.0),
]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
assert!(
pages[0].elements.len() >= 2,
"Page should have header + body elements"
);
}
#[test]
fn test_fixed_header_repeats_on_overflow() {
let mut children = vec![make_fixed_header("Page Header")];
for i in 0..120 {
children.push(make_text(&format!("Line {}", i), 12.0));
}
let doc = default_doc(children);
let pages = layout_doc(&doc);
assert!(
pages.len() >= 3,
"Should have 3+ pages, got {}",
pages.len()
);
for (i, page) in pages.iter().enumerate() {
assert!(
!page.elements.is_empty(),
"Page {} should have elements (header should render)",
i
);
}
}
#[test]
fn test_fixed_footer_renders() {
let doc = default_doc(vec![
make_fixed_footer("Footer Text"),
make_text("Body content", 12.0),
]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
assert!(
pages[0].elements.len() >= 2,
"Page should have footer + body elements"
);
}
#[test]
fn test_header_and_footer_together() {
let mut children = vec![make_fixed_header("Header"), make_fixed_footer("Footer")];
for i in 0..80 {
children.push(make_text(&format!("Content line {}", i), 12.0));
}
let doc = default_doc(children);
let pages = layout_doc(&doc);
assert!(
pages.len() >= 2,
"Should overflow to multiple pages, got {}",
pages.len()
);
for (i, page) in pages.iter().enumerate() {
assert!(
!page.elements.is_empty(),
"Page {} should have header/footer/content elements",
i
);
}
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_footer_reduces_content_area() {
let mut children_no_footer = Vec::new();
for i in 0..80 {
children_no_footer.push(make_text(&format!("Line {}", i), 12.0));
}
let doc_no_footer = default_doc(children_no_footer);
let pages_no_footer = layout_doc(&doc_no_footer);
let big_footer = Node {
kind: NodeKind::Fixed {
position: FixedPosition::Footer,
},
style: Style {
padding: Some(Edges::symmetric(40.0, 8.0)), ..Default::default()
},
children: vec![make_text("Big Footer", 14.0)],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let mut children_with_footer = vec![big_footer];
for i in 0..80 {
children_with_footer.push(make_text(&format!("Line {}", i), 12.0));
}
let doc_with_footer = default_doc(children_with_footer);
let pages_with_footer = layout_doc(&doc_with_footer);
assert!(
pages_with_footer.len() > pages_no_footer.len(),
"Doc with footer ({} pages) should have more pages than without ({} pages)",
pages_with_footer.len(),
pages_no_footer.len()
);
}
#[test]
fn test_fixed_element_json() {
let json = r#"{
"children": [
{
"kind": { "type": "Fixed", "position": "Header" },
"style": { "padding": { "top": 8, "right": 8, "bottom": 8, "left": 8 } },
"children": [
{ "kind": { "type": "Text", "content": "JSON Header" }, "style": {} }
]
},
{
"kind": { "type": "Text", "content": "Body text" },
"style": {}
}
]
}"#;
let bytes = forme::render_json(json).expect("Should parse Fixed node JSON");
assert_valid_pdf(&bytes);
}
#[test]
fn test_flex_wrap_single_line_fits() {
let row = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
flex_wrap: Some(FlexWrap::Wrap),
..Default::default()
},
vec![
make_styled_view(
Style {
width: Some(Dimension::Pt(100.0)),
..Default::default()
},
vec![make_text("A", 12.0)],
),
make_styled_view(
Style {
width: Some(Dimension::Pt(100.0)),
..Default::default()
},
vec![make_text("B", 12.0)],
),
make_styled_view(
Style {
width: Some(Dimension::Pt(100.0)),
..Default::default()
},
vec![make_text("C", 12.0)],
),
],
);
let doc = default_doc(vec![row]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
}
#[test]
fn test_flex_wrap_items_wrap_to_second_line() {
let mut items = Vec::new();
for i in 0..5 {
items.push(make_styled_view(
Style {
width: Some(Dimension::Pt(120.0)),
..Default::default()
},
vec![make_text(&format!("Item {}", i), 12.0)],
));
}
let row = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
flex_wrap: Some(FlexWrap::Wrap),
..Default::default()
},
items,
);
let doc = default_doc(vec![row]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
fn collect_rect_ys(elements: &[forme::layout::LayoutElement], ys: &mut Vec<f64>) {
for e in elements {
if matches!(e.draw, forme::layout::DrawCommand::Rect { .. }) {
ys.push(e.y);
}
collect_rect_ys(&e.children, ys);
}
}
let mut y_positions = Vec::new();
collect_rect_ys(&pages[0].elements, &mut y_positions);
let mut unique_ys: Vec<f64> = y_positions.clone();
unique_ys.sort_by(|a, b| a.partial_cmp(b).unwrap());
unique_ys.dedup_by(|a, b| (*a - *b).abs() < 1.0);
assert!(
unique_ys.len() >= 2,
"Wrapped items should produce at least 2 Y positions, got {:?}",
unique_ys
);
}
#[test]
fn test_flex_wrap_produces_valid_pdf() {
let mut items = Vec::new();
for i in 0..8 {
items.push(make_styled_view(
Style {
width: Some(Dimension::Pt(120.0)),
padding: Some(Edges::uniform(4.0)),
..Default::default()
},
vec![make_text(&format!("Cell {}", i), 10.0)],
));
}
let grid = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
flex_wrap: Some(FlexWrap::Wrap),
gap: Some(8.0),
..Default::default()
},
items,
);
let doc = default_doc(vec![grid]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_flex_wrap_nowrap_unchanged() {
let mut items = Vec::new();
for i in 0..10 {
items.push(make_styled_view(
Style {
width: Some(Dimension::Pt(80.0)),
..Default::default()
},
vec![make_text(&format!("{}", i), 10.0)],
));
}
let row = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
flex_wrap: Some(FlexWrap::NoWrap),
..Default::default()
},
items,
);
let doc = default_doc(vec![row]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
let y_positions: Vec<f64> = pages[0]
.elements
.iter()
.filter(|e| matches!(e.draw, forme::layout::DrawCommand::Rect { .. }))
.map(|e| e.y)
.collect();
if y_positions.len() > 1 {
let first_y = y_positions[0];
for y in &y_positions {
assert!(
(y - first_y).abs() < 1.0,
"NoWrap items should all be on same line, got different Y positions"
);
}
}
}
#[test]
fn test_flex_wrap_page_break_per_line() {
let mut items = Vec::new();
for i in 0..200 {
items.push(make_styled_view(
Style {
width: Some(Dimension::Pt(200.0)),
padding: Some(Edges::uniform(10.0)),
..Default::default()
},
vec![make_text(&format!("I{}", i), 12.0)],
));
}
let grid = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
flex_wrap: Some(FlexWrap::Wrap),
..Default::default()
},
items,
);
let doc = default_doc(vec![grid]);
let pages = layout_doc(&doc);
assert!(
pages.len() >= 2,
"200 wrapped items should span multiple pages, got {}",
pages.len()
);
}
#[test]
fn test_flex_wrap_with_row_gap() {
let mut items = Vec::new();
for i in 0..6 {
items.push(make_styled_view(
Style {
width: Some(Dimension::Pt(200.0)),
..Default::default()
},
vec![make_text(&format!("Item {}", i), 12.0)],
));
}
let grid_with_gap = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
flex_wrap: Some(FlexWrap::Wrap),
row_gap: Some(20.0),
..Default::default()
},
items.clone(),
);
let grid_no_gap = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
flex_wrap: Some(FlexWrap::Wrap),
row_gap: Some(0.0),
..Default::default()
},
items,
);
let doc_with_gap = default_doc(vec![grid_with_gap]);
let doc_no_gap = default_doc(vec![grid_no_gap]);
let pages_gap = layout_doc(&doc_with_gap);
let pages_no_gap = layout_doc(&doc_no_gap);
assert_eq!(pages_gap.len(), 1);
assert_eq!(pages_no_gap.len(), 1);
let max_y_gap = pages_gap[0]
.elements
.iter()
.map(|e| e.y + e.height)
.fold(0.0f64, f64::max);
let max_y_no_gap = pages_no_gap[0]
.elements
.iter()
.map(|e| e.y + e.height)
.fold(0.0f64, f64::max);
assert!(
max_y_gap > max_y_no_gap,
"Grid with row_gap ({:.1}) should use more vertical space than without ({:.1})",
max_y_gap,
max_y_no_gap
);
}
#[test]
fn test_flex_wrap_json_deserialization() {
let json = r#"{
"children": [
{
"kind": { "type": "View" },
"style": { "flexDirection": "Row", "flexWrap": "Wrap", "gap": 8 },
"children": [
{
"kind": { "type": "View" },
"style": { "width": { "Pt": 200 } },
"children": [
{ "kind": { "type": "Text", "content": "A" }, "style": {} }
]
},
{
"kind": { "type": "View" },
"style": { "width": { "Pt": 200 } },
"children": [
{ "kind": { "type": "Text", "content": "B" }, "style": {} }
]
},
{
"kind": { "type": "View" },
"style": { "width": { "Pt": 200 } },
"children": [
{ "kind": { "type": "Text", "content": "C" }, "style": {} }
]
}
]
}
]
}"#;
let bytes = forme::render_json(json).expect("Should parse flex-wrap JSON");
assert_valid_pdf(&bytes);
}
#[test]
fn test_table_cell_overflow_does_not_panic() {
let mut children = Vec::new();
for i in 0..40 {
children.push(make_text(&format!("Filler line {}", i), 12.0));
}
let long_text = "This is a cell with enough text to be reasonably tall. ".repeat(3);
let table = Node {
kind: NodeKind::Table { columns: vec![] },
style: Style::default(),
children: vec![
make_table_row(true, vec![make_table_cell(vec![make_text("Header", 10.0)])]),
make_table_row(
false,
vec![make_table_cell(vec![make_text(&long_text, 10.0)])],
),
make_table_row(
false,
vec![make_table_cell(vec![make_text("Normal row", 10.0)])],
),
],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
children.push(table);
let doc = default_doc(children);
let pages = layout_doc(&doc);
assert!(
pages.len() >= 2,
"Table near page bottom should cause page break, got {} pages",
pages.len()
);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_table_row_level_page_break_works() {
let rows: Vec<Vec<&str>> = (0..60)
.map(|i| {
vec![
Box::leak(format!("Row {}", i).into_boxed_str()) as &str,
"Data",
]
})
.collect();
let table = make_simple_table(vec!["Col A", "Col B"], rows);
let doc = default_doc(vec![table]);
let pages = layout_doc(&doc);
assert!(
pages.len() >= 2,
"60-row table should span multiple pages, got {}",
pages.len()
);
for (i, page) in pages.iter().enumerate() {
assert!(
!page.elements.is_empty(),
"Page {} should have elements (table header should repeat)",
i
);
}
}
#[test]
fn test_invalid_json_returns_parse_error() {
let result = forme::render_json("not valid json {{{");
assert!(result.is_err(), "Invalid JSON should return Err");
let err = result.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Failed to parse document"),
"Error should describe parse failure: {}",
msg
);
}
#[test]
fn test_wrong_schema_returns_parse_error() {
let result = forme::render_json(r#"{"wrong": "schema"}"#);
assert!(result.is_err(), "Wrong schema should return Err");
let err = result.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Hint:"), "Error should include hint: {}", msg);
}
#[test]
fn test_valid_doc_returns_ok() {
let json = r#"{"children": [{"kind": {"type": "Text", "content": "Hello"}, "style": {}}]}"#;
let result = forme::render_json(json);
assert!(
result.is_ok(),
"Valid JSON should return Ok, got: {:?}",
result.err()
);
}
#[test]
fn test_empty_json_object_returns_ok() {
let json = r#"{"children": []}"#;
let result = forme::render_json(json);
assert!(result.is_ok(), "Empty children should return Ok");
}
#[test]
fn test_page_number_placeholder_single_page() {
let doc = default_doc(vec![Node {
kind: NodeKind::Page {
config: PageConfig::default(),
},
style: Style::default(),
children: vec![
Node {
kind: NodeKind::Fixed {
position: FixedPosition::Footer,
},
style: Style::default(),
children: vec![make_text("Page {{pageNumber}} of {{totalPages}}", 12.0)],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
},
make_text("Hello", 12.0),
],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}]);
let pdf_bytes = forme::render(&doc).unwrap();
let pdf_str = String::from_utf8_lossy(&pdf_bytes);
assert!(
!pdf_str.contains("{{pageNumber}}"),
"Placeholder {{{{pageNumber}}}} should have been replaced"
);
assert!(
!pdf_str.contains("{{totalPages}}"),
"Placeholder {{{{totalPages}}}} should have been replaced"
);
}
#[test]
fn test_page_number_placeholder_multi_page() {
let mut page_children: Vec<Node> = vec![Node {
kind: NodeKind::Fixed {
position: FixedPosition::Footer,
},
style: Style {
font_size: Some(10.0),
..Style::default()
},
children: vec![make_text("{{pageNumber}}/{{totalPages}}", 10.0)],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}];
for _ in 0..80 {
page_children.push(make_text("Line of text to fill the page.", 12.0));
}
let doc = default_doc(vec![Node {
kind: NodeKind::Page {
config: PageConfig::default(),
},
style: Style::default(),
children: page_children,
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}]);
let pdf_bytes = forme::render(&doc).unwrap();
let pdf_str = String::from_utf8_lossy(&pdf_bytes);
assert!(
!pdf_str.contains("{{pageNumber}}"),
"All {{{{pageNumber}}}} placeholders should be replaced"
);
assert!(
!pdf_str.contains("{{totalPages}}"),
"All {{{{totalPages}}}} placeholders should be replaced"
);
}
#[test]
fn test_page_number_in_body_text() {
let doc = default_doc(vec![make_text(
"This is page {{pageNumber}} of {{totalPages}}.",
12.0,
)]);
let pdf_bytes = forme::render(&doc).unwrap();
let pdf_str = String::from_utf8_lossy(&pdf_bytes);
assert!(
!pdf_str.contains("{{pageNumber}}"),
"Placeholder should be replaced even in body text"
);
}
#[test]
fn test_no_placeholder_unchanged() {
let doc = default_doc(vec![make_text("Hello World", 12.0)]);
let pdf_bytes = forme::render(&doc).unwrap();
assert!(
pdf_bytes.starts_with(b"%PDF"),
"Should produce valid PDF without placeholders"
);
}
#[test]
fn test_text_with_href_produces_link_annotation() {
let doc = default_doc(vec![Node {
kind: NodeKind::Text {
content: "Click here".to_string(),
href: Some("https://example.com".to_string()),
runs: vec![],
},
style: Style {
font_size: Some(12.0),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/URI"),
"Text with href should produce /URI annotation"
);
assert!(
text.contains("example.com"),
"Annotation should contain the URL"
);
assert!(text.contains("/Annots"), "Page should have /Annots array");
}
#[test]
fn test_text_without_href_has_no_annotation() {
let doc = default_doc(vec![make_text("No link here", 12.0)]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
!text.contains("/URI"),
"Text without href should not produce annotations"
);
assert!(
!text.contains("/Annots"),
"Page should not have /Annots array"
);
}
#[test]
fn test_multiple_links_on_same_page() {
let doc = default_doc(vec![
Node {
kind: NodeKind::Text {
content: "Link 1".to_string(),
href: Some("https://example.com/1".to_string()),
runs: vec![],
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
},
Node {
kind: NodeKind::Text {
content: "Link 2".to_string(),
href: Some("https://example.com/2".to_string()),
runs: vec![],
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
},
]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
let uri_count = text.matches("/URI").count();
assert!(
uri_count >= 2,
"Should have at least 2 link annotations, got {}",
uri_count
);
}
#[test]
fn test_text_decoration_underline_json() {
let json = r#"{
"children": [
{
"kind": { "type": "Text", "content": "Underlined text" },
"style": { "textDecoration": "Underline" }
}
]
}"#;
let bytes = forme::render_json(json).expect("Should parse underline JSON");
assert_valid_pdf(&bytes);
}
#[test]
fn test_text_runs_render_valid_pdf() {
let doc = default_doc(vec![Node {
kind: NodeKind::Text {
content: String::new(),
href: None,
runs: vec![
TextRun {
content: "Hello ".to_string(),
style: Style::default(),
href: None,
},
TextRun {
content: "bold".to_string(),
style: Style {
font_weight: Some(700),
..Default::default()
},
href: None,
},
TextRun {
content: " world".to_string(),
style: Style::default(),
href: None,
},
],
},
style: Style {
font_size: Some(12.0),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_text_runs_with_href_per_run() {
let doc = default_doc(vec![Node {
kind: NodeKind::Text {
content: String::new(),
href: None,
runs: vec![
TextRun {
content: "Normal text ".to_string(),
style: Style::default(),
href: None,
},
TextRun {
content: "linked text".to_string(),
style: Style {
color: Some(Color::rgb(0.0, 0.0, 1.0)),
..Default::default()
},
href: Some("https://example.com".to_string()),
},
],
},
style: Style {
font_size: Some(12.0),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_text_runs_json_deserialization() {
let json = r#"{
"children": [
{
"kind": {
"type": "Text",
"content": "",
"runs": [
{ "content": "Hello ", "style": {} },
{ "content": "bold", "style": { "fontWeight": 700 } }
]
},
"style": { "fontSize": 14 }
}
]
}"#;
let bytes = forme::render_json(json).expect("Should parse text runs JSON");
assert_valid_pdf(&bytes);
}
#[test]
fn test_bookmarks_produce_outlines() {
let doc = default_doc(vec![
Node {
kind: NodeKind::View,
style: Style::default(),
children: vec![make_text("Chapter 1", 18.0)],
id: None,
source_location: None,
bookmark: Some("Chapter 1".to_string()),
href: None,
alt: None,
},
make_text("Content for chapter 1", 12.0),
Node {
kind: NodeKind::View,
style: Style::default(),
children: vec![make_text("Chapter 2", 18.0)],
id: None,
source_location: None,
bookmark: Some("Chapter 2".to_string()),
href: None,
alt: None,
},
make_text("Content for chapter 2", 12.0),
]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/Outlines"),
"Document with bookmarks should have /Outlines"
);
assert!(
text.contains("Chapter 1"),
"Outline should contain bookmark title 'Chapter 1'"
);
assert!(
text.contains("Chapter 2"),
"Outline should contain bookmark title 'Chapter 2'"
);
}
#[test]
fn test_no_bookmarks_no_outlines() {
let doc = default_doc(vec![make_text("No bookmarks here", 12.0)]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
!text.contains("/Outlines"),
"Document without bookmarks should not have /Outlines"
);
}
#[test]
fn test_bookmarks_json_deserialization() {
let json = r#"{
"children": [
{
"kind": { "type": "View" },
"style": {},
"bookmark": "Section A",
"children": [
{ "kind": { "type": "Text", "content": "Section A" }, "style": {} }
]
}
]
}"#;
let bytes = forme::render_json(json).expect("Should parse bookmark JSON");
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/Outlines"),
"Should produce outlines from JSON bookmark"
);
}
#[test]
fn test_bookmarks_on_breakable_view() {
let mut children = Vec::new();
for i in 0..80 {
children.push(make_text(&format!("Line {}", i), 12.0));
}
let bookmarked_view = Node {
kind: NodeKind::View,
style: Style::default(), children,
id: None,
source_location: None,
bookmark: Some("Breakable Chapter".to_string()),
href: None,
alt: None,
};
let doc = default_doc(vec![bookmarked_view]);
let pages = layout_doc(&doc);
assert!(
pages.len() >= 2,
"Breakable bookmarked view should span multiple pages, got {}",
pages.len()
);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/Outlines"),
"Breakable view with bookmark should produce /Outlines"
);
assert!(
text.contains("Breakable Chapter"),
"Outline should contain 'Breakable Chapter' bookmark title"
);
}
#[test]
fn test_multiple_bookmarked_views_mixed_sizes() {
let mut doc_children = Vec::new();
for i in 0..4 {
let name = format!("Category {}", i + 1);
let num_lines = if i % 2 == 0 { 10 } else { 60 };
let mut children = Vec::new();
for j in 0..num_lines {
children.push(make_text(&format!("{} line {}", name, j), 12.0));
}
doc_children.push(Node {
kind: NodeKind::View,
style: Style::default(),
children,
id: None,
source_location: None,
bookmark: Some(name),
href: None,
alt: None,
});
}
let doc = default_doc(doc_children);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/Outlines"), "Should have outlines");
for i in 1..=4 {
let name = format!("Category {}", i);
assert!(
text.contains(&format!("/Title ({})", name)),
"Missing bookmark for '{}'",
name
);
}
}
#[test]
fn test_absolute_position_does_not_affect_flow() {
let doc = default_doc(vec![make_styled_view(
Style {
width: Some(Dimension::Pt(200.0)),
height: Some(Dimension::Pt(200.0)),
..Default::default()
},
vec![
make_text("Flow child", 12.0),
Node {
kind: NodeKind::View,
style: Style {
position: Some(Position::Absolute),
top: Some(10.0),
left: Some(10.0),
width: Some(Dimension::Pt(50.0)),
height: Some(Dimension::Pt(50.0)),
background_color: Some(Color::rgb(1.0, 0.0, 0.0)),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
},
make_text("After absolute", 12.0),
],
)]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_absolute_position_json() {
let json = r#"{
"children": [
{
"kind": { "type": "View" },
"style": { "width": { "Pt": 300 }, "height": { "Pt": 300 } },
"children": [
{ "kind": { "type": "Text", "content": "Flow" }, "style": {} },
{
"kind": { "type": "View" },
"style": {
"position": "Absolute",
"top": 20, "right": 20,
"width": { "Pt": 80 },
"backgroundColor": { "r": 0.0, "g": 0.0, "b": 1.0, "a": 1.0 }
},
"children": [
{ "kind": { "type": "Text", "content": "Abs" }, "style": {} }
]
}
]
}
]
}"#;
let bytes = forme::render_json(json).expect("Should parse absolute position JSON");
assert_valid_pdf(&bytes);
}
#[test]
fn test_svg_basic_rect() {
let doc = default_doc(vec![Node {
kind: NodeKind::Svg {
width: 100.0,
height: 100.0,
view_box: Some("0 0 100 100".to_string()),
content: r##"<rect x="10" y="10" width="80" height="80" fill="#ff0000"/>"##.to_string(),
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_svg_circle_and_path() {
let doc = default_doc(vec![Node {
kind: NodeKind::Svg {
width: 200.0,
height: 200.0,
view_box: Some("0 0 200 200".to_string()),
content: r#"<circle cx="100" cy="100" r="50" fill="blue"/>
<path d="M 10 10 L 50 50 Z" stroke="black" fill="none"/>"#
.to_string(),
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_svg_json_deserialization() {
let json = r#"{
"children": [
{
"kind": {
"type": "Svg",
"width": 100,
"height": 100,
"viewBox": "0 0 100 100",
"content": "<rect x=\"0\" y=\"0\" width=\"100\" height=\"100\" fill=\"green\"/>"
},
"style": {}
}
]
}"#;
let bytes = forme::render_json(json).expect("Should parse SVG JSON");
assert_valid_pdf(&bytes);
}
#[test]
fn test_svg_page_break() {
let mut children = Vec::new();
for i in 0..50 {
children.push(make_text(&format!("Line {}", i), 12.0));
}
children.push(Node {
kind: NodeKind::Svg {
width: 200.0,
height: 200.0,
view_box: Some("0 0 200 200".to_string()),
content: r#"<rect x="0" y="0" width="200" height="200" fill="red"/>"#.to_string(),
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
});
let doc = default_doc(children);
let pages = layout_doc(&doc);
assert!(
pages.len() >= 2,
"SVG after many lines should push to next page"
);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_empty_svg_content() {
let doc = default_doc(vec![Node {
kind: NodeKind::Svg {
width: 50.0,
height: 50.0,
view_box: None,
content: String::new(),
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_orphan_control_moves_paragraph_to_next_page() {
let mut children = Vec::new();
for i in 0..43 {
children.push(make_text(&format!("Filler line {}", i), 12.0));
}
let long_text = "This is a paragraph with enough words to create multiple lines when rendered into the available page width. ";
let repeated = long_text.repeat(3);
children.push(Node {
kind: NodeKind::Text {
content: repeated,
href: None,
runs: vec![],
},
style: Style {
font_size: Some(12.0),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
});
let doc = default_doc(children);
let pages = layout_doc(&doc);
assert!(
pages.len() >= 2,
"Orphan control should push paragraph to next page, got {} pages",
pages.len()
);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_widow_control_adjusts_split_point() {
let mut children = Vec::new();
for i in 0..40 {
children.push(make_text(&format!("Filler line {}", i), 12.0));
}
let paragraph = Node {
kind: NodeKind::View,
style: Style {
wrap: Some(true),
..Default::default()
},
children: vec![
make_text("Paragraph line 1", 12.0),
make_text("Paragraph line 2", 12.0),
make_text("Paragraph line 3", 12.0),
make_text("Paragraph line 4", 12.0),
],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
children.push(paragraph);
let doc = default_doc(children);
let pages = layout_doc(&doc);
assert!(
pages.len() >= 2,
"Should overflow to at least 2 pages, got {}",
pages.len()
);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_widow_orphan_with_custom_settings() {
let mut children = Vec::new();
for i in 0..43 {
children.push(make_text(&format!("Filler {}", i), 12.0));
}
let text = "Line one. Line two. Line three.";
children.push(Node {
kind: NodeKind::Text {
content: text.to_string(),
href: None,
runs: vec![],
},
style: Style {
font_size: Some(12.0),
min_widow_lines: Some(1),
min_orphan_lines: Some(1),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
});
let doc = default_doc(children);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_align_content_center() {
let mut items = Vec::new();
for i in 0..4 {
items.push(make_styled_view(
Style {
width: Some(Dimension::Pt(200.0)),
..Default::default()
},
vec![make_text(&format!("Item {}", i), 12.0)],
));
}
let container = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
flex_wrap: Some(FlexWrap::Wrap),
height: Some(Dimension::Pt(300.0)),
align_content: Some(AlignContent::Center),
..Default::default()
},
items,
);
let doc = default_doc(vec![container]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
fn find_min_y(elems: &[forme::layout::LayoutElement]) -> f64 {
let mut min_y = f64::MAX;
for e in elems {
if matches!(e.draw, forme::layout::DrawCommand::Rect { .. }) {
min_y = min_y.min(e.y);
}
let child_min = find_min_y(&e.children);
min_y = min_y.min(child_min);
}
min_y
}
let container_elem = &pages[0].elements[0];
let items_min_y = find_min_y(&container_elem.children);
assert!(
items_min_y > 100.0,
"With align-content: center, flex items should be offset from top, got items_min_y={}",
items_min_y
);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_align_content_space_between() {
let mut items = Vec::new();
for i in 0..6 {
items.push(make_styled_view(
Style {
width: Some(Dimension::Pt(200.0)),
..Default::default()
},
vec![make_text(&format!("SB {}", i), 12.0)],
));
}
let container = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
flex_wrap: Some(FlexWrap::Wrap),
height: Some(Dimension::Pt(400.0)),
align_content: Some(AlignContent::SpaceBetween),
..Default::default()
},
items,
);
let doc = default_doc(vec![container]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_align_content_flex_end() {
let mut items = Vec::new();
for i in 0..4 {
items.push(make_styled_view(
Style {
width: Some(Dimension::Pt(200.0)),
..Default::default()
},
vec![make_text(&format!("FE {}", i), 12.0)],
));
}
let container = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
flex_wrap: Some(FlexWrap::Wrap),
height: Some(Dimension::Pt(300.0)),
align_content: Some(AlignContent::FlexEnd),
..Default::default()
},
items,
);
let doc = default_doc(vec![container]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_align_content_no_effect_without_fixed_height() {
let mut items = Vec::new();
for i in 0..4 {
items.push(make_styled_view(
Style {
width: Some(Dimension::Pt(200.0)),
..Default::default()
},
vec![make_text(&format!("NH {}", i), 12.0)],
));
}
let container = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
flex_wrap: Some(FlexWrap::Wrap),
align_content: Some(AlignContent::Center),
..Default::default()
},
items,
);
let doc = default_doc(vec![container]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_align_content_json_deserialization() {
let json = r#"{
"children": [
{
"kind": { "type": "View" },
"style": {
"flexDirection": "Row",
"flexWrap": "Wrap",
"height": { "Pt": 300 },
"alignContent": "Center"
},
"children": [
{
"kind": { "type": "View" },
"style": { "width": { "Pt": 200 } },
"children": [
{ "kind": { "type": "Text", "content": "A" }, "style": {} }
]
},
{
"kind": { "type": "View" },
"style": { "width": { "Pt": 200 } },
"children": [
{ "kind": { "type": "Text", "content": "B" }, "style": {} }
]
},
{
"kind": { "type": "View" },
"style": { "width": { "Pt": 200 } },
"children": [
{ "kind": { "type": "Text", "content": "C" }, "style": {} }
]
}
]
}
]
}"#;
let bytes = forme::render_json(json).expect("Should parse align-content JSON");
assert_valid_pdf(&bytes);
}
#[test]
fn test_table_cell_overflow_preserves_content() {
let very_long_text = "This is a very long cell content that should overflow. ".repeat(20);
let table = Node {
kind: NodeKind::Table { columns: vec![] },
style: Style::default(),
children: vec![
make_table_row(true, vec![make_table_cell(vec![make_text("Header", 10.0)])]),
make_table_row(
false,
vec![make_table_cell(vec![make_text(&very_long_text, 10.0)])],
),
make_table_row(
false,
vec![make_table_cell(vec![make_text("After overflow", 10.0)])],
),
],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let doc = default_doc(vec![table]);
let pages = layout_doc(&doc);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
assert!(
!pages.is_empty(),
"Table with overflow cell should produce at least 1 page"
);
}
#[test]
fn test_table_cell_overflow_near_page_bottom() {
let mut children = Vec::new();
for i in 0..35 {
children.push(make_text(&format!("Filler {}", i), 12.0));
}
let tall_cell_text = "Tall cell line. ".repeat(200);
let table = Node {
kind: NodeKind::Table { columns: vec![] },
style: Style::default(),
children: vec![
make_table_row(
true,
vec![
make_table_cell(vec![make_text("Col A", 10.0)]),
make_table_cell(vec![make_text("Col B", 10.0)]),
],
),
make_table_row(
false,
vec![
make_table_cell(vec![make_text(&tall_cell_text, 10.0)]),
make_table_cell(vec![make_text("Short", 10.0)]),
],
),
],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
children.push(table);
let doc = default_doc(children);
let pages = layout_doc(&doc);
assert!(
pages.len() >= 2,
"Table with tall cell near page bottom should create multiple pages, got {}",
pages.len()
);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_internal_link_produces_goto_annotation() {
let doc = default_doc(vec![
Node {
kind: NodeKind::Text {
content: "Go to Chapter 1".to_string(),
href: Some("#Chapter 1".to_string()),
runs: vec![],
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
},
make_page_break(),
Node {
kind: NodeKind::View,
style: Style::default(),
children: vec![make_text("Chapter 1 content", 12.0)],
id: None,
source_location: None,
bookmark: Some("Chapter 1".to_string()),
href: None,
alt: None,
},
]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/S /GoTo"),
"Internal link should produce /GoTo action"
);
assert!(
!text.contains("/S /URI"),
"Internal link should not produce /URI action"
);
}
#[test]
fn test_external_link_still_produces_uri() {
let doc = default_doc(vec![
Node {
kind: NodeKind::Text {
content: "Visit site".to_string(),
href: Some("https://example.com".to_string()),
runs: vec![],
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
},
Node {
kind: NodeKind::View,
style: Style::default(),
children: vec![make_text("Some section", 12.0)],
id: None,
source_location: None,
bookmark: Some("Some section".to_string()),
href: None,
alt: None,
},
]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/S /URI"),
"External link should produce /URI action"
);
assert!(
!text.contains("/S /GoTo"),
"External link should not produce /GoTo action"
);
}
#[test]
fn test_internal_link_no_matching_bookmark_skipped() {
let doc = default_doc(vec![Node {
kind: NodeKind::Text {
content: "Go to nowhere".to_string(),
href: Some("#Nonexistent".to_string()),
runs: vec![],
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
!text.contains("/Annots"),
"Missing bookmark target should produce no annotation"
);
assert!(
!text.contains("/S /GoTo"),
"Missing bookmark target should not produce /GoTo"
);
}
#[test]
fn test_multiple_internal_links_to_multiple_bookmarks() {
let doc = default_doc(vec![
Node {
kind: NodeKind::Text {
content: "Go to A".to_string(),
href: Some("#Section A".to_string()),
runs: vec![],
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
},
Node {
kind: NodeKind::Text {
content: "Go to B".to_string(),
href: Some("#Section B".to_string()),
runs: vec![],
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
},
make_page_break(),
Node {
kind: NodeKind::View,
style: Style::default(),
children: vec![make_text("Content A", 12.0)],
id: None,
source_location: None,
bookmark: Some("Section A".to_string()),
href: None,
alt: None,
},
Node {
kind: NodeKind::View,
style: Style::default(),
children: vec![make_text("Content B", 12.0)],
id: None,
source_location: None,
bookmark: Some("Section B".to_string()),
href: None,
alt: None,
},
]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
let goto_count = text.matches("/S /GoTo").count();
assert_eq!(
goto_count, 2,
"Should have 2 /GoTo annotations, got {}",
goto_count
);
}
#[test]
fn test_view_href_produces_link_annotation() {
let doc = default_doc(vec![
Node {
kind: NodeKind::View,
style: Style {
height: Some(Dimension::Pt(30.0)),
..Default::default()
},
children: vec![make_text("TOC entry", 10.0)],
id: None,
source_location: None,
bookmark: None,
href: Some("#Target".to_string()),
alt: None,
},
make_page_break(),
Node {
kind: NodeKind::View,
style: Style::default(),
children: vec![make_text("Target content", 12.0)],
id: None,
source_location: None,
bookmark: Some("Target".to_string()),
href: None,
alt: None,
},
]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/S /GoTo"),
"View with internal href should produce /GoTo annotation"
);
}
#[test]
fn test_internal_link_json_deserialization() {
let json = r##"{
"children": [
{
"kind": { "type": "Text", "content": "Jump to section", "href": "#my-section" },
"style": {}
},
{ "kind": { "type": "PageBreak" } },
{
"kind": { "type": "View" },
"bookmark": "my-section",
"children": [
{ "kind": { "type": "Text", "content": "Section content" } }
]
}
]
}"##;
let bytes = forme::render_json(json).expect("Should parse internal link JSON");
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/S /GoTo"),
"JSON internal link should produce /GoTo"
);
}
fn count_top_level_rects(page: &forme::layout::LayoutPage) -> usize {
page.elements
.iter()
.filter(|e| matches!(e.draw, forme::layout::DrawCommand::Rect { .. }))
.count()
}
fn has_rect_with_background(page: &forme::layout::LayoutPage) -> bool {
page.elements.iter().any(|e| {
matches!(
e.draw,
forme::layout::DrawCommand::Rect {
background: Some(_),
..
}
)
})
}
#[test]
fn test_breakable_view_with_background_splits_across_pages() {
let mut children = Vec::new();
for i in 0..60 {
children.push(make_text(&format!("Line {}", i), 14.0));
}
let view = make_styled_view(
Style {
background_color: Some(Color::rgb(0.9, 0.9, 1.0)),
..Default::default()
},
children,
);
let doc = Document {
children: vec![view],
metadata: Metadata::default(),
default_page: PageConfig {
size: PageSize::Custom {
width: 400.0,
height: 300.0,
},
margin: Edges::uniform(20.0),
wrap: true,
},
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let pages = layout_doc(&doc);
assert!(
pages.len() >= 2,
"View should overflow onto at least 2 pages, got {}",
pages.len()
);
for (i, page) in pages.iter().enumerate() {
assert!(
has_rect_with_background(page),
"Page {} should have a Rect element with background color",
i
);
}
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_breakable_view_background_does_not_overlap_footer() {
let page_height = 300.0;
let margin = 20.0;
let footer_padding = 20.0; let footer_font = 12.0;
let footer = Node {
kind: NodeKind::Fixed {
position: FixedPosition::Footer,
},
style: Style {
padding: Some(Edges::uniform(footer_padding)),
..Default::default()
},
children: vec![make_text("Footer", footer_font)],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let mut view_children = Vec::new();
for i in 0..60 {
view_children.push(make_text(&format!("Item {}", i), 14.0));
}
let view = make_styled_view(
Style {
background_color: Some(Color::rgb(0.8, 1.0, 0.8)),
..Default::default()
},
view_children,
);
let doc = Document {
children: vec![footer, view],
metadata: Metadata::default(),
default_page: PageConfig {
size: PageSize::Custom {
width: 400.0,
height: page_height,
},
margin: Edges::uniform(margin),
wrap: true,
},
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let pages = layout_doc(&doc);
assert!(
pages.len() >= 2,
"Should overflow to at least 2 pages, got {}",
pages.len()
);
let page_content_bottom = page_height - margin;
for (i, page) in pages.iter().enumerate() {
for elem in &page.elements {
if let forme::layout::DrawCommand::Rect {
background: Some(_),
..
} = &elem.draw
{
let rect_bottom = elem.y + elem.height;
assert!(
rect_bottom < page_content_bottom - 1.0,
"Page {}: background Rect bottom ({:.1}) should not reach content bottom ({:.1}) — footer space must be reserved",
i,
rect_bottom,
page_content_bottom,
);
}
}
}
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_breakable_view_without_visual_stays_unwrapped() {
let mut children = Vec::new();
for i in 0..60 {
children.push(make_text(&format!("Line {}", i), 14.0));
}
let view = make_view(children);
let doc = Document {
children: vec![view],
metadata: Metadata::default(),
default_page: PageConfig {
size: PageSize::Custom {
width: 400.0,
height: 300.0,
},
margin: Edges::uniform(20.0),
wrap: true,
},
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let pages = layout_doc(&doc);
assert!(pages.len() >= 2, "Should overflow onto multiple pages");
for (i, page) in pages.iter().enumerate() {
assert_eq!(
count_top_level_rects(page),
0,
"Page {} should have no Rect wrapper for a plain view",
i
);
}
}
#[test]
fn test_single_page_breakable_view_with_background_gets_wrapped() {
let view = make_styled_view(
Style {
background_color: Some(Color::rgb(1.0, 0.9, 0.9)),
padding: Some(Edges::uniform(10.0)),
..Default::default()
},
vec![make_text("Short content", 12.0)],
);
let doc = default_doc(vec![view]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1, "Should fit on one page");
assert!(
has_rect_with_background(&pages[0]),
"Single-page breakable view with background should get a Rect wrapper"
);
let rect = pages[0]
.elements
.iter()
.find(|e| {
matches!(
e.draw,
forme::layout::DrawCommand::Rect {
background: Some(_),
..
}
)
})
.expect("Should find Rect element");
assert!(
!rect.children.is_empty(),
"Rect wrapper should contain child elements"
);
}
#[test]
fn test_text_transform_uppercase_in_pdf() {
let doc = default_doc(vec![Node {
kind: NodeKind::Text {
content: "hello world".to_string(),
href: None,
runs: vec![],
},
style: Style {
font_size: Some(12.0),
text_transform: Some(TextTransform::Uppercase),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}]);
let pages = layout_doc(&doc);
let text_content = extract_text_from_pages(&pages);
assert!(
text_content.contains('H') && text_content.contains('W'),
"Text should be uppercased, got: {}",
text_content
);
assert!(
!text_content.contains('h'),
"Should not contain lowercase 'h', got: {}",
text_content
);
}
#[test]
fn test_text_transform_resolves_with_inheritance() {
let style = Style {
text_transform: Some(TextTransform::Uppercase),
..Default::default()
};
let parent_resolved = style.resolve(None, 500.0);
let child_style = Style::default();
let child_resolved = child_style.resolve(Some(&parent_resolved), 500.0);
assert!(matches!(
child_resolved.text_transform,
TextTransform::Uppercase
));
let child_override = Style {
text_transform: Some(TextTransform::Lowercase),
..Default::default()
};
let child_resolved = child_override.resolve(Some(&parent_resolved), 500.0);
assert!(matches!(
child_resolved.text_transform,
TextTransform::Lowercase
));
}
#[test]
fn test_opacity_produces_ext_gstate_in_pdf() {
let doc = default_doc(vec![make_styled_view(
Style {
opacity: Some(0.5),
background_color: Some(Color::rgb(1.0, 0.0, 0.0)),
width: Some(Dimension::Pt(100.0)),
height: Some(Dimension::Pt(50.0)),
..Default::default()
},
vec![make_text("Semi-transparent", 12.0)],
)]);
let pdf_bytes = render_to_pdf(&doc);
let pdf_str = String::from_utf8_lossy(&pdf_bytes);
assert!(
pdf_str.contains("/ExtGState"),
"PDF should contain /ExtGState resource"
);
assert!(
pdf_str.contains("/ca 0.5"),
"PDF should contain /ca 0.5 for fill opacity"
);
assert!(
pdf_str.contains("/CA 0.5"),
"PDF should contain /CA 0.5 for stroke opacity"
);
}
#[test]
fn test_opacity_1_produces_no_ext_gstate() {
let doc = default_doc(vec![make_text("Full opacity", 12.0)]);
let pdf_bytes = render_to_pdf(&doc);
let pdf_str = String::from_utf8_lossy(&pdf_bytes);
assert!(
!pdf_str.contains("/ExtGState"),
"PDF should NOT contain /ExtGState when all opacities are 1.0"
);
}
fn extract_text_from_pages(pages: &[forme::layout::LayoutPage]) -> String {
let mut text = String::new();
for page in pages {
extract_text_from_elements(&page.elements, &mut text);
}
text
}
fn extract_text_from_elements(elements: &[forme::layout::LayoutElement], text: &mut String) {
for el in elements {
if let forme::layout::DrawCommand::Text { lines, .. } = &el.draw {
for line in lines {
for glyph in &line.glyphs {
text.push(glyph.char_value);
}
}
}
extract_text_from_elements(&el.children, text);
}
}
#[test]
fn test_fonts_via_json_deserialization() {
let font_data = load_test_font();
if font_data.is_none() {
println!("Skipping test_fonts_via_json — no test font available");
return;
}
let font_data = font_data.unwrap();
let font_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &font_data);
let json = format!(
r#"{{
"children": [{{
"kind": {{ "type": "Text", "content": "Hello custom font" }},
"style": {{ "fontFamily": "MyFont", "fontSize": 16 }},
"children": []
}}],
"metadata": {{}},
"defaultPage": {{
"size": "A4",
"margin": {{ "top": 54, "right": 54, "bottom": 54, "left": 54 }},
"wrap": true
}},
"fonts": [{{
"family": "MyFont",
"src": "data:font/ttf;base64,{}",
"weight": 400,
"italic": false
}}]
}}"#,
font_b64
);
let bytes = forme::render_json(&json).unwrap();
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("CIDFontType2"),
"PDF should contain embedded custom font (CIDFontType2)"
);
}
#[test]
fn test_fonts_empty_array_renders_ok() {
let json = r#"{
"children": [{
"kind": { "type": "Text", "content": "Hello" },
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {
"size": "A4",
"margin": { "top": 54, "right": 54, "bottom": 54, "left": 54 },
"wrap": true
},
"fonts": []
}"#;
let bytes = forme::render_json(json).unwrap();
assert_valid_pdf(&bytes);
}
#[test]
fn test_fonts_field_omitted_renders_ok() {
let json = r#"{
"children": [{
"kind": { "type": "Text", "content": "Hello" },
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {
"size": "A4",
"margin": { "top": 54, "right": 54, "bottom": 54, "left": 54 },
"wrap": true
}
}"#;
let bytes = forme::render_json(json).unwrap();
assert_valid_pdf(&bytes);
}
#[test]
fn test_breakable_view_continuation_page_has_top_padding() {
let page_config = PageConfig {
size: PageSize::Custom {
width: 200.0,
height: 200.0,
},
margin: Edges::uniform(20.0),
wrap: true,
};
let padding = 15.0;
let breakable_view = make_styled_view(
Style {
background_color: Some(Color {
r: 0.0,
g: 0.5,
b: 0.0,
a: 1.0,
}),
padding: Some(Edges::uniform(padding)),
..Default::default()
},
vec![
make_text("First child on page 1", 12.0),
make_text("Second child on page 1", 12.0),
make_text("Third child on page 1", 12.0),
make_text("Fourth child on page 1", 12.0),
make_text("Fifth child on page 1", 12.0),
make_text("Sixth child on page 1", 12.0),
make_text("Seventh child overflows", 12.0),
make_text("Eighth child on page 2", 12.0),
make_text("Ninth child on page 2", 12.0),
make_text("Tenth child on page 2", 12.0),
],
);
let doc = Document {
children: vec![breakable_view],
metadata: Metadata::default(),
default_page: page_config,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let pages = layout_doc(&doc);
assert!(
pages.len() >= 2,
"Expected at least 2 pages, got {}",
pages.len()
);
for page_idx in 1..pages.len() {
let page = &pages[page_idx];
let wrapper = page
.elements
.iter()
.find(|el| matches!(el.draw, forme::layout::DrawCommand::Rect { .. }))
.expect(&format!(
"Page {} should have a wrapper Rect element",
page_idx + 1
));
assert!(
!wrapper.children.is_empty(),
"Page {} wrapper should have children",
page_idx + 1
);
let first_child = &wrapper.children[0];
let offset_from_rect_top = first_child.y - wrapper.y;
assert!(
(offset_from_rect_top - padding).abs() < 1.0,
"Page {}: first child should be {}pt below wrapper top, but was {}pt (child.y={}, wrapper.y={})",
page_idx + 1,
padding,
offset_from_rect_top,
first_child.y,
wrapper.y
);
}
}
use forme::template::evaluate_template;
use serde_json::json;
#[test]
fn test_template_ref_simple() {
let template = json!({
"children": [
{"kind": {"type": "Text", "content": {"$ref": "title"}}, "style": {}, "children": []}
],
"metadata": {},
"defaultPage": {"size": "A4", "margin": {"top": 54, "right": 54, "bottom": 54, "left": 54}, "wrap": true}
});
let data = json!({"title": "Hello World"});
let result = evaluate_template(&template, &data).unwrap();
assert_eq!(result["children"][0]["kind"]["content"], "Hello World");
}
#[test]
fn test_template_ref_nested_path() {
let template = json!({"$ref": "user.address.city"});
let data = json!({"user": {"address": {"city": "Portland"}}});
let result = evaluate_template(&template, &data).unwrap();
assert_eq!(result, json!("Portland"));
}
#[test]
fn test_template_each_basic() {
let template = json!({
"children": [
{
"$each": {"$ref": "items"},
"as": "$item",
"template": {
"kind": {"type": "Text", "content": {"$ref": "$item.name"}},
"style": {},
"children": []
}
}
]
});
let data = json!({"items": [{"name": "A"}, {"name": "B"}, {"name": "C"}]});
let result = evaluate_template(&template, &data).unwrap();
let children = result["children"].as_array().unwrap();
assert_eq!(children.len(), 3);
assert_eq!(children[0]["kind"]["content"], "A");
assert_eq!(children[1]["kind"]["content"], "B");
assert_eq!(children[2]["kind"]["content"], "C");
}
#[test]
fn test_template_each_nested() {
let template = json!({
"items": [
{
"$each": {"$ref": "groups"},
"as": "$group",
"template": {
"name": {"$ref": "$group.name"},
"members": [
{
"$each": {"$ref": "$group.members"},
"as": "$member",
"template": {"$ref": "$member"}
}
]
}
}
]
});
let data = json!({
"groups": [
{"name": "A", "members": ["x", "y"]},
{"name": "B", "members": ["z"]}
]
});
let result = evaluate_template(&template, &data).unwrap();
let items = result["items"].as_array().unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0]["name"], "A");
assert_eq!(items[0]["members"].as_array().unwrap().len(), 2);
assert_eq!(items[1]["members"].as_array().unwrap().len(), 1);
}
#[test]
fn test_template_each_empty_array() {
let template = json!({
"children": [
{
"$each": {"$ref": "items"},
"as": "$item",
"template": {"$ref": "$item"}
}
]
});
let data = json!({"items": []});
let result = evaluate_template(&template, &data).unwrap();
assert_eq!(result["children"].as_array().unwrap().len(), 0);
}
#[test]
fn test_template_if_truthy() {
let template = json!({
"$if": {"$ref": "showTitle"},
"then": {"kind": {"type": "Text", "content": "Title"}, "style": {}, "children": []},
"else": {"kind": {"type": "Text", "content": "No Title"}, "style": {}, "children": []}
});
let data = json!({"showTitle": true});
let result = evaluate_template(&template, &data).unwrap();
assert_eq!(result["kind"]["content"], "Title");
}
#[test]
fn test_template_if_falsy() {
let template = json!({
"$if": {"$ref": "showTitle"},
"then": "yes",
"else": "no"
});
let data = json!({"showTitle": false});
let result = evaluate_template(&template, &data).unwrap();
assert_eq!(result, json!("no"));
}
#[test]
fn test_template_if_with_operator() {
let template = json!({
"$if": {"$gt": [{"$ref": "count"}, 10]},
"then": "many",
"else": "few"
});
let data = json!({"count": 25});
let result = evaluate_template(&template, &data).unwrap();
assert_eq!(result, json!("many"));
}
#[test]
fn test_template_comparison_ops() {
let data = json!({"a": 5, "b": 10});
let eq = evaluate_template(&json!({"$eq": [{"$ref": "a"}, 5]}), &data).unwrap();
assert_eq!(eq, json!(true));
let ne = evaluate_template(&json!({"$ne": [{"$ref": "a"}, {"$ref": "b"}]}), &data).unwrap();
assert_eq!(ne, json!(true));
let gt = evaluate_template(&json!({"$gt": [{"$ref": "b"}, {"$ref": "a"}]}), &data).unwrap();
assert_eq!(gt, json!(true));
let lt = evaluate_template(&json!({"$lt": [{"$ref": "a"}, {"$ref": "b"}]}), &data).unwrap();
assert_eq!(lt, json!(true));
let gte = evaluate_template(&json!({"$gte": [{"$ref": "a"}, 5]}), &data).unwrap();
assert_eq!(gte, json!(true));
let lte = evaluate_template(&json!({"$lte": [{"$ref": "a"}, 5]}), &data).unwrap();
assert_eq!(lte, json!(true));
}
#[test]
fn test_template_arithmetic_ops() {
let data = json!({"x": 10, "y": 3});
let add = evaluate_template(&json!({"$add": [{"$ref": "x"}, {"$ref": "y"}]}), &data).unwrap();
assert_eq!(add, json!(13.0));
let sub = evaluate_template(&json!({"$sub": [{"$ref": "x"}, {"$ref": "y"}]}), &data).unwrap();
assert_eq!(sub, json!(7.0));
let mul = evaluate_template(&json!({"$mul": [{"$ref": "x"}, {"$ref": "y"}]}), &data).unwrap();
assert_eq!(mul, json!(30.0));
let div = evaluate_template(&json!({"$div": [{"$ref": "x"}, {"$ref": "y"}]}), &data).unwrap();
let div_val = div.as_f64().unwrap();
assert!((div_val - 3.333333).abs() < 0.001);
}
#[test]
fn test_template_string_ops() {
let data = json!({"name": "hello"});
let upper = evaluate_template(&json!({"$upper": {"$ref": "name"}}), &data).unwrap();
assert_eq!(upper, json!("HELLO"));
let lower = evaluate_template(&json!({"$lower": "WORLD"}), &data).unwrap();
assert_eq!(lower, json!("world"));
}
#[test]
fn test_template_concat() {
let data = json!({"first": "John", "last": "Doe"});
let template = json!({"$concat": [{"$ref": "first"}, " ", {"$ref": "last"}]});
let result = evaluate_template(&template, &data).unwrap();
assert_eq!(result, json!("John Doe"));
}
#[test]
fn test_template_format() {
let data = json!({"price": 42.5});
let template = json!({"$format": [{"$ref": "price"}, "0.00"]});
let result = evaluate_template(&template, &data).unwrap();
assert_eq!(result, json!("42.50"));
}
#[test]
fn test_template_cond() {
let data = json!({"premium": true});
let template = json!({"$cond": [{"$ref": "premium"}, "gold", "standard"]});
let result = evaluate_template(&template, &data).unwrap();
assert_eq!(result, json!("gold"));
}
#[test]
fn test_template_count() {
let data = json!({"items": [1, 2, 3, 4, 5]});
let template = json!({"$count": {"$ref": "items"}});
let result = evaluate_template(&template, &data).unwrap();
assert_eq!(result, json!(5));
}
#[test]
fn test_template_missing_ref_omitted() {
let template = json!({"a": {"$ref": "exists"}, "b": {"$ref": "missing"}});
let data = json!({"exists": "yes"});
let result = evaluate_template(&template, &data).unwrap();
assert_eq!(result["a"], json!("yes"));
assert!(result.get("b").is_none());
}
#[test]
fn test_template_passthrough_primitives() {
let template = json!({
"type": "Text",
"content": "static",
"fontSize": 12,
"bold": true,
"empty": null
});
let data = json!({});
let result = evaluate_template(&template, &data).unwrap();
assert_eq!(result["type"], "Text");
assert_eq!(result["content"], "static");
assert_eq!(result["fontSize"], 12);
assert_eq!(result["bold"], true);
assert!(result["empty"].is_null());
}
#[test]
fn test_template_full_render() {
let template_json = serde_json::to_string(&json!({
"children": [
{
"kind": {"type": "Text", "content": {"$ref": "title"}},
"style": {"fontSize": 24},
"children": []
},
{
"kind": {"type": "View"},
"style": {},
"children": [
{
"$each": {"$ref": "items"},
"as": "$item",
"template": {
"kind": {"type": "Text", "content": {"$ref": "$item"}},
"style": {},
"children": []
}
}
]
}
],
"metadata": {"title": {"$ref": "title"}},
"defaultPage": {"size": "A4", "margin": {"top": 54, "right": 54, "bottom": 54, "left": 54}, "wrap": true}
})).unwrap();
let data_json = r#"{"title": "Invoice #001", "items": ["Widget A", "Widget B"]}"#;
let pdf = forme::render_template(&template_json, data_json).unwrap();
assert_valid_pdf(&pdf);
}
#[test]
fn test_template_div_by_zero() {
let data = json!({});
let result = evaluate_template(&json!({"$div": [10, 0]}), &data).unwrap();
assert_eq!(result, json!(0.0));
}
#[test]
fn test_document_lang_in_pdf_catalog() {
let doc = Document {
children: vec![make_text("Hello", 12.0)],
metadata: Metadata {
title: None,
author: None,
subject: None,
creator: None,
lang: Some("en-US".to_string()),
},
default_page: PageConfig::default(),
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/Lang (en-US)"),
"PDF catalog should contain /Lang"
);
}
#[test]
fn test_document_lang_omitted_when_none() {
let doc = default_doc(vec![make_text("Hello", 12.0)]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
!text.contains("/Lang"),
"PDF catalog should not contain /Lang when not set"
);
}
#[test]
fn test_image_href_produces_link_annotation() {
let one_px_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
let doc = default_doc(vec![Node {
kind: NodeKind::Image {
src: one_px_png.to_string(),
width: Some(100.0),
height: Some(50.0),
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: Some("https://example.com".to_string()),
alt: None,
}]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/Annots"),
"Image with href should produce annotations"
);
assert!(
text.contains("https://example.com"),
"Annotation should contain the URL"
);
}
#[test]
fn test_alt_deserializes_from_json() {
let json_str = r#"{
"children": [{
"kind": {"type": "Image", "src": "test.png", "width": 100, "height": 50},
"style": {},
"children": [],
"alt": "A test image"
}],
"metadata": {},
"defaultPage": {"size": "A4", "margin": {"top": 54, "right": 54, "bottom": 54, "left": 54}, "wrap": true}
}"#;
let doc: Document = serde_json::from_str(json_str).unwrap();
assert_eq!(doc.children[0].alt.as_deref(), Some("A test image"));
}
#[test]
fn test_lang_deserializes_from_json() {
let json_str = r#"{
"children": [],
"metadata": {"lang": "fr-FR"},
"defaultPage": {"size": "A4", "margin": {"top": 54, "right": 54, "bottom": 54, "left": 54}, "wrap": true}
}"#;
let doc: Document = serde_json::from_str(json_str).unwrap();
assert_eq!(doc.metadata.lang.as_deref(), Some("fr-FR"));
}
#[test]
fn test_hyphenation_json_round_trip() {
let json_str = r#"{
"children": [{
"kind": {"type": "Page", "config": {"size": "A4", "margin": {"top": 54, "right": 54, "bottom": 54, "left": 54}, "wrap": true}},
"style": {},
"children": [{
"kind": {"type": "Text", "content": "extraordinary"},
"style": {"hyphens": "auto"},
"children": []
}]
}],
"metadata": {},
"defaultPage": {"size": "A4", "margin": {"top": 54, "right": 54, "bottom": 54, "left": 54}, "wrap": true}
}"#;
let doc: Document = serde_json::from_str(json_str).unwrap();
let text_node = &doc.children[0].children[0];
assert_eq!(text_node.style.hyphens, Some(Hyphens::Auto));
}
#[test]
fn test_hyphenation_inherits() {
let parent_style = Style {
hyphens: Some(Hyphens::Auto),
..Default::default()
};
let resolved_parent = parent_style.resolve(None, 500.0);
assert_eq!(resolved_parent.hyphens, Hyphens::Auto);
let child_style = Style::default();
let resolved_child = child_style.resolve(Some(&resolved_parent), 500.0);
assert_eq!(resolved_child.hyphens, Hyphens::Auto);
let child_override = Style {
hyphens: Some(Hyphens::None),
..Default::default()
};
let resolved_override = child_override.resolve(Some(&resolved_parent), 500.0);
assert_eq!(resolved_override.hyphens, Hyphens::None);
}
#[test]
fn test_hyphenation_min_content_in_flex() {
let font_context = FontContext::new();
let engine = LayoutEngine::new();
let text_no_hyphen = Node {
kind: NodeKind::Text {
content: "extraordinary".to_string(),
href: None,
runs: vec![],
},
style: Style {
font_size: Some(12.0),
hyphens: Some(Hyphens::Manual),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let style_no_hyphen = text_no_hyphen.style.resolve(None, 500.0);
let min_width_no_hyphen =
engine.measure_min_content_width(&text_no_hyphen, &style_no_hyphen, &font_context);
let text_with_hyphen = Node {
kind: NodeKind::Text {
content: "extraordinary".to_string(),
href: None,
runs: vec![],
},
style: Style {
font_size: Some(12.0),
hyphens: Some(Hyphens::Auto),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let style_with_hyphen = text_with_hyphen.style.resolve(None, 500.0);
let min_width_with_hyphen =
engine.measure_min_content_width(&text_with_hyphen, &style_with_hyphen, &font_context);
assert!(
min_width_with_hyphen < min_width_no_hyphen,
"With auto hyphenation, min-content ({min_width_with_hyphen}) should be smaller than without ({min_width_no_hyphen})"
);
}
#[test]
fn test_justified_text_produces_valid_pdf() {
let doc = Document {
children: vec![Node {
kind: NodeKind::Page {
config: PageConfig {
size: PageSize::Letter,
margin: Edges { top: 36.0, right: 36.0, bottom: 36.0, left: 36.0 },
wrap: true,
},
},
style: Style::default(),
children: vec![Node {
kind: NodeKind::Text {
content: "The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog again.".to_string(),
href: None,
runs: vec![],
},
style: Style {
text_align: Some(TextAlign::Justify),
font_size: Some(12.0),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
metadata: Metadata::default(),
default_page: PageConfig {
size: PageSize::Letter,
margin: Edges { top: 72.0, right: 72.0, bottom: 72.0, left: 72.0 },
wrap: true,
},
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let bytes = forme::render(&doc).expect("Should render justified text");
assert!(bytes.len() > 100);
assert!(bytes.starts_with(b"%PDF"));
}
#[test]
fn test_lang_inherits_to_text_nodes() {
let doc = Document {
children: vec![Node {
kind: NodeKind::Page {
config: PageConfig {
size: PageSize::A4,
margin: Edges {
top: 36.0,
right: 36.0,
bottom: 36.0,
left: 36.0,
},
wrap: true,
},
},
style: Style::default(),
children: vec![make_text("Hallo Welt", 12.0)],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
metadata: Metadata {
lang: Some("de".to_string()),
..Default::default()
},
default_page: PageConfig {
size: PageSize::A4,
margin: Edges {
top: 72.0,
right: 72.0,
bottom: 72.0,
left: 72.0,
},
wrap: true,
},
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let bytes = forme::render(&doc).expect("Should render with document lang");
assert!(bytes.starts_with(b"%PDF"));
}
#[test]
fn test_per_node_lang_override() {
let doc = Document {
children: vec![Node {
kind: NodeKind::Page {
config: PageConfig {
size: PageSize::A4,
margin: Edges {
top: 36.0,
right: 36.0,
bottom: 36.0,
left: 36.0,
},
wrap: true,
},
},
style: Style::default(),
children: vec![Node {
kind: NodeKind::Text {
content: "Bonjour le monde".to_string(),
href: None,
runs: vec![],
},
style: Style {
lang: Some("fr".to_string()),
font_size: Some(12.0),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
metadata: Metadata {
lang: Some("de".to_string()),
..Default::default()
},
default_page: PageConfig {
size: PageSize::A4,
margin: Edges {
top: 72.0,
right: 72.0,
bottom: 72.0,
left: 72.0,
},
wrap: true,
},
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let bytes = forme::render(&doc).expect("Should render with per-node lang override");
assert!(bytes.starts_with(b"%PDF"));
}
#[test]
fn test_tagged_pdf_has_struct_tree_root() {
let doc = Document {
children: vec![Node::page(
PageConfig::default(),
Style::default(),
vec![make_text("Hello World", 16.0)],
)],
metadata: Default::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: true,
pdfa: None,
default_style: None,
embedded_data: None,
};
let bytes = forme::render(&doc).unwrap();
let pdf_str = String::from_utf8_lossy(&bytes);
assert!(
pdf_str.contains("/MarkInfo << /Marked true >>"),
"Tagged PDF must have /MarkInfo in Catalog"
);
assert!(
pdf_str.contains("/StructTreeRoot"),
"Tagged PDF must have /StructTreeRoot in Catalog"
);
assert!(
pdf_str.contains("/Type /StructTreeRoot"),
"Tagged PDF must have StructTreeRoot object"
);
assert!(
pdf_str.contains("/Document /Document"),
"Tagged PDF must have RoleMap with Document role"
);
}
#[test]
fn test_tagged_pdf_parent_tree_consistency() {
let mut children = Vec::new();
for i in 0..60 {
children.push(make_text(
&format!(
"Paragraph {}: Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
i + 1
),
11.0,
));
}
let doc = Document {
children: vec![Node::page(
PageConfig::default(),
Style::default(),
children,
)],
metadata: Default::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: true,
pdfa: None,
default_style: None,
embedded_data: None,
};
let bytes = forme::render(&doc).unwrap();
let pdf_str = String::from_utf8_lossy(&bytes);
assert!(
pdf_str.contains("/StructParents 0"),
"First page must have /StructParents 0"
);
assert!(
pdf_str.contains("/Nums ["),
"ParentTree must have /Nums array"
);
assert!(
pdf_str.contains("/Type /MCR"),
"Tagged PDF must have marked content references in structure elements"
);
assert!(
pdf_str.contains("/MCID 0"),
"Tagged PDF must have MCID 0 reference in structure elements"
);
}
#[test]
fn test_tagged_pdf_nested_text_roles() {
let outer_text = Node {
kind: NodeKind::Text {
content: "Hello ".to_string(),
href: None,
runs: vec![TextRun {
content: "bold world".to_string(),
style: Style {
font_weight: Some(700),
..Default::default()
},
href: None,
}],
},
style: Style {
font_size: Some(12.0),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let doc = Document {
children: vec![Node::page(
PageConfig::default(),
Style::default(),
vec![outer_text],
)],
metadata: Default::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: true,
pdfa: None,
default_style: None,
embedded_data: None,
};
let bytes = forme::render(&doc).unwrap();
let pdf_str = String::from_utf8_lossy(&bytes);
assert!(pdf_str.contains("/S /P"), "Outer Text should map to /S /P");
assert!(
pdf_str.contains("/S /Span"),
"TextLine elements should map to /S /Span"
);
assert!(pdf_str.contains("/Type /StructTreeRoot"));
}
#[test]
fn test_tagged_pdf_table_th_td() {
let header_row = Node {
kind: NodeKind::TableRow { is_header: true },
style: Style::default(),
children: vec![Node {
kind: NodeKind::TableCell {
col_span: 1,
row_span: 1,
},
style: Style::default(),
children: vec![make_text("Name", 10.0)],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let body_row = Node {
kind: NodeKind::TableRow { is_header: false },
style: Style::default(),
children: vec![Node {
kind: NodeKind::TableCell {
col_span: 1,
row_span: 1,
},
style: Style::default(),
children: vec![make_text("Alice", 10.0)],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let table = Node {
kind: NodeKind::Table {
columns: vec![ColumnDef {
width: ColumnWidth::Fraction(1.0),
}],
},
style: Style::default(),
children: vec![header_row, body_row],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let doc = Document {
children: vec![Node::page(
PageConfig::default(),
Style::default(),
vec![table],
)],
metadata: Default::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: true,
pdfa: None,
default_style: None,
embedded_data: None,
};
let bytes = forme::render(&doc).unwrap();
let pdf_str = String::from_utf8_lossy(&bytes);
assert!(
pdf_str.contains("/S /TR"),
"Must have /S /TR structure element"
);
assert!(
pdf_str.contains("/S /TH"),
"Header cells must map to /S /TH"
);
assert!(pdf_str.contains("/S /TD"), "Body cells must map to /S /TD");
}
#[test]
fn test_tagged_pdf_figure_alt_text() {
let svg_node = Node {
kind: NodeKind::Svg {
width: 100.0,
height: 100.0,
view_box: None,
content: r#"<rect width="100" height="100" fill="red"/>"#.to_string(),
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: Some("A red square".to_string()),
};
let doc = Document {
children: vec![Node::page(
PageConfig::default(),
Style::default(),
vec![svg_node],
)],
metadata: Default::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: true,
pdfa: None,
default_style: None,
embedded_data: None,
};
let bytes = forme::render(&doc).unwrap();
let pdf_str = String::from_utf8_lossy(&bytes);
assert!(
pdf_str.contains("/S /Figure"),
"SVG with alt text must produce /S /Figure"
);
assert!(
pdf_str.contains("/Alt (A red square)"),
"Figure must carry /Alt text"
);
}
#[test]
fn test_direction_json_deserialization() {
let json = r#"{
"defaultPage": { "width": 612, "height": 792 },
"children": [{
"kind": { "type": "Text", "content": "Ù…Ø±ØØ¨Ø§" },
"style": { "direction": "rtl", "fontSize": 14 }
}]
}"#;
let doc: Document = serde_json::from_str(json).unwrap();
let style = &doc.children[0].style;
assert!(matches!(style.direction, Some(Direction::Rtl)));
}
#[test]
fn test_direction_auto_detection() {
let json = r#"{
"defaultPage": { "width": 612, "height": 792 },
"children": [{
"kind": { "type": "Text", "content": "Hello World" },
"style": { "direction": "auto", "fontSize": 14 }
}]
}"#;
let doc: Document = serde_json::from_str(json).unwrap();
assert!(matches!(
doc.children[0].style.direction,
Some(Direction::Auto)
));
let bytes = forme::render(&doc).unwrap();
assert!(!bytes.is_empty());
}
#[test]
fn test_rtl_text_produces_valid_pdf() {
let json = r#"{
"defaultPage": { "width": 612, "height": 792 },
"children": [{
"kind": { "type": "Text", "content": "Ù…Ø±ØØ¨Ø§ بالعالم" },
"style": { "fontSize": 14, "direction": "rtl" }
}]
}"#;
let doc: Document = serde_json::from_str(json).unwrap();
let bytes = forme::render(&doc).unwrap();
let pdf_str = String::from_utf8_lossy(&bytes);
assert!(pdf_str.starts_with("%PDF-1.7"));
assert!(pdf_str.contains("%%EOF"));
}
#[test]
fn test_mixed_ltr_rtl_produces_valid_pdf() {
let json = r#"{
"defaultPage": { "width": 612, "height": 792 },
"children": [{
"kind": { "type": "Text", "content": "Hello Ù…Ø±ØØ¨Ø§ World" },
"style": { "fontSize": 14 }
}]
}"#;
let doc: Document = serde_json::from_str(json).unwrap();
let bytes = forme::render(&doc).unwrap();
let pdf_str = String::from_utf8_lossy(&bytes);
assert!(pdf_str.starts_with("%PDF-1.7"));
}
#[test]
fn test_rtl_direction_defaults_text_align_right() {
let style = Style {
direction: Some(Direction::Rtl),
font_size: Some(12.0),
..Default::default()
};
let resolved = style.resolve(None, 500.0);
assert!(
matches!(resolved.text_align, TextAlign::Right),
"RTL direction should default text-align to Right"
);
}
#[test]
fn test_direction_inherits_from_parent() {
let parent_style = Style {
direction: Some(Direction::Rtl),
font_size: Some(14.0),
..Default::default()
};
let parent_resolved = parent_style.resolve(None, 500.0);
let child_style = Style {
font_size: Some(12.0),
..Default::default()
};
let child_resolved = child_style.resolve(Some(&parent_resolved), 500.0);
assert!(
matches!(child_resolved.direction, Direction::Rtl),
"Child should inherit direction from parent"
);
}
#[test]
fn test_grid_display_json_deserialization() {
let json = r#"{
"defaultPage": { "width": 612, "height": 792 },
"children": [{
"kind": { "type": "View" },
"style": {
"display": "Grid",
"gridTemplateColumns": [{ "Fr": 1.0 }, { "Fr": 1.0 }, { "Fr": 1.0 }]
},
"children": [
{ "kind": { "type": "Text", "content": "A" }, "style": { "fontSize": 12 } },
{ "kind": { "type": "Text", "content": "B" }, "style": { "fontSize": 12 } },
{ "kind": { "type": "Text", "content": "C" }, "style": { "fontSize": 12 } }
]
}]
}"#;
let doc: Document = serde_json::from_str(json).unwrap();
let bytes = forme::render(&doc).unwrap();
let pdf_str = String::from_utf8_lossy(&bytes);
assert!(pdf_str.starts_with("%PDF-1.7"));
}
#[test]
fn test_grid_fixed_and_fr_columns() {
let json = r#"{
"defaultPage": { "width": 612, "height": 792 },
"children": [{
"kind": { "type": "View" },
"style": {
"display": "Grid",
"gridTemplateColumns": [{ "Pt": 100 }, { "Fr": 1.0 }, { "Fr": 2.0 }],
"gap": 10
},
"children": [
{ "kind": { "type": "Text", "content": "Fixed" }, "style": { "fontSize": 12 } },
{ "kind": { "type": "Text", "content": "1fr" }, "style": { "fontSize": 12 } },
{ "kind": { "type": "Text", "content": "2fr" }, "style": { "fontSize": 12 } }
]
}]
}"#;
let doc: Document = serde_json::from_str(json).unwrap();
let bytes = forme::render(&doc).unwrap();
assert!(!bytes.is_empty());
}
#[test]
fn test_grid_multiple_rows() {
let json = r#"{
"defaultPage": { "width": 612, "height": 792 },
"children": [{
"kind": { "type": "View" },
"style": {
"display": "Grid",
"gridTemplateColumns": [{ "Fr": 1.0 }, { "Fr": 1.0 }],
"rowGap": 5,
"columnGap": 10
},
"children": [
{ "kind": { "type": "Text", "content": "A" }, "style": { "fontSize": 12 } },
{ "kind": { "type": "Text", "content": "B" }, "style": { "fontSize": 12 } },
{ "kind": { "type": "Text", "content": "C" }, "style": { "fontSize": 12 } },
{ "kind": { "type": "Text", "content": "D" }, "style": { "fontSize": 12 } }
]
}]
}"#;
let doc: Document = serde_json::from_str(json).unwrap();
let bytes = forme::render(&doc).unwrap();
assert!(!bytes.is_empty());
}
#[test]
fn test_grid_explicit_placement() {
let json = r#"{
"defaultPage": { "width": 612, "height": 792 },
"children": [{
"kind": { "type": "View" },
"style": {
"display": "Grid",
"gridTemplateColumns": [{ "Fr": 1.0 }, { "Fr": 1.0 }, { "Fr": 1.0 }]
},
"children": [
{
"kind": { "type": "Text", "content": "Placed" },
"style": {
"fontSize": 12,
"gridPlacement": { "columnStart": 2, "rowStart": 1 }
}
},
{ "kind": { "type": "Text", "content": "Auto 1" }, "style": { "fontSize": 12 } },
{ "kind": { "type": "Text", "content": "Auto 2" }, "style": { "fontSize": 12 } }
]
}]
}"#;
let doc: Document = serde_json::from_str(json).unwrap();
let bytes = forme::render(&doc).unwrap();
assert!(!bytes.is_empty());
}
#[test]
fn test_grid_display_default_is_flex() {
let style = Style::default();
let resolved = style.resolve(None, 500.0);
assert!(
matches!(resolved.display, Display::Flex),
"Display should default to Flex"
);
}
#[test]
fn test_grid_track_resolution() {
use forme::layout::grid;
let tracks = vec![
GridTrackSize::Pt(100.0),
GridTrackSize::Fr(1.0),
GridTrackSize::Fr(2.0),
];
let sizes = grid::resolve_tracks(&tracks, 400.0, 0.0, &[]);
assert!((sizes[0] - 100.0).abs() < 0.001);
assert!((sizes[1] - 100.0).abs() < 0.001);
assert!((sizes[2] - 200.0).abs() < 0.001);
}
#[test]
fn test_qrcode_renders_to_pdf() {
let doc = Document {
children: vec![Node {
kind: NodeKind::QrCode {
data: "https://formepdf.com".to_string(),
size: Some(100.0),
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
metadata: Metadata::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let pdf = forme::render(&doc).expect("QR code should render to PDF");
assert!(pdf.len() > 100, "PDF should have content");
assert!(pdf.starts_with(b"%PDF"), "Should be a valid PDF");
}
#[test]
fn test_qrcode_with_explicit_size() {
let doc = Document {
children: vec![Node {
kind: NodeKind::QrCode {
data: "test".to_string(),
size: Some(50.0),
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
metadata: Metadata::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let (pdf, layout) = forme::render_with_layout(&doc).expect("Should render");
assert!(pdf.starts_with(b"%PDF"));
assert_eq!(layout.pages.len(), 1);
let qr_elem = &layout.pages[0].elements[0];
assert!((qr_elem.width - 50.0).abs() < 0.1);
assert!((qr_elem.height - 50.0).abs() < 0.1);
}
#[test]
fn test_qrcode_page_break() {
let mut children: Vec<Node> = Vec::new();
for _ in 0..50 {
children.push(make_text("Line of text to fill the page up", 12.0));
}
children.push(Node {
kind: NodeKind::QrCode {
data: "overflow".to_string(),
size: Some(200.0),
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
});
let doc = Document {
children,
metadata: Metadata::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let (pdf, layout) = forme::render_with_layout(&doc).expect("Should render");
assert!(pdf.starts_with(b"%PDF"));
assert!(layout.pages.len() >= 2, "QR code should cause a page break");
}
#[test]
fn test_qrcode_json_deserialization() {
let json = r#"{
"children": [{
"kind": { "type": "QrCode", "data": "hello world", "size": 80 },
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"#;
let pdf = forme::render_json(json).expect("QR code JSON should render");
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_font_fallback_chain_in_document() {
let doc = Document {
children: vec![Node {
kind: NodeKind::Text {
content: "Fallback test".to_string(),
href: None,
runs: vec![],
},
style: Style {
font_family: Some("Missing, Helvetica".to_string()),
font_size: Some(12.0),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
metadata: Metadata::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let pdf = forme::render(&doc).expect("Fallback chain should render");
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_text_overflow_ellipsis_json() {
let json = r#"{
"children": [{
"kind": { "type": "Text", "content": "This is a very long text that should be truncated with ellipsis because the container is narrow" },
"style": { "textOverflow": "Ellipsis", "fontSize": 12 },
"children": []
}],
"metadata": {},
"defaultPage": { "size": { "Custom": { "width": 100, "height": 200 } }, "margin": { "top": 10, "right": 10, "bottom": 10, "left": 10 } },
"fonts": []
}"#;
let pdf = forme::render_json(json).expect("Ellipsis text should render");
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_text_overflow_clip_json() {
let json = r#"{
"children": [{
"kind": { "type": "Text", "content": "This is a long text that will be clipped" },
"style": { "textOverflow": "Clip", "fontSize": 12 },
"children": []
}],
"metadata": {},
"defaultPage": { "size": { "Custom": { "width": 100, "height": 200 } }, "margin": { "top": 10, "right": 10, "bottom": 10, "left": 10 } },
"fonts": []
}"#;
let pdf = forme::render_json(json).expect("Clip text should render");
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_text_overflow_ellipsis_single_line() {
let doc = Document {
children: vec![Node {
kind: NodeKind::Text {
content: "This is a long text that needs truncation".to_string(),
href: None,
runs: vec![],
},
style: Style {
font_size: Some(12.0),
text_overflow: Some(TextOverflow::Ellipsis),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
metadata: Metadata::default(),
default_page: PageConfig {
size: PageSize::Custom {
width: 100.0,
height: 200.0,
},
margin: Edges::uniform(10.0),
wrap: true,
},
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let (_pdf, layout) = forme::render_with_layout(&doc).expect("Should render");
assert_eq!(layout.pages.len(), 1);
let text_elem = &layout.pages[0].elements[0];
assert_eq!(
text_elem.children.len(),
1,
"Ellipsis should produce exactly 1 line"
);
}
#[test]
fn test_flex_row_stretch_enables_justify_content() {
let left_child = make_styled_view(
Style {
flex_grow: Some(1.0),
justify_content: Some(JustifyContent::Center),
..Default::default()
},
vec![make_text("Short", 12.0)],
);
let right_child = make_styled_view(
Style {
flex_grow: Some(1.0),
height: Some(Dimension::Pt(100.0)),
..Default::default()
},
vec![make_text("Tall", 12.0)],
);
let row = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
..Default::default()
},
vec![left_child, right_child],
);
let doc = default_doc(vec![row]);
let (_pdf, layout) = forme::render_with_layout(&doc).expect("Should render");
assert_eq!(layout.pages.len(), 1);
let page = &layout.pages[0];
let row_elem = &page.elements[0];
let left_col = &row_elem.children[0];
let right_col = &row_elem.children[1];
assert!(
(left_col.height - right_col.height).abs() < 1.0,
"Left col height ({}) should match right col height ({})",
left_col.height,
right_col.height,
);
let text_in_left = &left_col.children[0]; let offset_from_top = text_in_left.y - left_col.y;
assert!(
offset_from_top > 1.0,
"Text should be vertically offset from top (offset={}), justifyContent:center should center it",
offset_from_top,
);
}
#[test]
fn test_flex_row_stretch_enables_flex_grow() {
let inner_grow = make_styled_view(
Style {
flex_grow: Some(1.0),
..Default::default()
},
vec![make_text("Grows", 12.0)],
);
let left_child = make_styled_view(
Style {
flex_grow: Some(1.0),
..Default::default()
},
vec![inner_grow],
);
let right_child = make_styled_view(
Style {
flex_grow: Some(1.0),
height: Some(Dimension::Pt(120.0)),
..Default::default()
},
vec![make_text("Tall", 12.0)],
);
let row = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
..Default::default()
},
vec![left_child, right_child],
);
let doc = default_doc(vec![row]);
let (_pdf, layout) = forme::render_with_layout(&doc).expect("Should render");
assert_eq!(layout.pages.len(), 1);
let page = &layout.pages[0];
let row_elem = &page.elements[0];
let left_col = &row_elem.children[0];
assert!(
(left_col.height - 120.0).abs() < 1.0,
"Left col height ({}) should be ~120 (stretched to match right)",
left_col.height,
);
let inner = &left_col.children[0];
assert!(
inner.height > 100.0,
"Inner flex-grow child height ({}) should expand to fill stretched parent",
inner.height,
);
}
#[test]
fn test_line_breaking_greedy_vs_optimal_both_work() {
let paragraph = "The quick brown fox jumps over the lazy dog near the riverbank where the tall reeds sway gently in the warm afternoon breeze.";
let make_text_with_mode = |mode: LineBreaking| Node {
kind: NodeKind::Text {
content: paragraph.to_string(),
href: None,
runs: vec![],
},
style: Style {
font_size: Some(10.0),
line_breaking: Some(mode),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let doc_optimal = default_doc(vec![make_text_with_mode(LineBreaking::Optimal)]);
let doc_greedy = default_doc(vec![make_text_with_mode(LineBreaking::Greedy)]);
let pages_optimal = layout_doc(&doc_optimal);
let pages_greedy = layout_doc(&doc_greedy);
assert_eq!(pages_optimal.len(), 1);
assert_eq!(pages_greedy.len(), 1);
assert!(!pages_optimal[0].elements.is_empty());
assert!(!pages_greedy[0].elements.is_empty());
}
#[test]
fn test_line_breaking_mode_inherits() {
let doc = default_doc(vec![make_styled_view(
Style {
line_breaking: Some(LineBreaking::Greedy),
..Default::default()
},
vec![make_text(
"Some text content that spans multiple lines in this narrow container.",
10.0,
)],
)]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
assert!(!pages[0].elements.is_empty());
}
#[test]
fn test_grid_justified_text_does_not_overflow() {
let french = "Le chiffre d'affaires consolide a atteint douze virgule un millions de dollars, soit une augmentation de vingt-trois pour cent par rapport a l'exercice precedent. L'expansion dans trois nouveaux marches a contribue a une croissance trimestrielle de trente et un pour cent des nouvelles acquisitions de clients.";
let text_node = Node {
kind: NodeKind::Text {
content: french.to_string(),
href: None,
runs: vec![],
},
style: Style {
font_size: Some(8.0),
line_height: Some(1.5),
text_align: Some(TextAlign::Justify),
hyphens: Some(Hyphens::Auto),
lang: Some("fr".to_string()),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let card = make_styled_view(
Style {
padding: Some(Edges::uniform(14.0)),
border_width: Some(EdgeValues::uniform(1.0)),
border_color: Some(EdgeValues::uniform(Color {
r: 0.73,
g: 0.97,
b: 0.83,
a: 1.0,
})),
background_color: Some(Color {
r: 0.94,
g: 0.99,
b: 0.96,
a: 1.0,
}),
..Default::default()
},
vec![text_node],
);
let grid = make_styled_view(
Style {
display: Some(Display::Grid),
grid_template_columns: Some(vec![GridTrackSize::Fr(1.0), GridTrackSize::Fr(1.0)]),
gap: Some(14.0),
..Default::default()
},
vec![card.clone(), card],
);
let doc = default_doc(vec![Node {
kind: NodeKind::Page {
config: PageConfig {
size: PageSize::Letter,
margin: Edges::uniform(40.0),
..Default::default()
},
},
style: Style::default(),
children: vec![grid],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
fn check_overflow(el: &forme::layout::LayoutElement, depth: usize) {
let parent_right = el.x + el.width;
for child in &el.children {
let child_right = child.x + child.width;
if child_right > parent_right + 0.5 {
eprintln!(
"OVERFLOW depth={}: child ({:.2}..{:.2} w={:.2}) > parent ({:.2}..{:.2} w={:.2}) by {:.2}pt type={:?}",
depth, child.x, child_right, child.width,
el.x, parent_right, el.width,
child_right - parent_right, child.node_type,
);
}
check_overflow(child, depth + 1);
}
}
for el in &pages[0].elements {
check_overflow(el, 0);
}
}
#[test]
fn test_arabic_text_with_font_fallback() {
let font_bytes = match std::fs::read("/System/Library/Fonts/Supplemental/Arial Unicode.ttf") {
Ok(b) => b,
Err(_) => return, };
let mut font_ctx = FontContext::new();
font_ctx
.registry_mut()
.register("ArialUnicode", 400, false, font_bytes);
let doc_json = r#"{
"defaultPage": { "width": 612, "height": 792 },
"children": [{
"kind": { "type": "Text", "content": "ØÙ‚Ù‚ الإيراد Ø§Ù„Ù…ÙˆØØ¯ للربع الرابع" },
"style": { "fontFamily": "Helvetica, ArialUnicode", "fontSize": 12 }
}]
}"#;
let doc: Document = serde_json::from_str(doc_json).unwrap();
let engine = LayoutEngine::new();
let pages = engine.layout(&doc, &font_ctx);
assert!(!pages.is_empty());
let bytes = forme::render(&doc).unwrap();
assert!(!bytes.is_empty());
}
#[test]
fn test_builtin_noto_sans_cyrillic() {
let doc = default_doc(vec![make_text(
"\u{041F}\u{0440}\u{0438}\u{0432}\u{0435}\u{0442}",
12.0,
)]);
let pdf = render_to_pdf(&doc);
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
pdf_str.contains("NotoSans"),
"PDF should contain embedded Noto Sans for Cyrillic text"
);
}
#[test]
fn test_builtin_noto_sans_greek() {
let doc = default_doc(vec![make_text("\u{03B1}\u{03B2}\u{03B3}", 12.0)]);
let pdf = render_to_pdf(&doc);
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
pdf_str.contains("NotoSans"),
"PDF should contain embedded Noto Sans for Greek text"
);
}
#[test]
fn test_document_default_style() {
let doc = Document {
children: vec![Node::page(
PageConfig::default(),
Style::default(),
vec![make_text("Hello", 12.0)],
)],
metadata: Metadata::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: false,
pdfa: None,
default_style: Some(Style {
font_family: Some("Courier".to_string()),
font_size: Some(16.0),
..Default::default()
}),
embedded_data: None,
};
let pdf = render_to_pdf(&doc);
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
pdf_str.contains("Courier"),
"PDF should use Courier from document default_style"
);
}
#[test]
fn test_embedded_data_round_trip() {
let data = r#"{"invoice":123,"items":["widget","gadget"]}"#;
let doc = Document {
children: vec![Node::page(
PageConfig::default(),
Style::default(),
vec![make_text("Invoice", 12.0)],
)],
metadata: Metadata::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: Some(data.to_string()),
};
let pdf = render_to_pdf(&doc);
assert_valid_pdf(&pdf);
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
pdf_str.contains("forme-data.json"),
"PDF should contain embedded file name"
);
assert!(
pdf_str.contains("/Type /EmbeddedFile"),
"PDF should contain EmbeddedFile object"
);
assert!(
pdf_str.contains("/Type /Filespec"),
"PDF should contain Filespec object"
);
assert!(
pdf_str.contains("/EmbeddedFiles"),
"Catalog should reference EmbeddedFiles"
);
assert!(
pdf_str.contains("/AFRelationship /Data"),
"Filespec should have AFRelationship"
);
}
#[test]
fn test_no_embedded_data() {
let doc = default_doc(vec![make_text("Hello", 12.0)]);
let pdf = render_to_pdf(&doc);
assert_valid_pdf(&pdf);
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
!pdf_str.contains("forme-data.json"),
"PDF without embedded data should not contain forme-data.json"
);
assert!(
!pdf_str.contains("/EmbeddedFiles"),
"PDF without embedded data should not have EmbeddedFiles"
);
}
#[test]
fn test_embedded_data_via_json() {
let json = r#"{
"children": [{ "kind": { "type": "Text", "content": "Test" }, "style": {}, "children": [] }],
"embeddedData": "{\"key\":\"value\"}"
}"#;
let pdf = forme::render_json(json).unwrap();
assert!(pdf.starts_with(b"%PDF-1.7"));
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
pdf_str.contains("forme-data.json"),
"JSON-deserialized embedded data should be present in PDF"
);
}
#[test]
fn test_barcode_renders_to_pdf() {
let doc = Document {
children: vec![Node {
kind: NodeKind::Barcode {
data: "ABC-123".to_string(),
format: forme::barcode::BarcodeFormat::Code128,
width: Some(200.0),
height: 60.0,
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
metadata: Metadata::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let pdf = forme::render(&doc).expect("Barcode should render to PDF");
assert!(pdf.len() > 100, "PDF should have content");
assert!(pdf.starts_with(b"%PDF"), "Should be a valid PDF");
}
#[test]
fn test_barcode_json_deserialization() {
let json = r#"{
"children": [{
"kind": { "type": "Barcode", "data": "HELLO", "format": "Code39", "height": 50 },
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"#;
let pdf = forme::render_json(json).expect("Barcode JSON should render");
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_barcode_code128_default_format() {
let json = r#"{
"children": [{
"kind": { "type": "Barcode", "data": "12345", "height": 40 },
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"#;
let pdf = forme::render_json(json).expect("Default format barcode should render");
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_barcode_layout_dimensions() {
let doc = Document {
children: vec![Node {
kind: NodeKind::Barcode {
data: "TEST".to_string(),
format: forme::barcode::BarcodeFormat::Code39,
width: Some(150.0),
height: 40.0,
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
metadata: Metadata::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let (pdf, layout) = forme::render_with_layout(&doc).expect("Should render");
assert!(pdf.starts_with(b"%PDF"));
assert_eq!(layout.pages.len(), 1);
let elem = &layout.pages[0].elements[0];
assert!((elem.width - 150.0).abs() < 0.1);
assert!((elem.height - 40.0).abs() < 0.1);
}
#[test]
fn auto_margin_horizontal_centers_child() {
let child = make_styled_view(
Style {
width: Some(Dimension::Pt(200.0)),
height: Some(Dimension::Pt(50.0)),
margin: Some(MarginEdges {
top: EdgeValue::Pt(0.0),
right: EdgeValue::Auto,
bottom: EdgeValue::Pt(0.0),
left: EdgeValue::Auto,
}),
..Default::default()
},
vec![],
);
let page = Node::page(
PageConfig {
margin: Edges::uniform(0.0),
..Default::default()
},
Style::default(),
vec![child],
);
let doc = Document {
children: vec![page],
metadata: Metadata::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let (_pdf, layout) = forme::render_with_layout(&doc).expect("Should render");
assert_eq!(layout.pages.len(), 1);
let elem = &layout.pages[0].elements[0];
assert!((elem.width - 200.0).abs() < 0.1, "width: {}", elem.width);
let expected_x = (595.28 - 200.0) / 2.0;
assert!(
(elem.x - expected_x).abs() < 0.5,
"x: {}, expected: {}",
elem.x,
expected_x
);
}
#[test]
fn auto_margin_left_pushes_right() {
let child = make_styled_view(
Style {
width: Some(Dimension::Pt(100.0)),
height: Some(Dimension::Pt(50.0)),
margin: Some(MarginEdges {
top: EdgeValue::Pt(0.0),
right: EdgeValue::Pt(0.0),
bottom: EdgeValue::Pt(0.0),
left: EdgeValue::Auto,
}),
..Default::default()
},
vec![],
);
let page = Node::page(
PageConfig {
margin: Edges::uniform(0.0),
..Default::default()
},
Style::default(),
vec![child],
);
let doc = Document {
children: vec![page],
metadata: Metadata::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let (_pdf, layout) = forme::render_with_layout(&doc).expect("Should render");
let elem = &layout.pages[0].elements[0];
let expected_x = 595.28 - 100.0;
assert!(
(elem.x - expected_x).abs() < 0.5,
"x: {}, expected: {}",
elem.x,
expected_x
);
}
#[test]
fn auto_margin_deserializes_from_json() {
let json = r#"{
"children": [{
"kind": { "type": "Page", "config": { "size": "A4", "margin": { "top": 0, "right": 0, "bottom": 0, "left": 0 }, "wrap": true } },
"style": {},
"children": [{
"kind": { "type": "View" },
"style": {
"width": { "Pt": 200 },
"height": { "Pt": 50 },
"margin": { "top": 0, "right": "auto", "bottom": 0, "left": "auto" }
},
"children": []
}]
}],
"metadata": {},
"defaultPage": { "size": "A4", "margin": { "top": 0, "right": 0, "bottom": 0, "left": 0 }, "wrap": true }
}"#;
let (pdf, layout) = forme::render_json_with_layout(json).expect("Should render from JSON");
assert!(pdf.starts_with(b"%PDF"));
assert_eq!(layout.pages.len(), 1);
let elem = &layout.pages[0].elements[0];
assert!((elem.width - 200.0).abs() < 0.1);
let expected_x = (595.28 - 200.0) / 2.0;
assert!(
(elem.x - expected_x).abs() < 0.5,
"x: {}, expected: {}",
elem.x,
expected_x
);
}
#[test]
fn test_bar_chart_renders_to_pdf() {
let json = r##"{
"children": [{
"kind": {
"type": "BarChart",
"data": [
{ "label": "Q1", "value": 100 },
{ "label": "Q2", "value": 150 },
{ "label": "Q3", "value": 80 },
{ "label": "Q4", "value": 200 },
{ "label": "Q5", "value": 120 }
],
"width": 400,
"height": 200,
"show_labels": true,
"show_values": true,
"show_grid": true
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"##;
let pdf = forme::render_json(json).expect("BarChart should render");
assert!(pdf.starts_with(b"%PDF"));
assert!(pdf.len() > 200);
}
#[test]
fn test_line_chart_renders_to_pdf() {
let json = r##"{
"children": [{
"kind": {
"type": "LineChart",
"series": [
{ "name": "Revenue", "data": [100, 150, 130, 200], "color": "#2b6cb0" },
{ "name": "Expenses", "data": [80, 120, 100, 160], "color": "#e53e3e" }
],
"labels": ["Q1", "Q2", "Q3", "Q4"],
"width": 400,
"height": 200,
"show_points": true,
"show_grid": true
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"##;
let pdf = forme::render_json(json).expect("LineChart should render");
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_pie_chart_renders_to_pdf() {
let json = r##"{
"children": [{
"kind": {
"type": "PieChart",
"data": [
{ "label": "A", "value": 40, "color": "#1a365d" },
{ "label": "B", "value": 30, "color": "#2b6cb0" },
{ "label": "C", "value": 20, "color": "#63b3ed" },
{ "label": "D", "value": 10, "color": "#90cdf4" }
],
"width": 200,
"height": 200,
"donut": false,
"show_legend": true
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"##;
let pdf = forme::render_json(json).expect("PieChart should render");
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_pie_chart_donut_renders_to_pdf() {
let json = r##"{
"children": [{
"kind": {
"type": "PieChart",
"data": [
{ "label": "Yes", "value": 70 },
{ "label": "No", "value": 30 }
],
"width": 200,
"height": 200,
"donut": true,
"show_legend": false
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"##;
let pdf = forme::render_json(json).expect("PieChart donut should render");
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_area_chart_renders_to_pdf() {
let json = r##"{
"children": [{
"kind": {
"type": "AreaChart",
"series": [
{ "name": "Users", "data": [10, 50, 80, 120, 160], "color": "#38a169" },
{ "name": "Revenue", "data": [5, 30, 60, 90, 140], "color": "#805ad5" }
],
"labels": ["Jan", "Feb", "Mar", "Apr", "May"],
"width": 400,
"height": 200,
"show_grid": true
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"##;
let pdf = forme::render_json(json).expect("AreaChart should render");
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_dot_plot_renders_to_pdf() {
let json = r##"{
"children": [{
"kind": {
"type": "DotPlot",
"groups": [
{ "name": "Group A", "color": "#1a365d", "data": [[1, 2], [3, 4], [5, 6], [7, 8]] },
{ "name": "Group B", "color": "#e53e3e", "data": [[2, 3], [4, 5], [6, 7], [8, 9]] },
{ "name": "Group C", "color": "#38a169", "data": [[1, 5], [3, 7], [5, 3], [7, 1]] },
{ "name": "Group D", "color": "#805ad5", "data": [[2, 6], [4, 2], [6, 8], [8, 4]] }
],
"width": 400,
"height": 300,
"show_legend": true,
"dot_size": 4
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"##;
let pdf = forme::render_json(json).expect("DotPlot should render");
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_chart_with_title() {
let json = r##"{
"children": [{
"kind": {
"type": "BarChart",
"data": [
{ "label": "A", "value": 50 },
{ "label": "B", "value": 75 }
],
"width": 300,
"height": 200,
"show_labels": true,
"show_values": false,
"show_grid": false,
"title": "Sales Report"
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"##;
let pdf = forme::render_json(json).expect("Chart with title should render");
assert!(pdf.starts_with(b"%PDF"));
assert!(
pdf.len() > 200,
"PDF should have content for chart with title"
);
}
#[test]
fn test_chart_respects_custom_colors() {
let json = r##"{
"children": [{
"kind": {
"type": "BarChart",
"data": [
{ "label": "A", "value": 50, "color": "#ff0000" },
{ "label": "B", "value": 75, "color": "#00ff00" }
],
"width": 300,
"height": 200,
"show_labels": true,
"show_values": false,
"show_grid": false
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"##;
let pdf = forme::render_json(json).expect("Chart with custom colors should render");
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_bar_chart_layout_dimensions() {
let doc = Document {
children: vec![Node {
kind: NodeKind::BarChart {
data: vec![
forme::model::ChartDataPoint {
label: "A".into(),
value: 100.0,
color: None,
},
forme::model::ChartDataPoint {
label: "B".into(),
value: 200.0,
color: None,
},
],
width: 350.0,
height: 250.0,
color: None,
show_labels: true,
show_values: false,
show_grid: false,
title: None,
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
metadata: Metadata::default(),
default_page: PageConfig::default(),
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
};
let (pdf, layout) = forme::render_with_layout(&doc).expect("Should render");
assert!(pdf.starts_with(b"%PDF"));
assert_eq!(layout.pages.len(), 1);
let elem = &layout.pages[0].elements[0];
assert!((elem.width - 350.0).abs() < 0.1);
assert!((elem.height - 250.0).abs() < 0.1);
}
#[test]
fn test_multiple_charts_on_same_page() {
let json = r##"{
"children": [
{
"kind": {
"type": "BarChart",
"data": [{ "label": "A", "value": 50 }],
"width": 300,
"height": 150,
"show_labels": true,
"show_values": false,
"show_grid": false
},
"style": {},
"children": []
},
{
"kind": {
"type": "LineChart",
"series": [{ "name": "S1", "data": [10, 20, 30] }],
"labels": ["A", "B", "C"],
"width": 300,
"height": 150,
"show_points": false,
"show_grid": false
},
"style": {},
"children": []
}
],
"metadata": {},
"defaultPage": {},
"fonts": []
}"##;
let pdf = forme::render_json(json).expect("Multiple charts should render");
assert!(pdf.starts_with(b"%PDF"));
}