mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-02 02:49:16 +00:00
refactor & improvements
This commit is contained in:
@@ -9,8 +9,8 @@ crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
dreport-core = { path = "../core" }
|
||||
taffy = "0.7"
|
||||
cosmic-text = { version = "0.12", default-features = false, features = ["std", "swash"] }
|
||||
taffy = "0.9"
|
||||
cosmic-text = { version = "0.18", default-features = false, features = ["std", "swash"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rxing = { version = "0.8", default-features = false, features = ["encoding_rs"] }
|
||||
|
||||
@@ -130,7 +130,7 @@ fn render_text_cosmic(
|
||||
buffer.set_size(&mut font_system, Some(img_w as f32), Some(text_h as f32));
|
||||
|
||||
let attrs = Attrs::new().family(Family::SansSerif);
|
||||
buffer.set_text(&mut font_system, text, attrs, Shaping::Advanced);
|
||||
buffer.set_text(&mut font_system, text, &attrs, Shaping::Advanced, None);
|
||||
buffer.shape_until_scroll(&mut font_system, false);
|
||||
|
||||
let mut swash_cache = SwashCache::new();
|
||||
|
||||
@@ -125,7 +125,13 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_path() {
|
||||
fn test_resolve_path_simple() {
|
||||
let data: Value = serde_json::json!({"name": "test"});
|
||||
assert_eq!(value_to_string(resolve_path(&data, "name")), "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_path_nested() {
|
||||
let data: Value = serde_json::json!({
|
||||
"firma": {
|
||||
"unvan": "Acme A.Ş.",
|
||||
@@ -140,10 +146,30 @@ mod tests {
|
||||
value_to_string(resolve_path(&data, "firma.vergiNo")),
|
||||
"123"
|
||||
);
|
||||
assert_eq!(
|
||||
value_to_string(resolve_path(&data, "nonexistent.path")),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_path_missing() {
|
||||
let data: Value = serde_json::json!({"name": "test"});
|
||||
let result = resolve_path(&data, "nonexistent.path");
|
||||
assert!(result.is_null());
|
||||
assert_eq!(value_to_string(result), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_path_deep_missing() {
|
||||
let data: Value = serde_json::json!({"a": {"b": 42}});
|
||||
let result = resolve_path(&data, "a.b.c.d");
|
||||
assert!(result.is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_value_to_string_types() {
|
||||
assert_eq!(value_to_string(&serde_json::json!("hello")), "hello");
|
||||
assert_eq!(value_to_string(&serde_json::json!(42)), "42");
|
||||
assert_eq!(value_to_string(&serde_json::json!(3.14)), "3.14");
|
||||
assert_eq!(value_to_string(&serde_json::json!(true)), "true");
|
||||
assert_eq!(value_to_string(&serde_json::json!(null)), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -158,4 +184,261 @@ mod tests {
|
||||
assert!(arr.is_array());
|
||||
assert_eq!(arr.as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_template_text_binding() {
|
||||
let template = Template {
|
||||
id: "t1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![
|
||||
TemplateElement::Text(TextElement {
|
||||
id: "el_name".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
style: TextStyle::default(),
|
||||
content: None,
|
||||
binding: ScalarBinding { path: "firma.unvan".to_string() },
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let data = serde_json::json!({
|
||||
"firma": { "unvan": "Acme Teknoloji A.Ş." }
|
||||
});
|
||||
|
||||
let resolved = resolve_template(&template, &data);
|
||||
assert_eq!(
|
||||
resolved.texts.get("el_name").unwrap(),
|
||||
"Acme Teknoloji A.Ş."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_template_text_with_prefix() {
|
||||
let template = Template {
|
||||
id: "t1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![
|
||||
TemplateElement::Text(TextElement {
|
||||
id: "el_no".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
style: TextStyle::default(),
|
||||
content: Some("Fatura No: ".to_string()),
|
||||
binding: ScalarBinding { path: "fatura.no".to_string() },
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let data = serde_json::json!({
|
||||
"fatura": { "no": "FTR-001" }
|
||||
});
|
||||
|
||||
let resolved = resolve_template(&template, &data);
|
||||
assert_eq!(
|
||||
resolved.texts.get("el_no").unwrap(),
|
||||
"Fatura No: FTR-001"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_template_static_text() {
|
||||
let template = Template {
|
||||
id: "t1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
style: TextStyle::default(),
|
||||
content: "FATURA".to_string(),
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let resolved = resolve_template(&template, &serde_json::json!({}));
|
||||
assert_eq!(resolved.texts.get("title").unwrap(), "FATURA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_template_table_binding() {
|
||||
let template = Template {
|
||||
id: "t1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![
|
||||
TemplateElement::RepeatingTable(RepeatingTableElement {
|
||||
id: "tbl".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
data_source: ArrayBinding { path: "kalemler".to_string() },
|
||||
columns: vec![
|
||||
TableColumn {
|
||||
id: "col_adi".to_string(),
|
||||
field: "adi".to_string(),
|
||||
title: "Urun Adi".to_string(),
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
align: "left".to_string(),
|
||||
format: None,
|
||||
},
|
||||
TableColumn {
|
||||
id: "col_tutar".to_string(),
|
||||
field: "tutar".to_string(),
|
||||
title: "Tutar".to_string(),
|
||||
width: SizeValue::Fixed { value: 30.0 },
|
||||
align: "right".to_string(),
|
||||
format: None,
|
||||
},
|
||||
],
|
||||
style: TableStyle::default(),
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let data = serde_json::json!({
|
||||
"kalemler": [
|
||||
{ "adi": "Widget", "tutar": 100 },
|
||||
{ "adi": "Gadget", "tutar": 200 }
|
||||
]
|
||||
});
|
||||
|
||||
let resolved = resolve_template(&template, &data);
|
||||
let table = resolved.tables.get("tbl").unwrap();
|
||||
assert_eq!(table.rows.len(), 2);
|
||||
assert_eq!(table.rows[0], vec!["Widget", "100"]);
|
||||
assert_eq!(table.rows[1], vec!["Gadget", "200"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_template_table_empty_array() {
|
||||
let template = Template {
|
||||
id: "t1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![
|
||||
TemplateElement::RepeatingTable(RepeatingTableElement {
|
||||
id: "tbl".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
data_source: ArrayBinding { path: "items".to_string() },
|
||||
columns: vec![
|
||||
TableColumn {
|
||||
id: "c1".to_string(),
|
||||
field: "name".to_string(),
|
||||
title: "Name".to_string(),
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
align: "left".to_string(),
|
||||
format: None,
|
||||
},
|
||||
],
|
||||
style: TableStyle::default(),
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let data = serde_json::json!({ "items": [] });
|
||||
let resolved = resolve_template(&template, &data);
|
||||
let table = resolved.tables.get("tbl").unwrap();
|
||||
assert_eq!(table.rows.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_template_missing_binding_path() {
|
||||
let template = Template {
|
||||
id: "t1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![
|
||||
TemplateElement::Text(TextElement {
|
||||
id: "el_missing".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
style: TextStyle::default(),
|
||||
content: None,
|
||||
binding: ScalarBinding { path: "does.not.exist".to_string() },
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let data = serde_json::json!({});
|
||||
let resolved = resolve_template(&template, &data);
|
||||
// Missing binding path should resolve to empty string
|
||||
assert_eq!(resolved.texts.get("el_missing").unwrap(), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,16 +15,16 @@ pub fn pt_to_mm(pt: f32) -> f64 {
|
||||
/// SizeValue → taffy Dimension (width veya height için)
|
||||
fn size_value_to_dimension(sv: &SizeValue) -> Dimension {
|
||||
match sv {
|
||||
SizeValue::Fixed { value } => Dimension::Length(mm_to_pt(*value)),
|
||||
SizeValue::Auto => Dimension::Auto,
|
||||
SizeValue::Fixed { value } => Dimension::length(mm_to_pt(*value)),
|
||||
SizeValue::Auto => Dimension::auto(),
|
||||
// Fr için dimension Auto, flex_grow ayrıca set edilir
|
||||
SizeValue::Fr { .. } => Dimension::Auto,
|
||||
SizeValue::Fr { .. } => Dimension::auto(),
|
||||
}
|
||||
}
|
||||
|
||||
/// SizeValue → taffy LengthPercentage (min/max constraint'ler için)
|
||||
fn mm_to_length(mm: f64) -> Dimension {
|
||||
Dimension::Length(mm_to_pt(mm))
|
||||
Dimension::length(mm_to_pt(mm))
|
||||
}
|
||||
|
||||
/// Fr değerini döndür (yoksa 0)
|
||||
@@ -78,7 +78,7 @@ pub fn apply_size_to_style(
|
||||
if main_fr > 0.0 {
|
||||
style.flex_grow = main_fr;
|
||||
style.flex_shrink = 1.0;
|
||||
style.flex_basis = Dimension::Length(0.0);
|
||||
style.flex_basis = Dimension::length(0.0);
|
||||
|
||||
// min-width: 0 (row) veya min-height: 0 (column) ayarla —
|
||||
// taffy'de min_size default Auto = içerik boyutunun altına küçülemez.
|
||||
@@ -86,12 +86,12 @@ pub fn apply_size_to_style(
|
||||
match parent_direction {
|
||||
Some("row") => {
|
||||
if size.min_width.is_none() {
|
||||
style.min_size.width = Dimension::Length(0.0);
|
||||
style.min_size.width = Dimension::length(0.0);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if size.min_height.is_none() {
|
||||
style.min_size.height = Dimension::Length(0.0);
|
||||
style.min_size.height = Dimension::length(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,14 +113,14 @@ pub fn container_to_style(el: &ContainerElement, parent_direction: Option<&str>)
|
||||
_ => FlexDirection::Column,
|
||||
},
|
||||
gap: Size {
|
||||
width: LengthPercentage::Length(mm_to_pt(el.gap)),
|
||||
height: LengthPercentage::Length(mm_to_pt(el.gap)),
|
||||
width: LengthPercentage::length(mm_to_pt(el.gap)),
|
||||
height: LengthPercentage::length(mm_to_pt(el.gap)),
|
||||
},
|
||||
padding: Rect {
|
||||
top: LengthPercentage::Length(mm_to_pt(el.padding.top)),
|
||||
right: LengthPercentage::Length(mm_to_pt(el.padding.right)),
|
||||
bottom: LengthPercentage::Length(mm_to_pt(el.padding.bottom)),
|
||||
left: LengthPercentage::Length(mm_to_pt(el.padding.left)),
|
||||
top: LengthPercentage::length(mm_to_pt(el.padding.top)),
|
||||
right: LengthPercentage::length(mm_to_pt(el.padding.right)),
|
||||
bottom: LengthPercentage::length(mm_to_pt(el.padding.bottom)),
|
||||
left: LengthPercentage::length(mm_to_pt(el.padding.left)),
|
||||
},
|
||||
align_items: Some(match el.align.as_str() {
|
||||
"center" => AlignItems::Center,
|
||||
@@ -142,8 +142,8 @@ pub fn container_to_style(el: &ContainerElement, parent_direction: Option<&str>)
|
||||
PositionMode::Absolute { x, y } => {
|
||||
style.position = Position::Absolute;
|
||||
style.inset = Rect {
|
||||
top: LengthPercentageAuto::Length(mm_to_pt(*y)),
|
||||
left: LengthPercentageAuto::Length(mm_to_pt(*x)),
|
||||
top: LengthPercentageAuto::length(mm_to_pt(*y)),
|
||||
left: LengthPercentageAuto::length(mm_to_pt(*x)),
|
||||
right: auto(),
|
||||
bottom: auto(),
|
||||
};
|
||||
@@ -158,10 +158,10 @@ pub fn container_to_style(el: &ContainerElement, parent_direction: Option<&str>)
|
||||
if let Some(bw) = el.style.border_width {
|
||||
let bpt = mm_to_pt(bw);
|
||||
style.border = Rect {
|
||||
top: LengthPercentage::Length(bpt),
|
||||
right: LengthPercentage::Length(bpt),
|
||||
bottom: LengthPercentage::Length(bpt),
|
||||
left: LengthPercentage::Length(bpt),
|
||||
top: LengthPercentage::length(bpt),
|
||||
right: LengthPercentage::length(bpt),
|
||||
bottom: LengthPercentage::length(bpt),
|
||||
left: LengthPercentage::length(bpt),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,8 +180,8 @@ pub fn leaf_style(
|
||||
PositionMode::Absolute { x, y } => {
|
||||
style.position = Position::Absolute;
|
||||
style.inset = Rect {
|
||||
top: LengthPercentageAuto::Length(mm_to_pt(*y)),
|
||||
left: LengthPercentageAuto::Length(mm_to_pt(*x)),
|
||||
top: LengthPercentageAuto::length(mm_to_pt(*y)),
|
||||
left: LengthPercentageAuto::length(mm_to_pt(*x)),
|
||||
right: auto(),
|
||||
bottom: auto(),
|
||||
};
|
||||
@@ -197,6 +197,7 @@ pub fn leaf_style(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dreport_core::models::{ContainerStyle, Padding};
|
||||
|
||||
#[test]
|
||||
fn test_mm_to_pt_conversion() {
|
||||
@@ -205,18 +206,171 @@ mod tests {
|
||||
assert!((pt - 595.28).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mm_to_pt_one_inch() {
|
||||
// 1 inch = 25.4mm = 72pt
|
||||
let pt = mm_to_pt(25.4);
|
||||
assert!((pt - 72.0).abs() < 0.01, "25.4mm should be ~72pt, got {}", pt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pt_to_mm_conversion() {
|
||||
// 72pt = 25.4mm (1 inch)
|
||||
let mm = pt_to_mm(72.0);
|
||||
assert!((mm - 25.4).abs() < 0.01, "72pt should be ~25.4mm, got {}", mm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_mm_pt_mm() {
|
||||
// mm → pt → mm should preserve value within tolerance
|
||||
let original = 100.0_f64;
|
||||
let pt = mm_to_pt(original);
|
||||
let back = pt_to_mm(pt);
|
||||
assert!(
|
||||
(back - original).abs() < 0.01,
|
||||
"Roundtrip failed: {} → {}pt → {}",
|
||||
original,
|
||||
pt,
|
||||
back
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mm_to_pt_zero() {
|
||||
assert_eq!(mm_to_pt(0.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pt_to_mm_zero() {
|
||||
assert!((pt_to_mm(0.0) - 0.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixed_size() {
|
||||
let sv = SizeValue::Fixed { value: 50.0 };
|
||||
match size_value_to_dimension(&sv) {
|
||||
Dimension::Length(pt) => assert!((pt - mm_to_pt(50.0)).abs() < 0.01),
|
||||
_ => panic!("Expected Length"),
|
||||
}
|
||||
assert_eq!(size_value_to_dimension(&sv), Dimension::length(mm_to_pt(50.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_size() {
|
||||
let sv = SizeValue::Auto;
|
||||
assert_eq!(size_value_to_dimension(&sv), Dimension::auto());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fr_maps_to_auto_dimension() {
|
||||
let sv = SizeValue::Fr { value: 2.0 };
|
||||
assert!(matches!(size_value_to_dimension(&sv), Dimension::Auto));
|
||||
assert_eq!(size_value_to_dimension(&sv), Dimension::auto());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fr_value_extraction() {
|
||||
assert_eq!(fr_value(&SizeValue::Fr { value: 3.0 }), 3.0);
|
||||
assert_eq!(fr_value(&SizeValue::Auto), 0.0);
|
||||
assert_eq!(fr_value(&SizeValue::Fixed { value: 10.0 }), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_size_fr_sets_flex_grow() {
|
||||
let size = SizeConstraint {
|
||||
width: SizeValue::Fr { value: 2.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
};
|
||||
let mut style = Style::default();
|
||||
apply_size_to_style(&mut style, &size, Some("row"));
|
||||
assert_eq!(style.flex_grow, 2.0);
|
||||
assert_eq!(style.flex_basis, Dimension::length(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_size_fixed_no_flex_grow() {
|
||||
let size = SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 50.0 },
|
||||
height: SizeValue::Fixed { value: 30.0 },
|
||||
..Default::default()
|
||||
};
|
||||
let mut style = Style::default();
|
||||
apply_size_to_style(&mut style, &size, Some("row"));
|
||||
assert_eq!(style.flex_grow, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_size_min_max_constraints() {
|
||||
let size = SizeConstraint {
|
||||
width: SizeValue::Auto,
|
||||
height: SizeValue::Auto,
|
||||
min_width: Some(20.0),
|
||||
max_width: Some(100.0),
|
||||
min_height: Some(10.0),
|
||||
max_height: Some(50.0),
|
||||
};
|
||||
let mut style = Style::default();
|
||||
apply_size_to_style(&mut style, &size, None);
|
||||
assert_eq!(style.min_size.width, Dimension::length(mm_to_pt(20.0)));
|
||||
assert_eq!(style.max_size.width, Dimension::length(mm_to_pt(100.0)));
|
||||
assert_eq!(style.min_size.height, Dimension::length(mm_to_pt(10.0)));
|
||||
assert_eq!(style.max_size.height, Dimension::length(mm_to_pt(50.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_to_style_direction() {
|
||||
let el = ContainerElement {
|
||||
id: "test".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "row".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding { top: 10.0, right: 10.0, bottom: 10.0, left: 10.0 },
|
||||
align: "center".to_string(),
|
||||
justify: "space-between".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![],
|
||||
};
|
||||
let style = container_to_style(&el, None);
|
||||
assert_eq!(style.flex_direction, FlexDirection::Row);
|
||||
assert_eq!(style.align_items, Some(AlignItems::Center));
|
||||
assert_eq!(style.justify_content, Some(JustifyContent::SpaceBetween));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_to_style_absolute() {
|
||||
let el = ContainerElement {
|
||||
id: "test".to_string(),
|
||||
position: PositionMode::Absolute { x: 20.0, y: 30.0 },
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![],
|
||||
};
|
||||
let style = container_to_style(&el, None);
|
||||
assert_eq!(style.position, Position::Absolute);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_leaf_style_flow() {
|
||||
let size = SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 60.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
};
|
||||
let style = leaf_style(&size, &PositionMode::Flow, Some("column"));
|
||||
assert_eq!(style.position, Position::Relative);
|
||||
assert_eq!(style.size.width, Dimension::length(mm_to_pt(60.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_leaf_style_absolute() {
|
||||
let size = SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 40.0 },
|
||||
height: SizeValue::Fixed { value: 20.0 },
|
||||
..Default::default()
|
||||
};
|
||||
let style = leaf_style(&size, &PositionMode::Absolute { x: 10.0, y: 15.0 }, None);
|
||||
assert_eq!(style.position, Position::Absolute);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,3 +189,220 @@ pub fn expand_table(
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::data_resolve::{ResolvedData, ResolvedTable};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn make_table(num_columns: usize) -> RepeatingTableElement {
|
||||
let columns: Vec<TableColumn> = (0..num_columns)
|
||||
.map(|i| TableColumn {
|
||||
id: format!("col_{}", i),
|
||||
field: format!("field_{}", i),
|
||||
title: format!("Column {}", i),
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
align: "left".to_string(),
|
||||
format: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
RepeatingTableElement {
|
||||
id: "tbl".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
data_source: ArrayBinding { path: "items".to_string() },
|
||||
columns,
|
||||
style: TableStyle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_resolved(table_id: &str, rows: Vec<Vec<String>>) -> ResolvedData {
|
||||
let mut tables = HashMap::new();
|
||||
tables.insert(table_id.to_string(), ResolvedTable { rows });
|
||||
ResolvedData {
|
||||
texts: HashMap::new(),
|
||||
tables,
|
||||
barcodes: HashMap::new(),
|
||||
images: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_table_structure() {
|
||||
let table = make_table(2);
|
||||
let resolved = make_resolved("tbl", vec![
|
||||
vec!["A".to_string(), "1".to_string()],
|
||||
vec!["B".to_string(), "2".to_string()],
|
||||
]);
|
||||
|
||||
let container = expand_table(&table, &resolved);
|
||||
|
||||
// Wrapper container properties
|
||||
assert_eq!(container.id, "tbl");
|
||||
assert_eq!(container.direction, "column");
|
||||
|
||||
// Children: header row + 2 data rows (no border_color so no separator line)
|
||||
assert_eq!(container.children.len(), 3);
|
||||
|
||||
// First child is header row container
|
||||
match &container.children[0] {
|
||||
TemplateElement::Container(c) => {
|
||||
assert_eq!(c.id, "tbl_header");
|
||||
assert_eq!(c.direction, "row");
|
||||
assert_eq!(c.children.len(), 2); // 2 columns
|
||||
// Check header cell text
|
||||
match &c.children[0] {
|
||||
TemplateElement::StaticText(t) => assert_eq!(t.content, "Column 0"),
|
||||
_ => panic!("Expected StaticText for header cell"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected Container for header row"),
|
||||
}
|
||||
|
||||
// Data rows
|
||||
for (row_idx, child) in container.children[1..].iter().enumerate() {
|
||||
match child {
|
||||
TemplateElement::Container(c) => {
|
||||
assert_eq!(c.id, format!("tbl_row_{}", row_idx));
|
||||
assert_eq!(c.direction, "row");
|
||||
assert_eq!(c.children.len(), 2);
|
||||
}
|
||||
_ => panic!("Expected Container for data row"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_table_empty_data() {
|
||||
let table = make_table(3);
|
||||
let resolved = make_resolved("tbl", vec![]);
|
||||
|
||||
let container = expand_table(&table, &resolved);
|
||||
|
||||
// Only header row, no data rows
|
||||
assert_eq!(container.children.len(), 1);
|
||||
|
||||
// Header should still have all 3 columns
|
||||
match &container.children[0] {
|
||||
TemplateElement::Container(c) => {
|
||||
assert_eq!(c.children.len(), 3);
|
||||
}
|
||||
_ => panic!("Expected Container for header row"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_table_column_count() {
|
||||
let table = make_table(4);
|
||||
let resolved = make_resolved("tbl", vec![
|
||||
vec!["a".into(), "b".into(), "c".into(), "d".into()],
|
||||
]);
|
||||
|
||||
let container = expand_table(&table, &resolved);
|
||||
|
||||
// header + 1 data row
|
||||
assert_eq!(container.children.len(), 2);
|
||||
|
||||
// Both header and data row should have 4 cells
|
||||
match &container.children[0] {
|
||||
TemplateElement::Container(c) => assert_eq!(c.children.len(), 4),
|
||||
_ => panic!("Expected Container"),
|
||||
}
|
||||
match &container.children[1] {
|
||||
TemplateElement::Container(c) => assert_eq!(c.children.len(), 4),
|
||||
_ => panic!("Expected Container"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_table_data_cell_content() {
|
||||
let table = make_table(2);
|
||||
let resolved = make_resolved("tbl", vec![
|
||||
vec!["Hello".to_string(), "42".to_string()],
|
||||
]);
|
||||
|
||||
let container = expand_table(&table, &resolved);
|
||||
|
||||
// Data row cells should contain the resolved text
|
||||
match &container.children[1] {
|
||||
TemplateElement::Container(c) => {
|
||||
match &c.children[0] {
|
||||
TemplateElement::StaticText(t) => assert_eq!(t.content, "Hello"),
|
||||
_ => panic!("Expected StaticText"),
|
||||
}
|
||||
match &c.children[1] {
|
||||
TemplateElement::StaticText(t) => assert_eq!(t.content, "42"),
|
||||
_ => panic!("Expected StaticText"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected Container"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_table_with_border_adds_separator() {
|
||||
let mut table = make_table(2);
|
||||
table.style.border_color = Some("#000000".to_string());
|
||||
let resolved = make_resolved("tbl", vec![
|
||||
vec!["A".to_string(), "1".to_string()],
|
||||
]);
|
||||
|
||||
let container = expand_table(&table, &resolved);
|
||||
|
||||
// header + separator line + 1 data row = 3
|
||||
assert_eq!(container.children.len(), 3);
|
||||
|
||||
// Second child should be a Line
|
||||
match &container.children[1] {
|
||||
TemplateElement::Line(l) => {
|
||||
assert_eq!(l.id, "tbl_header_line");
|
||||
}
|
||||
_ => panic!("Expected Line separator after header"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_table_zebra_stripes() {
|
||||
let mut table = make_table(1);
|
||||
table.style.zebra_odd = Some("#f0f0f0".to_string());
|
||||
table.style.zebra_even = Some("#ffffff".to_string());
|
||||
let resolved = make_resolved("tbl", vec![
|
||||
vec!["row0".into()],
|
||||
vec!["row1".into()],
|
||||
vec!["row2".into()],
|
||||
]);
|
||||
|
||||
let container = expand_table(&table, &resolved);
|
||||
|
||||
// header + 3 data rows
|
||||
assert_eq!(container.children.len(), 4);
|
||||
|
||||
// row_0 (even index) => zebra_odd
|
||||
match &container.children[1] {
|
||||
TemplateElement::Container(c) => {
|
||||
assert_eq!(c.style.background_color, Some("#f0f0f0".to_string()));
|
||||
}
|
||||
_ => panic!("Expected Container"),
|
||||
}
|
||||
// row_1 (odd index) => zebra_even
|
||||
match &container.children[2] {
|
||||
TemplateElement::Container(c) => {
|
||||
assert_eq!(c.style.background_color, Some("#ffffff".to_string()));
|
||||
}
|
||||
_ => panic!("Expected Container"),
|
||||
}
|
||||
// row_2 (even index) => zebra_odd
|
||||
match &container.children[3] {
|
||||
TemplateElement::Container(c) => {
|
||||
assert_eq!(c.style.background_color, Some("#f0f0f0".to_string()));
|
||||
}
|
||||
_ => panic!("Expected Container"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ impl TextMeasurer {
|
||||
.family(Family::Name(family_name))
|
||||
.weight(weight);
|
||||
|
||||
buffer.set_text(&mut self.font_system, text, attrs, Shaping::Advanced);
|
||||
buffer.set_text(&mut self.font_system, text, &attrs, Shaping::Advanced, None);
|
||||
buffer.shape_until_scroll(&mut self.font_system, false);
|
||||
|
||||
let mut max_width: f32 = 0.0;
|
||||
|
||||
@@ -54,8 +54,8 @@ pub fn compute(
|
||||
display: Display::Flex,
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: Size {
|
||||
width: Dimension::Length(page_w_pt),
|
||||
height: Dimension::Length(page_h_pt),
|
||||
width: Dimension::length(page_w_pt),
|
||||
height: Dimension::length(page_h_pt),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
@@ -197,7 +197,7 @@ fn build_element(
|
||||
// Line: genişlik parent'tan, yükseklik stroke width
|
||||
let mut leaf_style = style;
|
||||
if matches!(e.size.height, SizeValue::Auto) {
|
||||
leaf_style.size.height = Dimension::Length(mm_to_pt(stroke_w));
|
||||
leaf_style.size.height = Dimension::length(mm_to_pt(stroke_w));
|
||||
}
|
||||
|
||||
let node = taffy.new_leaf(leaf_style).unwrap();
|
||||
@@ -246,10 +246,10 @@ fn build_element(
|
||||
let default_h = if is_qr { 20.0 } else { 15.0 }; // mm
|
||||
let default_w = if is_qr { 20.0 } else { 40.0 }; // mm
|
||||
if matches!(e.size.height, SizeValue::Auto) {
|
||||
style.min_size.height = Dimension::Length(mm_to_pt(default_h));
|
||||
style.min_size.height = Dimension::length(mm_to_pt(default_h));
|
||||
}
|
||||
if matches!(e.size.width, SizeValue::Auto) {
|
||||
style.min_size.width = Dimension::Length(mm_to_pt(default_w));
|
||||
style.min_size.width = Dimension::length(mm_to_pt(default_w));
|
||||
}
|
||||
|
||||
let node = taffy.new_leaf(style).unwrap();
|
||||
|
||||
4
layout-engine/tests/fixtures/visual_test_data.json
vendored
Normal file
4
layout-engine/tests/fixtures/visual_test_data.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"company": "Acme Test Corp.",
|
||||
"date": "2026-01-15"
|
||||
}
|
||||
73
layout-engine/tests/fixtures/visual_test_template.json
vendored
Normal file
73
layout-engine/tests/fixtures/visual_test_template.json
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"id": "visual_test",
|
||||
"name": "Visual Test",
|
||||
"page": { "width": 210, "height": 297 },
|
||||
"fonts": ["Noto Sans"],
|
||||
"root": {
|
||||
"id": "root",
|
||||
"type": "container",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"direction": "column",
|
||||
"gap": 5,
|
||||
"padding": { "top": 15, "right": 15, "bottom": 15, "left": 15 },
|
||||
"align": "stretch",
|
||||
"justify": "start",
|
||||
"style": {},
|
||||
"children": [
|
||||
{
|
||||
"id": "header",
|
||||
"type": "static_text",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"style": { "fontSize": 18, "fontWeight": "bold", "color": "#1a1a1a" },
|
||||
"content": "VISUAL TEST DOCUMENT"
|
||||
},
|
||||
{
|
||||
"id": "line1",
|
||||
"type": "line",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
|
||||
"style": { "strokeColor": "#333333", "strokeWidth": 0.5 }
|
||||
},
|
||||
{
|
||||
"id": "info_box",
|
||||
"type": "container",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
|
||||
"direction": "column",
|
||||
"gap": 2,
|
||||
"padding": { "top": 5, "right": 5, "bottom": 5, "left": 5 },
|
||||
"align": "start",
|
||||
"justify": "start",
|
||||
"style": { "backgroundColor": "#f0f4f8", "borderColor": "#cbd5e1", "borderWidth": 0.5 },
|
||||
"children": [
|
||||
{
|
||||
"id": "company",
|
||||
"type": "text",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"style": { "fontSize": 12, "fontWeight": "bold", "color": "#1e293b" },
|
||||
"binding": { "type": "scalar", "path": "company" }
|
||||
},
|
||||
{
|
||||
"id": "date_text",
|
||||
"type": "text",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"style": { "fontSize": 10, "color": "#64748b" },
|
||||
"binding": { "type": "scalar", "path": "date" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "body_text",
|
||||
"type": "static_text",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
|
||||
"style": { "fontSize": 11, "color": "#334155" },
|
||||
"content": "This is a visual regression test document. Layout and text rendering should be consistent across runs."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
321
layout-engine/tests/layout_integration.rs
Normal file
321
layout-engine/tests/layout_integration.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
//! Integration tests for the layout engine's compute_layout() public API.
|
||||
|
||||
use dreport_core::models::*;
|
||||
use dreport_layout::{compute_layout, FontData, LayoutResult};
|
||||
|
||||
fn load_test_fonts() -> Vec<FontData> {
|
||||
let font_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("backend/fonts");
|
||||
|
||||
let mut fonts = Vec::new();
|
||||
for entry in std::fs::read_dir(&font_dir).expect("backend/fonts directory not found") {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "ttf") {
|
||||
let family = path
|
||||
.file_stem()
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split('-')
|
||||
.next()
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
// Map NotoSans → "Noto Sans", NotoSansMono → "Noto Sans Mono"
|
||||
let family = if family == "NotoSansMono" {
|
||||
"Noto Sans Mono".to_string()
|
||||
} else if family == "NotoSans" {
|
||||
"Noto Sans".to_string()
|
||||
} else {
|
||||
family
|
||||
};
|
||||
fonts.push(FontData {
|
||||
family,
|
||||
data: std::fs::read(&path).unwrap(),
|
||||
});
|
||||
}
|
||||
}
|
||||
fonts
|
||||
}
|
||||
|
||||
fn simple_template() -> Template {
|
||||
Template {
|
||||
id: "test".to_string(),
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings {
|
||||
width: 210.0,
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding {
|
||||
top: 15.0,
|
||||
right: 15.0,
|
||||
bottom: 15.0,
|
||||
left: 15.0,
|
||||
},
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
style: TextStyle {
|
||||
font_size: Some(14.0),
|
||||
font_weight: Some("bold".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
content: "Hello World".to_string(),
|
||||
})],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_layout_single_page() {
|
||||
let template = simple_template();
|
||||
let data = serde_json::json!({});
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
let result: LayoutResult = compute_layout(&template, &data, &fonts);
|
||||
|
||||
assert_eq!(result.pages.len(), 1);
|
||||
let page = &result.pages[0];
|
||||
assert_eq!(page.width_mm, 210.0);
|
||||
assert_eq!(page.height_mm, 297.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_layout_elements_within_page() {
|
||||
let template = simple_template();
|
||||
let data = serde_json::json!({});
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
let result = compute_layout(&template, &data, &fonts);
|
||||
let page = &result.pages[0];
|
||||
|
||||
// Should have at least root + title = 2 elements
|
||||
assert!(
|
||||
page.elements.len() >= 2,
|
||||
"Expected at least 2 elements, got {}",
|
||||
page.elements.len()
|
||||
);
|
||||
|
||||
for el in &page.elements {
|
||||
// All positions should be non-negative
|
||||
assert!(
|
||||
el.x_mm >= 0.0,
|
||||
"Element {} has negative x: {}",
|
||||
el.id,
|
||||
el.x_mm
|
||||
);
|
||||
assert!(
|
||||
el.y_mm >= 0.0,
|
||||
"Element {} has negative y: {}",
|
||||
el.id,
|
||||
el.y_mm
|
||||
);
|
||||
// All dimensions should be non-negative
|
||||
assert!(
|
||||
el.width_mm >= 0.0,
|
||||
"Element {} has negative width: {}",
|
||||
el.id,
|
||||
el.width_mm
|
||||
);
|
||||
assert!(
|
||||
el.height_mm >= 0.0,
|
||||
"Element {} has negative height: {}",
|
||||
el.id,
|
||||
el.height_mm
|
||||
);
|
||||
// Elements should be within page bounds (with small tolerance for rounding)
|
||||
assert!(
|
||||
el.x_mm + el.width_mm <= page.width_mm + 1.0,
|
||||
"Element {} exceeds page width: x={}+w={} > {}",
|
||||
el.id,
|
||||
el.x_mm,
|
||||
el.width_mm,
|
||||
page.width_mm
|
||||
);
|
||||
assert!(
|
||||
el.y_mm + el.height_mm <= page.height_mm + 1.0,
|
||||
"Element {} exceeds page height: y={}+h={} > {}",
|
||||
el.id,
|
||||
el.y_mm,
|
||||
el.height_mm,
|
||||
page.height_mm
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_layout_text_content_resolved() {
|
||||
let template = simple_template();
|
||||
let data = serde_json::json!({});
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
let result = compute_layout(&template, &data, &fonts);
|
||||
let page = &result.pages[0];
|
||||
|
||||
let title = page.elements.iter().find(|e| e.id == "title").unwrap();
|
||||
match &title.content {
|
||||
Some(dreport_layout::ResolvedContent::Text { value }) => {
|
||||
assert_eq!(value, "Hello World");
|
||||
}
|
||||
other => panic!("Expected Text content, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_layout_with_data_binding() {
|
||||
let template = Template {
|
||||
id: "t1".to_string(),
|
||||
name: "Binding Test".to_string(),
|
||||
page: PageSettings {
|
||||
width: 210.0,
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding {
|
||||
top: 10.0,
|
||||
right: 10.0,
|
||||
bottom: 10.0,
|
||||
left: 10.0,
|
||||
},
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![TemplateElement::Text(TextElement {
|
||||
id: "bound_text".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
style: TextStyle {
|
||||
font_size: Some(12.0),
|
||||
..Default::default()
|
||||
},
|
||||
content: None,
|
||||
binding: ScalarBinding {
|
||||
path: "company.name".to_string(),
|
||||
},
|
||||
})],
|
||||
},
|
||||
};
|
||||
|
||||
let data = serde_json::json!({
|
||||
"company": { "name": "Acme Corp" }
|
||||
});
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
let result = compute_layout(&template, &data, &fonts);
|
||||
let page = &result.pages[0];
|
||||
|
||||
let bound = page
|
||||
.elements
|
||||
.iter()
|
||||
.find(|e| e.id == "bound_text")
|
||||
.unwrap();
|
||||
match &bound.content {
|
||||
Some(dreport_layout::ResolvedContent::Text { value }) => {
|
||||
assert_eq!(value, "Acme Corp");
|
||||
}
|
||||
other => panic!("Expected Text content, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_layout_multiple_children_ordering() {
|
||||
let template = Template {
|
||||
id: "t1".to_string(),
|
||||
name: "Order Test".to_string(),
|
||||
page: PageSettings {
|
||||
width: 210.0,
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding {
|
||||
top: 10.0,
|
||||
right: 10.0,
|
||||
bottom: 10.0,
|
||||
left: 10.0,
|
||||
},
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "first".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
style: TextStyle {
|
||||
font_size: Some(12.0),
|
||||
..Default::default()
|
||||
},
|
||||
content: "First".to_string(),
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "second".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
style: TextStyle {
|
||||
font_size: Some(12.0),
|
||||
..Default::default()
|
||||
},
|
||||
content: "Second".to_string(),
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let data = serde_json::json!({});
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
let result = compute_layout(&template, &data, &fonts);
|
||||
let page = &result.pages[0];
|
||||
|
||||
let first = page.elements.iter().find(|e| e.id == "first").unwrap();
|
||||
let second = page.elements.iter().find(|e| e.id == "second").unwrap();
|
||||
|
||||
// In column direction, second should be below first
|
||||
assert!(
|
||||
second.y_mm > first.y_mm,
|
||||
"Second element (y={}) should be below first (y={})",
|
||||
second.y_mm,
|
||||
first.y_mm
|
||||
);
|
||||
}
|
||||
256
layout-engine/tests/pdf_render_test.rs
Normal file
256
layout-engine/tests/pdf_render_test.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
//! PDF render integration tests.
|
||||
//! Only compiled on non-WASM targets since pdf_render uses krilla (native only).
|
||||
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use dreport_core::models::*;
|
||||
use dreport_layout::{compute_layout, FontData};
|
||||
|
||||
fn load_test_fonts() -> Vec<FontData> {
|
||||
let font_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("backend/fonts");
|
||||
|
||||
let mut fonts = Vec::new();
|
||||
for entry in std::fs::read_dir(&font_dir).expect("backend/fonts directory not found") {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "ttf") {
|
||||
let family = path
|
||||
.file_stem()
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split('-')
|
||||
.next()
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
let family = if family == "NotoSansMono" {
|
||||
"Noto Sans Mono".to_string()
|
||||
} else if family == "NotoSans" {
|
||||
"Noto Sans".to_string()
|
||||
} else {
|
||||
family
|
||||
};
|
||||
fonts.push(FontData {
|
||||
family,
|
||||
data: std::fs::read(&path).unwrap(),
|
||||
});
|
||||
}
|
||||
}
|
||||
fonts
|
||||
}
|
||||
|
||||
fn simple_template() -> Template {
|
||||
Template {
|
||||
id: "pdf_test".to_string(),
|
||||
name: "PDF Test".to_string(),
|
||||
page: PageSettings {
|
||||
width: 210.0,
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding {
|
||||
top: 15.0,
|
||||
right: 15.0,
|
||||
bottom: 15.0,
|
||||
left: 15.0,
|
||||
},
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
style: TextStyle {
|
||||
font_size: Some(18.0),
|
||||
font_weight: Some("bold".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
content: "PDF Render Test".to_string(),
|
||||
})],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pdf_produces_valid_output() {
|
||||
let template = simple_template();
|
||||
let data = serde_json::json!({});
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
let layout = compute_layout(&template, &data, &fonts);
|
||||
let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
|
||||
|
||||
// PDF should not be empty
|
||||
assert!(
|
||||
!pdf_bytes.is_empty(),
|
||||
"PDF output should not be empty"
|
||||
);
|
||||
|
||||
// PDF should start with %PDF magic bytes
|
||||
assert!(
|
||||
pdf_bytes.starts_with(b"%PDF"),
|
||||
"PDF output should start with %PDF magic bytes, got: {:?}",
|
||||
&pdf_bytes[..std::cmp::min(10, pdf_bytes.len())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pdf_with_multiple_elements() {
|
||||
let template = Template {
|
||||
id: "pdf_multi".to_string(),
|
||||
name: "PDF Multi".to_string(),
|
||||
page: PageSettings {
|
||||
width: 210.0,
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding {
|
||||
top: 15.0,
|
||||
right: 15.0,
|
||||
bottom: 15.0,
|
||||
left: 15.0,
|
||||
},
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "header".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
style: TextStyle {
|
||||
font_size: Some(16.0),
|
||||
font_weight: Some("bold".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
content: "FATURA".to_string(),
|
||||
}),
|
||||
TemplateElement::Line(LineElement {
|
||||
id: "sep".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
style: LineStyle {
|
||||
stroke_color: Some("#000000".to_string()),
|
||||
stroke_width: Some(0.5),
|
||||
},
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "body".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
style: TextStyle {
|
||||
font_size: Some(11.0),
|
||||
..Default::default()
|
||||
},
|
||||
content: "Bu bir test belgesidir.".to_string(),
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let data = serde_json::json!({});
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
let layout = compute_layout(&template, &data, &fonts);
|
||||
let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
|
||||
|
||||
assert!(!pdf_bytes.is_empty());
|
||||
assert!(pdf_bytes.starts_with(b"%PDF"));
|
||||
|
||||
// A PDF with multiple elements should be reasonably sized
|
||||
assert!(
|
||||
pdf_bytes.len() > 100,
|
||||
"PDF with multiple elements should be >100 bytes, got {}",
|
||||
pdf_bytes.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pdf_with_container_styles() {
|
||||
let template = Template {
|
||||
id: "pdf_styled".to_string(),
|
||||
name: "PDF Styled".to_string(),
|
||||
page: PageSettings {
|
||||
width: 210.0,
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding {
|
||||
top: 20.0,
|
||||
right: 20.0,
|
||||
bottom: 20.0,
|
||||
left: 20.0,
|
||||
},
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle {
|
||||
background_color: Some("#f0f0f0".to_string()),
|
||||
border_color: Some("#333333".to_string()),
|
||||
border_width: Some(1.0),
|
||||
..Default::default()
|
||||
},
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "text".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
style: TextStyle {
|
||||
font_size: Some(12.0),
|
||||
color: Some("#ff0000".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
content: "Styled text".to_string(),
|
||||
})],
|
||||
},
|
||||
};
|
||||
|
||||
let data = serde_json::json!({});
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
let layout = compute_layout(&template, &data, &fonts);
|
||||
let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
|
||||
|
||||
assert!(!pdf_bytes.is_empty());
|
||||
assert!(pdf_bytes.starts_with(b"%PDF"));
|
||||
}
|
||||
BIN
layout-engine/tests/snapshots/visual_test_reference.png
Normal file
BIN
layout-engine/tests/snapshots/visual_test_reference.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
205
layout-engine/tests/visual_test.rs
Normal file
205
layout-engine/tests/visual_test.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
//! Visual regression tests for PDF rendering.
|
||||
//!
|
||||
//! Generates PDF from fixture template+data, converts to PNG via pdftoppm,
|
||||
//! and compares against reference snapshots.
|
||||
//!
|
||||
//! Set UPDATE_SNAPSHOTS=1 to update reference images.
|
||||
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
mod visual {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use dreport_core::models::Template;
|
||||
use dreport_layout::{compute_layout, FontData};
|
||||
use dreport_layout::pdf_render::render_pdf;
|
||||
|
||||
fn fixtures_dir() -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
|
||||
}
|
||||
|
||||
fn snapshots_dir() -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots")
|
||||
}
|
||||
|
||||
fn load_test_fonts() -> Vec<FontData> {
|
||||
let font_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("backend/fonts");
|
||||
|
||||
let mut fonts = Vec::new();
|
||||
for entry in fs::read_dir(&font_dir).expect("backend/fonts directory not found") {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "ttf") {
|
||||
let family = path
|
||||
.file_stem()
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split('-')
|
||||
.next()
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
let family = if family == "NotoSansMono" {
|
||||
"Noto Sans Mono".to_string()
|
||||
} else if family == "NotoSans" {
|
||||
"Noto Sans".to_string()
|
||||
} else {
|
||||
family
|
||||
};
|
||||
fonts.push(FontData {
|
||||
family,
|
||||
data: fs::read(&path).unwrap(),
|
||||
});
|
||||
}
|
||||
}
|
||||
fonts
|
||||
}
|
||||
|
||||
fn generate_test_pdf(template_name: &str, data_name: &str) -> Vec<u8> {
|
||||
let template_json = fs::read_to_string(fixtures_dir().join(template_name)).unwrap();
|
||||
let data_json = fs::read_to_string(fixtures_dir().join(data_name)).unwrap();
|
||||
|
||||
let template: Template = serde_json::from_str(&template_json).unwrap();
|
||||
let data: serde_json::Value = serde_json::from_str(&data_json).unwrap();
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
let layout = compute_layout(&template, &data, &fonts);
|
||||
render_pdf(&layout, &fonts).expect("PDF render failed")
|
||||
}
|
||||
|
||||
fn pdf_to_png(pdf_bytes: &[u8], output_path: &Path) -> bool {
|
||||
// Write PDF to temp file
|
||||
let temp_pdf = output_path.with_extension("pdf");
|
||||
fs::write(&temp_pdf, pdf_bytes).unwrap();
|
||||
|
||||
// pdftoppm appends .png to the output prefix, so strip the extension
|
||||
let output_prefix = output_path.with_extension("");
|
||||
|
||||
let result = Command::new("pdftoppm")
|
||||
.args(["-png", "-r", "150", "-singlefile"])
|
||||
.arg(&temp_pdf)
|
||||
.arg(&output_prefix)
|
||||
.output();
|
||||
|
||||
// Clean up temp PDF
|
||||
let _ = fs::remove_file(&temp_pdf);
|
||||
|
||||
match result {
|
||||
Ok(output) => {
|
||||
if !output.status.success() {
|
||||
eprintln!(
|
||||
"pdftoppm failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("pdftoppm not available - skipping visual test");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_images(
|
||||
actual_path: &Path,
|
||||
reference_path: &Path,
|
||||
max_diff_ratio: f64,
|
||||
) -> Result<f64, String> {
|
||||
let actual =
|
||||
image::open(actual_path).map_err(|e| format!("Failed to open actual: {}", e))?;
|
||||
let reference =
|
||||
image::open(reference_path).map_err(|e| format!("Failed to open reference: {}", e))?;
|
||||
|
||||
let actual_rgba = actual.to_rgba8();
|
||||
let reference_rgba = reference.to_rgba8();
|
||||
|
||||
if actual_rgba.dimensions() != reference_rgba.dimensions() {
|
||||
return Err(format!(
|
||||
"Dimension mismatch: actual {:?} vs reference {:?}",
|
||||
actual_rgba.dimensions(),
|
||||
reference_rgba.dimensions()
|
||||
));
|
||||
}
|
||||
|
||||
let total_pixels = (actual_rgba.width() * actual_rgba.height()) as f64;
|
||||
let mut diff_pixels = 0u64;
|
||||
|
||||
for (a, r) in actual_rgba.pixels().zip(reference_rgba.pixels()) {
|
||||
// Allow per-channel tolerance of 2 for font rendering differences
|
||||
let channel_diff = a
|
||||
.0
|
||||
.iter()
|
||||
.zip(r.0.iter())
|
||||
.any(|(ac, rc)| (*ac as i32 - *rc as i32).unsigned_abs() > 2);
|
||||
if channel_diff {
|
||||
diff_pixels += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let diff_ratio = diff_pixels as f64 / total_pixels;
|
||||
|
||||
if diff_ratio > max_diff_ratio {
|
||||
Err(format!(
|
||||
"Visual diff too large: {:.4}% pixels differ (threshold: {:.4}%)",
|
||||
diff_ratio * 100.0,
|
||||
max_diff_ratio * 100.0
|
||||
))
|
||||
} else {
|
||||
Ok(diff_ratio)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visual_snapshot_basic() {
|
||||
let pdf_bytes =
|
||||
generate_test_pdf("visual_test_template.json", "visual_test_data.json");
|
||||
assert!(!pdf_bytes.is_empty(), "PDF should not be empty");
|
||||
|
||||
let snap_dir = snapshots_dir();
|
||||
fs::create_dir_all(&snap_dir).unwrap();
|
||||
|
||||
let actual_png = snap_dir.join("visual_test_actual.png");
|
||||
let reference_png = snap_dir.join("visual_test_reference.png");
|
||||
|
||||
if !pdf_to_png(&pdf_bytes, &actual_png) {
|
||||
eprintln!("Skipping visual comparison - pdftoppm not available");
|
||||
return;
|
||||
}
|
||||
|
||||
let update_snapshots = std::env::var("UPDATE_SNAPSHOTS").is_ok();
|
||||
|
||||
if !reference_png.exists() || update_snapshots {
|
||||
// First run or explicit update: save as reference
|
||||
fs::copy(&actual_png, &reference_png).unwrap();
|
||||
println!("Reference snapshot saved to {:?}", reference_png);
|
||||
// Clean up actual
|
||||
let _ = fs::remove_file(&actual_png);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compare
|
||||
match compare_images(&actual_png, &reference_png, 0.01) {
|
||||
Ok(diff) => {
|
||||
println!(
|
||||
"Visual test passed: {:.4}% pixels differ",
|
||||
diff * 100.0
|
||||
);
|
||||
let _ = fs::remove_file(&actual_png);
|
||||
}
|
||||
Err(err) => {
|
||||
// Keep actual for debugging
|
||||
panic!(
|
||||
"Visual regression detected: {}. Actual saved at {:?}",
|
||||
err, actual_png
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user