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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
}
}
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");
}
fn decompress_pdf_streams(pdf: &[u8]) -> String {
use miniz_oxide::inflate::decompress_to_vec_zlib;
let mut result = String::new();
let needle_start = b"\nstream\n";
let needle_end = b"\nendstream";
let mut pos = 0;
while pos + needle_start.len() < pdf.len() {
let found = pdf[pos..]
.windows(needle_start.len())
.position(|w| w == needle_start);
let idx = match found {
Some(i) => i,
None => break,
};
let abs = pos + idx + needle_start.len();
let end_found = pdf[abs..]
.windows(needle_end.len())
.position(|w| w == needle_end);
let end_idx = match end_found {
Some(i) => i,
None => break,
};
let stream_bytes = &pdf[abs..abs + end_idx];
if let Ok(decompressed) = decompress_to_vec_zlib(stream_bytes) {
if let Ok(s) = std::str::from_utf8(&decompressed) {
result.push_str(s);
result.push('\n');
}
}
pos = abs + end_idx;
}
result
}
#[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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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.pdf_ua,
doc.embedded_data.as_deref(),
&doc.attachments,
doc.zugferd.as_ref(),
doc.flatten_forms,
forme::model::PdfVersion::V1_7,
false,
)
.unwrap()
.0
}
#[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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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,
false,
None,
&[],
None,
false,
forme::model::PdfVersion::V1_7,
false,
)
.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,
pages: FixedPageFilter::default(),
page_name: None,
exclude_page_names: vec![],
},
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,
pages: FixedPageFilter::default(),
page_name: None,
exclude_page_names: vec![],
},
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,
pages: FixedPageFilter::default(),
page_name: None,
exclude_page_names: vec![],
},
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,
pages: FixedPageFilter::default(),
page_name: None,
exclude_page_names: vec![],
},
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,
pages: FixedPageFilter::default(),
page_name: None,
exclude_page_names: vec![],
},
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
);
}
#[derive(Debug)]
struct ParsedLink {
rect: [f64; 4],
uri: Option<String>,
goto: bool,
struct_parent: bool,
}
fn parse_link_annotations(pdf: &[u8]) -> Vec<ParsedLink> {
let text = String::from_utf8_lossy(pdf);
let mut out = Vec::new();
for chunk in text.split("/Subtype /Link").skip(1) {
let dict_end = chunk.find("endobj").unwrap_or(chunk.len());
let dict = &chunk[..dict_end];
let r_start = dict.find("/Rect [").expect("link annotation has /Rect") + 7;
let r_end = r_start + dict[r_start..].find(']').unwrap();
let nums: Vec<f64> = dict[r_start..r_end]
.split_whitespace()
.map(|n| n.parse().unwrap())
.collect();
let uri = dict.find("/URI (").map(|s| {
let s = s + 6;
let e = s + dict[s..].find(')').unwrap();
dict[s..e].to_string()
});
out.push(ParsedLink {
rect: [nums[0], nums[1], nums[2], nums[3]],
uri,
goto: dict.contains("/S /GoTo"),
struct_parent: dict.contains("/StructParent "),
});
}
out
}
fn inline_link_json(text_style: &str, runs: &str, text_href: Option<&str>, tagged: bool) -> String {
let href = text_href
.map(|h| format!(r#", "href": "{h}""#))
.unwrap_or_default();
format!(
r#"{{
"tagged": {tagged},
"children": [
{{
"kind": {{ "type": "Text", "content": ""{href}, "runs": [{runs}] }},
"style": {{ "fontSize": 12{text_style} }},
"children": []
}}
]
}}"#
)
}
const SEE_DOCS_NOW: &str = r#"
{ "content": "See " },
{ "content": "docs", "href": "https://example.com/inline" },
{ "content": " now." }"#;
const SEE_DOCS_NOW_PLAIN: &str = r#"
{ "content": "See " },
{ "content": "docs" },
{ "content": " now." }"#;
#[test]
fn test_inline_link_run_produces_span_sized_annotation() {
let bytes = forme::render_json(&inline_link_json("", SEE_DOCS_NOW, None, false)).unwrap();
assert_valid_pdf(&bytes);
let links = parse_link_annotations(&bytes);
assert_eq!(
links.len(),
1,
"exactly one link annotation for the linked span, got {links:?}"
);
let link = &links[0];
assert_eq!(link.uri.as_deref(), Some("https://example.com/inline"));
let control = forme::render_json(&inline_link_json(
"",
SEE_DOCS_NOW_PLAIN,
Some("https://example.com/inline"),
false,
))
.unwrap();
let whole = parse_link_annotations(&control);
assert_eq!(whole.len(), 1, "whole-Text control: {whole:?}");
let w = whole[0].rect;
let r = link.rect;
assert!(
r[0] > w[0] + 15.0,
"span starts after 'See ': span {r:?} para {w:?}"
);
assert!(
r[2] < w[2],
"span ends inside the paragraph: {r:?} vs {w:?}"
);
let width = r[2] - r[0];
assert!(
(width - 25.34).abs() < 0.5,
"span rect covers exactly 'docs' (25.34pt), got {width:.2}"
);
assert!(
r[1] >= w[1] - 0.01 && r[3] <= w[3] + 0.01 && r[3] > r[1],
"span rect lies within the text line vertically: {r:?} vs {w:?}"
);
}
#[test]
fn test_inline_link_run_wrapping_two_lines_gets_rect_per_line() {
let runs = r#"
{ "content": "See the " },
{ "content": "sit amet consectetur adipiscing", "href": "https://example.com/wrap" },
{ "content": " elit." }"#;
let style = r#", "width": { "Pt": 110 }"#;
let bytes = forme::render_json(&inline_link_json(style, runs, None, false)).unwrap();
let links = parse_link_annotations(&bytes);
assert!(
links.len() >= 2,
"a span wrapping onto a second line gets one rect per line, got {links:?}"
);
for l in &links {
assert_eq!(l.uri.as_deref(), Some("https://example.com/wrap"));
assert!(l.rect[2] - l.rect[0] <= 110.0 + 0.01, "{l:?}");
}
let mut sorted: Vec<[f64; 4]> = links.iter().map(|l| l.rect).collect();
sorted.sort_by(|a, b| b[1].partial_cmp(&a[1]).unwrap());
for pair in sorted.windows(2) {
assert!(
pair[1][3] <= pair[0][1] + 0.01,
"per-line rects must not overlap: {:?} then {:?}",
pair[0],
pair[1]
);
}
let control = forme::render_json(&inline_link_json(
style,
runs,
Some("https://example.com/wrap"),
false,
))
.unwrap();
let whole = parse_link_annotations(&control);
assert_eq!(whole.len(), 1, "whole-Text control: {whole:?}");
let para = whole[0].rect;
for l in &links {
assert!(
l.rect[1] >= para[1] - 0.01 && l.rect[3] <= para[3] + 0.01,
"every per-line rect lies within the paragraph: {l:?} vs {para:?}"
);
}
assert!(
sorted[0][0] > para[0] + 1.0,
"the first line's rect starts mid-line: {:?} vs {para:?}",
sorted[0]
);
}
#[test]
fn test_whole_text_href_with_runs_does_not_double_emit() {
let runs = r#"
{ "content": "See " },
{ "content": "docs", "href": "https://example.com/whole" },
{ "content": " now." }"#;
let bytes = forme::render_json(&inline_link_json(
"",
runs,
Some("https://example.com/whole"),
false,
))
.unwrap();
let links = parse_link_annotations(&bytes);
assert_eq!(
links.len(),
1,
"one annotation, not one per span: {links:?}"
);
}
#[test]
fn test_inline_internal_link_run_produces_goto() {
let json = r##"{
"children": [
{
"kind": { "type": "Text", "content": "", "runs": [
{ "content": "Jump to " },
{ "content": "the section", "href": "#Section A" },
{ "content": " below." }
] },
"style": { "fontSize": 12 },
"children": []
},
{
"kind": { "type": "Text", "content": "Section A" },
"style": { "fontSize": 12 },
"bookmark": "Section A",
"children": []
}
]
}"##;
let bytes = forme::render_json(json).unwrap();
let links = parse_link_annotations(&bytes);
assert_eq!(links.len(), 1, "{links:?}");
assert!(links[0].goto, "internal run link is a GoTo: {links:?}");
assert!(links[0].uri.is_none());
}
#[test]
fn test_inline_link_run_tagged_has_struct_parent() {
let bytes = forme::render_json(&inline_link_json("", SEE_DOCS_NOW, None, true)).unwrap();
let links = parse_link_annotations(&bytes);
assert_eq!(links.len(), 1, "{links:?}");
assert!(
links[0].struct_parent,
"tagged inline link annotation carries /StructParent: {links:?}"
);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/S /Link"),
"a /Link structure element holds the annotation"
);
}
#[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_styled_breakable_bookmark_emits_exactly_one_outline_entry() {
let mut children = Vec::new();
for i in 0..80 {
children.push(make_text(&format!("Line {}", i), 12.0));
}
let view = Node {
kind: NodeKind::View,
style: Style {
background_color: Some(Color::rgb(0.9, 0.9, 0.9)),
padding: Some(Edges::uniform(20.0)),
..Default::default()
},
children,
id: None,
source_location: None,
bookmark: Some("Styled Chapter".to_string()),
href: None,
alt: None,
};
let doc = default_doc(vec![view]);
let pages = layout_doc(&doc);
assert!(
pages.len() >= 2,
"Test needs the overflow path; got {} page(s)",
pages.len()
);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
let title_count = text.matches("/Title (Styled Chapter)").count();
assert_eq!(
title_count, 1,
"One `bookmark` prop must produce exactly one outline entry, got {}",
title_count
);
let outlines_dict = text
.split("/Type /Outlines")
.nth(1)
.expect("PDF should contain an /Outlines dictionary");
let outlines_dict = &outlines_dict[..outlines_dict.find(">>").unwrap_or(outlines_dict.len())];
assert!(
outlines_dict.contains("/Count 1"),
"Outlines dictionary should report /Count 1, got: << /Type /Outlines{}>>",
outlines_dict
);
}
#[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,
..Default::default()
},
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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,
pages: FixedPageFilter::default(),
page_name: None,
exclude_page_names: vec![],
},
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,
..Default::default()
},
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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,
..Default::default()
},
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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,
..Default::default()
};
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,
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let pages = layout_doc(&doc);
assert!(
pages.len() >= 2,
"Expected at least 2 pages, got {}",
pages.len()
);
for (page_idx, page) in pages.iter().enumerate().skip(1) {
let wrapper = page
.elements
.iter()
.find(|el| matches!(el.draw, forme::layout::DrawCommand::Rect { .. }))
.unwrap_or_else(|| panic!("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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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,
..Default::default()
},
},
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,
..Default::default()
},
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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,
..Default::default()
},
},
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,
..Default::default()
},
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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,
..Default::default()
},
},
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,
..Default::default()
},
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: true,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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("/RoleMap "),
"Tagged PDF must reference a RoleMap from the StructTreeRoot"
);
assert!(
!pdf_str.contains("/Document /Document"),
"RoleMap must not self-map the standard /Document type (7.1-6)"
);
}
#[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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: true,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: true,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: true,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: true,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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,
..Default::default()
},
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
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,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: Some(data.to_string()),
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
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"));
}
#[test]
fn test_text_field_renders_to_pdf() {
let json = r#"{
"children": [{
"kind": {
"type": "TextField",
"name": "full_name",
"width": 200,
"height": 24,
"multiline": false,
"password": false,
"read_only": false,
"font_size": 12
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"#;
let pdf = forme::render_json(json).expect("TextField should render");
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
pdf_str.contains("/AcroForm"),
"PDF should contain AcroForm dictionary"
);
assert!(
pdf_str.contains("/FT /Tx"),
"PDF should contain text field type"
);
assert!(
pdf_str.contains("/T (full_name)"),
"PDF should contain field name"
);
}
#[test]
fn test_checkbox_renders_to_pdf() {
let json = r#"{
"children": [{
"kind": {
"type": "Checkbox",
"name": "agree_terms",
"checked": true,
"width": 14,
"height": 14,
"read_only": false
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"#;
let pdf = forme::render_json(json).expect("Checkbox should render");
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
pdf_str.contains("/AcroForm"),
"PDF should contain AcroForm dictionary"
);
assert!(
pdf_str.contains("/FT /Btn"),
"PDF should contain button field type"
);
assert!(
pdf_str.contains("/T (agree_terms)"),
"PDF should contain field name"
);
assert!(
pdf_str.contains("/V /Yes"),
"Checked checkbox should have /V /Yes"
);
}
#[test]
fn test_dropdown_renders_to_pdf() {
let json = r#"{
"children": [{
"kind": {
"type": "Dropdown",
"name": "country",
"options": ["US", "UK", "CA"],
"width": 200,
"height": 24,
"read_only": false,
"font_size": 12
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"#;
let pdf = forme::render_json(json).expect("Dropdown should render");
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
pdf_str.contains("/AcroForm"),
"PDF should contain AcroForm dictionary"
);
assert!(
pdf_str.contains("/FT /Ch"),
"PDF should contain choice field type"
);
assert!(
pdf_str.contains("/T (country)"),
"PDF should contain field name"
);
assert!(pdf_str.contains("/Opt"), "PDF should contain options array");
}
#[test]
fn test_radio_button_group_renders_to_pdf() {
let json = r#"{
"children": [
{
"kind": {
"type": "RadioButton",
"name": "plan",
"value": "free",
"checked": true,
"width": 14,
"height": 14,
"read_only": false
},
"style": {},
"children": []
},
{
"kind": {
"type": "RadioButton",
"name": "plan",
"value": "pro",
"checked": false,
"width": 14,
"height": 14,
"read_only": false
},
"style": {},
"children": []
},
{
"kind": {
"type": "RadioButton",
"name": "plan",
"value": "team",
"checked": false,
"width": 14,
"height": 14,
"read_only": false
},
"style": {},
"children": []
}
],
"metadata": {},
"defaultPage": {},
"fonts": []
}"#;
let pdf = forme::render_json(json).expect("RadioButton group should render");
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
pdf_str.contains("/AcroForm"),
"PDF should contain AcroForm dictionary"
);
assert!(
pdf_str.contains("/T (plan)"),
"PDF should contain radio group name"
);
assert!(
pdf_str.contains("/Ff 49152"),
"Radio parent should have radio+noToggleToOff flags"
);
assert!(
pdf_str.contains("/Kids"),
"Radio parent should have /Kids array"
);
assert!(
pdf_str.contains("/V /free"),
"Radio group should have /V /free (checked value)"
);
}
#[test]
fn test_mixed_form_fields_on_same_page() {
let json = r#"{
"children": [
{
"kind": {
"type": "TextField",
"name": "name",
"width": 200,
"height": 24,
"multiline": false,
"password": false,
"read_only": false,
"font_size": 12
},
"style": {},
"children": []
},
{
"kind": { "type": "Text", "content": "Some text between fields" },
"style": {},
"children": []
},
{
"kind": {
"type": "Checkbox",
"name": "agree",
"checked": false,
"width": 14,
"height": 14,
"read_only": false
},
"style": {},
"children": []
},
{
"kind": {
"type": "Dropdown",
"name": "color",
"options": ["Red", "Green", "Blue"],
"width": 150,
"height": 24,
"read_only": false,
"font_size": 12
},
"style": {},
"children": []
}
],
"metadata": {},
"defaultPage": {},
"fonts": []
}"#;
let pdf = forme::render_json(json).expect("Mixed form fields should render");
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(pdf_str.contains("/AcroForm"), "PDF should contain AcroForm");
assert!(pdf_str.contains("/FT /Tx"), "PDF should contain text field");
assert!(pdf_str.contains("/FT /Btn"), "PDF should contain checkbox");
assert!(
pdf_str.contains("/FT /Ch"),
"PDF should contain choice field"
);
}
#[test]
fn test_no_form_fields_no_acroform() {
let json = r#"{
"children": [{
"kind": { "type": "Text", "content": "Hello, no forms here" },
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"#;
let pdf = forme::render_json(json).expect("Should render without form fields");
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
!pdf_str.contains("/AcroForm"),
"PDF without form fields should not have /AcroForm"
);
}
#[test]
fn test_text_field_with_value_and_placeholder() {
let json = r#"{
"children": [{
"kind": {
"type": "TextField",
"name": "email",
"value": "test@example.com",
"placeholder": "Enter email",
"width": 250,
"height": 30,
"multiline": false,
"password": false,
"read_only": false,
"font_size": 14
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"#;
let pdf = forme::render_json(json).expect("TextField with value should render");
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
pdf_str.contains("/V (test@example.com)"),
"PDF should contain field value"
);
}
#[test]
fn test_text_field_multiline_flag() {
let json = r#"{
"children": [{
"kind": {
"type": "TextField",
"name": "notes",
"width": 300,
"height": 100,
"multiline": true,
"password": false,
"read_only": false,
"font_size": 12
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"#;
let pdf = forme::render_json(json).expect("Multiline text field should render");
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
pdf_str.contains("/Ff 4096"),
"Multiline text field should have bit 13 set"
);
}
#[test]
fn test_dropdown_with_selected_value() {
let json = r#"{
"children": [{
"kind": {
"type": "Dropdown",
"name": "size",
"options": ["Small", "Medium", "Large"],
"value": "Medium",
"width": 150,
"height": 24,
"read_only": false,
"font_size": 12
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"#;
let pdf = forme::render_json(json).expect("Dropdown with value should render");
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
pdf_str.contains("/V (Medium)"),
"Dropdown should have selected value"
);
}
#[test]
fn test_flatten_text_field_with_value() {
let json = r#"{
"children": [{
"kind": {
"type": "TextField", "name": "name", "width": 200, "height": 24,
"value": "Jane Smith", "font_size": 12,
"multiline": false, "password": false, "read_only": false
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": [],
"flattenForms": true
}"#;
let pdf = forme::render_json(json).expect("Flattened text field should render");
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
!pdf_str.contains("/AcroForm"),
"Flattened PDF should not contain /AcroForm"
);
assert!(
!pdf_str.contains("/FT /Tx"),
"Flattened PDF should not contain text field widgets"
);
assert!(pdf.starts_with(b"%PDF-1.7"));
}
#[test]
fn test_flatten_checkbox_checked() {
let json = r#"{
"children": [{
"kind": {
"type": "Checkbox", "name": "agree", "width": 14, "height": 14,
"checked": true, "read_only": false
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": [],
"flattenForms": true
}"#;
let pdf = forme::render_json(json).expect("Flattened checkbox should render");
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
!pdf_str.contains("/AcroForm"),
"Flattened PDF should not contain /AcroForm"
);
assert!(
!pdf_str.contains("/FT /Btn"),
"Flattened PDF should not contain button field widgets"
);
}
#[test]
fn test_flatten_dropdown_with_value() {
let json = r#"{
"children": [{
"kind": {
"type": "Dropdown", "name": "size", "width": 150, "height": 24,
"options": ["Small", "Medium", "Large"], "value": "Medium",
"read_only": false, "font_size": 12
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": [],
"flattenForms": true
}"#;
let pdf = forme::render_json(json).expect("Flattened dropdown should render");
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
!pdf_str.contains("/AcroForm"),
"Flattened PDF should not contain /AcroForm"
);
assert!(
!pdf_str.contains("/FT /Ch"),
"Flattened PDF should not contain choice field widgets"
);
assert!(pdf.starts_with(b"%PDF-1.7"));
}
#[test]
fn test_flatten_radio_button_group() {
let json = r#"{
"children": [
{
"kind": {
"type": "RadioButton", "name": "color", "value": "red",
"width": 14, "height": 14, "checked": true, "read_only": false
},
"style": {}, "children": []
},
{
"kind": {
"type": "RadioButton", "name": "color", "value": "blue",
"width": 14, "height": 14, "checked": false, "read_only": false
},
"style": {}, "children": []
}
],
"metadata": {},
"defaultPage": {},
"fonts": [],
"flattenForms": true
}"#;
let pdf = forme::render_json(json).expect("Flattened radio buttons should render");
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
!pdf_str.contains("/AcroForm"),
"Flattened PDF should not contain /AcroForm"
);
assert!(
!pdf_str.contains("/FT /Btn"),
"Flattened PDF should not contain button field widgets"
);
assert!(pdf.starts_with(b"%PDF-1.7"));
}
#[test]
fn test_flatten_empty_text_field() {
let json = r#"{
"children": [{
"kind": {
"type": "TextField", "name": "empty", "width": 200, "height": 24,
"font_size": 12,
"multiline": false, "password": false, "read_only": false
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": [],
"flattenForms": true
}"#;
let pdf = forme::render_json(json).expect("Flattened empty field should render");
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
!pdf_str.contains("/AcroForm"),
"Flattened PDF should not contain /AcroForm"
);
assert!(pdf.starts_with(b"%PDF-1.7"));
}
#[test]
fn test_non_flattened_default_has_acroform() {
let json = r#"{
"children": [{
"kind": {
"type": "TextField", "name": "name", "width": 200, "height": 24,
"value": "Test", "font_size": 12,
"multiline": false, "password": false, "read_only": false
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": []
}"#;
let pdf = forme::render_json(json).expect("Non-flattened form should render");
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
pdf_str.contains("/AcroForm"),
"Non-flattened PDF should contain /AcroForm"
);
}
#[test]
fn test_flatten_mixed_content() {
let json = r#"{
"children": [
{
"kind": { "type": "Text", "content": "Hello World" },
"style": {},
"children": []
},
{
"kind": {
"type": "TextField", "name": "field1", "width": 200, "height": 24,
"value": "Static Value", "font_size": 12,
"multiline": false, "password": false, "read_only": false
},
"style": {},
"children": []
},
{
"kind": {
"type": "Checkbox", "name": "check1", "width": 14, "height": 14,
"checked": true, "read_only": false
},
"style": {},
"children": []
}
],
"metadata": {},
"defaultPage": {},
"fonts": [],
"flattenForms": true
}"#;
let pdf = forme::render_json(json).expect("Flattened mixed content should render");
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
!pdf_str.contains("/AcroForm"),
"Flattened PDF should not contain /AcroForm"
);
assert!(pdf.starts_with(b"%PDF-1.7"));
assert!(
!pdf_str.contains("/FT /Tx"),
"Flattened PDF should not contain text field widgets"
);
}
#[test]
fn test_flatten_password_field_renders_dots() {
let json = r#"{
"children": [{
"kind": {
"type": "TextField", "name": "pw", "width": 200, "height": 24,
"value": "secret", "font_size": 12,
"multiline": false, "password": true, "read_only": false
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": [],
"flattenForms": true
}"#;
let pdf = forme::render_json(json).expect("Flattened password field should render");
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
!pdf_str.contains("/AcroForm"),
"Flattened PDF should not contain /AcroForm"
);
assert!(
!pdf_str.contains("secret"),
"Flattened password field should not contain plain text value"
);
assert!(pdf.starts_with(b"%PDF-1.7"));
}
#[test]
fn test_flatten_multiline_long_word() {
let long_word = "A".repeat(50);
let json = format!(
r#"{{
"children": [{{
"kind": {{
"type": "TextField", "name": "long", "width": 80, "height": 100,
"value": "{}", "font_size": 10,
"multiline": true, "password": false, "read_only": false
}},
"style": {{}},
"children": []
}}],
"metadata": {{}},
"defaultPage": {{}},
"fonts": [],
"flattenForms": true
}}"#,
long_word
);
let pdf = forme::render_json(&json).expect("Flattened multiline long word should render");
assert!(pdf.starts_with(b"%PDF-1.7"));
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
!pdf_str.contains("/AcroForm"),
"Flattened PDF should not contain /AcroForm"
);
}
#[test]
fn test_flatten_text_field_non_ascii() {
let json = r#"{
"children": [{
"kind": {
"type": "TextField", "name": "price", "width": 200, "height": 24,
"value": "\u20AC100 \u2013 price", "font_size": 12,
"multiline": false, "password": false, "read_only": false
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": [],
"flattenForms": true
}"#;
let pdf = forme::render_json(json).expect("Flattened non-ASCII text should render");
assert!(pdf.starts_with(b"%PDF-1.7"));
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
!pdf_str.contains("/AcroForm"),
"Flattened PDF should not contain /AcroForm"
);
}
#[test]
fn test_pdf_ua_has_viewer_preferences() {
let doc = Document {
children: vec![Node::text("Accessible doc", Style::default())],
metadata: Metadata {
title: Some("UA Test".to_string()),
..Default::default()
},
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: true,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/ViewerPreferences"),
"PDF/UA should include /ViewerPreferences"
);
assert!(
text.contains("/DisplayDocTitle true"),
"PDF/UA should set /DisplayDocTitle true"
);
}
#[test]
fn test_pdf_ua_has_xmp_pdfuaid() {
let doc = Document {
children: vec![Node::text("Accessible doc", Style::default())],
metadata: Metadata {
title: Some("UA Test".to_string()),
..Default::default()
},
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: true,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("pdfuaid:part"),
"PDF/UA should include pdfuaid:part in XMP metadata"
);
}
#[test]
fn test_pdf_ua_forces_tagging() {
let doc = Document {
children: vec![Node::text("Tagged by UA", Style::default())],
metadata: Metadata::default(),
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: true,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/StructTreeRoot"),
"PDF/UA should force tagging even when tagged=false"
);
assert!(
text.contains("/Marked true"),
"PDF/UA should have /Marked true in /MarkInfo"
);
}
#[test]
fn test_pdf_ua_and_pdfa_combined_xmp() {
let doc = Document {
children: vec![Node::text("Accessible", Style::default())],
metadata: Metadata {
title: Some("UA Only".to_string()),
..Default::default()
},
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: true,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("pdfuaid"),
"PDF/UA doc should have pdfuaid in XMP"
);
assert!(
text.contains("/ViewerPreferences"),
"PDF/UA doc should have /ViewerPreferences"
);
assert!(
text.contains("/StructTreeRoot"),
"PDF/UA doc should have structure tree"
);
}
#[test]
fn test_tagged_pdf_has_tab_order() {
let doc = Document {
children: vec![Node::text("Tab order test", Style::default())],
metadata: Metadata::default(),
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: true,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/Tabs /S"),
"Tagged PDF pages must have /Tabs /S for structure-order tab navigation"
);
}
#[test]
fn test_untagged_pdf_no_tab_order() {
let doc = Document {
children: vec![Node::text("No tabs", Style::default())],
metadata: Metadata::default(),
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
!text.contains("/Tabs"),
"Non-tagged PDF should not contain /Tabs"
);
}
#[test]
fn test_tagged_role_map_omits_standard_self_mappings() {
let doc = Document {
children: vec![Node::text("RoleMap test", Style::default())],
metadata: Metadata::default(),
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: true,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
for identity in [
"/H1 /H1",
"/L /L",
"/THead /THead",
"/Link /Link",
"/BlockQuote /BlockQuote",
] {
assert!(
!text.contains(identity),
"RoleMap must not self-map standard type ({identity}) — 7.1-6"
);
}
assert!(
text.contains("/RoleMap "),
"StructTreeRoot must still reference a (now empty) RoleMap"
);
}
#[test]
fn test_tagged_struct_tree_has_lang() {
let doc = Document {
children: vec![Node::text("Lang test", Style::default())],
metadata: Metadata {
lang: Some("en-US".to_string()),
..Default::default()
},
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: true,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/Type /StructTreeRoot"),
"Tagged doc must have StructTreeRoot"
);
let lang_count = text.matches("/Lang (en-US)").count();
assert!(
lang_count >= 2,
"Expected /Lang (en-US) on both Catalog and StructTreeRoot, found {} occurrences",
lang_count
);
}
#[test]
fn test_tagged_watermark_not_in_structure_tree() {
let json = r#"{
"children": [{
"kind": { "type": "Page", "config": { "size": "A4", "margin": { "top": 54, "right": 54, "bottom": 54, "left": 54 }, "wrap": true } },
"style": {},
"children": [
{
"kind": { "type": "Watermark", "text": "DRAFT", "font_size": 60, "angle": -45 },
"style": {},
"children": []
},
{
"kind": { "type": "Text", "content": "Real content", "runs": [] },
"style": {},
"children": []
}
]
}],
"metadata": {},
"tagged": true
}"#;
let bytes = forme::render_json(json).unwrap();
assert_valid_pdf(&bytes);
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/Type /StructTreeRoot"));
let struct_elem_count = text.matches("/Type /StructElem").count();
assert_eq!(
struct_elem_count, 2,
"Watermark should not be in structure tree (expected 2 StructElems: Div + P, got {})",
struct_elem_count
);
}
fn generate_test_cert_and_key() -> (String, String) {
use rsa::pkcs8::EncodePrivateKey;
let mut rng = rsa::rand_core::OsRng;
let rsa_key = rsa::RsaPrivateKey::new(&mut rng, 2048).unwrap();
let key_pem = rsa_key
.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
.unwrap()
.to_string();
let key_pair = rcgen::KeyPair::from_pem(&key_pem).unwrap();
let mut params = rcgen::CertificateParams::new(vec!["Test Signer".to_string()]).unwrap();
params.distinguished_name = {
let mut dn = rcgen::DistinguishedName::new();
dn.push(rcgen::DnType::CommonName, "Test Signer");
dn
};
let cert = params.self_signed(&key_pair).unwrap();
(cert.pem(), key_pem)
}
#[test]
fn test_certify_pdf_basic() {
let (cert_pem, key_pem) = generate_test_cert_and_key();
let doc = default_doc(vec![make_text("Signed document", 12.0)]);
let unsigned_pdf = render_to_pdf(&doc);
let config = forme::CertificationConfig {
certificate_pem: cert_pem,
private_key_pem: key_pem,
reason: None,
location: None,
contact: None,
visible: false,
x: None,
y: None,
width: None,
height: None,
};
let signed_pdf = forme::certify_pdf(&unsigned_pdf, &config).unwrap();
let text = String::from_utf8_lossy(&signed_pdf);
assert!(
text.contains("/Type /Sig"),
"Must contain signature dictionary"
);
assert!(
text.contains("/Filter /Adobe.PPKLite"),
"Must have Adobe.PPKLite filter"
);
assert!(
text.contains("/SubFilter /adbe.pkcs7.detached"),
"Must use PKCS#7 detached subfilter"
);
}
#[test]
fn test_certify_pdf_has_acroform() {
let (cert_pem, key_pem) = generate_test_cert_and_key();
let doc = default_doc(vec![make_text("AcroForm test", 12.0)]);
let unsigned_pdf = render_to_pdf(&doc);
let config = forme::CertificationConfig {
certificate_pem: cert_pem,
private_key_pem: key_pem,
reason: None,
location: None,
contact: None,
visible: false,
x: None,
y: None,
width: None,
height: None,
};
let signed_pdf = forme::certify_pdf(&unsigned_pdf, &config).unwrap();
let text = String::from_utf8_lossy(&signed_pdf);
assert!(text.contains("/AcroForm"), "Must contain AcroForm");
assert!(text.contains("/SigFlags 3"), "Must have SigFlags 3");
}
#[test]
fn test_certify_pdf_byterange() {
let (cert_pem, key_pem) = generate_test_cert_and_key();
let doc = default_doc(vec![make_text("ByteRange test", 12.0)]);
let unsigned_pdf = render_to_pdf(&doc);
let config = forme::CertificationConfig {
certificate_pem: cert_pem,
private_key_pem: key_pem,
reason: None,
location: None,
contact: None,
visible: false,
x: None,
y: None,
width: None,
height: None,
};
let signed_pdf = forme::certify_pdf(&unsigned_pdf, &config).unwrap();
let text = String::from_utf8_lossy(&signed_pdf);
let br_start = text.find("/ByteRange [").expect("Must contain /ByteRange");
let br_end = text[br_start..].find(']').unwrap() + br_start + 1;
let br_str = &text[br_start + 12..br_end - 1]; let values: Vec<usize> = br_str
.split_whitespace()
.filter_map(|s| s.parse().ok())
.collect();
assert_eq!(values.len(), 4, "ByteRange must have exactly 4 values");
assert_eq!(values[0], 0, "ByteRange must start at 0");
assert!(
values[1] < values[2],
"First range end must be before second range start"
);
assert_eq!(
values[1] + values[2] + values[3],
values[1] + values[2] + values[3],
"Values must be consistent"
);
assert_eq!(
values[2] + values[3],
signed_pdf.len(),
"ByteRange end must equal file length"
);
}
#[test]
fn test_certify_pdf_contents_not_placeholder() {
let (cert_pem, key_pem) = generate_test_cert_and_key();
let doc = default_doc(vec![make_text("Contents test", 12.0)]);
let unsigned_pdf = render_to_pdf(&doc);
let config = forme::CertificationConfig {
certificate_pem: cert_pem,
private_key_pem: key_pem,
reason: None,
location: None,
contact: None,
visible: false,
x: None,
y: None,
width: None,
height: None,
};
let signed_pdf = forme::certify_pdf(&unsigned_pdf, &config).unwrap();
let text = String::from_utf8_lossy(&signed_pdf);
let contents_start = text.find("/Contents <").expect("Must contain /Contents");
let hex_start = contents_start + 11; let hex_end = text[hex_start..].find('>').unwrap() + hex_start;
let hex_str = &text[hex_start..hex_end];
let all_zeros = hex_str.chars().all(|c| c == '0');
assert!(!all_zeros, "/Contents must not be all zeros after signing");
}
#[test]
fn test_certify_pdf_invisible() {
let (cert_pem, key_pem) = generate_test_cert_and_key();
let doc = default_doc(vec![make_text("Invisible sig", 12.0)]);
let unsigned_pdf = render_to_pdf(&doc);
let config = forme::CertificationConfig {
certificate_pem: cert_pem,
private_key_pem: key_pem,
reason: None,
location: None,
contact: None,
visible: false,
x: None,
y: None,
width: None,
height: None,
};
let signed_pdf = forme::certify_pdf(&unsigned_pdf, &config).unwrap();
let text = String::from_utf8_lossy(&signed_pdf);
assert!(
text.contains("/Rect [0 0 0 0]"),
"Invisible signature must have zero rect"
);
}
#[test]
fn test_certify_pdf_reason_location() {
let (cert_pem, key_pem) = generate_test_cert_and_key();
let doc = default_doc(vec![make_text("Reason/location test", 12.0)]);
let unsigned_pdf = render_to_pdf(&doc);
let config = forme::CertificationConfig {
certificate_pem: cert_pem,
private_key_pem: key_pem,
reason: Some("Approved for release".to_string()),
location: Some("New York, NY".to_string()),
contact: Some("signer@example.com".to_string()),
visible: false,
x: None,
y: None,
width: None,
height: None,
};
let signed_pdf = forme::certify_pdf(&unsigned_pdf, &config).unwrap();
let text = String::from_utf8_lossy(&signed_pdf);
assert!(
text.contains("/Reason (Approved for release)"),
"Must contain reason string"
);
assert!(
text.contains("/Location (New York, NY)"),
"Must contain location string"
);
assert!(
text.contains("/ContactInfo (signer@example.com)"),
"Must contain contact info"
);
}
#[test]
fn test_certify_pdf_invalid_cert() {
let doc = default_doc(vec![make_text("Invalid cert test", 12.0)]);
let unsigned_pdf = render_to_pdf(&doc);
let config = forme::CertificationConfig {
certificate_pem: "not a real PEM certificate".to_string(),
private_key_pem: "not a real PEM key".to_string(),
reason: None,
location: None,
contact: None,
visible: false,
x: None,
y: None,
width: None,
height: None,
};
let result = forme::certify_pdf(&unsigned_pdf, &config);
assert!(result.is_err(), "Invalid cert should produce an error");
let err = result.unwrap_err().to_string();
assert!(
err.contains("certificate") || err.contains("PEM"),
"Error message should mention certificate: {}",
err
);
}
#[test]
fn test_certify_pdf_key_mismatch() {
let (cert_pem, _key_pem) = generate_test_cert_and_key();
let (_other_cert, other_key) = generate_test_cert_and_key();
let doc = default_doc(vec![make_text("Key mismatch test", 12.0)]);
let unsigned_pdf = render_to_pdf(&doc);
let config = forme::CertificationConfig {
certificate_pem: cert_pem,
private_key_pem: other_key, reason: None,
location: None,
contact: None,
visible: false,
x: None,
y: None,
width: None,
height: None,
};
let result = forme::certify_pdf(&unsigned_pdf, &config);
assert!(result.is_err(), "Mismatched key should produce an error");
let err = result.unwrap_err().to_string();
assert!(
err.contains("do not match"),
"Error should mention key mismatch: {}",
err
);
}
#[test]
fn test_certify_at_render_time() {
let (cert_pem, key_pem) = generate_test_cert_and_key();
let doc = Document {
children: vec![make_text("Render-time signed", 12.0)],
metadata: Metadata::default(),
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: Some(forme::CertificationConfig {
certificate_pem: cert_pem,
private_key_pem: key_pem,
reason: Some("Auto-signed at render".to_string()),
location: None,
contact: None,
visible: false,
x: None,
y: None,
width: None,
height: None,
}),
pdf_version: Default::default(),
pdf_ua2: false,
};
let pdf = forme::render(&doc).unwrap();
let text = String::from_utf8_lossy(&pdf);
assert!(
text.contains("/Type /Sig"),
"Render-time signing must produce signature"
);
assert!(
text.contains("/Reason (Auto-signed at render)"),
"Reason must appear in signed output"
);
assert!(text.contains("/AcroForm"), "Must have AcroForm");
}
#[test]
fn test_certify_pdf_visible_has_appearance_stream() {
let (cert_pem, key_pem) = generate_test_cert_and_key();
let doc = default_doc(vec![make_text("Visible sig test", 12.0)]);
let unsigned_pdf = render_to_pdf(&doc);
let config = forme::CertificationConfig {
certificate_pem: cert_pem,
private_key_pem: key_pem,
reason: Some("Contract approval".to_string()),
location: None,
contact: None,
visible: true,
x: Some(50.0),
y: Some(600.0),
width: Some(200.0),
height: Some(60.0),
};
let signed_pdf = forme::certify_pdf(&unsigned_pdf, &config).unwrap();
let text = String::from_utf8_lossy(&signed_pdf);
assert!(
!text.contains("/Rect [0 0 0 0]"),
"Visible signature must not have zero rect"
);
assert!(
text.contains("/Rect [50.00 600.00 250.00 660.00]"),
"Rect must match configured position/size"
);
assert!(
text.contains("/AP <<"),
"Visible signature must have /AP entry"
);
assert!(
text.contains("/Subtype /Form"),
"Must have Form XObject for appearance"
);
assert!(
text.contains("Digitally signed by"),
"Appearance must contain 'Digitally signed by' text"
);
assert!(
text.contains("Test Signer"),
"Appearance must contain the signer CN"
);
assert!(
text.contains("Contract approval"),
"Appearance must contain the reason"
);
assert!(
text.contains("/DR <<"),
"AcroForm must have /DR for font resources"
);
}
#[test]
fn test_double_certification_unique_names() {
let (cert_pem, key_pem) = generate_test_cert_and_key();
let doc = default_doc(vec![make_text("Double certify test", 12.0)]);
let unsigned_pdf = render_to_pdf(&doc);
let config = forme::CertificationConfig {
certificate_pem: cert_pem.clone(),
private_key_pem: key_pem.clone(),
reason: None,
location: None,
contact: None,
visible: false,
x: None,
y: None,
width: None,
height: None,
};
let signed_once = forme::certify_pdf(&unsigned_pdf, &config).unwrap();
let text1 = String::from_utf8_lossy(&signed_once);
assert!(
text1.contains("/T (Signature1)"),
"First signature must be named Signature1"
);
let signed_twice = forme::certify_pdf(&signed_once, &config).unwrap();
let text2 = String::from_utf8_lossy(&signed_twice);
assert!(
text2.contains("/T (Signature2)"),
"Second signature must be named Signature2"
);
assert!(
text2.contains("/T (Signature1)"),
"First signature must still be present"
);
}
#[test]
fn test_certify_preserves_acroform_metadata() {
let text_field = Node {
kind: NodeKind::TextField {
name: "field1".to_string(),
value: Some("Hello".to_string()),
placeholder: None,
multiline: false,
password: false,
read_only: false,
max_length: None,
font_size: 12.0,
width: 200.0,
height: 24.0,
},
style: Default::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let doc = default_doc(vec![text_field]);
let pdf = forme::render(&doc).unwrap();
let text_before = String::from_utf8_lossy(&pdf);
assert!(
!text_before.contains("/NeedAppearances"),
"NeedAppearances is deprecated (PDF 2.0) and redundant: every widget carries /AP"
);
assert!(text_before.contains("/DA"), "Original PDF must have /DA");
assert!(
text_before.contains("/AP"),
"widgets must carry authored appearance streams"
);
let (cert_pem, key_pem) = generate_test_cert_and_key();
let config = forme::CertificationConfig {
certificate_pem: cert_pem,
private_key_pem: key_pem,
reason: None,
location: None,
contact: None,
visible: false,
x: None,
y: None,
width: None,
height: None,
};
let signed_pdf = forme::certify_pdf(&pdf, &config).unwrap();
let text_after = String::from_utf8_lossy(&signed_pdf);
assert!(
!text_after.contains("/NeedAppearances"),
"Signing must not introduce the deprecated flag"
);
assert!(text_after.contains("/DA"), "Signed PDF must preserve /DA");
assert!(
text_after.contains("/FT /Tx"),
"Signed PDF must still contain the text field"
);
}
#[test]
fn test_tagged_pdf_form_fields_have_form_role() {
let text_field = Node {
kind: NodeKind::TextField {
name: "name".to_string(),
value: Some("Test".to_string()),
placeholder: None,
multiline: false,
password: false,
read_only: false,
max_length: None,
font_size: 12.0,
width: 200.0,
height: 24.0,
},
style: Default::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let checkbox = Node {
kind: NodeKind::Checkbox {
name: "agree".to_string(),
checked: true,
read_only: false,
width: 14.0,
height: 14.0,
},
style: Default::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let mut doc = default_doc(vec![text_field, checkbox]);
doc.tagged = true;
let pdf = forme::render(&doc).unwrap();
let text = String::from_utf8_lossy(&pdf);
assert!(
text.contains("/S /Form"),
"Form fields must be tagged with /S /Form in structure tree"
);
}
#[test]
fn test_flatten_forms_renders_placeholder() {
let json = r#"{
"children": [{
"kind": {
"type": "TextField", "name": "name", "width": 200, "height": 24,
"placeholder": "Enter your name", "font_size": 12,
"multiline": false, "password": false, "read_only": false
},
"style": {},
"children": []
}],
"metadata": {},
"defaultPage": {},
"fonts": [],
"flattenForms": true
}"#;
let pdf = forme::render_json(json).expect("Flattened form with placeholder should render");
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
!pdf_str.contains("/AcroForm"),
"Flattened PDF should not contain /AcroForm"
);
assert!(
!pdf_str.contains("/FT /Tx"),
"Flattened PDF should not contain text field widgets"
);
let decompressed = decompress_pdf_streams(&pdf);
assert!(
decompressed.contains("Enter your name"),
"Flattened form must render placeholder text in content stream when value is empty"
);
assert!(
decompressed.contains("0.6 g"),
"Placeholder text must be rendered in grey (0.6 g)"
);
}
#[test]
fn test_checkbox_renders_checkmark() {
let checkbox = Node {
kind: NodeKind::Checkbox {
name: "agree".to_string(),
checked: true,
read_only: false,
width: 14.0,
height: 14.0,
},
style: Default::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let doc = default_doc(vec![checkbox]);
let pdf = forme::render(&doc).unwrap();
let text = String::from_utf8_lossy(&pdf);
assert!(
text.contains("0.2 0.2 0.2 rg"),
"Checkmark must use fill color (rg), not stroke color (RG)"
);
assert!(
!text.contains("0 0 m 14 14 l S"),
"Must not draw X (diagonal stroke lines) for checkbox"
);
}
#[test]
fn test_latin_extended_character_widths() {
use forme::font::StandardFont;
let m = StandardFont::Helvetica.metrics();
let test_str = "ÅÄÖÉÈÊÑÜÚÙû";
let font_size = 24.0;
let mut total_width = 0.0;
let mut prev_x = 0.0;
for ch in test_str.chars() {
let w = m.char_width(ch, font_size);
assert!(
w > 3.0,
"Character '{}' (U+{:04X}) has suspiciously small width: {} at {}pt",
ch,
ch as u32,
w,
font_size
);
if total_width > 0.0 {
assert!(
total_width > prev_x,
"Character '{}' x position ({}) not advancing from previous ({})",
ch,
total_width,
prev_x
);
}
prev_x = total_width;
total_width += w;
}
assert!(
total_width > 100.0,
"Total width of '{}' at {}pt should be >100pt, got {}",
test_str,
font_size,
total_width
);
let text_node = make_text(test_str, font_size);
let doc = default_doc(vec![text_node]);
let (pdf, layout, _) = forme::render_with_layout(&doc).unwrap();
assert!(pdf.starts_with(b"%PDF"), "Output should be a valid PDF");
assert_eq!(layout.pages.len(), 1);
}
#[test]
fn test_page_placeholder_measurement_width() {
use forme::font::StandardFont;
let m = StandardFont::Helvetica.metrics();
let font_size = 12.0;
let expected_text = "Page 00 of 00";
let expected_width = m.measure_string(expected_text, font_size, 0.0);
let placeholder_text = "Page {{pageNumber}} of {{totalPages}}";
let literal_width = m.measure_string(placeholder_text, font_size, 0.0);
assert!(
expected_width < literal_width,
"Substituted width ({}) should be less than literal placeholder width ({})",
expected_width,
literal_width
);
let text_node = make_text(placeholder_text, font_size);
let doc = default_doc(vec![text_node]);
let (_pdf, layout, _) = forme::render_with_layout(&doc).unwrap();
assert_eq!(layout.pages.len(), 1);
}
#[test]
fn test_page_placeholder_survives_line_breaking() {
let text_node = make_text("Page {{pageNumber}} of {{totalPages}}", 12.0);
let page = Node {
kind: NodeKind::Page {
config: PageConfig {
size: PageSize::Custom {
width: 80.0,
height: 200.0,
},
margin: Edges::uniform(5.0),
wrap: true,
..Default::default()
},
},
style: Style::default(),
children: vec![text_node],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let doc = Document {
children: vec![page],
metadata: Metadata::default(),
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let (pdf, _layout, _) = forme::render_with_layout(&doc).unwrap();
assert!(pdf.starts_with(b"%PDF"), "Output should be a valid PDF");
}
#[test]
fn test_page_placeholder_no_sentinel_in_text_operators() {
let text_node = make_text("{{pageNumber}} / {{totalPages}}", 12.0);
let doc = default_doc(vec![text_node]);
let (pdf, _layout, _) = forme::render_with_layout(&doc).unwrap();
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
!pdf_str.contains("\\002"),
"Sentinel \\x02 octal must not appear in PDF text"
);
assert!(
!pdf_str.contains("\\003"),
"Sentinel \\x03 octal must not appear in PDF text"
);
}
#[test]
fn test_two_pass_single_page_digit_count() {
let text_node = make_text("{{pageNumber}}/{{totalPages}}", 12.0);
let doc = default_doc(vec![text_node]);
let (pdf, layout, _) = forme::render_with_layout(&doc).unwrap();
assert_eq!(layout.pages.len(), 1);
assert_valid_pdf(&pdf);
}
#[test]
fn test_two_pass_multi_page_common_case() {
let mut children = Vec::new();
for i in 0..60 {
children.push(make_text(
&format!("Paragraph {} with enough text to fill space. {{{{pageNumber}}}}/{{{{totalPages}}}}", i),
11.0,
));
}
let page = Node {
kind: NodeKind::Page {
config: PageConfig {
size: PageSize::A4,
margin: Edges::uniform(40.0),
wrap: true,
..Default::default()
},
},
style: Style::default(),
children,
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let doc = Document {
children: vec![page],
metadata: Metadata::default(),
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let (pdf, layout, _) = forme::render_with_layout(&doc).unwrap();
assert!(layout.pages.len() >= 2, "Should be multi-page");
assert_valid_pdf(&pdf);
}
#[test]
fn test_render_performance() {
use std::time::Instant;
let mut children = Vec::new();
for i in 0..200 {
children.push(make_text(
&format!(
"Line {}: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Page {{{{pageNumber}}}} of {{{{totalPages}}}}.",
i
),
10.0,
));
}
let page = Node {
kind: NodeKind::Page {
config: PageConfig {
size: PageSize::A4,
margin: Edges::uniform(50.0),
wrap: true,
..Default::default()
},
},
style: Style::default(),
children,
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let doc = Document {
children: vec![page],
metadata: Metadata::default(),
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let start = Instant::now();
let (pdf, layout, _) = forme::render_with_layout(&doc).unwrap();
let elapsed = start.elapsed();
eprintln!(
"Performance: {} pages rendered in {:?}",
layout.pages.len(),
elapsed
);
assert_valid_pdf(&pdf);
assert!(layout.pages.len() >= 2, "Should produce multiple pages");
}
#[test]
fn test_multi_weight_font_resolution() {
use base64::Engine as _;
let font_data = std::fs::read(concat!(
env!("CARGO_MANIFEST_DIR"),
"/fonts/NotoSans-Regular.ttf"
))
.unwrap();
let font_b64 = base64::engine::general_purpose::STANDARD.encode(&font_data);
fn text_with_weight(content: &str, weight: u32) -> Node {
Node {
kind: NodeKind::Text {
content: content.to_string(),
href: None,
runs: vec![],
},
style: Style {
font_family: Some("TestFont".into()),
font_weight: Some(weight),
font_size: Some(14.0),
..Style::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
let doc = Document {
children: vec![
text_with_weight("Light text", 200),
text_with_weight("Regular text", 400),
text_with_weight("Bold text", 700),
],
fonts: vec![
FontEntry {
family: "TestFont".into(),
src: font_b64.clone(),
weight: 200,
italic: false,
},
FontEntry {
family: "TestFont".into(),
src: font_b64.clone(),
weight: 400,
italic: false,
},
FontEntry {
family: "TestFont".into(),
src: font_b64,
weight: 700,
italic: false,
},
],
metadata: Metadata::default(),
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
tagged: false,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdfa: None,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let pdf = forme::render(&doc).unwrap();
assert_valid_pdf(&pdf);
let pdf_str = String::from_utf8_lossy(&pdf);
let testfont_count = pdf_str.matches("/BaseFont /TestFont").count();
assert!(
testfont_count >= 3,
"Expected at least 3 distinct TestFont /BaseFont entries, got {}",
testfont_count
);
}
#[test]
fn test_svg_opacity_produces_ext_gstate() {
let doc = Document {
children: vec![Node {
kind: NodeKind::Svg {
width: 100.0,
height: 100.0,
view_box: None,
content:
r##"<rect x="0" y="0" width="100" height="100" fill="#ff0000" opacity="0.5"/>"##
.to_string(),
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
fonts: vec![],
metadata: Metadata::default(),
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
tagged: false,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdfa: None,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let pdf = forme::render(&doc).unwrap();
assert_valid_pdf(&pdf);
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
pdf_str.contains("/ca 0.5000") && pdf_str.contains("/CA 0.5000"),
"PDF should contain ExtGState with 0.5 opacity for SVG element"
);
assert!(
pdf_str.contains("/GS"),
"PDF content stream should reference a graphics state"
);
}
#[test]
fn test_svg_fill_opacity_produces_ext_gstate() {
let doc = Document {
children: vec![Node {
kind: NodeKind::Svg {
width: 100.0,
height: 100.0,
view_box: None,
content: r##"<rect x="0" y="0" width="50" height="50" fill="#00ff00" fill-opacity="0.3"/>"##
.to_string(),
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
fonts: vec![],
metadata: Metadata::default(),
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
tagged: false,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdfa: None,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let pdf = forme::render(&doc).unwrap();
assert_valid_pdf(&pdf);
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
pdf_str.contains("/ca 0.3000") && pdf_str.contains("/CA 0.3000"),
"PDF should contain ExtGState with 0.3 opacity for fill-opacity"
);
}
#[test]
fn test_svg_inherited_group_opacity() {
let doc = Document {
children: vec![Node {
kind: NodeKind::Svg {
width: 200.0,
height: 200.0,
view_box: None,
content: r#"<g opacity="0.5"><rect x="0" y="0" width="100" height="100" fill="blue"/></g>"#
.to_string(),
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
fonts: vec![],
metadata: Metadata::default(),
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
tagged: false,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdfa: None,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let pdf = forme::render(&doc).unwrap();
assert_valid_pdf(&pdf);
let pdf_str = String::from_utf8_lossy(&pdf);
assert!(
pdf_str.contains("/ca 0.5000"),
"PDF should contain ExtGState with inherited group opacity 0.5"
);
}
#[test]
fn test_opacity_wraps_children() {
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("Faded text", 12.0)],
)]);
let pdf_bytes = render_to_pdf(&doc);
let stream = decompress_pdf_streams(&pdf_bytes);
let gs_idx = stream
.find(" gs")
.expect("expected at least one /GS{n} gs reference");
let bt_idx = stream
.find("BT")
.expect("expected a BT text block in the content stream");
assert!(
bt_idx > gs_idx,
"BT must come after /GS gs so the text is inside the opacity wrap (gs at {}, BT at {})",
gs_idx,
bt_idx,
);
let et_idx = stream
.rfind("ET")
.expect("expected an ET in the content stream");
let last_q_idx = stream
.rfind("\nQ\n")
.or_else(|| stream.rfind("\nQ"))
.expect("expected a Q closing the opacity wrap");
assert!(
last_q_idx > et_idx,
"Q closing opacity must come after ET (ET at {}, Q at {})",
et_idx,
last_q_idx,
);
}
#[test]
fn test_nested_opacity_emits_two_gs_refs() {
let inner = make_styled_view(
Style {
opacity: Some(0.5),
background_color: Some(Color::rgb(0.0, 1.0, 0.0)),
width: Some(Dimension::Pt(50.0)),
height: Some(Dimension::Pt(25.0)),
..Default::default()
},
vec![make_text("inner", 12.0)],
);
let outer = 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![inner],
);
let doc = default_doc(vec![outer]);
let pdf_bytes = render_to_pdf(&doc);
let stream = decompress_pdf_streams(&pdf_bytes);
let gs_count = stream.matches(" gs").count();
assert!(
gs_count >= 2,
"expected at least 2 /GS{{n}} gs references for nested opacities, got {} (stream: {})",
gs_count,
stream,
);
let pdf_str = String::from_utf8_lossy(&pdf_bytes);
assert!(
pdf_str.contains("/ca 0.5000"),
"PDF should contain /ca 0.5000 ExtGState"
);
}
#[test]
fn test_box_shadow_renders_before_background() {
let view = make_styled_view(
Style {
box_shadow: Some(forme::style::BoxShadow {
offset_x: 2.0,
offset_y: 4.0,
blur: 0.0,
color: Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 0.2,
},
}),
background_color: Some(Color::rgb(1.0, 1.0, 1.0)),
width: Some(Dimension::Pt(100.0)),
height: Some(Dimension::Pt(50.0)),
..Default::default()
},
vec![],
);
let doc = default_doc(vec![view]);
let pdf_bytes = render_to_pdf(&doc);
let stream = decompress_last_flate_stream(&pdf_bytes).expect("expected page content stream");
let shadow_fill_idx = stream
.find("0.000 0.000 0.000 rg")
.expect("expected shadow fill in content stream");
let bg_idx = stream
.find("1.000 1.000 1.000 rg")
.expect("expected background fill in content stream");
assert!(
shadow_fill_idx < bg_idx,
"shadow fill (at {}) must precede background fill (at {})",
shadow_fill_idx,
bg_idx,
);
}
#[test]
fn test_box_shadow_alpha_creates_extgstate() {
let view = make_styled_view(
Style {
box_shadow: Some(forme::style::BoxShadow {
offset_x: 4.0,
offset_y: 4.0,
blur: 0.0,
color: Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 0.25,
},
}),
background_color: Some(Color::rgb(1.0, 1.0, 1.0)),
width: Some(Dimension::Pt(100.0)),
height: Some(Dimension::Pt(50.0)),
..Default::default()
},
vec![],
);
let doc = default_doc(vec![view]);
let pdf_bytes = render_to_pdf(&doc);
let pdf_str = String::from_utf8_lossy(&pdf_bytes);
assert!(
pdf_str.contains("/ca 0.2500"),
"expected /ca 0.2500 ExtGState for shadow alpha 0.25"
);
}
#[test]
fn test_page_background_image_registers_xobject() {
let one_px_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
let mut doc = default_doc(vec![make_text("Foreground", 12.0)]);
doc.default_page.background_image = Some(one_px_png.to_string());
let pdf_bytes = render_to_pdf(&doc);
let pdf_str = String::from_utf8_lossy(&pdf_bytes);
assert!(
pdf_str.contains("/Subtype /Image"),
"expected image XObject (/Subtype /Image) in PDF",
);
assert!(
pdf_str.contains("/XObject << /Im0"),
"expected page resources to include /XObject << /Im0 ...",
);
let content = decompress_last_flate_stream(&pdf_bytes)
.expect("expected at least one FlateDecode page content stream");
let do_idx = content
.find(" Do")
.expect("expected /Im{n} Do for page background in the content stream");
let bt_idx = content
.find("BT")
.expect("expected BT for foreground text in the content stream");
assert!(
do_idx < bt_idx,
"background Do (at {}) must precede text BT (at {}) so it renders behind",
do_idx,
bt_idx,
);
}
fn decompress_last_flate_stream(pdf: &[u8]) -> Option<String> {
use miniz_oxide::inflate::decompress_to_vec_zlib;
let header_prefix = b"<< /Length ";
let header_suffix = b" /Filter /FlateDecode >>\nstream\n";
let mut last: Option<(usize, usize)> = None;
let mut search_from = 0usize;
while let Some(rel) = pdf[search_from..]
.windows(header_prefix.len())
.position(|w| w == header_prefix)
{
let prefix_end = search_from + rel + header_prefix.len();
let mut digit_end = prefix_end;
while digit_end < pdf.len() && pdf[digit_end].is_ascii_digit() {
digit_end += 1;
}
if digit_end == prefix_end {
search_from = prefix_end;
continue;
}
if digit_end + header_suffix.len() > pdf.len()
|| &pdf[digit_end..digit_end + header_suffix.len()] != header_suffix
{
search_from = prefix_end;
continue;
}
let length: usize = std::str::from_utf8(&pdf[prefix_end..digit_end])
.ok()?
.parse()
.ok()?;
let stream_start = digit_end + header_suffix.len();
last = Some((stream_start, length));
search_from = stream_start + length;
}
let (abs, len) = last?;
let bytes = &pdf[abs..abs + len];
let decompressed = decompress_to_vec_zlib(bytes).ok()?;
String::from_utf8(decompressed).ok()
}
#[test]
fn test_page_background_opacity_creates_extgstate() {
let one_px_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
let config = PageConfig {
background_image: Some(one_px_png.to_string()),
background_opacity: Some(0.08),
..Default::default()
};
let doc = Document {
children: vec![make_text("on top", 12.0)],
metadata: Metadata::default(),
default_page: config,
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
pdf_ua: false,
flatten_forms: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let pdf_bytes = render_to_pdf(&doc);
let pdf_str = String::from_utf8_lossy(&pdf_bytes);
assert!(
pdf_str.contains("/ca 0.0800"),
"expected /ca 0.0800 ExtGState for backgroundOpacity 0.08"
);
}
#[test]
fn test_overflow_hidden_with_border_radius_uses_rounded_clip() {
let inner = make_text("clipped content", 12.0);
let outer = make_styled_view(
Style {
overflow: Some(forme::style::Overflow::Hidden),
border_radius: Some(forme::style::CornerValues::uniform(12.0)),
background_color: Some(Color::rgb(0.9, 0.9, 0.9)),
width: Some(Dimension::Pt(120.0)),
height: Some(Dimension::Pt(60.0)),
..Default::default()
},
vec![inner],
);
let doc = default_doc(vec![outer]);
let pdf_bytes = render_to_pdf(&doc);
let stream = decompress_pdf_streams(&pdf_bytes);
let has_rounded_clip = stream.contains("h\nW n") || stream.contains("h\nW n\n");
assert!(
has_rounded_clip,
"expected rounded clip path (h\\nW n) in content stream; got: {}",
stream,
);
}
#[test]
fn test_overflow_hidden_without_border_radius_uses_rect_clip() {
let inner = make_text("clipped content", 12.0);
let outer = make_styled_view(
Style {
overflow: Some(forme::style::Overflow::Hidden),
background_color: Some(Color::rgb(0.9, 0.9, 0.9)),
width: Some(Dimension::Pt(120.0)),
height: Some(Dimension::Pt(60.0)),
..Default::default()
},
vec![inner],
);
let doc = default_doc(vec![outer]);
let pdf_bytes = render_to_pdf(&doc);
let stream = decompress_pdf_streams(&pdf_bytes);
assert!(
stream.contains(" re W n"),
"expected rectangular clip `re W n` in content stream; got: {}",
stream,
);
}
#[test]
fn test_word_spacing_emits_tw_operator() {
let doc = default_doc(vec![Node {
kind: NodeKind::Text {
content: "hello world wide".to_string(),
href: None,
runs: vec![],
},
style: Style {
font_size: Some(12.0),
word_spacing: Some(4.0),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}]);
let pdf_bytes = render_to_pdf(&doc);
let stream = decompress_pdf_streams(&pdf_bytes);
assert!(
stream.contains("4.0000 Tw"),
"expected `4.0000 Tw` in content stream, got: {}",
stream,
);
}
#[test]
fn test_default_text_no_tw_operator() {
let doc = default_doc(vec![make_text("hello world", 12.0)]);
let pdf_bytes = render_to_pdf(&doc);
let stream = decompress_pdf_streams(&pdf_bytes);
assert!(
!stream.contains(" Tw"),
"expected no Tw in default text content stream, got: {}",
stream,
);
}
#[test]
fn test_opacity_one_emits_no_extgstate_wrap() {
let doc = default_doc(vec![make_styled_view(
Style {
opacity: Some(1.0),
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("Full alpha", 12.0)],
)]);
let pdf_bytes = render_to_pdf(&doc);
let pdf_str = String::from_utf8_lossy(&pdf_bytes);
assert!(
!pdf_str.contains("/ExtGState"),
"no /ExtGState resource should be emitted when all opacities are 1.0"
);
}
#[test]
fn test_linear_gradient_emits_shading_type_2() {
let view = make_styled_view(
Style {
background: Some(forme::style::Background::Linear(
forme::style::LinearGradient {
angle_deg: 90.0,
stops: vec![
forme::style::GradientStop {
position: 0.0,
color: Color::rgb(1.0, 0.0, 0.0),
},
forme::style::GradientStop {
position: 1.0,
color: Color::rgb(0.0, 0.0, 1.0),
},
],
},
)),
width: Some(Dimension::Pt(100.0)),
height: Some(Dimension::Pt(50.0)),
..Default::default()
},
vec![],
);
let doc = default_doc(vec![view]);
let pdf_bytes = render_to_pdf(&doc);
let pdf_str = String::from_utf8_lossy(&pdf_bytes);
assert!(
pdf_str.contains("/ShadingType 2"),
"expected /ShadingType 2 (axial) for linear gradient",
);
assert!(
pdf_str.contains("/FunctionType 2"),
"expected /FunctionType 2 (exponential) for color interpolation",
);
assert!(
pdf_str.contains("/Shading << /Sh0"),
"expected page resource dict to reference /Sh0 shading",
);
let stream = decompress_last_flate_stream(&pdf_bytes).expect("expected page content stream");
assert!(
stream.contains("/Sh0 sh"),
"expected `/Sh0 sh` operator in content stream, got: {}",
stream,
);
}
#[test]
fn test_radial_gradient_emits_shading_type_3() {
let view = make_styled_view(
Style {
background: Some(forme::style::Background::Radial(
forme::style::RadialGradient {
stops: vec![
forme::style::GradientStop {
position: 0.0,
color: Color::rgb(1.0, 1.0, 1.0),
},
forme::style::GradientStop {
position: 1.0,
color: Color::rgb(0.0, 0.0, 0.0),
},
],
},
)),
width: Some(Dimension::Pt(80.0)),
height: Some(Dimension::Pt(80.0)),
..Default::default()
},
vec![],
);
let doc = default_doc(vec![view]);
let pdf_bytes = render_to_pdf(&doc);
let pdf_str = String::from_utf8_lossy(&pdf_bytes);
assert!(
pdf_str.contains("/ShadingType 3"),
"expected /ShadingType 3 (radial) for radial gradient",
);
assert!(
pdf_str.contains("/Coords [40.000 40.000 0 40.000 40.000 40.000]"),
"expected radial Coords [cx cy 0 cx cy r] = [40 40 0 40 40 40], got PDF: {}",
&pdf_str[..pdf_str.len().min(2000)],
);
}
#[test]
fn test_linear_gradient_180deg_axis_goes_top_to_bottom() {
let view = make_styled_view(
Style {
background: Some(forme::style::Background::Linear(
forme::style::LinearGradient {
angle_deg: 180.0,
stops: vec![
forme::style::GradientStop {
position: 0.0,
color: Color::rgb(1.0, 1.0, 1.0),
},
forme::style::GradientStop {
position: 1.0,
color: Color::rgb(0.0, 0.0, 0.0),
},
],
},
)),
width: Some(Dimension::Pt(200.0)),
height: Some(Dimension::Pt(100.0)),
..Default::default()
},
vec![],
);
let doc = default_doc(vec![view]);
let pdf_bytes = render_to_pdf(&doc);
let pdf_str = String::from_utf8_lossy(&pdf_bytes);
let has_top_to_bottom = pdf_str.contains("/Coords [100.000 100.000 100.000 0.000]")
|| pdf_str.contains("/Coords [100.000 100.000 100.000 -0.000]");
assert!(
has_top_to_bottom,
"expected 180deg Coords [w/2 h w/2 0] = [100 100 100 0], got snippet: {}",
&pdf_str[..pdf_str.len().min(2000)],
);
}
#[test]
fn test_multi_stop_gradient_emits_stitching_function() {
let view = make_styled_view(
Style {
background: Some(forme::style::Background::Linear(
forme::style::LinearGradient {
angle_deg: 90.0,
stops: vec![
forme::style::GradientStop {
position: 0.0,
color: Color::rgb(1.0, 0.0, 0.0),
},
forme::style::GradientStop {
position: 0.5,
color: Color::rgb(0.0, 1.0, 0.0),
},
forme::style::GradientStop {
position: 1.0,
color: Color::rgb(0.0, 0.0, 1.0),
},
],
},
)),
width: Some(Dimension::Pt(100.0)),
height: Some(Dimension::Pt(50.0)),
..Default::default()
},
vec![],
);
let doc = default_doc(vec![view]);
let pdf_bytes = render_to_pdf(&doc);
let pdf_str = String::from_utf8_lossy(&pdf_bytes);
assert!(
pdf_str.contains("/FunctionType 3"),
"expected /FunctionType 3 (stitching) for multi-stop gradient",
);
let type2_count = pdf_str.matches("/FunctionType 2").count();
assert_eq!(
type2_count, 2,
"expected 2 Type 2 sub-functions for a 3-stop gradient, got {}",
type2_count,
);
assert!(
pdf_str.contains("/Bounds [0.5000]"),
"expected /Bounds [0.5000] for the interior stop, got: {}",
&pdf_str[..pdf_str.len().min(2000)],
);
assert!(
pdf_str.contains("/Encode [0 1 0 1]"),
"expected /Encode [0 1 0 1] for 2 sub-functions, got: {}",
&pdf_str[..pdf_str.len().min(2000)],
);
}
#[test]
fn test_gradient_stops_normalized_when_out_of_order() {
let view = make_styled_view(
Style {
background: Some(forme::style::Background::Linear(
forme::style::LinearGradient {
angle_deg: 90.0,
stops: vec![
forme::style::GradientStop {
position: 1.0,
color: Color::rgb(0.0, 0.0, 1.0),
},
forme::style::GradientStop {
position: 0.0,
color: Color::rgb(1.0, 0.0, 0.0),
},
],
},
)),
width: Some(Dimension::Pt(100.0)),
height: Some(Dimension::Pt(50.0)),
..Default::default()
},
vec![],
);
let doc = default_doc(vec![view]);
let pdf_bytes = render_to_pdf(&doc);
let pdf_str = String::from_utf8_lossy(&pdf_bytes);
assert!(
pdf_str.contains("/C0 [1.0000 0.0000 0.0000] /C1 [0.0000 0.0000 1.0000]"),
"expected normalized order red→blue in C0/C1, got: {}",
&pdf_str[..pdf_str.len().min(2000)],
);
}
#[test]
fn test_flex_row_percentage_widths_resolve_against_parent() {
let child30 = make_styled_view(
Style {
width: Some(Dimension::Percent(30.0)),
..Default::default()
},
vec![make_text("30%", 12.0)],
);
let child70 = make_styled_view(
Style {
width: Some(Dimension::Percent(70.0)),
..Default::default()
},
vec![make_text("70%", 12.0)],
);
let row = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
..Default::default()
},
vec![child30, child70],
);
let doc = default_doc(vec![row]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
let row_el = &pages[0].elements[0];
assert_eq!(row_el.children.len(), 2);
let row_w = row_el.width;
let expected_30 = row_w * 0.3;
let expected_70 = row_w * 0.7;
assert!(
(row_el.children[0].width - expected_30).abs() < 1.0,
"30% child width {:.2} != expected {:.2} (row_w={:.2})",
row_el.children[0].width,
expected_30,
row_w,
);
assert!(
(row_el.children[1].width - expected_70).abs() < 1.0,
"70% child width {:.2} != expected {:.2} (row_w={:.2})",
row_el.children[1].width,
expected_70,
row_w,
);
}
#[test]
fn test_flex_row_equal_percentage_widths_split_evenly() {
let mk_child = || {
make_styled_view(
Style {
width: Some(Dimension::Percent(100.0)),
..Default::default()
},
vec![make_text("x", 12.0)],
)
};
let row = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
..Default::default()
},
vec![mk_child(), mk_child()],
);
let doc = default_doc(vec![row]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
let row_el = &pages[0].elements[0];
assert_eq!(row_el.children.len(), 2);
let half = row_el.width / 2.0;
for (i, child) in row_el.children.iter().enumerate() {
assert!(
(child.width - half).abs() < 1.0,
"child{} width {:.2} != expected {:.2}",
i,
child.width,
half,
);
}
}
#[test]
fn test_grid_page_break_keeps_columns_aligned() {
let make_tall_cell = |label: &str| {
make_styled_view(
Style {
padding: Some(Edges::uniform(4.0)),
background_color: Some(Color {
r: 0.9,
g: 0.9,
b: 0.9,
a: 1.0,
}),
height: Some(Dimension::Pt(400.0)),
..Default::default()
},
vec![make_text(label, 12.0)],
)
};
let spacer = make_styled_view(
Style {
height: Some(Dimension::Pt(500.0)),
..Default::default()
},
vec![],
);
let grid = make_styled_view(
Style {
display: Some(Display::Grid),
grid_template_columns: Some(vec![
GridTrackSize::Fr(1.0),
GridTrackSize::Fr(1.0),
GridTrackSize::Fr(1.0),
]),
gap: Some(8.0),
..Default::default()
},
vec![
make_tall_cell("A"),
make_tall_cell("B"),
make_tall_cell("C"),
],
);
let doc = default_doc(vec![spacer, grid]);
let pages = layout_doc(&doc);
assert_eq!(
pages.len(),
2,
"expected 2 pages, got {} — grid columns split across pages",
pages.len(),
);
fn collect_matching<'a>(
el: &'a forme::layout::LayoutElement,
out: &mut Vec<&'a forme::layout::LayoutElement>,
) {
if (el.height - 400.0).abs() < 0.5 {
out.push(el);
}
for c in &el.children {
collect_matching(c, out);
}
}
let mut tall_cells: Vec<&forme::layout::LayoutElement> = Vec::new();
for el in &pages[1].elements {
collect_matching(el, &mut tall_cells);
}
assert_eq!(
tall_cells.len(),
3,
"expected 3 tall cells on page 2, found {}",
tall_cells.len(),
);
let first_y = tall_cells[0].y;
for c in &tall_cells {
assert!(
(c.y - first_y).abs() < 0.5,
"grid cells not aligned: y={} vs {}",
c.y,
first_y,
);
}
}
fn make_text_fixed_width(content: &str, width: f64, align: TextAlign) -> Node {
Node {
kind: NodeKind::Text {
content: content.to_string(),
href: None,
runs: vec![],
},
style: Style {
font_size: Some(12.0),
width: Some(Dimension::Pt(width)),
text_align: Some(align),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
fn letter_doc_with_inset_48(child: Node) -> Document {
default_doc(vec![Node {
kind: NodeKind::Page {
config: PageConfig {
size: PageSize::Letter,
margin: Edges::uniform(48.0),
..Default::default()
},
},
style: Style::default(),
children: vec![child],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}])
}
fn collect_by_type<'a>(
el: &'a forme::layout::LayoutElement,
ty: &str,
out: &mut Vec<&'a forme::layout::LayoutElement>,
) {
if el.node_type.as_deref() == Some(ty) {
out.push(el);
}
for c in &el.children {
collect_by_type(c, ty, out);
}
}
#[test]
fn test_flex_row_text_fixed_width_box_not_parent_width() {
let row = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
justify_content: Some(JustifyContent::FlexEnd),
..Default::default()
},
vec![
make_text_fixed_width("Tax:", 120.0, TextAlign::Right),
make_text_fixed_width("$10.00", 80.0, TextAlign::Right),
],
);
let pages = layout_doc(&letter_doc_with_inset_48(row));
assert_eq!(pages.len(), 1);
let band_right = 612.0 - 48.0;
let mut texts = Vec::new();
for el in &pages[0].elements {
collect_by_type(el, "Text", &mut texts);
}
assert_eq!(texts.len(), 2, "expected 2 Text boxes, got {}", texts.len());
let mut widths: Vec<f64> = texts.iter().map(|t| t.width).collect();
widths.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert!(
(widths[0] - 80.0).abs() < 0.5 && (widths[1] - 120.0).abs() < 0.5,
"Text boxes should be 80 and 120, got {:?}",
widths,
);
let mut lines = Vec::new();
for el in &pages[0].elements {
collect_by_type(el, "TextLine", &mut lines);
}
assert!(!lines.is_empty(), "expected TextLine elements");
for line in &lines {
assert!(
line.x + line.width <= band_right + 0.5,
"glyph line runs off-page: x={:.2} width={:.2} (right={:.2} > band {:.2})",
line.x,
line.width,
line.x + line.width,
band_right,
);
assert!(
line.x >= 48.0 - 0.5,
"glyph line starts before content band: x={:.2}",
line.x,
);
}
}
#[test]
fn test_flex_row_text_fixed_width_box_flex_start() {
let row = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
justify_content: Some(JustifyContent::FlexStart),
..Default::default()
},
vec![
make_text_fixed_width("Tax:", 120.0, TextAlign::Right),
make_text_fixed_width("$10.00", 80.0, TextAlign::Right),
],
);
let pages = layout_doc(&letter_doc_with_inset_48(row));
assert_eq!(pages.len(), 1);
let mut texts = Vec::new();
for el in &pages[0].elements {
collect_by_type(el, "Text", &mut texts);
}
assert_eq!(texts.len(), 2);
let mut widths: Vec<f64> = texts.iter().map(|t| t.width).collect();
widths.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert!(
(widths[0] - 80.0).abs() < 0.5 && (widths[1] - 120.0).abs() < 0.5,
"Text boxes should be 80 and 120, got {:?}",
widths,
);
}
fn make_mt_mb_child(top: EdgeValue, bottom: EdgeValue, w: f64, h: f64) -> Node {
make_styled_view(
Style {
width: Some(Dimension::Pt(w)),
height: Some(Dimension::Pt(h)),
margin: Some(MarginEdges {
top,
right: EdgeValue::Pt(0.0),
bottom,
left: EdgeValue::Pt(0.0),
}),
..Default::default()
},
vec![],
)
}
fn parent_with_height_doc(parent_h: Option<f64>, child: Node) -> Document {
let parent = make_styled_view(
Style {
width: Some(Dimension::Pt(100.0)),
height: parent_h.map(Dimension::Pt),
..Default::default()
},
vec![child],
);
default_doc(vec![Node::page(
PageConfig {
margin: Edges::uniform(0.0),
..Default::default()
},
Style::default(),
vec![parent],
)])
}
#[test]
fn test_column_mt_auto_pushes_child_to_bottom() {
let child = make_mt_mb_child(EdgeValue::Auto, EdgeValue::Pt(0.0), 40.0, 10.0);
let pages = layout_doc(&parent_with_height_doc(Some(50.0), child));
assert_eq!(pages.len(), 1);
let parent_el = &pages[0].elements[0];
assert_eq!(parent_el.children.len(), 1);
let child_el = &parent_el.children[0];
let expected_top = parent_el.y + parent_el.height - child_el.height;
assert!(
(child_el.y - expected_top).abs() < 0.5,
"mt-auto child.y={:.2} expected {:.2} (parent y={:.2} h={:.2} child h={:.2})",
child_el.y,
expected_top,
parent_el.y,
parent_el.height,
child_el.height,
);
}
#[test]
fn test_column_mt_and_mb_auto_centers_child() {
let child = make_mt_mb_child(EdgeValue::Auto, EdgeValue::Auto, 40.0, 10.0);
let pages = layout_doc(&parent_with_height_doc(Some(50.0), child));
assert_eq!(pages.len(), 1);
let parent_el = &pages[0].elements[0];
let child_el = &parent_el.children[0];
let expected_top = parent_el.y + (parent_el.height - child_el.height) / 2.0;
assert!(
(child_el.y - expected_top).abs() < 0.5,
"mt+mb auto centered: child.y={:.2} expected {:.2}",
child_el.y,
expected_top,
);
}
#[test]
fn test_column_mb_auto_alone_holds_child_at_top() {
let child = make_mt_mb_child(EdgeValue::Pt(0.0), EdgeValue::Auto, 40.0, 10.0);
let pages = layout_doc(&parent_with_height_doc(Some(50.0), child));
assert_eq!(pages.len(), 1);
let parent_el = &pages[0].elements[0];
let child_el = &parent_el.children[0];
assert!(
(child_el.y - parent_el.y).abs() < 0.5,
"mb-auto alone: child.y={:.2} expected parent.y={:.2} (no shift)",
child_el.y,
parent_el.y,
);
}
#[test]
fn test_column_mt_auto_noop_without_fixed_parent_height() {
let child = make_mt_mb_child(EdgeValue::Auto, EdgeValue::Pt(0.0), 40.0, 10.0);
let pages = layout_doc(&parent_with_height_doc(None, child));
assert_eq!(pages.len(), 1);
let parent_el = &pages[0].elements[0];
let child_el = &parent_el.children[0];
assert!(
(child_el.y - parent_el.y).abs() < 0.5,
"no fixed parent height: child should stay at top, child.y={:.2} parent.y={:.2}",
child_el.y,
parent_el.y,
);
assert!(
(parent_el.height - child_el.height).abs() < 0.5,
"parent auto-height should ≈ child height: parent.h={:.2} child.h={:.2}",
parent_el.height,
child_el.height,
);
}
fn make_svg(width: f64, height: f64, view_box: Option<&str>, content: &str) -> Node {
Node {
kind: NodeKind::Svg {
width,
height,
view_box: view_box.map(|s| s.to_string()),
content: content.to_string(),
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
#[test]
fn test_svg_viewbox_scales_to_box() {
let doc = default_doc(vec![make_svg(
200.0,
80.0,
Some("0 0 661 176"),
r##"<rect x="0" y="0" width="661" height="176" fill="#0000ff"/>"##,
)]);
let pdf = render_to_pdf(&doc);
assert_valid_pdf(&pdf);
let stream = decompress_pdf_streams(&pdf);
let s = 200.0 / 661.0;
let ty = (80.0 - s * 176.0) / 2.0;
let expected_scale_cm = format!("{:.4} 0 0 {:.4} 0.00 {:.2} cm", s, s, ty);
assert!(
stream.contains(&expected_scale_cm),
"expected scale cm `{}` in stream:\n{}",
expected_scale_cm,
&stream[..stream.len().min(800)],
);
}
#[test]
fn test_svg_viewbox_aspect_letterboxes() {
let doc = default_doc(vec![make_svg(
200.0,
200.0,
Some("0 0 100 100"),
r##"<rect x="0" y="0" width="100" height="100" fill="#ff0000"/>"##,
)]);
let pdf = render_to_pdf(&doc);
let stream = decompress_pdf_streams(&pdf);
assert!(
stream.contains("2.0000 0 0 2.0000"),
"expected scale 2.0 for matching aspect ratios, content:\n{}",
&stream[..stream.len().min(600)],
);
let doc2 = default_doc(vec![make_svg(
300.0,
100.0,
Some("0 0 100 100"),
r##"<rect x="0" y="0" width="100" height="100" fill="#00ff00"/>"##,
)]);
let pdf2 = render_to_pdf(&doc2);
let stream2 = decompress_pdf_streams(&pdf2);
assert!(
stream2.contains("1.0000 0 0 1.0000 100.00 0.00"),
"expected scale 1.0 + tx 100 for letterbox case, content:\n{}",
&stream2[..stream2.len().min(600)],
);
}
#[test]
fn test_svg_no_viewbox_unchanged() {
let doc = default_doc(vec![make_svg(
120.0,
60.0,
None, r##"<rect x="0" y="0" width="120" height="60" fill="#000000"/>"##,
)]);
let pdf = render_to_pdf(&doc);
let stream = decompress_pdf_streams(&pdf);
assert!(
stream.contains("1.0000 0 0 1.0000 0.00 0.00"),
"no-viewBox case should produce identity scale + zero centering, content:\n{}",
&stream[..stream.len().min(600)],
);
}
#[test]
fn test_canvas_renders_at_display_coordinates() {
let doc = default_doc(vec![Node {
kind: NodeKind::Canvas {
width: 100.0,
height: 50.0,
operations: vec![
CanvasOp::SetFillColor {
r: 255.0,
g: 0.0,
b: 0.0,
},
CanvasOp::Rect {
x: 10.0,
y: 10.0,
width: 30.0,
height: 20.0,
},
CanvasOp::Fill,
],
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}]);
let pdf = render_to_pdf(&doc);
assert_valid_pdf(&pdf);
let stream = decompress_pdf_streams(&pdf);
assert!(
stream.contains("1.0000 0 0 1.0000 0.00 0.00"),
"Canvas should keep identity viewBox transform, content:\n{}",
&stream[..stream.len().min(600)],
);
}
#[test]
fn test_view_auto_height_wraps_table_tightly() {
fn cell(text: &str) -> Node {
let inner = Node {
kind: NodeKind::View,
style: Style {
font_size: Some(10.0),
..Default::default()
},
children: vec![Node {
kind: NodeKind::Text {
content: text.to_string(),
href: None,
runs: vec![],
},
style: Style::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,
};
Node {
kind: NodeKind::TableCell {
col_span: 1,
row_span: 1,
},
style: Style {
padding: Some(Edges::symmetric(1.0, 2.0)),
..Default::default()
},
children: vec![inner],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
let header_row = make_table_row(
true,
vec![cell("Description"), cell("Quantity"), cell("Unit Price")],
);
let body_data = [
("Website Redesign", "1", "4500"),
("Brand Identity Package", "1", "2200"),
("SEO Audit & Strategy", "1", "1800"),
("Monthly Hosting", "12", "29"),
];
let mut rows: Vec<Node> = vec![header_row];
for (d, q, u) in body_data {
rows.push(make_table_row(false, vec![cell(d), cell(q), cell(u)]));
}
let table = Node {
kind: NodeKind::Table {
columns: vec![
ColumnDef {
width: ColumnWidth::Auto,
},
ColumnDef {
width: ColumnWidth::Auto,
},
ColumnDef {
width: ColumnWidth::Auto,
},
],
},
style: Style::default(),
children: rows,
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let view = make_styled_view(
Style {
background_color: Some(Color {
r: 1.0,
g: 0.0,
b: 0.0,
a: 1.0,
}),
..Default::default()
},
vec![table],
);
let doc = default_doc(vec![Node::page(
PageConfig {
size: PageSize::Letter,
margin: Edges::uniform(48.0),
..Default::default()
},
Style::default(),
vec![view],
)]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
let view_el = &pages[0].elements[0];
assert_eq!(view_el.node_type.as_deref(), Some("View"));
assert_eq!(view_el.children.len(), 1, "expected the Table wrapper");
let table_el = &view_el.children[0];
assert_eq!(table_el.node_type.as_deref(), Some("Table"));
assert_eq!(table_el.children.len(), 5, "expected 5 TableRow children");
let row_h_sum: f64 = table_el.children.iter().map(|r| r.height).sum();
assert!(
(view_el.height - row_h_sum).abs() < 0.5,
"View height ({:.2}) should equal sum of row heights ({:.2})",
view_el.height,
row_h_sum,
);
assert!(
view_el.height < 100.0,
"View height should be ≈80pt, not page-sized — got {:.2}",
view_el.height,
);
}
#[test]
fn test_table_row_measures_max_of_cells_not_sum() {
fn cell_with_content_height(h: f64) -> Node {
let content = make_styled_view(
Style {
height: Some(Dimension::Pt(h)),
..Default::default()
},
vec![],
);
Node {
kind: NodeKind::TableCell {
col_span: 1,
row_span: 1,
},
style: Style::default(),
children: vec![content],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
let row = make_table_row(
false,
vec![
cell_with_content_height(20.0),
cell_with_content_height(35.0),
cell_with_content_height(12.0),
],
);
let view = make_styled_view(Style::default(), vec![row]);
let doc = default_doc(vec![Node::page(
PageConfig {
size: PageSize::Letter,
margin: Edges::uniform(40.0),
..Default::default()
},
Style::default(),
vec![view],
)]);
let pages = layout_doc(&doc);
let view_el = &pages[0].elements[0];
assert!(
(view_el.height - 35.0).abs() < 0.5,
"View around a TableRow with cell heights [20, 35, 12] should be 35 (max), got {:.2}",
view_el.height,
);
}
fn build_issue4_doc(use_header_flag: bool, num_body_rows: usize) -> Document {
fn cell(text: &str) -> Node {
let inner = make_styled_view(
Style {
padding: Some(Edges::symmetric(0.0, 6.0)),
..Default::default()
},
vec![Node {
kind: NodeKind::Text {
content: text.to_string(),
href: None,
runs: vec![],
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
);
Node {
kind: NodeKind::TableCell {
col_span: 1,
row_span: 1,
},
style: Style {
padding: Some(Edges::symmetric(8.0, 0.0)),
..Default::default()
},
children: vec![inner],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
let labels = ["Manufacturer", "Part #", "Qty", "Desc", "Unit"];
let header_row = make_table_row(use_header_flag, labels.iter().map(|l| cell(l)).collect());
let body_rows: Vec<Node> = (0..num_body_rows)
.map(|r| {
make_table_row(
false,
(0..5)
.map(|i| {
if i == 3 {
cell(&format!("Line item {} description", r + 1))
} else {
cell(&format!("c{}-{}", i, r))
}
})
.collect(),
)
})
.collect();
let mut rows = vec![header_row];
rows.extend(body_rows);
let table = Node {
kind: NodeKind::Table {
columns: vec![
ColumnDef {
width: ColumnWidth::Fixed(100.0),
},
ColumnDef {
width: ColumnWidth::Fixed(90.0),
},
ColumnDef {
width: ColumnWidth::Fixed(50.0),
},
ColumnDef {
width: ColumnWidth::Auto,
},
ColumnDef {
width: ColumnWidth::Fixed(36.0),
},
],
},
style: Style::default(),
children: rows,
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let spacer = make_styled_view(
Style {
height: Some(Dimension::Pt(700.0)),
..Default::default()
},
vec![],
);
default_doc(vec![Node::page(
PageConfig {
size: PageSize::Letter,
margin: Edges::uniform(36.0),
..Default::default()
},
Style::default(),
vec![spacer, table],
)])
}
#[test]
fn test_table_repeating_header_does_not_inflate_pages() {
let with_header = layout_doc(&build_issue4_doc(true, 24));
let plain = layout_doc(&build_issue4_doc(false, 24));
assert_eq!(
with_header.len(),
plain.len(),
"<Row header> produced {} pages but the same content with <Row> produced {} — \
the header flag should never inflate page count",
with_header.len(),
plain.len(),
);
}
#[test]
fn test_table_repeating_header_no_duplicate_cells_per_page() {
let pages = layout_doc(&build_issue4_doc(true, 24));
fn collect_cells(el: &forme::layout::LayoutElement, out: &mut Vec<(f64, f64)>) {
if el.node_type.as_deref() == Some("TableCell") {
out.push((el.x, el.y));
}
for c in &el.children {
collect_cells(c, out);
}
}
for (idx, page) in pages.iter().enumerate() {
let mut cell_positions: Vec<(f64, f64)> = Vec::new();
for el in &page.elements {
collect_cells(el, &mut cell_positions);
}
let n = cell_positions.len();
for i in 0..n {
for j in (i + 1)..n {
let (xi, yi) = cell_positions[i];
let (xj, yj) = cell_positions[j];
assert!(
(xi - xj).abs() > 0.5 || (yi - yj).abs() > 0.5,
"page {}: two TableCells at the same position ({:.2}, {:.2}) — \
symptom of the issue 4 doubled/sliding column bug",
idx,
xi,
yi,
);
}
}
}
}
fn render_transformed_view(style: Style) -> String {
let view = make_styled_view(style, vec![]);
let doc = default_doc(vec![view]);
let pdf = render_to_pdf(&doc);
decompress_pdf_streams(&pdf)
}
#[test]
fn test_transform_rotate_emits_cm_matrix() {
let stream = render_transformed_view(Style {
width: Some(Dimension::Pt(100.0)),
height: Some(Dimension::Pt(50.0)),
background_color: Some(Color {
r: 0.0,
g: 0.5,
b: 0.9,
a: 1.0,
}),
transform: Some(vec![TransformOp::Rotate { deg: 45.0 }]),
..Default::default()
});
assert!(
stream.contains("0.707107 -0.707107 0.707107 0.707107 0 0 cm"),
"expected rotate(45) cm matrix in stream; got:\n{}",
&stream[..stream.len().min(2000)]
);
}
#[test]
fn test_transform_scale_emits_cm_matrix() {
let stream = render_transformed_view(Style {
width: Some(Dimension::Pt(100.0)),
height: Some(Dimension::Pt(50.0)),
background_color: Some(Color {
r: 0.0,
g: 0.5,
b: 0.9,
a: 1.0,
}),
transform: Some(vec![TransformOp::Scale { x: 2.0, y: 0.5 }]),
..Default::default()
});
assert!(
stream.contains("2.000000 0 0 0.500000 0 0 cm"),
"expected scale(2, 0.5) cm matrix in stream; got:\n{}",
&stream[..stream.len().min(2000)]
);
}
#[test]
fn test_transform_compose_rotate_and_scale() {
let stream = render_transformed_view(Style {
width: Some(Dimension::Pt(100.0)),
height: Some(Dimension::Pt(50.0)),
background_color: Some(Color {
r: 0.0,
g: 0.5,
b: 0.9,
a: 1.0,
}),
transform: Some(vec![
TransformOp::Rotate { deg: 45.0 },
TransformOp::Scale { x: 1.2, y: 1.2 },
]),
..Default::default()
});
assert!(
stream.contains("0.707107 -0.707107 0.707107 0.707107 0 0 cm"),
"expected rotate cm in compose"
);
assert!(
stream.contains("1.200000 0 0 1.200000 0 0 cm"),
"expected scale cm in compose"
);
}
#[test]
fn test_transform_does_not_affect_layout_flow() {
let first = make_styled_view(
Style {
width: Some(Dimension::Pt(100.0)),
height: Some(Dimension::Pt(40.0)),
transform: Some(vec![TransformOp::Rotate { deg: 45.0 }]),
..Default::default()
},
vec![],
);
let second = make_styled_view(
Style {
width: Some(Dimension::Pt(100.0)),
height: Some(Dimension::Pt(40.0)),
..Default::default()
},
vec![],
);
let doc = default_doc(vec![first, second]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
let elements = &pages[0].elements;
assert!(elements.len() >= 2);
let first_y = elements[0].y;
let first_h = elements[0].height;
let second_y = elements[1].y;
assert!(
(second_y - (first_y + first_h)).abs() < 0.5,
"transformed sibling shifted layout: second.y={:.2}, expected {:.2}",
second_y,
first_y + first_h,
);
}
fn make_heading(level: u8, text: &str) -> Node {
Node {
kind: NodeKind::Heading {
level,
content: text.to_string(),
href: None,
runs: vec![],
},
style: Style {
font_size: Some(match level {
1 => 32.0,
2 => 24.0,
3 => 20.0,
_ => 16.0,
}),
font_weight: Some(700),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
#[test]
fn test_heading_emits_h1_through_h6_structure_roles_in_tagged_pdf() {
let doc = Document {
children: vec![Node::page(
PageConfig::default(),
Style::default(),
vec![
make_heading(1, "h1"),
make_heading(2, "h2"),
make_heading(3, "h3"),
make_heading(4, "h4"),
make_heading(5, "h5"),
make_heading(6, "h6"),
],
)],
metadata: Default::default(),
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: true,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let bytes = forme::render(&doc).unwrap();
let pdf_str = String::from_utf8_lossy(&bytes);
for level in 1..=6 {
let needle = format!("/S /H{}", level);
assert!(
pdf_str.contains(&needle),
"tagged PDF missing structure role {} (looking for `{}`)",
level,
needle,
);
}
}
#[test]
fn test_heading_renders_text_content() {
let doc = Document {
children: vec![Node::page(
PageConfig::default(),
Style::default(),
vec![make_heading(1, "Annual Report")],
)],
metadata: Default::default(),
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: false,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let bytes = forme::render(&doc).unwrap();
let stream = decompress_pdf_streams(&bytes);
assert!(
stream.contains("Annual Report") || stream.contains("(Annual Report)"),
"heading text content missing from rendered stream"
);
}
#[test]
fn test_no_transform_no_cm_wrapper() {
let stream = render_transformed_view(Style {
width: Some(Dimension::Pt(100.0)),
height: Some(Dimension::Pt(50.0)),
background_color: Some(Color {
r: 0.0,
g: 0.5,
b: 0.9,
a: 1.0,
}),
..Default::default()
});
assert!(
!stream.contains("0.707107 -0.707107"),
"rotate cm matrix appeared in a no-transform render"
);
}
fn make_list_item(text: &str) -> Node {
Node {
kind: NodeKind::ListItem,
style: Style::default(),
children: vec![make_text(text, 12.0)],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
fn make_list(ordered: bool, marker_type: ListMarkerType, start: u32, items: Vec<Node>) -> Node {
Node {
kind: NodeKind::List {
ordered,
marker_type,
start,
},
style: Style::default(),
children: items,
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
#[test]
fn test_unordered_list_renders_bullet_markers() {
let doc = default_doc(vec![make_list(
false,
ListMarkerType::Disc,
1,
vec![
make_list_item("first"),
make_list_item("second"),
make_list_item("third"),
],
)]);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
let stream = decompress_pdf_streams(&bytes);
assert!(
stream.contains("first") && stream.contains("second") && stream.contains("third"),
"expected all three item bodies in the rendered stream"
);
}
#[test]
fn test_ordered_decimal_list_renders_numbered_markers() {
let doc = default_doc(vec![make_list(
true,
ListMarkerType::Decimal,
1,
vec![
make_list_item("a"),
make_list_item("b"),
make_list_item("c"),
],
)]);
let bytes = render_to_pdf(&doc);
let stream = decompress_pdf_streams(&bytes);
for n in 1..=3 {
let marker = format!("{}.", n);
assert!(
stream.contains(&marker),
"expected marker `{}` in the stream",
marker
);
}
}
#[test]
fn test_ordered_list_respects_start_index() {
let doc = default_doc(vec![make_list(
true,
ListMarkerType::Decimal,
5,
vec![make_list_item("a"), make_list_item("b")],
)]);
let stream = decompress_pdf_streams(&render_to_pdf(&doc));
assert!(stream.contains("5."), "expected start=5 marker");
assert!(stream.contains("6."), "expected next marker after start");
assert!(!stream.contains("1."), "1. should not appear when start=5");
}
#[test]
fn test_ordered_lower_roman_markers() {
let doc = default_doc(vec![make_list(
true,
ListMarkerType::LowerRoman,
1,
vec![
make_list_item("a"),
make_list_item("b"),
make_list_item("c"),
make_list_item("d"),
make_list_item("e"),
],
)]);
let stream = decompress_pdf_streams(&render_to_pdf(&doc));
for m in ["i.", "ii.", "iii.", "iv.", "v."] {
assert!(stream.contains(m), "expected `{}` in stream", m);
}
}
#[test]
fn test_ordered_lower_alpha_markers() {
let doc = default_doc(vec![make_list(
true,
ListMarkerType::LowerAlpha,
1,
vec![
make_list_item("a"),
make_list_item("b"),
make_list_item("c"),
],
)]);
let stream = decompress_pdf_streams(&render_to_pdf(&doc));
for m in ["a.", "b.", "c."] {
assert!(stream.contains(m), "expected `{}` in stream", m);
}
}
#[test]
fn test_list_emits_l_li_lbl_in_tagged_pdf() {
let doc = Document {
children: vec![Node::page(
PageConfig::default(),
Style::default(),
vec![make_list(
true,
ListMarkerType::Decimal,
1,
vec![make_list_item("first"), make_list_item("second")],
)],
)],
metadata: Default::default(),
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
tagged: true,
pdfa: None,
default_style: None,
embedded_data: None,
flatten_forms: false,
pdf_ua: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let bytes = forme::render(&doc).unwrap();
let pdf_str = String::from_utf8_lossy(&bytes);
for needle in ["/S /L", "/S /LI", "/S /Lbl"] {
assert!(
pdf_str.contains(needle),
"tagged PDF missing `{}` structure role",
needle
);
}
}
#[test]
fn test_list_item_content_offset_past_marker_gutter() {
let list = make_list(
false,
ListMarkerType::Disc,
1,
vec![make_list_item("hello")],
);
let doc = default_doc(vec![list]);
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
let list_el = &pages[0].elements[0];
assert_eq!(list_el.node_type.as_deref(), Some("List"));
let item = &list_el.children[0];
assert_eq!(item.node_type.as_deref(), Some("ListItem"));
let mut lbl_x: Option<f64> = None;
let mut body_x: Option<f64> = None;
for child in &item.children {
match child.node_type.as_deref() {
Some("Lbl") => lbl_x = Some(child.x),
Some("Text") => body_x = Some(child.x),
_ => {}
}
}
let lbl = lbl_x.expect("marker Lbl missing");
let body = body_x.expect("body Text missing");
assert!(
body > lbl,
"body x ({:.2}) should be greater than marker x ({:.2})",
body,
lbl,
);
}
fn count_header_table_rows(page: &forme::layout::LayoutPage) -> usize {
fn walk(el: &forme::layout::LayoutElement, count: &mut usize) {
if el.is_header_row && el.node_type.as_deref() == Some("TableRow") {
*count += 1;
}
for c in &el.children {
walk(c, count);
}
}
let mut count = 0;
for el in &page.elements {
walk(el, &mut count);
}
count
}
#[test]
fn test_table_header_no_orphan_when_first_body_row_doesnt_fit() {
let spacer = make_styled_view(
Style {
height: Some(Dimension::Pt(700.0)),
..Default::default()
},
vec![],
);
let table = Node {
kind: NodeKind::Table {
columns: vec![ColumnDef {
width: ColumnWidth::Fraction(1.0),
}],
},
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("Row 1", 10.0)])]),
make_table_row(false, vec![make_table_cell(vec![make_text("Row 2", 10.0)])]),
],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let doc = default_doc(vec![spacer, table]);
let pages = layout_doc(&doc);
assert_eq!(
pages.len(),
2,
"Expected exactly 2 pages, got {}",
pages.len()
);
assert_eq!(
count_header_table_rows(&pages[0]),
0,
"Page 1 must not contain an orphaned header row"
);
assert_eq!(
count_header_table_rows(&pages[1]),
1,
"Page 2 should contain the single header row"
);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_table_long_header_text_no_page_contamination() {
let spacer = make_styled_view(
Style {
height: Some(Dimension::Pt(588.0)),
..Default::default()
},
vec![],
);
let long_token = "Amount".repeat(40);
let table = Node {
kind: NodeKind::Table {
columns: vec![
ColumnDef {
width: ColumnWidth::Fraction(0.7),
},
ColumnDef {
width: ColumnWidth::Fraction(0.3),
},
],
},
style: Style::default(),
children: vec![
make_table_row(
true,
vec![
make_table_cell(vec![make_text("Description", 9.0)]),
make_table_cell(vec![make_text(&long_token, 9.0)]),
],
),
make_table_row(
false,
vec![
make_table_cell(vec![make_text("Website Redesign", 9.0)]),
make_table_cell(vec![make_text("$4500.00", 9.0)]),
],
),
make_table_row(
false,
vec![
make_table_cell(vec![make_text("Brand Identity Package", 9.0)]),
make_table_cell(vec![make_text("$2200.00", 9.0)]),
],
),
],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let doc = default_doc(vec![spacer, table]);
let pages = layout_doc(&doc);
assert_eq!(
pages.len(),
2,
"Expected exactly 2 pages (no contamination), got {}",
pages.len()
);
assert_eq!(
count_header_table_rows(&pages[0]),
0,
"Page 1 must not contain the table header"
);
assert_eq!(
count_header_table_rows(&pages[1]),
1,
"Page 2 should contain the single header row"
);
let bytes = render_to_pdf(&doc);
assert_valid_pdf(&bytes);
}
#[test]
fn test_svg_stroke_linecap_round_emits_pdf_j_operator() {
let doc = default_doc(vec![Node {
kind: NodeKind::Svg {
width: 200.0,
height: 100.0,
view_box: Some("0 0 200 100".to_string()),
content: r##"<path d="M 10 10 L 100 90" stroke="black" stroke-width="4" fill="none" stroke-linecap="round"/>"##.to_string(),
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}]);
let pdf = render_to_pdf(&doc);
assert_valid_pdf(&pdf);
let streams = decompress_pdf_streams(&pdf);
assert!(
streams.contains("1 J"),
"PDF content stream should contain `1 J` (round linecap). Streams:\n{}",
streams
);
}
#[test]
fn test_svg_stroke_linecap_default_emits_butt() {
let doc = default_doc(vec![Node {
kind: NodeKind::Svg {
width: 200.0,
height: 100.0,
view_box: Some("0 0 200 100".to_string()),
content:
r##"<path d="M 10 10 L 100 90" stroke="black" stroke-width="4" fill="none"/>"##
.to_string(),
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}]);
let pdf = render_to_pdf(&doc);
assert_valid_pdf(&pdf);
let streams = decompress_pdf_streams(&pdf);
assert!(
streams.contains("0 J"),
"PDF content stream should contain `0 J` (butt linecap, default). Streams:\n{}",
streams
);
assert!(
!streams.contains("1 J") && !streams.contains("2 J"),
"No round/square linecap should appear without an explicit attribute. Streams:\n{}",
streams
);
}
#[test]
fn test_svg_stroke_linejoin_round_emits_pdf_j_operator() {
let doc = default_doc(vec![Node {
kind: NodeKind::Svg {
width: 200.0,
height: 100.0,
view_box: Some("0 0 200 100".to_string()),
content: r##"<path d="M 10 10 L 50 90 L 100 10" stroke="black" stroke-width="4" fill="none" stroke-linejoin="round"/>"##.to_string(),
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}]);
let pdf = render_to_pdf(&doc);
assert_valid_pdf(&pdf);
let streams = decompress_pdf_streams(&pdf);
assert!(
streams.contains("1 j"),
"PDF content stream should contain `1 j` (round linejoin). Streams:\n{}",
streams
);
}
#[test]
fn test_svg_stroke_linecap_inherited_from_group() {
let doc = default_doc(vec![Node {
kind: NodeKind::Svg {
width: 200.0,
height: 100.0,
view_box: Some("0 0 200 100".to_string()),
content: r##"<g stroke-linecap="round"><path d="M 10 10 L 100 90" stroke="black" stroke-width="4" fill="none"/></g>"##.to_string(),
},
style: Style::default(),
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}]);
let pdf = render_to_pdf(&doc);
assert_valid_pdf(&pdf);
let streams = decompress_pdf_streams(&pdf);
assert!(
streams.contains("1 J"),
"PDF content stream should contain `1 J` (round linecap inherited from <g>). Streams:\n{}",
streams
);
}
fn all_layout_text(layout: &forme::LayoutInfo) -> String {
fn walk(els: &[forme::layout::ElementInfo], out: &mut String) {
for el in els {
if let Some(t) = &el.text_content {
out.push_str(t);
out.push(' ');
}
walk(&el.children, out);
}
}
let mut out = String::new();
for page in &layout.pages {
walk(&page.elements, &mut out);
}
out
}
#[test]
fn siblings_after_overflowing_flex_row_still_render() {
let tall_text = vec!["line"; 200].join("\n");
let row = make_styled_view(
Style {
flex_direction: Some(FlexDirection::Row),
..Default::default()
},
vec![make_text(&tall_text, 12.0)],
);
let after = make_text("CONTENT AFTER THE FLEX ROW", 12.0);
let doc = Document {
children: vec![row, after],
metadata: Metadata::default(),
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
default_style: None,
tagged: false,
pdfa: None,
pdf_ua: false,
embedded_data: None,
flatten_forms: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let (_pdf, layout, _) = forme::render_with_layout(&doc).expect("Should render");
let text = all_layout_text(&layout);
assert!(
text.contains("CONTENT AFTER THE FLEX ROW"),
"sibling after an overflowing flex row was swallowed; pages: {}",
layout.pages.len()
);
assert!(
layout
.pages
.first()
.map(|p| !p.elements.is_empty())
.unwrap_or(false),
"first page must not be empty"
);
}
#[test]
fn colspan_cell_spans_columns_and_next_cell_lands_after_them() {
fn cell(text: &str, col_span: u32) -> Node {
Node {
kind: NodeKind::TableCell {
col_span,
row_span: 1,
},
style: Style::default(),
children: vec![make_text(text, 12.0)],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
fn row(cells: Vec<Node>) -> Node {
Node {
kind: NodeKind::TableRow { is_header: false },
style: Style::default(),
children: cells,
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
let table = Node {
kind: NodeKind::Table { columns: vec![] }, style: Style::default(),
children: vec![
row(vec![cell("a", 1), cell("b", 1), cell("c", 1)]),
row(vec![cell("Total", 2), cell("$74.00", 1)]),
],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let doc = default_doc(vec![table]);
let (_pdf, layout, _) = forme::render_with_layout(&doc).expect("Should render");
fn cells_of_rows(els: &[forme::layout::ElementInfo], out: &mut Vec<Vec<(f64, f64)>>) {
for el in els {
if el.node_type == "TableRow" {
out.push(
el.children
.iter()
.filter(|c| c.node_type == "TableCell")
.map(|c| (c.x, c.width))
.collect(),
);
}
cells_of_rows(&el.children, out);
}
}
let mut rows: Vec<Vec<(f64, f64)>> = Vec::new();
for page in &layout.pages {
cells_of_rows(&page.elements, &mut rows);
}
assert_eq!(rows.len(), 2);
let (normal, totals) = (&rows[0], &rows[1]);
assert_eq!(normal.len(), 3);
assert_eq!(totals.len(), 2);
let col_w = normal[0].1;
assert!(
(totals[0].1 - 2.0 * col_w).abs() < 0.5,
"colspan=2 cell width {} should be 2 × column width {col_w}",
totals[0].1
);
assert!(
(totals[1].0 - normal[2].0).abs() < 0.5,
"cell after colspan starts at {}, third column starts at {}",
totals[1].0,
normal[2].0
);
}
#[test]
fn unbreakable_table_moves_to_next_page_whole() {
let spacer = Node {
kind: NodeKind::View,
style: Style {
height: Some(Dimension::Pt(650.0)),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
fn row(label: &str, is_header: bool) -> Node {
Node {
kind: NodeKind::TableRow { is_header },
style: Style::default(),
children: vec![Node {
kind: NodeKind::TableCell {
col_span: 1,
row_span: 1,
},
style: Style::default(),
children: vec![make_text(label, 12.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![] },
style: Style {
wrap: Some(false), ..Default::default()
},
children: vec![
row("hdr", true),
row("r1", false),
row("r2", false),
row("r3", false),
row("r4", false),
row("r5", false),
row("r6", false),
],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let doc = default_doc(vec![spacer, table]);
let (_pdf, layout, _) = forme::render_with_layout(&doc).expect("Should render");
let mut row_pages: Vec<usize> = Vec::new();
fn walk_rows(els: &[forme::layout::ElementInfo], page: usize, out: &mut Vec<usize>) {
for el in els {
if el.node_type == "TableRow" {
out.push(page);
}
walk_rows(&el.children, page, out);
}
}
for (i, page) in layout.pages.iter().enumerate() {
walk_rows(&page.elements, i, &mut row_pages);
}
assert_eq!(
row_pages.len(),
7,
"7 source rows, no repeated header (repetition means it split): {row_pages:?}"
);
let first = row_pages[0];
assert!(
row_pages.iter().all(|p| *p == first),
"unbreakable table must stay on one page: {row_pages:?}"
);
assert_eq!(first, 1, "table must have moved off the crowded first page");
}
fn fixed_band(height: f64, pages: FixedPageFilter) -> Node {
Node {
kind: NodeKind::Fixed {
position: FixedPosition::Header,
pages,
page_name: None,
exclude_page_names: vec![],
},
style: Style::default(),
children: vec![Node {
kind: NodeKind::View,
style: Style {
height: Some(Dimension::Pt(height)),
background_color: Some(Color::rgb(0.9, 0.9, 0.9)),
..Default::default()
},
children: vec![make_text("RUNNING HEADER", 10.0)],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
fn tall_content(lines: usize) -> Node {
make_text(&vec!["content line"; lines].join("\n"), 12.0)
}
#[test]
fn first_page_config_gives_page_one_its_own_margins() {
let doc = Document {
children: vec![tall_content(60)],
metadata: Metadata::default(),
default_page: PageConfig {
margin: Edges::uniform(54.0),
..Default::default()
},
first_page: Some(PageConfig {
margin: Edges {
top: 120.0,
right: 54.0,
bottom: 54.0,
left: 54.0,
},
..Default::default()
}),
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
default_style: None,
tagged: false,
pdfa: None,
pdf_ua: false,
embedded_data: None,
flatten_forms: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let (_pdf, layout, _) = forme::render_with_layout(&doc).expect("Should render");
assert!(layout.pages.len() >= 2, "content must flow to page 2");
assert_eq!(layout.pages[0].content_y, 120.0, ":first margin-top");
assert_eq!(layout.pages[1].content_y, 54.0, "later pages use default");
}
#[test]
fn not_first_header_skips_page_one() {
let doc = Document {
children: vec![
fixed_band(40.0, FixedPageFilter::NotFirst),
tall_content(80),
],
metadata: Metadata::default(),
default_page: PageConfig::default(),
first_page: None,
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
default_style: None,
tagged: false,
pdfa: None,
pdf_ua: false,
embedded_data: None,
flatten_forms: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let (_pdf, layout, _) = forme::render_with_layout(&doc).expect("Should render");
assert!(layout.pages.len() >= 2);
let page_has_header = |i: usize| -> bool {
let mut found = false;
fn walk(
els: &[forme::layout::ElementInfo],
f: &mut impl FnMut(&forme::layout::ElementInfo),
) {
for el in els {
f(el);
walk(&el.children, f);
}
}
walk(&layout.pages[i].elements, &mut |el| {
if el
.text_content
.as_deref()
.is_some_and(|t| t.contains("RUNNING HEADER"))
{
found = true;
}
});
found
};
assert!(
!page_has_header(0),
"NotFirst header must not appear on page 1"
);
assert!(page_has_header(1), "NotFirst header must appear on page 2");
let first_el_y = |i: usize| {
layout.pages[i]
.elements
.iter()
.map(|e| e.y)
.fold(f64::MAX, f64::min)
};
assert!(
(first_el_y(0) - 54.0).abs() < 0.5,
"page 1 content at margin, got {}",
first_el_y(0)
);
assert!(
(first_el_y(1) - 54.0).abs() < 0.5,
"page 2 band at margin top"
);
}
#[test]
fn first_page_restores_margin_when_its_band_is_suppressed() {
let band_height = 72.0;
let doc = Document {
children: vec![
fixed_band(band_height, FixedPageFilter::NotFirst),
tall_content(80),
],
metadata: Metadata::default(),
default_page: PageConfig {
margin: Edges {
top: 0.0,
right: 54.0,
bottom: 54.0,
left: 54.0,
},
..Default::default()
},
first_page: Some(PageConfig {
margin: Edges {
top: band_height,
right: 54.0,
bottom: 54.0,
left: 54.0,
},
..Default::default()
}),
left_page: None,
right_page: None,
named_pages: Default::default(),
attachments: vec![],
zugferd: None,
fonts: vec![],
default_style: None,
tagged: false,
pdfa: None,
pdf_ua: false,
embedded_data: None,
flatten_forms: false,
certification: None,
pdf_version: Default::default(),
pdf_ua2: false,
};
let (_pdf, layout, _) = forme::render_with_layout(&doc).expect("Should render");
assert!(layout.pages.len() >= 2);
let first_el_y = |i: usize| {
layout.pages[i]
.elements
.iter()
.map(|e| e.y)
.fold(f64::MAX, f64::min)
};
assert_eq!(
layout.pages[0].content_y, band_height,
"page 1 config must carry the restored margin"
);
assert!(
(first_el_y(0) - band_height).abs() < 0.5,
"page 1 content must start at the restored margin ({band_height}pt), got {}",
first_el_y(0)
);
assert_eq!(layout.pages[1].content_y, 0.0);
assert!(
(first_el_y(1) - 0.0).abs() < 0.5,
"page 2 band must sit at the physical top, got {}",
first_el_y(1)
);
let mut content_min_y = f64::MAX;
fn walk(els: &[forme::layout::ElementInfo], f: &mut impl FnMut(&forme::layout::ElementInfo)) {
for el in els {
f(el);
walk(&el.children, f);
}
}
walk(&layout.pages[1].elements, &mut |el| {
if el
.text_content
.as_deref()
.is_some_and(|t| t.contains("content line"))
{
content_min_y = content_min_y.min(el.y);
}
});
assert!(
(content_min_y - band_height).abs() < 1.0,
"page 2 body content must start below the band (~{band_height}pt), got {content_min_y}"
);
}
#[test]
fn middle_aligned_cell_centers_in_the_row_box() {
fn cell(text: &str, valign: Option<VerticalAlign>) -> Node {
Node {
kind: NodeKind::TableCell {
col_span: 1,
row_span: 1,
},
style: Style {
vertical_align: valign,
..Default::default()
},
children: vec![make_text(text, 12.0)],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
let table = Node {
kind: NodeKind::Table { columns: vec![] },
style: Style::default(),
children: vec![Node {
kind: NodeKind::TableRow { is_header: false },
style: Style::default(),
children: vec![
cell("one\ntwo\nthree", None),
cell("mid", Some(VerticalAlign::Middle)),
],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let doc = default_doc(vec![table]);
let (_pdf, layout, _) = forme::render_with_layout(&doc).expect("Should render");
fn find_cells<'a>(
els: &'a [forme::layout::ElementInfo],
out: &mut Vec<&'a forme::layout::ElementInfo>,
) {
for el in els {
if el.node_type == "TableCell" {
out.push(el);
}
find_cells(&el.children, out);
}
}
let mut cells = Vec::new();
for page in &layout.pages {
find_cells(&page.elements, &mut cells);
}
assert_eq!(cells.len(), 2);
let (tall, short) = (cells[0], cells[1]);
assert!(tall.height > 40.0, "three lines: {}", tall.height);
fn first_text(els: &[forme::layout::ElementInfo]) -> Option<&forme::layout::ElementInfo> {
for el in els {
if el.kind == "None" && el.node_type == "Text" {
return Some(el);
}
if let Some(t) = first_text(&el.children) {
return Some(t);
}
}
None
}
let text = first_text(&short.children).expect("short cell text");
let row_center = short.y + short.height / 2.0;
let text_center = text.y + text.height / 2.0;
assert!(
(text_center - row_center).abs() < 0.01,
"middle-aligned content center {text_center} must equal row center {row_center}"
);
}
#[test]
fn max_width_clamps_and_auto_margins_center_the_column() {
let column = Node {
kind: NodeKind::View,
style: Style {
max_width: Some(Dimension::Pt(400.0)),
margin: Some(MarginEdges {
top: EdgeValue::Pt(0.0),
right: EdgeValue::Auto,
bottom: EdgeValue::Pt(0.0),
left: EdgeValue::Auto,
}),
background_color: Some(Color::rgb(0.95, 0.95, 0.95)),
..Default::default()
},
children: vec![make_text("column content", 12.0)],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let body = make_view(vec![column]);
let doc = default_doc(vec![body]);
let (_pdf, layout, _) = forme::render_with_layout(&doc).expect("Should render");
let page = &layout.pages[0];
let el = &page.elements[0].children[0];
assert_eq!(el.node_type, "View");
assert!(
(el.width - 400.0).abs() < 0.01,
"max-width must clamp the auto width to 400, got {}",
el.width
);
let expected_x = page.content_x + (page.content_width - 400.0) / 2.0;
assert!(
(el.x - expected_x).abs() < 0.01,
"auto margins must center the clamped column: x {} != {}",
el.x,
expected_x
);
}
fn find_first_by_type<'a>(
elems: &'a [forme::layout::LayoutElement],
ty: &str,
) -> Option<&'a forme::layout::LayoutElement> {
for e in elems {
if e.node_type.as_deref() == Some(ty) {
return Some(e);
}
if let Some(found) = find_first_by_type(&e.children, ty) {
return Some(found);
}
}
None
}
#[test]
fn table_cell_css_height_is_minimum_row_height() {
let cell = Node {
kind: NodeKind::TableCell {
col_span: 1,
row_span: 1,
},
style: Style {
height: Some(Dimension::Pt(100.0)),
..Default::default()
},
children: vec![make_text("x", 10.0)],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let row = make_table_row(false, vec![cell]);
let table = Node {
kind: NodeKind::Table { columns: vec![] },
style: Style::default(),
children: vec![row],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let doc = default_doc(vec![table]);
let pages = layout_doc(&doc);
let row = find_first_by_type(&pages[0].elements, "TableRow").expect("TableRow laid out");
assert!(
(row.height - 100.0).abs() < 0.01,
"cell height:100pt must set the row height as a minimum; got {}",
row.height
);
}
fn block(rel_offsets: Option<(f64, f64)>) -> Node {
let (position, top, left) = match rel_offsets {
Some((t, l)) => (Some(Position::Relative), Some(t), Some(l)),
None => (None, None, None),
};
Node {
kind: NodeKind::View,
style: Style {
width: Some(Dimension::Pt(50.0)),
height: Some(Dimension::Pt(40.0)),
position,
top,
left,
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
#[test]
fn relative_offset_shifts_paint_but_not_flow() {
let offset_doc = default_doc(vec![block(None), block(Some((10.0, 5.0))), block(None)]);
let base_doc = default_doc(vec![block(None), block(None), block(None)]);
let off = layout_doc(&offset_doc);
let base = layout_doc(&base_doc);
let (o, b) = (&off[0].elements, &base[0].elements);
assert!(
(o[1].x - (b[1].x + 5.0)).abs() < 0.001,
"left:5 → +5pt x: {} vs {}",
o[1].x,
b[1].x
);
assert!(
(o[1].y - (b[1].y + 10.0)).abs() < 0.001,
"top:10 → +10pt y: {} vs {}",
o[1].y,
b[1].y
);
assert_eq!(o[2].y, b[2].y, "sibling flow must not move");
assert_eq!(o[2].x, b[2].x, "sibling flow must not move");
assert_eq!(o[0].y, b[0].y, "preceding sibling unchanged");
}
fn valign_cell(font: f64, text: &str, va: VerticalAlign, extra_block: Option<f64>) -> Node {
let mut children = vec![make_text(text, font)];
if let Some(h) = extra_block {
children.push(Node {
kind: NodeKind::View,
style: Style {
height: Some(Dimension::Pt(h)),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
});
}
Node {
kind: NodeKind::TableCell {
col_span: 1,
row_span: 1,
},
style: Style {
vertical_align: Some(va),
..Default::default()
},
children,
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
fn one_row_table(cells: Vec<Node>) -> Document {
let row = make_table_row(false, cells);
let table = Node {
kind: NodeKind::Table { columns: vec![] },
style: Style::default(),
children: vec![row],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
default_doc(vec![table])
}
fn texts_y(elems: &[forme::layout::LayoutElement], out: &mut Vec<f64>) {
for e in elems {
if e.node_type.as_deref() == Some("Text") {
out.push(e.y);
}
texts_y(&e.children, out);
}
}
#[test]
fn baseline_aligns_first_baselines_across_cells() {
let doc = one_row_table(vec![
valign_cell(12.0, "a", VerticalAlign::Baseline, None),
valign_cell(24.0, "b", VerticalAlign::Baseline, None),
]);
let pages = layout_doc(&doc);
let mut ys = Vec::new();
texts_y(&pages[0].elements, &mut ys);
assert_eq!(ys.len(), 2, "two text lines");
const K: f64 = (1.4 - 1.11719) / 2.0 + 0.90527;
let (a_y, b_y) = (ys[0], ys[1]);
assert!(
((a_y + 12.0 * K) - (b_y + 24.0 * K)).abs() < 0.001,
"first baselines must coincide: a {} b {}",
a_y,
b_y
);
assert!(
(a_y - (b_y + 12.0 * K)).abs() < 0.001,
"shove = 12.56pt: {a_y} vs {b_y}"
);
}
#[test]
fn baseline_shove_grows_the_row_and_does_not_clip() {
let row_h = |va: VerticalAlign| {
let doc = one_row_table(vec![
valign_cell(12.0, "a", va, Some(80.0)),
valign_cell(40.0, "b", va, None),
]);
let pages = layout_doc(&doc);
find_first_by_type(&pages[0].elements, "TableRow")
.expect("row")
.height
};
let baseline_h = row_h(VerticalAlign::Baseline);
let top_h = row_h(VerticalAlign::Top);
const K: f64 = (1.4 - 1.11719) / 2.0 + 0.90527;
assert!(
(baseline_h - top_h - 28.0 * K).abs() < 0.01,
"baseline row must grow by the 29.31pt shove: baseline {} vs top {}",
baseline_h,
top_h
);
}
fn bordered_box(width_pt: f64, style: forme::style::BorderStyle) -> Document {
use forme::style::{BorderStyle as _BS, Color, EdgeValues};
let _ = _BS::Solid;
let node = Node {
kind: NodeKind::View,
style: Style {
width: Some(Dimension::Pt(120.0)),
height: Some(Dimension::Pt(50.0)),
border_width: Some(EdgeValues::uniform(width_pt)),
border_style: Some(EdgeValues::uniform(style)),
border_color: Some(EdgeValues::uniform(Color::BLACK)),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
default_doc(vec![node])
}
#[test]
fn dashed_border_emits_calibrated_dash_pattern() {
use forme::style::BorderStyle;
let pdf = forme::render(&bordered_box(4.0, BorderStyle::Dashed)).expect("render");
let content = decompress_pdf_streams(&pdf);
assert!(
content.contains("[8.00 4.00] 0 d"),
"dash 2×/gap 1× for a 4pt border"
);
assert!(!content.contains("1 J"), "dashed uses butt cap, not round");
let solid =
decompress_pdf_streams(&forme::render(&bordered_box(4.0, BorderStyle::Solid)).unwrap());
assert!(
!solid.contains("] 0 d"),
"solid border emits no dash operator"
);
}
#[test]
fn dotted_border_emits_round_dots() {
use forme::style::BorderStyle;
let pdf = forme::render(&bordered_box(4.0, BorderStyle::Dotted)).expect("render");
let content = decompress_pdf_streams(&pdf);
assert!(content.contains("1 J"), "dotted uses a round cap");
assert!(
content.contains("[0 8.00] 0 d"),
"dots spaced 2×width centre-to-centre"
);
}
#[test]
fn test_parity_translation_excludes_page_anchored_watermarks() {
let many_paras: String = (0..60)
.map(|i| {
format!(
r#"{{ "kind": {{ "type": "Text", "content": "paragraph {i} body" }}, "style": {{ "marginBottom": 10 }}, "children": [] }}"#
)
})
.collect::<Vec<_>>()
.join(",");
let json = format!(
r#"{{
"defaultPage": {{ "size": {{ "Custom": {{ "width": 400, "height": 400 }} }}, "margin": {{ "top": 40, "right": 50, "bottom": 40, "left": 50 }}, "wrap": true }},
"rightPage": {{ "size": {{ "Custom": {{ "width": 400, "height": 400 }} }}, "margin": {{ "top": 40, "right": 30, "bottom": 40, "left": 70 }}, "wrap": true }},
"leftPage": {{ "size": {{ "Custom": {{ "width": 400, "height": 400 }} }}, "margin": {{ "top": 40, "right": 70, "bottom": 40, "left": 30 }}, "wrap": true }},
"children": [
{{ "kind": {{ "type": "Watermark", "text": "DRAFT", "font_size": 40, "angle": -45 }}, "style": {{}}, "children": [] }},
{many_paras}
],
"metadata": {{}}
}}"#
);
let (_pdf, layout, _warnings) =
forme::render_json_with_layout(&json).expect("parity watermark doc renders");
assert!(
layout.pages.len() >= 2,
"need 2+ pages, got {}",
layout.pages.len()
);
fn find_x(
els: &[forme::layout::ElementInfo],
pred: &dyn Fn(&forme::layout::ElementInfo) -> bool,
) -> Option<f64> {
for e in els {
if pred(e) {
return Some(e.x);
}
if let Some(x) = find_x(&e.children, pred) {
return Some(x);
}
}
None
}
let wm_x_p1 = find_x(&layout.pages[0].elements, &|e| {
e.node_type == "Watermark" || e.kind.contains("Watermark")
})
.expect("watermark on page 1");
let wm_x_p2 = find_x(&layout.pages[1].elements, &|e| {
e.node_type == "Watermark" || e.kind.contains("Watermark")
})
.expect("watermark on page 2");
assert!(
(wm_x_p1 - wm_x_p2).abs() < 0.01,
"watermark is page-anchored and must not translate: page1 x={wm_x_p1}, page2 x={wm_x_p2}"
);
let text_x_p1 =
find_x(&layout.pages[0].elements, &|e| e.node_type == "TextLine").expect("text on page 1");
let text_x_p2 =
find_x(&layout.pages[1].elements, &|e| e.node_type == "TextLine").expect("text on page 2");
assert!(
(text_x_p1 - 70.0).abs() < 0.5,
"page 1 (right) flow x: {text_x_p1}"
);
assert!(
(text_x_p2 - 30.0).abs() < 0.5,
"page 2 (left) flow x: {text_x_p2}"
);
}
fn sentinel_test_doc(extra_document_fields: &str) -> String {
let font_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
include_bytes!("../fonts/NotoSans-Regular.ttf"),
);
format!(
r#"{{
"children": [
{{ "kind": {{ "type": "Fixed", "position": "Footer" }}, "style": {{}}, "children": [
{{ "kind": {{ "type": "Text", "content": "Page {{{{pageNumber}}}} of {{{{totalPages}}}}" }},
"style": {{ "fontFamily": "TestSans", "fontSize": 9 }}, "children": [] }}
]}},
{{ "kind": {{ "type": "Text", "content": "body" }}, "style": {{ "marginBottom": 900 }}, "children": [] }},
{{ "kind": {{ "type": "Text", "content": "second page body" }}, "style": {{}}, "children": [] }}
],
"metadata": {{ "lang": "de-DE" }},
"defaultPage": {{ "size": "A4", "margin": {{ "top": 50, "right": 50, "bottom": 50, "left": 50 }}, "wrap": true }},
"defaultStyle": {{ "fontFamily": "TestSans", "fontSize": 11 }},
"fonts": [{{ "family": "TestSans", "src": "data:font/ttf;base64,{font_b64}", "weight": 400, "italic": false }}]{extra_document_fields}
}}"#
)
}
fn base_fonts(pdf: &[u8]) -> Vec<String> {
let text = String::from_utf8_lossy(pdf);
let mut fonts: Vec<String> = Vec::new();
for m in text.split("/BaseFont /").skip(1) {
let name: String = m
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '+' || *c == ',')
.collect();
if !fonts.contains(&name) {
fonts.push(name);
}
}
fonts
}
#[test]
fn sentinel_page_numbers_stay_in_the_custom_font() {
let pdf = forme::render_json(&sentinel_test_doc("")).expect("renders");
let fonts = base_fonts(&pdf);
assert!(
!fonts.iter().any(|f| f.contains("Helvetica")),
"page-number digits must use the footer's font, not base-14 Helvetica; got {fonts:?}"
);
assert!(
fonts.iter().any(|f| f.contains("TestSans")),
"the custom font must be present; got {fonts:?}"
);
}
#[test]
fn sentinel_digits_are_subset_and_text_mapped() {
let pdf = forme::render_json(&sentinel_test_doc("")).expect("renders");
let streams = decompress_pdf_streams(&pdf);
for digit in ["<0031>", "<0032>"] {
assert!(
streams.contains(digit),
"ToUnicode must map the page-number digits (missing {digit})"
);
}
}
#[test]
fn sentinel_page_numbers_survive_pdfa_and_pdfua() {
let json = sentinel_test_doc(r#", "pdfa": "2a", "pdfUa": true"#);
let result = forme::render_json(&json);
assert!(
result.is_ok(),
"PDF/A + UA with custom-font page numbers must render: {:?}",
result.err()
);
}
#[test]
fn sentinel_run_path_comma_chain_stays_in_first_family() {
let font_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
include_bytes!("../fonts/NotoSans-Regular.ttf"),
);
let json = format!(
r#"{{
"children": [
{{ "kind": {{ "type": "Fixed", "position": "Footer" }}, "style": {{}}, "children": [
{{ "kind": {{ "type": "Text", "content": "", "runs": [
{{ "content": "Page ", "style": {{ "fontFamily": "TestSans, Helvetica" }} }},
{{ "content": "{{{{pageNumber}}}}", "style": {{ "fontFamily": "TestSans, Helvetica" }} }}
] }}, "style": {{ "fontSize": 9 }}, "children": [] }}
]}},
{{ "kind": {{ "type": "Text", "content": "body" }}, "style": {{ "fontFamily": "TestSans", "marginBottom": 900 }}, "children": [] }},
{{ "kind": {{ "type": "Text", "content": "page two" }}, "style": {{ "fontFamily": "TestSans" }}, "children": [] }}
],
"metadata": {{}},
"defaultPage": {{ "size": "A4", "margin": {{ "top": 50, "right": 50, "bottom": 50, "left": 50 }}, "wrap": true }},
"fonts": [{{ "family": "TestSans", "src": "data:font/ttf;base64,{font_b64}", "weight": 400, "italic": false }}]
}}"#
);
let pdf = forme::render_json(&json).expect("renders");
let fonts = base_fonts(&pdf);
assert!(
!fonts.iter().any(|f| f.contains("Helvetica")),
"run-path sentinel must stay in the chain's first family; got {fonts:?}"
);
}
#[test]
fn sentinel_default_font_documents_are_unchanged() {
let json = r#"{
"children": [
{ "kind": { "type": "Fixed", "position": "Footer" }, "style": {}, "children": [
{ "kind": { "type": "Text", "content": "Page {{pageNumber}} of {{totalPages}}" }, "style": { "fontSize": 9 }, "children": [] }
]},
{ "kind": { "type": "Text", "content": "body" }, "style": { "marginBottom": 900 }, "children": [] },
{ "kind": { "type": "Text", "content": "second page body" }, "style": {}, "children": [] }
],
"metadata": {},
"defaultPage": { "size": "A4", "margin": { "top": 50, "right": 50, "bottom": 50, "left": 50 }, "wrap": true }
}"#;
let pdf = forme::render_json(json).expect("renders");
let fonts = base_fonts(&pdf);
assert_eq!(
fonts,
vec!["Helvetica".to_string()],
"default-font documents must keep exactly Helvetica"
);
}
#[test]
fn clip_content_x_wraps_page_content_in_a_clip_path() {
let json = r#"{
"children": [
{ "kind": { "type": "Text", "content": "clipped page" }, "style": {} }
],
"defaultPage": {
"clipContentX": true,
"margin": { "top": 54, "right": 54, "bottom": 54, "left": 54 }
}
}"#;
let bytes = forme::render_json(json).expect("Should parse clip JSON");
assert_valid_pdf(&bytes);
let ops = decompress_pdf_streams(&bytes);
assert!(
ops.contains("54.00 0 487.28 841.89 re W n"),
"clip rect must cover content-box x and full-page y: {ops}"
);
let json_plain = r#"{
"children": [
{ "kind": { "type": "Text", "content": "unclipped page" }, "style": {} }
]
}"#;
let plain_ops = decompress_pdf_streams(&forme::render_json(json_plain).unwrap());
assert!(
!plain_ops.contains("re W n"),
"no clip path without clipContentX"
);
}
#[test]
fn border_or_background_on_text_node_reports_render_defect() {
let json = r#"{
"children": [
{ "kind": { "type": "Text", "content": "ruled" },
"style": { "borderWidth": { "top": 0, "right": 0, "bottom": 1, "left": 0 },
"backgroundColor": { "r": 1, "g": 0, "b": 0, "a": 1 } },
"children": [] }
]
}"#;
let doc: forme::Document = serde_json::from_str(json).expect("parse");
let (_, warnings) = forme::render_with_warnings(&doc).expect("render");
assert!(
warnings
.iter()
.any(|w| w.contains("render defect") && w.contains("border on a text node")),
"border defect must be reported: {warnings:?}"
);
assert!(
warnings
.iter()
.any(|w| w.contains("render defect") && w.contains("background on a text node")),
"background defect must be reported: {warnings:?}"
);
}
#[test]
fn wrap_row_relocating_lines_at_the_boundary_is_not_a_sequential_split() {
let pairs: String = (1..=8)
.map(|i| {
format!(
r#"{{ "kind": {{ "type": "View" }}, "style": {{ "width": {{ "Percent": 40 }}, "height": {{ "Pt": 100 }} }},
"children": [ {{ "kind": {{ "type": "Text", "content": "pair{i} cell" }}, "style": {{}}, "children": [] }} ] }},"#
)
})
.collect();
let pairs = pairs.trim_end_matches(',');
let json = format!(
r#"{{
"children": [
{{ "kind": {{ "type": "View" }}, "style": {{ "height": {{ "Pt": 560 }} }}, "children": [] }},
{{ "kind": {{ "type": "View" }}, "style": {{ "flexDirection": "Row", "flexWrap": "Wrap" }},
"children": [ {pairs} ] }}
],
"metadata": {{}}
}}"#
);
let (_pdf, layout, warnings) = forme::render_json_with_layout(&json).expect("wrap row renders");
fn page_of(layout: &forme::layout::LayoutInfo, needle: &str) -> Option<usize> {
fn has(els: &[forme::layout::ElementInfo], needle: &str) -> bool {
els.iter().any(|e| {
e.text_content
.as_deref()
.is_some_and(|t| t.contains(needle))
|| has(&e.children, needle)
})
}
layout.pages.iter().position(|p| has(&p.elements, needle))
}
assert!(layout.pages.len() > 1, "the row must cross the boundary");
assert_ne!(
page_of(&layout, "pair1 cell"),
page_of(&layout, "pair8 cell"),
"the row's last line must land past its first"
);
assert!(
!warnings.iter().any(|w| w.contains("sequentially")),
"line-by-line wrap relocation is not a sequential split: {warnings:?}"
);
}
#[test]
fn absolute_child_anchors_to_the_first_fragment_of_a_split_parent() {
let filler = r#"
{ "kind": { "type": "Text", "content": "A" }, "style": {}, "children": [] },
{ "kind": { "type": "View" }, "style": { "height": { "Pt": 700 } }, "children": [] },
{ "kind": { "type": "Text", "content": "B" }, "style": {}, "children": [] },
{ "kind": { "type": "View" }, "style": { "height": { "Pt": 700 } }, "children": [] },
{ "kind": { "type": "Text", "content": "C" }, "style": {}, "children": [] }
"#;
let json = format!(
r#"{{
"children": [
{{ "kind": {{ "type": "View" }}, "style": {{ "position": "Relative" }},
"children": [
{{ "kind": {{ "type": "View" }},
"style": {{ "position": "Absolute", "top": 0, "left": 0, "width": {{ "Pt": 80 }} }},
"children": [ {{ "kind": {{ "type": "Text", "content": "anchor badge" }}, "style": {{}}, "children": [] }} ] }},
{filler}
] }}
],
"metadata": {{}}
}}"#
);
let (_pdf, layout, _warnings) =
forme::render_json_with_layout(&json).expect("split positioned parent renders");
fn page_of(layout: &forme::layout::LayoutInfo, needle: &str) -> Option<usize> {
fn has(els: &[forme::layout::ElementInfo], needle: &str) -> bool {
els.iter().any(|e| {
e.text_content
.as_deref()
.is_some_and(|t| t.contains(needle))
|| has(&e.children, needle)
})
}
layout.pages.iter().position(|p| has(&p.elements, needle))
}
assert!(
layout.pages.len() > 1,
"the positioned parent must span pages, got {}",
layout.pages.len()
);
assert_eq!(
page_of(&layout, "anchor badge"),
Some(0),
"top-anchored absolute belongs to the FIRST fragment"
);
}
#[test]
fn multi_style_runs_fall_back_to_builtin_noto_like_single_style_text() {
let json = r#"{
"children": [
{ "kind": { "type": "Text", "content": "", "runs": [
{ "content": "x " },
{ "content": "Жук", "font_weight": 700 }
] }, "style": {}, "children": [] }
],
"metadata": {}
}"#;
let bytes = forme::render_json(json).expect("runs with non-WinAnsi char render");
let pdf_str = String::from_utf8_lossy(&bytes);
assert!(
pdf_str.contains("NotoSans"),
"the runs path must embed builtin Noto Sans for a non-WinAnsi char"
);
}
#[test]
fn unmapped_glyph_reports_a_render_defect_naming_the_character() {
let json = r#"{
"children": [
{ "kind": { "type": "Text", "content": "x ≤ 5" }, "style": {}, "children": [] }
],
"metadata": {}
}"#;
let (_pdf, warnings) = {
let doc: forme::Document = serde_json::from_str(json).expect("parse");
forme::render_with_warnings(&doc).expect("render")
};
let defect = warnings
.iter()
.find(|w| w.starts_with("render defect:") && w.contains("U+2264"));
assert!(
defect.is_some(),
"the '?' substitution must report itself: {warnings:?}"
);
assert!(
defect.unwrap().contains("register a font"),
"the message must point at the fix: {}",
defect.unwrap()
);
}
#[test]
fn covered_text_reports_no_missing_glyph_defect() {
let json = r#"{
"children": [
{ "kind": { "type": "Text", "content": "plain Жук text" }, "style": {}, "children": [] }
],
"metadata": {}
}"#;
let doc: forme::Document = serde_json::from_str(json).expect("parse");
let (_pdf, warnings) = forme::render_with_warnings(&doc).expect("render");
assert!(
!warnings.iter().any(|w| w.contains("rendered as \"?\"")),
"covered text must not warn: {warnings:?}"
);
}
fn doc20(extra: &str) -> String {
format!(
r#"{{ "children": [ {{ "kind": {{ "type": "Text", "content": "hello 2.0" }}, "style": {{ "fontFamily": "Noto Sans" }}, "children": [] }} ],
"metadata": {{ "title": "Two Point Oh" }}, "pdfVersion": "2.0"{extra} }}"#
)
}
fn doc20_base14(extra: &str) -> String {
format!(
r#"{{ "children": [ {{ "kind": {{ "type": "Text", "content": "hello 2.0" }}, "style": {{}}, "children": [] }} ],
"metadata": {{}}, "pdfVersion": "2.0"{extra} }}"#
)
}
#[test]
fn pdf20_header_and_no_trailer_info() {
let bytes = forme::render_json(&doc20("")).expect("2.0 doc renders");
assert!(bytes.starts_with(b"%PDF-2.0"), "header must declare 2.0");
let text = String::from_utf8_lossy(&bytes);
assert!(!text.contains("/Info "), "no trailer /Info in 2.0 output");
assert!(
text.contains("/Metadata"),
"2.0 output must always carry an XMP metadata stream"
);
assert!(
text.contains("Two Point Oh"),
"the title must reach the XMP packet"
);
}
#[test]
fn pdf20_requires_embeddable_fonts() {
let e = forme::render_json(&doc20_base14("")).unwrap_err();
let msg = e.to_string();
assert!(
msg.contains("Helvetica") && msg.contains("fonts-standard") && msg.contains("2.0"),
"must name the font and the remedy: {msg}"
);
}
#[test]
fn pdf20_rejects_17_based_conformance_claims() {
let e = forme::render_json(&doc20(r#", "pdfa": "2b""#)).unwrap_err();
assert!(
e.to_string().contains("PDF/A-2") && e.to_string().contains("2.0"),
"A-2 under 2.0 must error by name: {e}"
);
let e = forme::render_json(&doc20(r#", "pdfa": "3b""#)).unwrap_err();
assert!(
e.to_string().contains("PDF/A-3") && e.to_string().contains("2.0"),
"A-3 under 2.0 must error by name: {e}"
);
let e = forme::render_json(&doc20(r#", "pdfUa": true"#)).unwrap_err();
assert!(
e.to_string().contains("PDF/UA-1") && e.to_string().contains("2.0"),
"UA-1 under 2.0 must error by name: {e}"
);
}
#[test]
fn pdf17_output_is_byte_identical_with_the_version_field_present() {
let plain = r#"{ "children": [ { "kind": { "type": "Text", "content": "hello" }, "style": {}, "children": [] } ], "metadata": {} }"#;
let explicit = r#"{ "children": [ { "kind": { "type": "Text", "content": "hello" }, "style": {}, "children": [] } ], "metadata": {}, "pdfVersion": "1.7" }"#;
let a = forme::render_json(plain).unwrap();
let b = forme::render_json(explicit).unwrap();
assert_eq!(a, b, "explicit 1.7 must be byte-identical to the default");
assert!(a.starts_with(b"%PDF-1.7"));
}
fn doca4(level: &str, extra: &str) -> String {
format!(
r#"{{ "children": [ {{ "kind": {{ "type": "Text", "content": "archival 2.0" }}, "style": {{ "fontFamily": "Noto Sans" }}, "children": [] }} ],
"metadata": {{ "title": "A4 Doc" }}, "pdfa": "{level}"{extra} }}"#
)
}
#[test]
fn pdfa4_identification_and_implied_version() {
let bytes = forme::render_json(&doca4("4", "")).expect("A-4 doc renders");
assert!(bytes.starts_with(b"%PDF-2.0"), "A-4 implies the 2.0 header");
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("<pdfaid:part>4</pdfaid:part>"));
assert!(text.contains("<pdfaid:rev>2020</pdfaid:rev>"));
assert!(
!text.contains("pdfaid:conformance"),
"base A-4 must not provide a conformance property"
);
assert!(!text.contains("/Info "), "no trailer /Info under A-4");
assert!(
text.contains("/OutputIntent"),
"device color needs an output intent"
);
}
#[test]
fn pdfa4f_conformance_f_and_attachments() {
let bytes = forme::render_json(&doca4(
"4f",
r#", "attachments": [ { "name": "data.csv", "src": "YSxiCjEsMg==", "mimeType": "text/csv", "relationship": "Supplement" } ]"#,
))
.expect("A-4f doc with attachment renders");
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("<pdfaid:part>4</pdfaid:part>"));
assert!(text.contains("<pdfaid:conformance>F</pdfaid:conformance>"));
assert!(text.contains("<pdfaid:rev>2020</pdfaid:rev>"));
assert!(
text.contains("/EmbeddedFiles"),
"4f requires the EmbeddedFiles name tree"
);
assert!(text.contains("/AFRelationship"));
}
#[test]
fn pdfa4_base_refuses_attachments_by_name() {
let e = forme::render_json(&doca4(
"4",
r#", "attachments": [ { "name": "data.csv", "src": "YSxiCjEsMg==", "mimeType": "text/csv", "relationship": "Supplement" } ]"#,
))
.unwrap_err();
let msg = e.to_string();
assert!(
msg.contains("4f"),
"the refusal must point at the 4f remedy: {msg}"
);
}
#[test]
fn pdfa4f_without_files_is_refused() {
let e = forme::render_json(&doca4("4f", "")).unwrap_err();
let msg = e.to_string();
assert!(
msg.contains("at least one embedded file") && msg.contains("\"4\""),
"must explain and point at plain A-4: {msg}"
);
}
fn docua2(claims: &str) -> String {
format!(
r##"{{ "children": [
{{ "kind": {{ "type": "Heading", "content": "Report", "level": 1 }}, "style": {{ "fontFamily": "Noto Sans" }}, "children": [] }},
{{ "kind": {{ "type": "Text", "content": "See the appendix.", "href": "https://example.com" }}, "style": {{ "fontFamily": "Noto Sans" }}, "children": [] }},
{{ "kind": {{ "type": "List", "ordered": true, "marker_type": "decimal" }}, "style": {{ "fontFamily": "Noto Sans" }}, "children": [
{{ "kind": {{ "type": "ListItem" }}, "style": {{}}, "children": [ {{ "kind": {{ "type": "Text", "content": "First" }}, "style": {{}}, "children": [] }} ] }}
] }},
{{ "kind": {{ "type": "Table", "columns": [] }}, "style": {{ "fontFamily": "Noto Sans" }}, "children": [
{{ "kind": {{ "type": "TableRow", "is_header": true }}, "style": {{ "backgroundColor": {{ "r": 0.93, "g": 0.93, "b": 0.93, "a": 1.0 }} }}, "children": [
{{ "kind": {{ "type": "TableCell" }}, "style": {{}}, "children": [ {{ "kind": {{ "type": "Text", "content": "Item" }}, "style": {{}}, "children": [] }} ] }},
{{ "kind": {{ "type": "TableCell" }}, "style": {{}}, "children": [ {{ "kind": {{ "type": "Text", "content": "Value" }}, "style": {{}}, "children": [] }} ] }}
] }},
{{ "kind": {{ "type": "TableRow" }}, "style": {{}}, "children": [
{{ "kind": {{ "type": "TableCell", "col_span": 2 }}, "style": {{}}, "children": [ {{ "kind": {{ "type": "Text", "content": "Total" }}, "style": {{}}, "children": [] }} ] }}
] }}
] }},
{{ "kind": {{ "type": "QrCode", "data": "https://example.com", "size": 60 }}, "alt": "QR link", "style": {{}}, "children": [] }}
], "metadata": {{ "title": "UA2 Doc", "lang": "en-US" }}{claims} }}"##
)
}
fn struct_elem_body<'a>(text: &'a str, role_marker: &str) -> &'a str {
let start = text
.find(role_marker)
.unwrap_or_else(|| panic!("no {role_marker} element in output"));
let rest = &text[start..];
let end = rest.find("endobj").expect("dict runs to endobj");
&rest[..end]
}
#[test]
fn pdfua2_identification_and_implied_version() {
let bytes = forme::render_json(&docua2(r#", "pdfUa2": true"#)).expect("UA-2 doc renders");
assert!(
bytes.starts_with(b"%PDF-2.0"),
"UA-2 implies the 2.0 header"
);
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("<pdfuaid:part>2</pdfuaid:part>"));
assert!(text.contains("<pdfuaid:rev>2024</pdfuaid:rev>"));
assert!(text.contains("/ViewerPreferences << /DisplayDocTitle true >>"));
assert!(!text.contains("/Info "), "no trailer /Info under 2.0");
}
#[test]
fn pdfua2_single_document_root_in_20_namespace() {
let bytes = forme::render_json(&docua2(r#", "pdfUa2": true"#)).unwrap();
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("(http://iso.org/pdf2/ssn)"),
"2.0 namespace object"
);
assert!(text.contains("/Namespaces ["), "root carries /Namespaces");
let doc = struct_elem_body(&text, "/S /Document ");
assert!(doc.contains("/NS "), "Document element is in a namespace");
assert!(
!doc.contains("/MCR"),
"<Document> shall not contain content items: {doc}"
);
}
#[test]
fn pdfua2_grouping_elements_contain_no_content_items() {
let bytes = forme::render_json(&docua2(r#", "pdfUa2": true"#)).unwrap();
let text = String::from_utf8_lossy(&bytes);
for marker in ["/S /Table ", "/S /TR ", "/S /L ", "/S /LI "] {
let body = struct_elem_body(&text, marker);
assert!(
!body.contains("/MCR"),
"{marker} element must hold no marked content: {body}"
);
}
let bytes17 = forme::render_json(&docua2(r#", "pdfUa": true"#)).unwrap();
let text17 = String::from_utf8_lossy(&bytes17);
let body17 = struct_elem_body(&text17, "/S /Table ");
assert!(
!body17.contains("/MCR"),
"UA-1 Table holds no content items of its own: {body17}"
);
}
#[test]
fn pdfua2_list_numbering_attribute() {
let bytes = forme::render_json(&docua2(r#", "pdfUa2": true"#)).unwrap();
let text = String::from_utf8_lossy(&bytes);
let l = struct_elem_body(&text, "/S /L ");
assert!(
l.contains("/A << /O /List /ListNumbering /Decimal >>"),
"ordered decimal list declares its numbering: {l}"
);
assert!(text.contains("/S /Lbl"), "marker tags as Lbl");
assert!(text.contains("/S /LBody"), "item content wraps in LBody");
}
#[test]
fn pdfua2_graphics_tag_as_figures() {
let bytes = forme::render_json(&docua2(r#", "pdfUa2": true"#)).unwrap();
let text = String::from_utf8_lossy(&bytes);
let fig = struct_elem_body(&text, "/S /Figure ");
assert!(fig.contains("/Alt (QR link)"), "figure carries alt: {fig}");
let bytes17 = forme::render_json(&docua2(r#", "pdfUa": true"#)).unwrap();
assert!(
!String::from_utf8_lossy(&bytes17).contains("/S /Figure"),
"UA-1 shape keeps the /Div fallback"
);
}
#[test]
fn pdfua2_contradictions_error_by_name() {
let e = forme::render_json(&docua2(r#", "pdfUa2": true, "pdfUa": true"#)).unwrap_err();
assert!(
e.to_string().contains("pdfUa2 contradicts pdfUa"),
"UA-1 x UA-2 must error by name: {e}"
);
let e = forme::render_json(&docua2(r#", "pdfUa2": true, "pdfa": "2a""#)).unwrap_err();
assert!(
e.to_string()
.contains("pdfUa2 contradicts a 1.7-based pdfa level"),
"UA-2 x A-2 must error by name: {e}"
);
}
#[test]
fn pdfua2_composes_with_pdfa4() {
let bytes =
forme::render_json(&docua2(r#", "pdfUa2": true, "pdfa": "4""#)).expect("compose renders");
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("<pdfaid:part>4</pdfaid:part>"));
assert!(text.contains("<pdfuaid:part>2</pdfuaid:part>"));
assert!(text.contains("<pdfuaid:rev>2024</pdfuaid:rev>"));
}
#[test]
fn pdfua2_machine_readable_graphics_carry_actual_text() {
let json = r##"{ "children": [
{ "kind": { "type": "Text", "content": "scan this" }, "style": { "fontFamily": "Noto Sans" }, "children": [] },
{ "kind": { "type": "QrCode", "data": "ABC-123", "size": 60 }, "style": { "fontFamily": "Noto Sans" }, "children": [] }
], "metadata": { "title": "QR Doc", "lang": "en-US" }, "pdfUa2": true }"##;
let bytes = forme::render_json(json).expect("renders");
let text = String::from_utf8_lossy(&bytes);
let fig = struct_elem_body(&text, "/S /Figure ");
assert!(
fig.contains("/ActualText (ABC-123)"),
"encoded data as replacement text: {fig}"
);
let json_alt = json.replace(
r#""style": { "fontFamily": "Noto Sans" }"#,
r#""alt": "a code", "style": { "fontFamily": "Noto Sans" }"#,
);
let bytes = forme::render_json(&json_alt).unwrap();
let text = String::from_utf8_lossy(&bytes);
let fig = struct_elem_body(&text, "/S /Figure ");
assert!(fig.contains("/Alt (a code)") && !fig.contains("/ActualText"));
}
#[test]
fn pdfua2_internal_destinations_are_structure_destinations() {
let json = r##"{ "children": [
{ "kind": { "type": "Heading", "content": "Section One", "level": 2 }, "bookmark": "Section One", "style": { "fontFamily": "Noto Sans" }, "children": [] },
{ "kind": { "type": "Text", "content": "jump", "href": "#Section One" }, "style": { "fontFamily": "Noto Sans" }, "children": [] }
], "metadata": { "title": "Dest Doc", "lang": "en-US" }, "pdfUa2": true }"##;
let bytes = forme::render_json(json).expect("renders");
let text = String::from_utf8_lossy(&bytes);
let outline = struct_elem_body(&text, "/Title (Section One)");
assert!(
outline.contains("/SD ["),
"outline uses a structure destination: {outline}"
);
assert!(
!outline.contains("/Dest ["),
"no plain page destination in ua2 outlines"
);
assert!(!text.contains("999999999"), "SD placeholders all patched");
let goto = &text[text.find("/S /GoTo").expect("internal link action")..];
let goto = &goto[..goto.find(">>").unwrap()];
assert!(goto.contains("/SD ["), "GoTo carries /SD: {goto}");
let bytes17 = forme::render_json(&json.replace("pdfUa2", "pdfUa")).unwrap();
let text17 = String::from_utf8_lossy(&bytes17);
assert!(text17.contains("/Dest [") && !text17.contains("/SD ["));
}
#[test]
fn pdfua2_embedded_filespecs_carry_desc() {
let bytes = forme::render_json(&docua2(
r#", "pdfUa2": true, "pdfa": "4f", "attachments": [ { "name": "data.csv", "src": "YSxiCjEsMg==", "mimeType": "text/csv", "relationship": "Supplement" } ]"#,
))
.expect("4f + ua2 renders");
let text = String::from_utf8_lossy(&bytes);
let fs = struct_elem_body(&text, "/Type /Filespec ");
assert!(
fs.contains("/Desc (data.csv)"),
"Desc falls back to the name: {fs}"
);
}
#[test]
fn pdfua2_link_children_downgrade_to_span() {
let bytes = forme::render_json(&docua2(r#", "pdfUa2": true"#)).unwrap();
let text = String::from_utf8_lossy(&bytes);
let link = struct_elem_body(&text, "/S /Link ");
assert!(!link.contains("/S /P"), "no P inside Link");
let doc = struct_elem_body(&text, "/S /Document ");
assert!(!doc.contains("/S /Span"), "no Span child of Document");
}
#[test]
fn align_items_center_respects_max_width() {
let json = r##"{ "children": [
{ "kind": { "type": "View" }, "style": { "width": { "Pt": 400 }, "alignItems": "Center", "flexDirection": "Column" }, "children": [
{ "kind": { "type": "Text", "content": "a long paragraph of certificate prose that wraps to several lines at the clamped measure" },
"style": { "maxWidth": { "Pt": 200 }, "fontFamily": "Noto Sans" }, "children": [] }
] }
], "metadata": { "title": "Center Doc" } }"##;
let (_, layout, _) = forme::render_with_layout(&serde_json::from_str(json).unwrap()).unwrap();
let page = &layout.pages[0];
fn find_text(els: &[forme::layout::ElementInfo]) -> Option<(f64, f64)> {
for e in els {
if e.kind == "Text" {
return Some((e.x, e.width));
}
if let Some(r) = find_text(&e.children) {
return Some(r);
}
}
None
}
let (x, w) = find_text(&page.elements).expect("text element");
assert!(w <= 200.5, "wraps at the clamp: {w}");
let expected = 54.0 + (400.0 - 200.0) / 2.0;
assert!(
(x - expected).abs() < 1.0,
"block centered: x={x}, expected≈{expected} (w={w})"
);
}
#[test]
fn pdfa_alone_embeds_the_standard_font_substitute() {
use base64::Engine as _;
let font = base64::engine::general_purpose::STANDARD
.encode(include_bytes!("../fonts/NotoSans-Regular.ttf"));
let json = format!(
r#"{{ "children": [
{{ "kind": {{ "type": "Text", "content": "Invoice 2026-001" }}, "style": {{}}, "children": [] }}
],
"metadata": {{ "title": "A3b Doc" }},
"fonts": [{{ "family": "Liberation Sans", "src": "{font}", "weight": 400, "italic": false }}],
"pdfa": "3b" }}"#
);
let bytes = forme::render_json(&json).expect("pdfa 3b + registered substitute renders");
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/FontFile2"),
"the substitute is embedded as a font program"
);
}
#[test]
fn fractional_columns_summing_to_one_do_not_report_a_clamped_table() {
let doc = |width: f64| {
format!(
r#"{{ "children": [
{{ "kind": {{ "type": "View" }}, "style": {{ "width": {{ "Pt": {width} }} }}, "children": [
{{ "kind": {{ "type": "Table", "columns": [
{{ "width": {{ "Fraction": 0.6 }} }}, {{ "width": {{ "Fraction": 0.4 }} }}
] }}, "style": {{}}, "children": [
{{ "kind": {{ "type": "TableRow" }}, "style": {{}}, "children": [
{{ "kind": {{ "type": "TableCell" }}, "style": {{}}, "children": [
{{ "kind": {{ "type": "Text", "content": "left" }}, "style": {{}}, "children": [] }} ] }},
{{ "kind": {{ "type": "TableCell" }}, "style": {{}}, "children": [
{{ "kind": {{ "type": "Text", "content": "right" }}, "style": {{}}, "children": [] }} ] }}
] }}
] }}
] }}
], "metadata": {{ "title": "Fractions" }} }}"#
)
};
for width in [486.75, 487.25, 495.0] {
let (_, _, warnings) =
forme::render_with_layout(&serde_json::from_str(&doc(width)).unwrap()).unwrap();
let clamped: Vec<_> = warnings.iter().filter(|w| w.contains("clamped")).collect();
assert!(
clamped.is_empty(),
"width {width}: fractions summing to 1.0 fit exactly, got {clamped:?}"
);
}
let overfull = doc(487.0).replace("\"Fraction\": 0.6", "\"Fraction\": 0.8");
let (_, _, warnings) =
forme::render_with_layout(&serde_json::from_str(&overfull).unwrap()).unwrap();
assert!(
warnings.iter().any(|w| w.contains("clamped")),
"a 120%-wide table must still report: {warnings:?}"
);
}
#[test]
fn wrapped_flex_rows_also_fragment_as_parallel_columns() {
let tall: String = (0..120)
.map(|i| {
format!(
r#"{{ "kind": {{ "type": "Text", "content": "content line {i}" }}, "style": {{}}, "children": [] }}"#
)
})
.collect::<Vec<_>>()
.join(",");
let json = format!(
r#"{{ "children": [
{{ "kind": {{ "type": "View" }},
"style": {{ "flexDirection": "Row", "flexWrap": "Wrap" }},
"children": [
{{ "kind": {{ "type": "View" }}, "style": {{ "width": {{ "Percent": 33.0 }} }}, "children": [
{{ "kind": {{ "type": "Text", "content": "sidebar heading" }}, "style": {{}}, "children": [] }} ] }},
{{ "kind": {{ "type": "View" }}, "style": {{ "width": {{ "Percent": 67.0 }} }}, "children": [ {tall} ] }}
] }}
], "metadata": {{ "title": "Wrapped" }} }}"#
);
let (_, layout, warnings) =
forme::render_with_layout(&serde_json::from_str(&json).unwrap()).unwrap();
assert!(layout.pages.len() > 1, "the row must actually split");
assert!(
!warnings.iter().any(|w| w.contains("sequentially")),
"a wrapped row no longer splits sequentially, so nothing reports it: {warnings:?}"
);
}
#[test]
fn a_flex_row_continues_as_parallel_columns_across_a_page() {
let para = "Typography is the art of arranging type to make written language legible, \
readable, and appealing when displayed. The arrangement of type involves \
selecting typefaces, point sizes, line lengths, and line spacing. ";
let col = |n: usize| {
(0..n)
.map(|_| {
format!(
r#"{{ "kind": {{ "type": "Text", "content": "{}" }}, "style": {{ "fontSize": 9.0 }}, "children": [] }}"#,
para.repeat(6)
)
})
.collect::<Vec<_>>()
.join(",")
};
let json = format!(
r#"{{ "children": [
{{ "kind": {{ "type": "View" }}, "style": {{ "flexDirection": "Row", "gap": 24.0 }}, "children": [
{{ "kind": {{ "type": "View" }}, "style": {{ "flexGrow": 1.0, "flexBasis": {{ "Pt": 0.0 }} }}, "children": [ {} ] }},
{{ "kind": {{ "type": "View" }}, "style": {{ "flexGrow": 1.0, "flexBasis": {{ "Pt": 0.0 }} }}, "children": [ {} ] }}
] }}
], "metadata": {{ "title": "Two column" }} }}"#,
col(3),
col(3)
);
let (_, layout, warnings) =
forme::render_with_layout(&serde_json::from_str(&json).unwrap()).unwrap();
assert!(layout.pages.len() > 1, "the row must span pages");
let xs = |page: usize| -> Vec<f64> {
fn walk(els: &[forme::layout::ElementInfo], xs: &mut Vec<f64>) {
for e in els {
if e.node_type == "TextLine" {
xs.push(e.x.round());
}
walk(&e.children, xs);
}
}
let mut v = Vec::new();
walk(&layout.pages[page].elements, &mut v);
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
v.dedup();
v
};
let first = xs(0);
assert!(first.len() >= 2, "both columns on page 1: {first:?}");
let (left, right) = (first[0], *first.last().unwrap());
let second = xs(1);
assert!(
second.contains(&left) && second.contains(&right),
"both columns continue on page 2: {second:?} (page 1 had {first:?})"
);
assert!(
!warnings.iter().any(|w| w.contains("sequentially")),
"a fragmented row is not a sequential split: {warnings:?}"
);
}
#[test]
fn a_fragmenting_row_starts_in_the_space_that_is_there() {
let para = "Typography is the art of arranging type to make written language legible, \
readable, and appealing when displayed. The arrangement of type involves \
selecting typefaces, point sizes, line lengths, and line spacing. ";
let col = |n: usize| {
(0..n)
.map(|_| {
format!(
r#"{{ "kind": {{ "type": "Text", "content": "{}" }}, "style": {{ "fontSize": 9.0 }}, "children": [] }}"#,
para.repeat(6)
)
})
.collect::<Vec<_>>()
.join(",")
};
let json = format!(
r#"{{ "children": [
{{ "kind": {{ "type": "Heading", "content": "The Art of Digital Typography", "level": 1 }},
"style": {{ "fontSize": 26.0 }}, "children": [] }},
{{ "kind": {{ "type": "View" }}, "style": {{ "flexDirection": "Row", "gap": 24.0 }}, "children": [
{{ "kind": {{ "type": "View" }}, "style": {{ "flexGrow": 1.0, "flexBasis": {{ "Pt": 0.0 }} }}, "children": [ {} ] }},
{{ "kind": {{ "type": "View" }}, "style": {{ "flexGrow": 1.0, "flexBasis": {{ "Pt": 0.0 }} }}, "children": [ {} ] }}
] }}
], "metadata": {{ "title": "Header over columns" }} }}"#,
col(3),
col(3)
);
let (_, layout, _) = forme::render_with_layout(&serde_json::from_str(&json).unwrap()).unwrap();
let mut kinds = Vec::new();
fn walk(els: &[forme::layout::ElementInfo], out: &mut Vec<(String, f64)>) {
for e in els {
if e.node_type == "TextLine" {
out.push((e.node_type.clone(), e.x.round()));
}
walk(&e.children, out);
}
}
walk(&layout.pages[0].elements, &mut kinds);
let mut xs: Vec<f64> = kinds.iter().map(|(_, x)| *x).collect();
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
xs.dedup();
assert!(
xs.len() >= 2,
"page 1 must carry both columns, not just the header: {xs:?}"
);
let deepest = {
let mut ys: Vec<f64> = Vec::new();
fn walk_y(els: &[forme::layout::ElementInfo], ys: &mut Vec<f64>) {
for e in els {
if e.node_type == "TextLine" {
ys.push(e.y + e.height);
}
walk_y(&e.children, ys);
}
}
walk_y(&layout.pages[0].elements, &mut ys);
ys.into_iter().fold(0.0_f64, f64::max)
};
assert!(
deepest > 500.0,
"page 1 is filled, not abandoned after the header: content ends at y={deepest:.0}"
);
}
#[test]
fn a_stretched_column_fills_the_fragment_it_appears_on() {
let para = "Typography is the art of arranging type to make written language legible \
and readable. The arrangement involves selecting typefaces and sizes. ";
let col = |n: usize| {
(0..n)
.map(|_| {
format!(
r#"{{ "kind": {{ "type": "Text", "content": "{}" }}, "style": {{ "fontSize": 9.0 }}, "children": [] }}"#,
para.repeat(4)
)
})
.collect::<Vec<_>>()
.join(",")
};
let json = format!(
r#"{{ "children": [
{{ "kind": {{ "type": "View" }}, "style": {{ "flexDirection": "Row", "gap": 20.0 }}, "children": [
{{ "kind": {{ "type": "View" }},
"style": {{ "flexGrow": 1.0, "flexBasis": {{ "Pt": 0.0 }},
"backgroundColor": {{ "r": 0.93, "g": 0.94, "b": 0.96, "a": 1.0 }} }},
"children": [ {} ] }},
{{ "kind": {{ "type": "View" }},
"style": {{ "flexGrow": 1.0, "flexBasis": {{ "Pt": 0.0 }},
"backgroundColor": {{ "r": 0.93, "g": 0.94, "b": 0.96, "a": 1.0 }} }},
"children": [ {} ] }}
] }}
], "metadata": {{ "title": "Stretch" }} }}"#,
col(9),
col(2)
);
let (_, layout, _) = forme::render_with_layout(&serde_json::from_str(&json).unwrap()).unwrap();
assert!(layout.pages.len() > 1, "the row must fragment");
let mut boxes: Vec<(f64, f64)> = Vec::new();
for e in &layout.pages[0].elements {
if e.node_type == "View" && e.height > 100.0 {
boxes.push((e.x.round(), e.y + e.height));
}
}
boxes.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
assert!(
boxes.len() >= 2,
"both column boxes are on page 1: {boxes:?}"
);
let (left_bottom, right_bottom) = (boxes[0].1, boxes[boxes.len() - 1].1);
assert!(
(left_bottom - right_bottom).abs() < 1.0,
"a short column stretches to the same fragment bottom as a tall one: \
left ends {left_bottom:.0}, right ends {right_bottom:.0}"
);
}
#[test]
fn the_template_path_reports_the_render_defects_it_hits() {
let tall: String = (0..120)
.map(|i| {
format!(
r#"{{ "kind": {{ "type": "Text", "content": "content line {i}" }}, "style": {{}}, "children": [] }}"#
)
})
.collect::<Vec<_>>()
.join(",");
let json = format!(
r#"{{ "children": [
{{ "kind": {{ "type": "View" }},
"style": {{ "flexDirection": "Row", "flexWrap": "Wrap" }},
"children": [
{{ "kind": {{ "type": "View" }}, "style": {{ "width": {{ "Percent": 33.0 }} }}, "children": [
{{ "kind": {{ "type": "Text", "content": "sidebar heading" }},
"style": {{ "borderWidth": {{ "top": 0, "right": 0, "bottom": 1, "left": 0 }} }},
"children": [] }} ] }},
{{ "kind": {{ "type": "View" }}, "style": {{ "width": {{ "Percent": 67.0 }} }}, "children": [ {tall} ] }}
] }}
], "metadata": {{ "title": "Wrapped" }} }}"#
);
let (_, layout, warnings) = forme::render_template_with_layout(&json, "{}").unwrap();
assert!(layout.pages.len() > 1, "the row must actually cross pages");
assert!(
warnings
.iter()
.any(|w| w.contains("render defect") && w.contains("border on a text node")),
"the template path reports the defect it hit: {warnings:?}"
);
let (_, _, direct) = forme::render_with_layout(&serde_json::from_str(&json).unwrap()).unwrap();
assert_eq!(
warnings, direct,
"the template path's warnings match the direct render's"
);
}
#[test]
fn a_text_node_honours_its_width_constraints_not_only_width() {
let long = "the quick brown fox jumps over the lazy dog and keeps on running";
let width_of = |style: &str| -> f64 {
let json = format!(
r#"{{ "children": [ {{ "kind": {{ "type": "Text", "content": "{long}" }},
"style": {style}, "children": [] }} ] }}"#
);
let (_, layout, _) =
forme::render_with_layout(&serde_json::from_str(&json).unwrap()).unwrap();
layout.pages[0].elements[0].width
};
let unconstrained = width_of("{}");
assert!(
unconstrained > 200.0,
"control: an unconstrained line is wide ({unconstrained:.1}pt)"
);
let capped = width_of(r#"{ "maxWidth": { "Pt": 120.0 } }"#);
assert!(
(capped - 120.0).abs() < 0.5,
"maxWidth clamps a text box: {capped:.1}pt, cap 120pt"
);
let fixed = width_of(r#"{ "width": { "Pt": 150.0 } }"#);
assert!(
(fixed - 150.0).abs() < 0.5,
"an explicit width is unchanged by the clamp: {fixed:.1}pt"
);
let floored = width_of(r#"{ "width": { "Pt": 80.0 }, "minWidth": { "Pt": 300.0 } }"#);
assert!(
(floored - 300.0).abs() < 0.5,
"minWidth floors a text box: {floored:.1}pt, floor 300pt"
);
}
#[test]
fn a_stretched_column_paints_its_band_on_every_page_the_row_crosses() {
let para = "Typography is the art of arranging type to make written language legible \
and readable. The arrangement involves selecting typefaces and sizes. ";
let tall: String = (0..6)
.map(|_| {
format!(
r#"{{ "kind": {{ "type": "Text", "content": "{}" }}, "style": {{ "fontSize": 9.0 }}, "children": [] }}"#,
para.repeat(4)
)
})
.collect::<Vec<_>>()
.join(",");
let json = format!(
r#"{{ "children": [
{{ "kind": {{ "type": "View" }}, "style": {{ "flexDirection": "Row", "gap": 20.0 }}, "children": [
{{ "kind": {{ "type": "View" }},
"style": {{ "flexGrow": 1.0, "flexBasis": {{ "Pt": 0.0 }},
"backgroundColor": {{ "r": 0.93, "g": 0.94, "b": 0.96, "a": 1.0 }} }},
"children": [ {tall} ] }},
{{ "kind": {{ "type": "View" }},
"style": {{ "flexGrow": 1.0, "flexBasis": {{ "Pt": 0.0 }},
"backgroundColor": {{ "r": 0.96, "g": 0.93, "b": 0.93, "a": 1.0 }} }},
"children": [
{{ "kind": {{ "type": "Text", "content": "One short paragraph." }},
"style": {{ "fontSize": 9.0 }}, "children": [] }} ] }}
] }}
] }}"#
);
let (_, layout, _) = forme::render_with_layout(&serde_json::from_str(&json).unwrap()).unwrap();
assert!(layout.pages.len() >= 2, "the row must cross a page");
let page2 = &layout.pages[1];
let mut bands = 0;
fn walk(el: &forme::layout::ElementInfo, bands: &mut i32) {
if el.style.background_color.is_some() && el.width > 100.0 && el.height > 10.0 {
*bands += 1;
}
for c in &el.children {
walk(c, bands);
}
}
for el in &page2.elements {
walk(el, &mut bands);
}
assert_eq!(
bands, 2,
"both columns paint a band on page 2, not just the one with content"
);
}
#[test]
fn a_list_that_crosses_a_page_does_not_panic() {
let items: Vec<String> = (0..30)
.map(|i| {
format!(
r#"{{ "kind": {{ "type": "ListItem" }}, "style": {{}}, "children": [
{{ "kind": {{ "type": "Text", "content": "Item {i}. {}" }},
"style": {{ "fontSize": 11.0 }}, "children": [] }} ] }}"#,
"word ".repeat(40)
)
})
.collect();
let json = format!(
r#"{{ "children": [ {{ "kind": {{ "type": "List", "ordered": false,
"marker_type": "disc", "start": 1 }}, "style": {{}},
"children": [ {} ] }} ] }}"#,
items.join(",")
);
let (_, layout, _) = forme::render_with_layout(&serde_json::from_str(&json).unwrap()).unwrap();
assert!(
layout.pages.len() >= 2,
"the list must actually cross a page for this to test anything"
);
let mut items_seen = 0;
fn walk(el: &forme::layout::ElementInfo, n: &mut usize) {
if el.node_type == "ListItem" {
*n += 1;
}
for c in &el.children {
walk(c, n);
}
}
for p in &layout.pages {
for el in &p.elements {
walk(el, &mut items_seen);
}
}
assert_eq!(
items_seen, 30,
"all 30 list items are present after the break"
);
}
fn placements_of(layout: &forme::layout::LayoutInfo, needle: &str) -> Vec<(usize, f64)> {
fn walk(
els: &[forme::layout::ElementInfo],
needle: &str,
page: usize,
out: &mut Vec<(usize, f64)>,
) {
for e in els {
if e.text_content
.as_deref()
.is_some_and(|t| t.trim() == needle)
{
out.push((page, e.x));
}
walk(&e.children, needle, page, out);
}
}
let mut out = Vec::new();
for (i, p) in layout.pages.iter().enumerate() {
walk(&p.elements, needle, i, &mut out);
}
out
}
#[test]
fn a_grid_that_spans_pages_keeps_its_columns_in_the_same_places() {
let cells: String = (1..=9)
.map(|i| {
format!(
r#"{{ "kind": {{ "type": "View" }}, "style": {{ "height": {{ "Pt": 200 }} }},
"children": [ {{ "kind": {{ "type": "Text", "content": "cell{i}" }}, "style": {{}}, "children": [] }} ] }},"#
)
})
.collect();
let json = format!(
r#"{{
"children": [
{{ "kind": {{ "type": "View" }}, "style": {{ "height": {{ "Pt": 500 }} }}, "children": [] }},
{{ "kind": {{ "type": "View" }},
"style": {{ "display": "Grid",
"gridTemplateColumns": [{{ "Fr": 1.0 }}, {{ "Fr": 1.0 }}, {{ "Fr": 1.0 }}] }},
"children": [ {} ] }}
],
"metadata": {{}}
}}"#,
cells.trim_end_matches(',')
);
let (_pdf, layout, _w) = forme::render_json_with_layout(&json).expect("grid renders");
assert!(
layout.pages.len() > 1,
"the grid must span pages for this test to mean anything, got {} page(s)",
layout.pages.len()
);
let col_x: Vec<f64> = (1..=3)
.map(|i| placements_of(&layout, &format!("cell{i}"))[0].1)
.collect();
assert!(
col_x[0] < col_x[1] && col_x[1] < col_x[2],
"columns must be distinct and ordered: {col_x:?}"
);
let mut pages_seen = std::collections::BTreeSet::new();
for i in 1..=9 {
let places = placements_of(&layout, &format!("cell{i}"));
assert_eq!(places.len(), 1, "cell{i} must be laid out exactly once");
let (page, x) = places[0];
pages_seen.insert(page);
let expected = col_x[(i - 1) % 3];
assert!(
(x - expected).abs() < 0.01,
"cell{i} is in column {} on page {page}: x {x} != {expected}",
(i - 1) % 3
);
}
assert!(
pages_seen.len() > 1,
"the grid's own cells must land on more than one page, not just the document"
);
}
#[test]
fn a_table_that_breaks_keeps_its_cells_in_their_columns() {
let rows: String = (1..=80)
.map(|i| {
format!(
r#"{{ "kind": {{ "type": "TableRow", "is_header": false }}, "style": {{}}, "children": [
{{ "kind": {{ "type": "TableCell", "col_span": 1, "row_span": 1 }}, "style": {{}},
"children": [ {{ "kind": {{ "type": "Text", "content": "a{i}" }}, "style": {{}}, "children": [] }} ] }},
{{ "kind": {{ "type": "TableCell", "col_span": 1, "row_span": 1 }}, "style": {{}},
"children": [ {{ "kind": {{ "type": "Text", "content": "b{i}" }}, "style": {{}}, "children": [] }} ] }},
{{ "kind": {{ "type": "TableCell", "col_span": 1, "row_span": 1 }}, "style": {{}},
"children": [ {{ "kind": {{ "type": "Text", "content": "c{i}" }}, "style": {{}}, "children": [] }} ] }}
] }},"#
)
})
.collect();
let json = format!(
r#"{{
"children": [
{{ "kind": {{ "type": "Table", "columns": [] }}, "style": {{}},
"children": [ {} ] }}
],
"metadata": {{}}
}}"#,
rows.trim_end_matches(',')
);
let (_pdf, layout, _w) = forme::render_json_with_layout(&json).expect("table renders");
assert!(
layout.pages.len() > 1,
"the table must break for this test to mean anything, got {} page(s)",
layout.pages.len()
);
let cols = ["a", "b", "c"];
let col_x: Vec<f64> = cols
.iter()
.map(|c| placements_of(&layout, &format!("{c}1"))[0].1)
.collect();
assert!(
col_x[0] < col_x[1] && col_x[1] < col_x[2],
"columns must be distinct and ordered: {col_x:?}"
);
let mut rows_past_first_page = 0;
for i in 1..=80 {
for (c, prefix) in cols.iter().enumerate() {
let places = placements_of(&layout, &format!("{prefix}{i}"));
assert_eq!(places.len(), 1, "{prefix}{i} must be laid out exactly once");
let (page, x) = places[0];
if page > 0 && c == 0 {
rows_past_first_page += 1;
}
assert!(
(x - col_x[c]).abs() < 0.01,
"{prefix}{i} left its column on page {page}: x {x} != {}",
col_x[c]
);
}
}
assert!(
rows_past_first_page > 0,
"rows must actually continue past the first page"
);
}
#[test]
fn an_absolute_child_of_a_split_containing_block_is_emitted_once() {
let filler: String = (1..=4)
.map(|i| {
let lines: String = (1..=45)
.map(|j| {
format!(
r#"{{ "kind": {{ "type": "Text", "content": "block{i} line {j}" }}, "style": {{}}, "children": [] }},"#
)
})
.collect();
format!(
r#"{{ "kind": {{ "type": "View" }}, "style": {{}}, "children": [ {} ] }},"#,
lines.trim_end_matches(',')
)
})
.collect();
let json = format!(
r#"{{
"children": [
{{ "kind": {{ "type": "View" }}, "style": {{ "position": "Relative" }},
"children": [
{{ "kind": {{ "type": "View" }},
"style": {{ "position": "Absolute", "top": 0, "left": 0, "width": {{ "Pt": 80 }} }},
"children": [ {{ "kind": {{ "type": "Text", "content": "badge" }}, "style": {{}}, "children": [] }} ] }},
{}
] }}
],
"metadata": {{}}
}}"#,
filler.trim_end_matches(',')
);
let (_pdf, layout, _w) = forme::render_json_with_layout(&json).expect("renders");
assert!(
layout.pages.len() > 2,
"the containing block must span several pages, got {} page(s)",
layout.pages.len()
);
let blocks_on_later_pages = (1..=4)
.filter(|i| placements_of(&layout, &format!("block{i} line 1"))[0].0 > 0)
.count();
assert!(
blocks_on_later_pages >= 2,
"the block's own children must continue past page 0, not just the document"
);
let badge = placements_of(&layout, "badge");
assert_eq!(
badge.len(),
1,
"the badge must be emitted exactly once, not once per fragment; found on pages {:?}",
badge.iter().map(|(p, _)| *p).collect::<Vec<_>>()
);
assert_eq!(
badge[0].0, 0,
"and on the containing block's first fragment"
);
}
#[test]
fn a_fixed_height_box_that_does_not_fit_fragments_across_the_boundary() {
let json = r#"{ "children": [
{ "kind": { "type": "View" }, "style": { "height": { "Pt": 600 } }, "children": [] },
{ "kind": { "type": "View" },
"style": { "height": { "Pt": 500 },
"backgroundColor": { "r": 1, "g": 0, "b": 0, "a": 1 } },
"children": [ { "kind": { "type": "Text", "content": "inside" },
"style": {}, "children": [] } ] }
], "metadata": {} }"#;
let (_pdf, layout, _w) = forme::render_json_with_layout(json).expect("renders");
assert_eq!(
layout.pages.len(),
2,
"500pt of box under a 600pt spacer has to reach a second page"
);
fn band(els: &[forme::layout::ElementInfo]) -> Option<&forme::layout::ElementInfo> {
els.iter()
.find(|e| e.node_type == "View" && e.style.background_color.is_some() && e.height > 1.0)
}
let first = band(&layout.pages[0].elements)
.expect("the box must still paint on the page it starts on; losing it is the defect");
let second = band(&layout.pages[1].elements)
.expect("and must continue on the next page rather than stopping at the boundary");
let total = first.height + second.height;
assert!(
(total - 500.0).abs() < 0.5,
"the fragments must sum to the declared height: {:.1} + {:.1} = {total:.1}, not 500",
first.height,
second.height
);
assert!(
(first.height - 133.9).abs() < 1.0,
"the first fragment fills the space that was left: {:.1} != 133.9",
first.height
);
assert!(
second.y < first.y,
"the continuation starts at the top of its page"
);
fn has_text(els: &[forme::layout::ElementInfo], needle: &str) -> bool {
els.iter().any(|e| {
e.text_content
.as_deref()
.is_some_and(|t| t.trim() == needle)
|| has_text(&e.children, needle)
})
}
assert!(
has_text(&layout.pages[0].elements, "inside"),
"the text belongs at the top of the first fragment"
);
assert!(
!has_text(&layout.pages[1].elements, "inside"),
"and must not be duplicated onto the continuation"
);
}
#[test]
fn an_ordered_list_that_crosses_a_page_keeps_counting() {
let items: String = (1..=60)
.map(|i| {
format!(
r#"{{ "kind": {{ "type": "ListItem" }}, "style": {{}},
"children": [ {{ "kind": {{ "type": "Text", "content": "item{i}" }}, "style": {{}}, "children": [] }} ] }},"#
)
})
.collect();
let json = format!(
r#"{{ "children": [
{{ "kind": {{ "type": "List", "ordered": true, "marker_type": "decimal", "start": 1 }},
"style": {{}}, "children": [ {} ] }}
], "metadata": {{}} }}"#,
items.trim_end_matches(',')
);
let (_pdf, layout, _w) = forme::render_json_with_layout(&json).expect("list renders");
assert!(
layout.pages.len() > 1,
"60 items must cross a page for this test to mean anything, got {} page(s)",
layout.pages.len()
);
fn markers(els: &[forme::layout::ElementInfo], out: &mut Vec<String>) {
for e in els {
if let Some(t) = e.text_content.as_deref() {
let t = t.trim();
if t.ends_with('.') && t[..t.len() - 1].chars().all(|c| c.is_ascii_digit()) {
out.push(t.to_string());
}
}
markers(&e.children, out);
}
}
let mut seen = Vec::new();
for p in &layout.pages {
markers(&p.elements, &mut seen);
}
let expected: Vec<String> = (1..=60).map(|i| format!("{i}.")).collect();
assert_eq!(
seen, expected,
"the numbering must run 1 through 60 unbroken across the page boundary"
);
let mut first_page_markers = Vec::new();
markers(&layout.pages[0].elements, &mut first_page_markers);
assert!(
!first_page_markers.is_empty() && first_page_markers.len() < 60,
"the list must be split, not relocated: {} of 60 markers on page 1",
first_page_markers.len()
);
}
#[test]
fn a_too_tall_item_overflows_a_fixed_height_row_equally() {
let json = r#"{ "children": [
{ "kind": { "type": "View" },
"style": { "height": { "Pt": 16 }, "flexDirection": "Row", "alignItems": "Center",
"padding": { "top": 2, "right": 8, "bottom": 2, "left": 8 },
"borderWidth": { "top": 2, "right": 2, "bottom": 2, "left": 2 } },
"children": [ { "kind": { "type": "Text", "content": "+14.2% QoQ" },
"style": { "fontSize": 9, "fontWeight": 600 }, "children": [] } ] }
], "metadata": {} }"#;
let (_pdf, layout, _w) = forme::render_json_with_layout(json).expect("renders");
let badge = &layout.pages[0].elements[0];
assert!(
(badge.height - 16.0).abs() < 0.01,
"the box keeps its declared height: {}",
badge.height
);
let text = badge
.children
.iter()
.find(|c| c.node_type == "Text")
.expect("the text child");
let content_top = badge.y + 4.0;
let content_bottom = badge.y + badge.height - 4.0;
let content_height = content_bottom - content_top;
assert!(
text.height > content_height,
"precondition: the text ({:.2}pt) must be TALLER than the content box \
({content_height:.2}pt), or there is no overspill to centre",
text.height
);
let above = content_top - text.y;
let below = (text.y + text.height) - content_bottom;
assert!(
(above - below).abs() < 0.01,
"the overspill must be equal on both sides: {above:.2}pt above the \
content box, {below:.2}pt below. Sitting at the content-box top \
(above = 0) is the defect."
);
assert!(
above > 0.0,
"and it must actually overflow, not be clamped to the top"
);
}
#[test]
fn a_flex_item_margin_is_free_space_a_grow_sibling_cannot_take() {
let json = r#"{ "children": [
{ "kind": { "type": "View" },
"style": { "flexDirection": "Row", "justifyContent": "SpaceBetween",
"alignItems": "Center", "width": { "Pt": 500 } },
"children": [
{ "kind": { "type": "View" }, "style": { "flexGrow": 1, "flexDirection": "Column" },
"children": [ { "kind": { "type": "Text", "content": "title" }, "style": {}, "children": [] } ] },
{ "kind": { "type": "View" },
"style": { "width": { "Pt": 48 }, "height": { "Pt": 48 }, "flexShrink": 0,
"margin": { "top": 0, "right": 0, "bottom": 0, "left": 16 } },
"children": [] }
] }
], "metadata": {} }"#;
let (_pdf, layout, _w) = forme::render_json_with_layout(json).expect("renders");
let row = &layout.pages[0].elements[0];
let row_right = row.x + row.width;
let grower = &row.children[0];
let fixed = &row.children[1];
assert!(
(fixed.x + fixed.width) <= row_right + 0.01,
"the fixed item must stay inside the row: its right edge {:.2} is past \
the row's {row_right:.2}, by exactly the margin the grow sibling ate",
fixed.x + fixed.width
);
assert!(
(grower.width - (500.0 - 48.0 - 16.0)).abs() < 0.01,
"the grow item takes the free space LESS its sibling's margin: {:.2} != 436",
grower.width
);
}
fn tounicode_destinations(pdf: &[u8]) -> Vec<String> {
let text = decompress_pdf_streams(pdf);
let mut out = Vec::new();
let mut rest = text.as_str();
while let Some(start) = rest.find("beginbfchar") {
let body_start = start + "beginbfchar".len();
let Some(end) = rest[body_start..].find("endbfchar") else {
break;
};
let body = &rest[body_start..body_start + end];
let tokens: Vec<&str> = body
.split(['<', '>'])
.map(str::trim)
.filter(|t| !t.is_empty())
.collect();
for pair in tokens.chunks(2) {
if let [_, dst] = pair {
let units: Vec<u16> = (0..dst.len())
.step_by(4)
.map(|i| u16::from_str_radix(&dst[i..i + 4], 16).unwrap())
.collect();
out.push(String::from_utf16(&units).unwrap());
}
}
rest = &rest[body_start + end..];
}
out
}
fn noto_sans_bytes() -> Vec<u8> {
std::fs::read(concat!(
env!("CARGO_MANIFEST_DIR"),
"/fonts/NotoSans-Regular.ttf"
))
.expect("engine/fonts/NotoSans-Regular.ttf is in the repo")
}
fn render_with_ligature_font(kind: &str, style: &str) -> Vec<u8> {
let font_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
noto_sans_bytes(),
);
let json = format!(
r#"{{
"children": [{{ "kind": {kind}, "style": {style}, "children": [] }}],
"metadata": {{}},
"defaultPage": {{
"size": "A4",
"margin": {{ "top": 54, "right": 54, "bottom": 54, "left": 54 }},
"wrap": true
}},
"fonts": [{{
"family": "Lig",
"src": "data:font/ttf;base64,{font_b64}",
"weight": 400,
"italic": false
}}]
}}"#
);
forme::render_json(&json).expect("renders")
}
#[test]
fn test_ligature_glyphs_map_to_every_char_in_tounicode() {
let font = noto_sans_bytes();
for word in ["office", "field", "flow"] {
let glyphs = forme::text::shaping::shape_text(word, &font).unwrap();
assert!(
glyphs.len() < word.chars().count(),
"precondition: Noto Sans must ligate {word:?}, got {} glyphs for {} chars",
glyphs.len(),
word.chars().count()
);
}
let text = "office field flow";
let cases = [
(
"single-style",
format!(r#"{{ "type": "Text", "content": "{text}" }}"#),
r#"{ "fontFamily": "Lig", "fontSize": 14 }"#,
),
(
"fallback-chain",
format!(r#"{{ "type": "Text", "content": "{text}" }}"#),
r#"{ "fontFamily": "Lig, Helvetica", "fontSize": 14 }"#,
),
(
"runs",
r##"{ "type": "Text", "content": "", "runs": [
{ "content": "office ", "style": { "fontFamily": "Lig", "fontSize": 14 } },
{ "content": "field flow", "style": { "fontFamily": "Lig", "fontSize": 14, "color": { "r": 0.8, "g": 0.0, "b": 0.0, "a": 1.0 } } }
] }"##
.to_string(),
r#"{ "fontFamily": "Lig", "fontSize": 14 }"#,
),
];
for (name, kind, style) in cases {
let pdf = render_with_ligature_font(&kind, style);
let dests = tounicode_destinations(&pdf);
assert!(
!dests.is_empty(),
"{name}: expected a ToUnicode CMap with bfchar entries"
);
for lig in ["ffi", "fi", "fl"] {
assert!(
dests.iter().any(|d| d == lig),
"{name}: the {lig:?} ligature glyph must map to all of {lig:?} in \
the ToUnicode CMap, or text extraction drops characters \
(\"office\" -> \"ofice\"). Destinations: {dests:?}"
);
}
}
}
#[test]
fn fixed_and_watermark_content_inherits_document_default_style() {
let json = r##"{
"defaultStyle": {
"fontFamily": "Courier", "fontSize": 9,
"color": { "r": 0.8, "g": 0.0, "b": 0.0, "a": 1.0 }
},
"children": [
{ "kind": { "type": "Page", "config": { "size": "A4",
"margin": { "top": 54, "right": 54, "bottom": 54, "left": 54 }, "wrap": true } },
"style": {},
"children": [
{ "kind": { "type": "Fixed", "position": "Header" }, "style": {},
"children": [ { "kind": { "type": "Text", "content": "header text" }, "style": {}, "children": [] } ] },
{ "kind": { "type": "Fixed", "position": "Footer" }, "style": {},
"children": [ { "kind": { "type": "Text", "content": "footer {{pageNumber}}" }, "style": {}, "children": [] } ] },
{ "kind": { "type": "Watermark", "text": "DRAFT", "font_size": 60, "angle": -45 },
"style": { "color": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 }, "opacity": 0.1 },
"children": [] },
{ "kind": { "type": "Text", "content": "body text" }, "style": {}, "children": [] }
] }
],
"metadata": {}
}"##;
let doc: Document = serde_json::from_str(json).expect("valid document JSON");
let pages = layout_doc(&doc);
assert_eq!(pages.len(), 1);
type Line = (String, String, f64, Option<(f64, f64, f64)>, f64);
fn collect(els: &[forme::layout::LayoutElement], out: &mut Vec<Line>) {
for el in els {
let lines = match &el.draw {
forme::layout::DrawCommand::Text { lines, .. } => Some(lines),
forme::layout::DrawCommand::Watermark { lines, .. } => Some(lines),
_ => None,
};
for line in lines.into_iter().flatten() {
let text: String = line.glyphs.iter().map(|g| g.char_value).collect();
let g = line.glyphs.first().expect("a painted line has glyphs");
out.push((
text,
g.font_family.to_string(),
g.font_size,
g.color.map(|c| (c.r, c.g, c.b)),
el.y + el.height,
));
}
collect(&el.children, out);
}
}
let mut lines = Vec::new();
collect(&pages[0].elements, &mut lines);
let find = |needle: &str| -> Line {
lines
.iter()
.find(|(t, ..)| t.contains(needle))
.unwrap_or_else(|| panic!("no painted line containing {needle:?} in {lines:?}"))
.clone()
};
let red = (0.8, 0.0, 0.0);
let body = find("body text");
assert_eq!(
(body.1.as_str(), body.2, body.3),
("Courier", 9.0, Some(red)),
"control: the body inherits the document default style"
);
for needle in ["header text", "footer"] {
let line = find(needle);
assert_eq!(
(line.1.as_str(), line.2, line.3),
("Courier", 9.0, Some(red)),
"{needle:?} must inherit the document font, size and colour like the body"
);
}
let wm = find("DRAFT");
assert_eq!(
wm.1.as_str(),
"Courier",
"watermark inherits the document font family"
);
assert_eq!(wm.2, 60.0, "watermark keeps its own font size");
let content_bottom = pages[0].height - 54.0;
let footer_bottom = find("footer").4;
assert!(
footer_bottom <= content_bottom + 0.01,
"footer paints past the band reserved for it: bottom {footer_bottom:.2} > {content_bottom:.2}"
);
}
fn decode_text_string_after(haystack: &[u8], key: &str) -> Option<(String, bool)> {
let pos = haystack
.windows(key.len())
.position(|w| w == key.as_bytes())?;
let mut rest = &haystack[pos + key.len()..];
while rest.first() == Some(&b' ') {
rest = &rest[1..];
}
match rest.first()? {
b'<' => {
let end = rest.iter().position(|&b| b == b'>')?;
let hex = std::str::from_utf8(&rest[1..end]).ok()?;
let bytes: Vec<u8> = (0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
.collect();
assert_eq!(
&bytes[..2],
&[0xFE, 0xFF],
"a hex text string must start with the UTF-16BE BOM"
);
let units: Vec<u16> = bytes[2..]
.chunks(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
Some((String::from_utf16(&units).ok()?, true))
}
b'(' => {
let mut out = String::new();
let mut i = 1;
while rest[i] != b')' {
if rest[i] == b'\\' {
i += 1;
}
out.push(rest[i] as char);
i += 1;
}
Some((out, false))
}
_ => None,
}
}
fn info_dict_bytes(bytes: &[u8]) -> &[u8] {
let p = bytes
.windows(9)
.position(|w| w == b"/Producer")
.expect("an /Info dictionary with /Producer");
let start = bytes[..p]
.windows(3)
.rposition(|w| w == b"obj")
.expect("object header before /Producer");
let end = p + bytes[p..]
.windows(6)
.position(|w| w == b"endobj")
.expect("endobj after /Producer");
&bytes[start..end]
}
#[test]
fn docinfo_non_ascii_text_is_utf16be_with_bom() {
let title = "Übertragungsurkunde";
let author = "Zoë Brontë 𝄞";
let subject = "Größe (Ω)";
let json = format!(
r#"{{ "children": [ {{ "kind": {{ "type": "Text", "content": "Hello" }}, "style": {{}}, "children": [] }} ],
"metadata": {{ "title": "{title}", "author": "{author}", "subject": "{subject}" }} }}"#
);
let bytes = forme::render_json(&json).expect("renders");
let info = info_dict_bytes(&bytes);
for (key, want) in [
("/Title", title),
("/Author", author),
("/Subject", subject),
] {
let (got, hex) = decode_text_string_after(info, key)
.unwrap_or_else(|| panic!("{key} missing from /Info"));
assert!(
hex,
"{key} must be a <FEFF...> UTF-16BE string, got the literal {got:?}"
);
assert_eq!(got, want, "{key} must decode back to the input");
}
assert!(
!info.windows(2).any(|w| w == "Ü".as_bytes()),
"no raw UTF-8 may remain in /Info"
);
}
#[test]
fn docinfo_ascii_text_stays_an_escaped_literal() {
let json = r#"{ "children": [ { "kind": { "type": "Text", "content": "Hello" }, "style": {}, "children": [] } ],
"metadata": { "title": "Report (Q3) \\ final" } }"#;
let bytes = forme::render_json(json).expect("renders");
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains(r"/Title (Report \(Q3\) \\ final)"),
"an ASCII title must stay an escaped literal"
);
}
#[test]
fn outline_non_ascii_title_is_utf16be_with_bom() {
let json = r#"{ "children": [
{ "kind": { "type": "Text", "content": "Kapitel" }, "bookmark": "Übersicht", "style": {}, "children": [] }
], "metadata": {} }"#;
let bytes = forme::render_json(json).expect("renders");
let (got, hex) = decode_text_string_after(&bytes, "/Title").expect("an outline item /Title");
assert!(hex, "a non-ASCII outline title must be a <FEFF...> string");
assert_eq!(got, "Übersicht");
}
#[test]
fn docinfo_producer_and_creator_match_xmp() {
let json = r#"{ "children": [ { "kind": { "type": "Text", "content": "Hello" }, "style": { "fontFamily": "Noto Sans" }, "children": [] } ],
"metadata": { "title": "Übertragungsurkunde" }, "pdfa": "2b" }"#;
let bytes = forme::render_json(json).expect("PDF/A-2b renders");
let info = info_dict_bytes(&bytes);
let text = String::from_utf8_lossy(&bytes);
let xmp_value = |open: &str| -> String {
let s = text
.find(open)
.unwrap_or_else(|| panic!("XMP {open} missing"))
+ open.len();
let e = s + text[s..].find('<').unwrap();
text[s..e].to_string()
};
let (producer, _) = decode_text_string_after(info, "/Producer").unwrap();
assert_eq!(
producer,
xmp_value("<pdf:Producer>"),
"/Producer must equal pdf:Producer"
);
let (creator, _) = decode_text_string_after(info, "/Creator").unwrap();
assert_eq!(
creator,
xmp_value("<xmp:CreatorTool>"),
"/Creator must equal xmp:CreatorTool"
);
let (title, _) = decode_text_string_after(info, "/Title").unwrap();
assert_eq!(
title,
xmp_value(r#"<rdf:li xml:lang="x-default">"#),
"/Title must equal dc:title"
);
}
fn assert_utf16_text(hay: &[u8], key: &str, want: &str) {
let (got, hex) =
decode_text_string_after(hay, key).unwrap_or_else(|| panic!("{key} not found"));
assert!(
hex,
"{key} must be a <FEFF...> UTF-16BE string, got the literal {got:?}"
);
assert_eq!(got, want, "{key} must decode back to the input");
}
#[test]
fn structure_alt_non_ascii_is_utf16be() {
let json = r#"{ "children": [
{ "kind": { "type": "Svg", "width": 50, "height": 50, "content": "<rect width=\"50\" height=\"50\" fill=\"red\"/>" },
"alt": "Grünes Quadrat", "style": {}, "children": [] }
], "metadata": {}, "tagged": true }"#;
let bytes = forme::render_json(json).expect("renders");
assert_utf16_text(&bytes, "/Alt", "Grünes Quadrat");
}
#[test]
fn form_field_text_strings_non_ascii_are_utf16be() {
let json = r#"{ "children": [
{ "kind": { "type": "TextField", "name": "Straße", "width": 200, "height": 24,
"value": "Müller", "font_size": 12, "multiline": false, "password": false, "read_only": false },
"style": {}, "children": [] },
{ "kind": { "type": "Dropdown", "name": "größe", "width": 150, "height": 24,
"options": ["Klein", "Groß"], "value": "Groß", "font_size": 12, "read_only": false },
"style": {}, "children": [] }
], "metadata": {} }"#;
let bytes = forme::render_json(json).expect("renders");
let text = String::from_utf8_lossy(&bytes);
let find = |needle: &[u8]| bytes.windows(needle.len()).position(|w| w == needle);
let tx = find(b"/FT /Tx").expect("text field widget");
let tx = &bytes[tx..];
assert_utf16_text(tx, "/T ", "Straße");
assert_utf16_text(tx, "/V ", "Müller");
assert_utf16_text(tx, "/DV ", "Müller");
let ch = find(b"/FT /Ch").expect("choice widget");
let ch = &bytes[ch..];
assert_utf16_text(ch, "/T ", "größe");
assert_utf16_text(ch, "/V ", "Groß");
let opt = text[text.find("/Opt [").unwrap()..].to_string();
assert!(
opt.starts_with("/Opt [(Klein) <FEFF"),
"ASCII option stays a literal, non-ASCII becomes UTF-16BE: {}",
opt.chars().take(40).collect::<String>()
);
}
#[test]
fn attachment_desc_and_uf_non_ascii_are_utf16be() {
let json = r#"{ "children": [ { "kind": { "type": "Text", "content": "x" }, "style": {}, "children": [] } ],
"metadata": {},
"attachments": [ { "name": "Übersicht.csv", "src": "YSxiCjEsMg==", "mimeType": "text/csv", "description": "Monatsübersicht" } ] }"#;
let bytes = forme::render_json(json).expect("renders");
assert_utf16_text(&bytes, "/UF", "Übersicht.csv");
assert_utf16_text(&bytes, "/Desc", "Monatsübersicht");
}
#[test]
fn link_contents_non_ascii_is_utf16be() {
let internal = r##"{ "children": [
{ "kind": { "type": "Text", "content": "jump", "href": "#Übersicht" }, "style": {} },
{ "kind": { "type": "PageBreak" } },
{ "kind": { "type": "View" }, "bookmark": "Übersicht", "children": [ { "kind": { "type": "Text", "content": "target" } } ] }
] }"##;
let bytes = forme::render_json(internal).expect("renders");
assert_utf16_text(&bytes, "/F 4 /Contents", "Link to Übersicht");
let external = r#"{ "children": [
{ "kind": { "type": "Text", "content": "go", "href": "https://example.com/straße" }, "style": {} }
] }"#;
let bytes = forme::render_json(external).expect("renders");
assert_utf16_text(&bytes, "/F 4 /Contents", "https://example.com/straße");
assert!(
String::from_utf8_lossy(&bytes).contains("/URI (https://example.com/stra"),
"/URI stays a literal byte string"
);
}
#[test]
fn signature_text_strings_non_ascii_are_utf16be() {
let (cert_pem, key_pem) = generate_test_cert_and_key();
let unsigned_pdf = render_to_pdf(&default_doc(vec![make_text("sig", 12.0)]));
let config = forme::CertificationConfig {
certificate_pem: cert_pem,
private_key_pem: key_pem,
reason: Some("Geprüft".to_string()),
location: Some("Zürich".to_string()),
contact: Some("jürgen@example.com".to_string()),
visible: false,
x: None,
y: None,
width: None,
height: None,
};
let signed = forme::certify_pdf(&unsigned_pdf, &config).unwrap();
assert_utf16_text(&signed, "/Reason", "Geprüft");
assert_utf16_text(&signed, "/Location", "Zürich");
assert_utf16_text(&signed, "/ContactInfo", "jürgen@example.com");
}
#[test]
fn docinfo_emitted_for_subject_alone() {
let json = r#"{ "children": [ { "kind": { "type": "Text", "content": "x" }, "style": {}, "children": [] } ],
"metadata": { "subject": "Quarterly numbers" } }"#;
let bytes = forme::render_json(json).expect("renders");
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/Subject (Quarterly numbers)"),
"a subject-only document must still write /Info"
);
assert!(text.contains("/Info "), "the trailer must reference /Info");
}
fn justified_paragraph(text: Node) -> Document {
default_doc(vec![Node {
kind: NodeKind::View,
style: Style {
width: Some(Dimension::Pt(220.0)),
..Default::default()
},
children: vec![text],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}])
}
fn tw_at_each_show(stream: &str) -> Vec<f64> {
let mut stack = vec![0.0_f64];
let mut shown = Vec::new();
for line in stream.lines() {
let t = line.trim();
if t == "q" {
let top = *stack.last().unwrap();
stack.push(top);
} else if t == "Q" {
stack.pop();
if stack.is_empty() {
stack.push(0.0);
}
} else if let Some(v) = t.strip_suffix(" Tw") {
*stack.last_mut().unwrap() = v.trim().parse().unwrap();
} else if t.ends_with(" Tj") || t.ends_with(" TJ") {
shown.push(*stack.last().unwrap());
}
}
shown
}
#[test]
fn test_justified_last_line_is_drawn_with_natural_word_spacing() {
let doc = justified_paragraph(Node {
kind: NodeKind::Text {
content: "The quick brown fox jumps over the lazy dog and keeps running across the field until the end.".to_string(),
href: None,
runs: vec![],
},
style: Style {
font_size: Some(11.0),
text_align: Some(TextAlign::Justify),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
});
let stream = decompress_pdf_streams(&render_to_pdf(&doc));
let tw = tw_at_each_show(&stream);
assert!(tw.len() >= 3, "expected a multi-line paragraph, got {tw:?}");
assert!(
tw[..tw.len() - 1].iter().all(|w| *w > 0.0),
"precondition: the stretched lines carry word spacing, got {tw:?}"
);
assert_eq!(
*tw.last().unwrap(),
0.0,
"the last line must be drawn at natural word spacing, got {tw:?}"
);
}
#[test]
fn test_text_after_a_justified_paragraph_has_no_word_spacing() {
let justified = Node {
kind: NodeKind::Text {
content: "The quick brown fox jumps over the lazy dog and keeps running across the field until the end.".to_string(),
href: None,
runs: vec![],
},
style: Style {
font_size: Some(11.0),
text_align: Some(TextAlign::Justify),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
};
let doc = default_doc(vec![Node {
kind: NodeKind::View,
style: Style {
width: Some(Dimension::Pt(220.0)),
..Default::default()
},
children: vec![
justified,
make_text("A plain left-aligned line after it.", 11.0),
],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}]);
let stream = decompress_pdf_streams(&render_to_pdf(&doc));
let tw = tw_at_each_show(&stream);
assert_eq!(*tw.last().unwrap(), 0.0, "got {tw:?}");
}
#[test]
fn test_justified_multi_style_line_reaches_the_right_edge() {
let run = |content: &str, weight: u32| TextRun {
content: content.to_string(),
style: Style {
font_weight: Some(weight),
text_decoration: Some(TextDecoration::Underline),
..Default::default()
},
href: None,
};
let doc = justified_paragraph(Node {
kind: NodeKind::Text {
content: String::new(),
href: None,
runs: vec![
run("The quick brown fox jumps ", 400),
run(
"over the lazy dog and keeps running across the field until the end.",
700,
),
],
},
style: Style {
font_size: Some(11.0),
text_align: Some(TextAlign::Justify),
..Default::default()
},
children: vec![],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
});
let stream = decompress_pdf_streams(&render_to_pdf(&doc));
let mut segs: Vec<(f64, f64)> = Vec::new(); let lines: Vec<&str> = stream.lines().collect();
for w in lines.windows(2) {
if let (Some(m), Some(l)) = (w[0].strip_suffix(" m"), w[1].strip_suffix(" l")) {
let m: Vec<f64> = m
.split_whitespace()
.filter_map(|v| v.parse().ok())
.collect();
let l: Vec<f64> = l
.split_whitespace()
.filter_map(|v| v.parse().ok())
.collect();
if m.len() == 2 && l.len() == 2 && (m[1] - l[1]).abs() < 0.01 {
segs.push((l[1], l[0]));
}
}
}
let mut ys: Vec<f64> = segs.iter().map(|s| s.0).collect();
ys.sort_by(|a, b| b.partial_cmp(a).unwrap());
ys.dedup_by(|a, b| (*a - *b).abs() < 0.01);
assert!(
ys.len() >= 3,
"precondition: a multi-line paragraph, got {segs:?}"
);
let on = |y: f64| -> Vec<f64> {
segs.iter()
.filter(|s| (s.0 - y).abs() < 0.01)
.map(|s| s.1)
.collect()
};
assert!(
on(ys[0]).len() >= 2,
"precondition: two style groups on line one, got {segs:?}"
);
for y in &ys[..ys.len() - 1] {
let end = on(*y).into_iter().fold(f64::MIN, f64::max);
assert!(
(end - 274.0).abs() < 0.5,
"a justified line's underline should end at the right edge 274, ended at {end:.2} (all: {segs:?})"
);
}
}
#[test]
fn test_inline_link_rects_on_justified_lines_match_the_text() {
let runs = r#"{ "content": "The quick brown fox jumps over the lazy dog and keeps running across the field until the end.", "href": "https://example.com/j" }"#;
let style = r#", "width": { "Pt": 220 }, "textAlign": "Justify""#;
let bytes = forme::render_json(&inline_link_json(style, runs, None, false)).unwrap();
let mut links = parse_link_annotations(&bytes);
assert!(links.len() >= 3, "a rect per line, got {links:?}");
links.sort_by(|a, b| b.rect[1].partial_cmp(&a.rect[1]).unwrap());
for l in &links[..links.len() - 1] {
assert!(
(l.rect[2] - 274.0).abs() < 0.5,
"a justified line's link rect should end at 274, got {:?}",
l.rect
);
}
for l in &links {
assert!(l.rect[2] <= 274.0 + 0.5, "rect past the box: {:?}", l.rect);
}
}
#[test]
fn test_tagged_inline_link_text_is_inside_the_link_element() {
let bytes = forme::render_json(&inline_link_json("", SEE_DOCS_NOW, None, true)).unwrap();
let stream = decompress_pdf_streams(&bytes);
let bdc = stream
.find("/Link <</MCID ")
.expect("inline link text opens /Link marked content");
let mcid: u32 = stream[bdc + 14..]
.split(|c: char| !c.is_ascii_digit())
.next()
.unwrap()
.parse()
.unwrap();
let emc = bdc
+ stream[bdc..]
.find("EMC")
.expect("/Link marked content closes");
let inside = &stream[bdc..emc];
assert!(
inside.contains("(docs) Tj"),
"the link text is inside /Link: {inside}"
);
assert!(
!inside.contains("See") && !inside.contains("now"),
"only the link text is inside /Link: {inside}"
);
let text = String::from_utf8_lossy(&bytes);
let link = text
.split("/S /Link")
.nth(1)
.expect("a /Link structure element");
let link = &link[..link.find("endobj").unwrap()];
assert!(
link.contains(&format!("/MCID {mcid}")),
"/Link lists its text's MCID {mcid}: {link}"
);
assert!(link.contains("/OBJR"), "/Link lists its annotation: {link}");
}
#[test]
fn test_tagged_inline_link_wrapping_lines_stays_balanced() {
let runs = r#"
{ "content": "See the " },
{ "content": "sit amet consectetur adipiscing", "href": "https://example.com/wrap" },
{ "content": " elit." }"#;
let style = r#", "width": { "Pt": 110 }"#;
let bytes = forme::render_json(&inline_link_json(style, runs, None, true)).unwrap();
let stream = decompress_pdf_streams(&bytes);
let count = |pat: &str| stream.lines().filter(|l| l.trim() == pat).count();
let opens = stream
.lines()
.filter(|l| l.ends_with(" BDC") || l.ends_with(" BMC"))
.count();
assert_eq!(opens, count("EMC"), "marked content balanced:\n{stream}");
assert_eq!(count("BT"), count("ET"), "text objects balanced:\n{stream}");
let text = String::from_utf8_lossy(&bytes);
let links: Vec<&str> = text
.split("/S /Link")
.skip(1)
.map(|c| &c[..c.find("endobj").unwrap()])
.collect();
assert!(links.len() >= 2, "one /Link per line: {links:?}");
for l in &links {
assert!(
l.contains("/MCID") && l.contains("/OBJR"),
"text and annotation: {l}"
);
}
assert_eq!(
stream.matches("/Link <</MCID ").count(),
links.len(),
"each /Link element has one marked-content sequence"
);
}
fn visual_order(content: &str, direction: Direction) -> String {
visual_order_in(content, direction, false)
}
fn visual_order_in(content: &str, direction: Direction, runs: bool) -> String {
let font = std::fs::read("../packages/fonts-standard/fonts/LiberationSans-Regular.ttf")
.expect("Liberation Sans in the repo");
let mut font_context = FontContext::new();
font_context
.registry_mut()
.register("Liberation", 400, false, font);
let mut text = make_text(content, 16.0);
if runs {
text.kind = NodeKind::Text {
content: String::new(),
href: None,
runs: vec![TextRun {
content: content.to_string(),
style: Style::default(),
href: None,
}],
};
}
text.style.font_family = Some("Liberation".to_string());
text.style.direction = Some(direction);
let doc = default_doc(vec![text]);
let pages = LayoutEngine::new().layout(&doc, &font_context);
fn glyphs(els: &[forme::layout::LayoutElement], out: &mut Vec<(f64, char)>) {
for el in els {
if let forme::layout::DrawCommand::Text { lines, .. } = &el.draw {
for line in lines {
for g in &line.glyphs {
out.push((line.x + g.x_offset, g.char_value));
}
}
}
glyphs(&el.children, out);
}
}
let mut out = Vec::new();
glyphs(&pages[0].elements, &mut out);
out.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
out.into_iter().map(|(_, c)| c).collect()
}
#[test]
fn test_rtl_text_is_drawn_in_visual_order() {
for dir in [Direction::Rtl, Direction::Auto, Direction::Ltr] {
assert_eq!(
visual_order("שלום עולם", dir),
"םלוע םולש",
"a Hebrew line ({dir:?} paragraph) reads right to left"
);
}
}
#[test]
fn test_rtl_phrase_inside_ltr_text_is_reversed_in_place() {
assert_eq!(
visual_order("Total: שלום עולם today", Direction::Ltr),
"Total: םלוע םולש today"
);
}
#[test]
fn test_rtl_paragraph_with_embedded_ltr_matches_chrome_order() {
assert_eq!(
visual_order("המחיר הוא 120 שקלים עבור ABC בלבד", Direction::Rtl),
"דבלב ABC רובע םילקש 120 אוה ריחמה"
);
}
#[test]
fn test_styled_runs_reorder_like_plain_text() {
let cases = [
("שלום עולם", Direction::Rtl, "םלוע םולש"),
(
"Total: שלום עולם today",
Direction::Ltr,
"Total: םלוע םולש today",
),
(
"המחיר הוא 120 שקלים עבור ABC בלבד",
Direction::Rtl,
"דבלב ABC רובע םילקש 120 אוה ריחמה",
),
("123 שלום עולם", Direction::Rtl, "םלוע םולש 123"),
];
for (content, dir, expected) in cases {
assert_eq!(
visual_order_in(content, dir, false),
expected,
"plain text: {content:?}"
);
assert_eq!(
visual_order_in(content, dir, true),
expected,
"styled runs: {content:?}"
);
}
}
fn linked_view_with_text_runs(outer: &str, inner: &str) -> Document {
let doc_json = format!(
r#"{{
"children": [{{
"kind": {{ "type": "View" }}, "href": "{outer}", "style": {{}},
"children": [{{
"kind": {{ "type": "Text", "content": "", "runs": [
{{ "content": "Read the " }},
{{ "content": "docs", "href": "{inner}" }},
{{ "content": " here." }}
] }},
"style": {{}}, "children": []
}}]
}}],
"metadata": {{}}
}}"#
);
serde_json::from_str(&doc_json).expect("document JSON")
}
#[test]
fn test_a_link_nested_inside_a_different_link_warns() {
let doc = linked_view_with_text_runs("https://example.com/card", "https://example.com/docs");
let (pdf, warnings) = forme::render_with_warnings(&doc).expect("render");
let links = parse_link_annotations(&pdf);
assert_eq!(
links.len(),
1,
"only the outer link is clickable: {links:?}"
);
assert_eq!(links[0].uri.as_deref(), Some("https://example.com/card"));
assert!(
warnings.iter().any(|w| w.starts_with("render defect:")
&& w.contains("https://example.com/docs")
&& w.contains("https://example.com/card")),
"the dropped inner link is reported, got {warnings:?}"
);
}
#[test]
fn test_a_link_nested_inside_the_same_link_does_not_warn() {
let doc = linked_view_with_text_runs("https://example.com/card", "https://example.com/card");
let (_, warnings) = forme::render_with_warnings(&doc).expect("render");
assert!(
!warnings.iter().any(|w| w.contains("nested")),
"no loss, no warning: {warnings:?}"
);
}
fn liberation_lines(node: Node) -> Vec<(f64, f64, Vec<forme::layout::PositionedGlyph>)> {
let font = std::fs::read("../packages/fonts-standard/fonts/LiberationSans-Regular.ttf")
.expect("Liberation Sans in the repo");
let mut font_context = FontContext::new();
font_context
.registry_mut()
.register("Liberation", 400, false, font);
let doc = default_doc(vec![node]);
let pages = LayoutEngine::new().layout(&doc, &font_context);
fn walk(
els: &[forme::layout::LayoutElement],
out: &mut Vec<(f64, f64, Vec<forme::layout::PositionedGlyph>)>,
) {
for el in els {
if let forme::layout::DrawCommand::Text { lines, .. } = &el.draw {
for l in lines {
let right = l
.glyphs
.iter()
.filter(|g| g.char_value != ' ')
.map(|g| l.x + g.x_offset + g.x_advance)
.fold(f64::MIN, f64::max);
out.push((l.x, right, l.glyphs.clone()));
}
}
walk(&el.children, out);
}
}
let mut out = Vec::new();
walk(&pages[0].elements, &mut out);
out
}
fn liberation_justified_paragraph() -> Node {
let mut text = make_text(
"The quick brown fox jumps over the lazy dog and keeps running across the field until the end.",
11.0,
);
text.style.font_family = Some("Liberation".to_string());
text.style.text_align = Some(TextAlign::Justify);
Node {
kind: NodeKind::View,
style: Style {
width: Some(Dimension::Pt(220.0)),
..Default::default()
},
children: vec![text],
id: None,
source_location: None,
bookmark: None,
href: None,
alt: None,
}
}
#[test]
fn test_shaped_justified_lines_reach_the_edge_in_layout() {
let lines = liberation_lines(liberation_justified_paragraph());
assert!(lines.len() >= 3, "a multi-line paragraph");
for (_, right, _) in &lines[..lines.len() - 1] {
assert!(
(right - 274.0).abs() < 0.05,
"a justified line ends at the 274pt edge, got {right:.2}"
);
}
}
fn liberation_pdf(node: Node) -> Vec<u8> {
use base64::Engine as _;
let font = std::fs::read("../packages/fonts-standard/fonts/LiberationSans-Regular.ttf")
.expect("Liberation Sans in the repo");
let mut doc = default_doc(vec![node]);
doc.fonts = vec![FontEntry {
family: "Liberation".into(),
src: base64::engine::general_purpose::STANDARD.encode(&font),
weight: 400,
italic: false,
}];
render_to_pdf(&doc)
}
fn drawn_line_ends(pdf: &[u8]) -> Vec<(f64, f64)> {
let raw = String::from_utf8_lossy(pdf);
let w_at = raw.find("/W [").expect("a CIDFont /W array") + 4;
let w_src = &raw[w_at..w_at + raw[w_at..].find(" ]").unwrap()];
let nums: Vec<f64> = w_src
.replace(['[', ']'], " ")
.split_whitespace()
.map(|n| n.parse().unwrap())
.collect();
let widths: std::collections::HashMap<u16, f64> =
nums.chunks(2).map(|c| (c[0] as u16, c[1])).collect();
let stream = decompress_pdf_streams(pdf);
let (mut size, mut tc, mut x, mut y) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64);
let mut out = Vec::new();
let mut ink_end = f64::MIN;
let mut in_bt = false;
let show = |hex: &str, x: &mut f64, ink_end: &mut f64, size: f64, tc: f64| {
for i in (0..hex.len()).step_by(4) {
let gid = u16::from_str_radix(&hex[i..i + 4], 16).unwrap();
let w = widths.get(&gid).copied().unwrap_or(0.0) * size / 1000.0;
*x += w;
*ink_end = x.max(*ink_end);
*x += tc;
}
};
for line in stream.lines() {
let t = line.trim();
if t == "BT" {
in_bt = true;
ink_end = f64::MIN;
x = 0.0;
y = 0.0;
} else if t == "ET" {
if in_bt && ink_end > f64::MIN {
out.push((y, ink_end));
}
in_bt = false;
} else if let Some(v) = t.strip_suffix(" Tc") {
tc = v.trim().parse().unwrap();
} else if t.ends_with(" Tf") {
size = t.split_whitespace().nth(1).unwrap().parse().unwrap();
} else if let Some(v) = t.strip_suffix(" Td") {
let p: Vec<f64> = v.split_whitespace().map(|n| n.parse().unwrap()).collect();
x += p[0];
y += p[1];
} else if let Some(v) = t.strip_suffix(" Tj") {
let hex = v.trim().trim_start_matches('<').trim_end_matches('>');
let mut pen = x;
show(hex, &mut pen, &mut ink_end, size, tc);
} else if let Some(v) = t.strip_suffix(" TJ") {
let body = v.trim().trim_start_matches('[').trim_end_matches(']');
let mut pen = x;
let mut rest = body;
while !rest.trim().is_empty() {
let r = rest.trim_start();
if let Some(after) = r.strip_prefix('<') {
let end = after.find('>').unwrap();
show(&after[..end], &mut pen, &mut ink_end, size, tc);
rest = &after[end + 1..];
} else {
let end = r
.find(|c: char| c == '<' || c.is_whitespace())
.unwrap_or(r.len());
let n: f64 = r[..end].parse().unwrap();
pen -= n * size / 1000.0;
rest = &r[end..];
}
}
}
}
out
}
#[test]
fn test_registered_font_kerning_is_drawn_where_laid_out() {
let mut text = make_text("AVAWAY To Tomorrow Wave LTA", 24.0);
text.style.font_family = Some("Liberation".to_string());
let laid_out = liberation_lines(text.clone())[0].1;
let drawn = drawn_line_ends(&liberation_pdf(text));
assert_eq!(drawn.len(), 1, "one text object: {drawn:?}");
assert!(
(drawn[0].1 - laid_out).abs() < 0.05,
"drawn to {:.2}pt, laid out to {laid_out:.2}pt",
drawn[0].1
);
}
#[test]
fn test_registered_font_justification_is_drawn_to_the_edge() {
let drawn = drawn_line_ends(&liberation_pdf(liberation_justified_paragraph()));
assert!(drawn.len() >= 3, "{drawn:?}");
let mut by_line = drawn.clone();
by_line.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
for (_, end) in &by_line[..by_line.len() - 1] {
assert!(
(end - 274.0).abs() < 0.05,
"drawn to {end:.2}pt of 274pt: {by_line:?}"
);
}
}
#[test]
fn test_registered_font_text_without_adjustments_keeps_tj() {
let mut text = make_text("minimum", 12.0);
text.style.font_family = Some("Liberation".to_string());
let stream = decompress_pdf_streams(&liberation_pdf(text));
assert!(stream.contains("> Tj"), "plain Tj:\n{stream}");
assert!(!stream.contains(" TJ"), "no TJ array:\n{stream}");
}
#[test]
fn test_rtl_marks_keep_the_shapers_offsets() {
let font =
std::fs::read("../packages/fonts-standard/fonts/LiberationSans-Regular.ttf").unwrap();
let face = ttf_parser::Face::parse(&font, 0).unwrap();
let scale = 40.0 / face.units_per_em() as f64;
let mut shaped = forme::text::shaping::shape_text_with_direction("שָׁלוֹם", &font, true).unwrap();
shaped.reverse();
let mut pen = 0.0;
let expected: Vec<(u16, f64)> = shaped
.iter()
.map(|g| {
let x = pen + g.x_offset as f64 * scale;
pen += g.x_advance as f64 * scale;
(g.glyph_id, x)
})
.collect();
assert!(
shaped.iter().any(|g| g.x_advance == 0 && g.x_offset != 0),
"precondition: a zero-width mark the shaper offsets"
);
let mut text = make_text("שָׁלוֹם", 40.0);
text.style.font_family = Some("Liberation".to_string());
text.style.direction = Some(Direction::Rtl);
let lines = liberation_lines(text);
let got: Vec<(u16, f64)> = lines[0]
.2
.iter()
.map(|g| (g.glyph_id, g.x_offset))
.collect();
let start = got[0].1 - expected[0].1;
assert_eq!(
got.len(),
expected.len(),
"same glyphs: {got:?} vs {expected:?}"
);
for ((gid, x), (egid, ex)) in got.iter().zip(&expected) {
assert_eq!(gid, egid, "glyph order: {got:?} vs {expected:?}");
assert!(
(x - start - ex).abs() < 0.01,
"glyph {gid} at {x:.2}, shaper puts it at {:.2}: {got:?}",
ex + start
);
}
}
#[test]
fn test_registered_font_vertical_mark_offsets_are_drawn() {
let mut text = make_text("q\u{323}\u{307}", 40.0);
text.style.font_family = Some("Liberation".to_string());
let glyphs = &liberation_lines(text.clone())[0].2;
let rise = glyphs
.iter()
.map(|g| g.y_offset)
.find(|y| y.abs() > 0.001)
.expect("precondition: the shaper lowers a mark");
let stream = decompress_pdf_streams(&liberation_pdf(text));
let rises: Vec<f64> = stream
.lines()
.filter_map(|l| l.trim().strip_suffix(" Ts"))
.map(|v| v.trim().parse().unwrap())
.collect();
assert!(rises.iter().any(|r| (r - rise).abs() < 0.01));
assert_eq!(rises.last().copied(), Some(0.0), "text rise must reset");
}
#[test]
fn test_redaction_finds_text_drawn_with_tj_where_it_is_drawn() {
let mut text = make_text("AVAWAY Secret Tomorrow", 24.0);
text.style.font_family = Some("Liberation".to_string());
let (line_x, _, glyphs) = liberation_lines(text.clone()).remove(0);
let secret_x = line_x
+ glyphs
.iter()
.find(|g| g.char_value == 'S')
.expect("the S of Secret")
.x_offset;
let pdf = liberation_pdf(text);
assert!(
decompress_pdf_streams(&pdf).contains(" TJ"),
"precondition: drawn with TJ"
);
let regions = forme::find_text_regions(
&pdf,
&[RedactionPattern {
pattern: "Secret".into(),
pattern_type: PatternType::Literal,
page: None,
color: None,
}],
)
.expect("search");
assert_eq!(regions.len(), 1, "one match: {regions:?}");
assert!(
(regions[0].x - secret_x).abs() < 0.5,
"region starts at {:.2}, Secret is drawn at {secret_x:.2}",
regions[0].x
);
}
#[test]
fn test_registered_font_cid_map_is_only_split_for_conflicting_text() {
let plain = render_with_ligature_font(
r#"{ "type": "Text", "content": "office field flow" }"#,
r#"{ "fontFamily": "Lig", "fontSize": 14 }"#,
);
assert!(String::from_utf8_lossy(&plain).contains("/CIDToGIDMap /Identity"));
use base64::Engine;
let font = std::fs::read("tests/fixtures/fonts/NotoSansDevanagari-Regular.ttf").unwrap();
let doc = serde_json::json!({
"fonts": [{ "family": "Indic", "src": base64::engine::general_purpose::STANDARD.encode(font) }],
"children": [{ "kind": { "type": "Text", "content": "क कि की" }, "style": { "fontFamily": "Indic" } }]
});
let split = forme::render_json(&doc.to_string()).unwrap();
assert!(!String::from_utf8_lossy(&split).contains("/CIDToGIDMap /Identity"));
}
#[test]
fn test_surplus_glyphs_have_a_unicode_mapping() {
use base64::Engine;
let font = std::fs::read("tests/fixtures/fonts/NotoNaskhArabic-Regular.ttf").unwrap();
let text = "بًا";
let shaped = forme::text::shaping::shape_text_with_direction(text, &font, true).unwrap();
assert!(
shaped.len() > text.chars().count(),
"fixture must produce surplus glyphs"
);
let doc = serde_json::json!({
"fonts": [{ "family": "Arabic", "src": base64::engine::general_purpose::STANDARD.encode(font) }],
"children": [{ "kind": { "type": "Text", "content": text }, "style": { "fontFamily": "Arabic", "direction": "rtl" } }]
});
let pdf = forme::render_json(&doc.to_string()).unwrap();
assert!(tounicode_destinations(&pdf)
.iter()
.any(|text| text == "\u{200B}"));
}