Files
dreport/layout-engine/src/tree.rs

1437 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::HashMap;
use dreport_core::models::*;
use taffy::prelude::*;
use crate::data_resolve::ResolvedData;
use crate::sizing::{self, mm_to_pt, pt_to_mm};
use crate::table_layout;
use crate::text_measure::TextMeasurer;
use crate::{ElementLayout, LayoutError, LayoutResult, ResolvedContent, ResolvedStyle};
/// Taffy node ile dreport element arasındaki mapping
struct NodeInfo {
element_id: String,
element_type: String,
content: Option<ResolvedContent>,
style: ResolvedStyle,
children_ids: Vec<String>,
}
/// Taffy leaf node'lar için ölçüm context'i
struct MeasureContext {
text: String,
font_family: Option<String>,
font_size_pt: f32,
font_weight: Option<String>,
/// Rich text span'ları (varsa text/font_family/font_size_pt/font_weight yok sayılır)
rich_spans: Option<Vec<crate::text_measure::RichSpanMeasure>>,
}
/// Ana layout hesaplama fonksiyonu.
pub fn compute(
template: &Template,
resolved: &ResolvedData,
measurer: &mut TextMeasurer,
) -> Result<LayoutResult, LayoutError> {
let page_w_pt = mm_to_pt(template.page.width);
let page_width_mm = template.page.width;
// --- 1. Header layout (varsa) ---
let (header_elements, header_height_mm) = if let Some(ref header) = template.header {
compute_section(header, page_w_pt, page_width_mm, resolved, measurer)?
} else {
(vec![], 0.0)
};
// --- 2. Footer layout (varsa) ---
let (footer_elements, footer_height_mm) = if let Some(ref footer) = template.footer {
compute_section(footer, page_w_pt, page_width_mm, resolved, measurer)?
} else {
(vec![], 0.0)
};
// --- 3. Body layout — SINIRSIZ YÜKSEKLİK ---
let mut taffy = TaffyTree::<MeasureContext>::new();
taffy.disable_rounding();
let mut node_map: HashMap<NodeId, NodeInfo> = HashMap::new();
let page_width_mm = template.page.width;
let root_node = build_container(
&template.root,
&mut taffy,
&mut node_map,
resolved,
None,
measurer,
page_width_mm,
)?;
// Sayfa wrapper: sayfa genişliğinde ama yükseklik sınırsız (auto)
let page_style = Style {
display: Display::Flex,
flex_direction: FlexDirection::Column,
size: Size {
width: Dimension::length(page_w_pt),
height: Dimension::auto(),
},
..Default::default()
};
let page_node = taffy.new_with_children(page_style, &[root_node])?;
taffy
.compute_layout_with_measure(
page_node,
Size {
width: AvailableSpace::Definite(page_w_pt),
height: AvailableSpace::MaxContent,
},
|known_dimensions, available_space, _node_id, context, _style| {
measure_leaf(known_dimensions, available_space, context, measurer)
},
)?;
let body_elements = collect_layout(&taffy, root_node, &node_map, resolved, 0.0, 0.0)?;
// --- 4. Container break modlarını topla ---
let break_modes = collect_break_modes(&template.root);
// --- 4b. repeat_header == false olan tablo ID'lerini topla ---
let no_repeat_header_tables = collect_no_repeat_header_tables(&template.root);
// --- 5. Sayfalara böl ---
let input = crate::page_break::PageSplitInput {
body_elements,
page_height_mm: template.page.height,
header_height_mm,
footer_height_mm,
header_elements,
footer_elements,
page_width_mm: template.page.width,
break_modes,
page_number_formats: resolved.page_number_formats.clone(),
root_padding_top_mm: template.root.padding.top,
no_repeat_header_tables,
};
let pages = crate::page_break::split_into_pages(input);
Ok(LayoutResult { pages })
}
/// Header veya footer gibi bağımsız bir container section'ı hesapla.
/// Sayfa genişliğinde, auto yükseklikte layout yapar.
fn compute_section(
container: &ContainerElement,
page_w_pt: f32,
page_width_mm: f64,
resolved: &ResolvedData,
measurer: &mut TextMeasurer,
) -> Result<(Vec<ElementLayout>, f64), LayoutError> {
let mut taffy = TaffyTree::<MeasureContext>::new();
taffy.disable_rounding();
let mut node_map: HashMap<NodeId, NodeInfo> = HashMap::new();
let section_node = build_container(container, &mut taffy, &mut node_map, resolved, None, measurer, page_width_mm)?;
let wrapper_style = Style {
display: Display::Flex,
flex_direction: FlexDirection::Column,
size: Size {
width: Dimension::length(page_w_pt),
height: Dimension::auto(),
},
..Default::default()
};
let wrapper_node = taffy.new_with_children(wrapper_style, &[section_node])?;
taffy
.compute_layout_with_measure(
wrapper_node,
Size {
width: AvailableSpace::Definite(page_w_pt),
height: AvailableSpace::MaxContent,
},
|known_dimensions, available_space, _node_id, context, _style| {
measure_leaf(known_dimensions, available_space, context, measurer)
},
)?;
let elements = collect_layout(&taffy, section_node, &node_map, resolved, 0.0, 0.0)?;
// Section yüksekliği
let section_layout = taffy.layout(section_node)?;
let height_mm = pt_to_mm(section_layout.size.height);
Ok((elements, height_mm))
}
/// Template ağacındaki tüm container'ların break_inside modlarını topla.
fn collect_break_modes(root: &ContainerElement) -> HashMap<String, String> {
let mut modes = HashMap::new();
collect_break_modes_recursive(&TemplateElement::Container(root.clone()), &mut modes);
modes
}
fn collect_break_modes_recursive(el: &TemplateElement, modes: &mut HashMap<String, String>) {
if let TemplateElement::Container(c) = el {
modes.insert(c.id.clone(), c.break_inside.clone());
for child in &c.children {
collect_break_modes_recursive(child, modes);
}
}
}
/// repeat_header == false olan tablo ID'lerini topla.
fn collect_no_repeat_header_tables(root: &ContainerElement) -> std::collections::HashSet<String> {
let mut set = std::collections::HashSet::new();
collect_no_repeat_recursive(&TemplateElement::Container(root.clone()), &mut set);
set
}
fn collect_no_repeat_recursive(el: &TemplateElement, set: &mut std::collections::HashSet<String>) {
match el {
TemplateElement::Container(c) => {
for child in &c.children {
collect_no_repeat_recursive(child, set);
}
}
TemplateElement::RepeatingTable(t) => {
if t.repeat_header == Some(false) {
set.insert(t.id.clone());
}
}
_ => {}
}
}
/// Container element'ini taffy node ağacına ekle (recursive)
fn build_container(
el: &ContainerElement,
taffy: &mut TaffyTree<MeasureContext>,
node_map: &mut HashMap<NodeId, NodeInfo>,
resolved: &ResolvedData,
parent_direction: Option<&str>,
measurer: &mut TextMeasurer,
page_width_mm: f64,
) -> Result<NodeId, LayoutError> {
let style = sizing::container_to_style(el, parent_direction);
let direction = el.direction.as_str();
// Child'lar için kullanılabilir genişliği hesapla
// Container'ın kendi padding ve border'ını çıkar
let border_w = el.style.border_width.unwrap_or(0.0);
let container_own_width = match &el.size.width {
SizeValue::Fixed { value } => *value,
_ => page_width_mm, // Fr veya Auto ise parent'ın genişliğini kullan
};
let content_width_mm = container_own_width - el.padding.left - el.padding.right - border_w * 2.0;
let content_width_mm = content_width_mm.max(0.0);
let mut child_nodes = Vec::new();
let mut children_ids = Vec::new();
for child in &el.children {
let child_node = build_element(child, taffy, node_map, resolved, Some(direction), measurer, content_width_mm)?;
child_nodes.push(child_node);
children_ids.push(child.id().to_string());
}
let node = taffy.new_with_children(style, &child_nodes)?;
node_map.insert(
node,
NodeInfo {
element_id: el.id.clone(),
element_type: "container".to_string(),
content: None,
style: ResolvedStyle {
background_color: el.style.background_color.clone(),
border_color: el.style.border_color.clone(),
border_width: el.style.border_width,
border_radius: el.style.border_radius,
border_style: el.style.border_style.clone(),
..Default::default()
},
children_ids,
},
);
Ok(node)
}
/// Herhangi bir element tipini taffy node'a çevir
fn build_element(
el: &TemplateElement,
taffy: &mut TaffyTree<MeasureContext>,
node_map: &mut HashMap<NodeId, NodeInfo>,
resolved: &ResolvedData,
parent_direction: Option<&str>,
measurer: &mut TextMeasurer,
page_width_mm: f64,
) -> Result<NodeId, LayoutError> {
match el {
TemplateElement::Container(e) => {
build_container(e, taffy, node_map, resolved, parent_direction, measurer, page_width_mm)
}
TemplateElement::StaticText(e) => build_text_leaf(
taffy,
node_map,
&e.id,
"static_text",
resolved
.texts
.get(&e.id)
.map(|s| s.as_str())
.unwrap_or(&e.content),
&e.style,
&e.size,
&e.position,
parent_direction,
),
TemplateElement::Text(e) => {
let text = resolved.texts.get(&e.id).map(|s| s.as_str()).unwrap_or("");
build_text_leaf(
taffy,
node_map,
&e.id,
"text",
text,
&e.style,
&e.size,
&e.position,
parent_direction,
)
}
TemplateElement::PageNumber(e) => {
let text = resolved
.texts
.get(&e.id)
.map(|s| s.as_str())
.unwrap_or("1 / 1");
build_text_leaf(
taffy,
node_map,
&e.id,
"page_number",
text,
&e.style,
&e.size,
&e.position,
parent_direction,
)
}
TemplateElement::CurrentDate(e) => {
let text = resolved
.texts
.get(&e.id)
.map(|s| s.as_str())
.unwrap_or("");
build_text_leaf(
taffy,
node_map,
&e.id,
"current_date",
text,
&e.style,
&e.size,
&e.position,
parent_direction,
)
}
TemplateElement::CalculatedText(e) => {
let text = resolved
.texts
.get(&e.id)
.map(|s| s.as_str())
.unwrap_or("");
build_text_leaf(
taffy,
node_map,
&e.id,
"calculated_text",
text,
&e.style,
&e.size,
&e.position,
parent_direction,
)
}
TemplateElement::Line(e) => {
let stroke_w = e.style.stroke_width.unwrap_or(0.5);
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
// 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));
}
let node = taffy.new_leaf(leaf_style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "line".to_string(),
content: Some(ResolvedContent::Line),
style: ResolvedStyle {
stroke_color: e.style.stroke_color.clone(),
stroke_width: Some(stroke_w),
..Default::default()
},
children_ids: vec![],
},
);
Ok(node)
}
TemplateElement::Image(e) => {
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
let src = resolved.images.get(&e.id).cloned().unwrap_or_default();
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "image".to_string(),
content: Some(ResolvedContent::Image { src }),
style: ResolvedStyle {
object_fit: e.style.object_fit.clone(),
..Default::default()
},
children_ids: vec![],
},
);
Ok(node)
}
TemplateElement::Barcode(e) => {
let mut style = sizing::leaf_style(&e.size, &e.position, parent_direction);
let value = resolved.barcodes.get(&e.id).cloned().unwrap_or_default();
// Barcode leaf'e minimum boyut ver (MeasureFunc yok, Auto=0 olur)
let is_qr = e.format == "qr";
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));
}
if matches!(e.size.width, SizeValue::Auto) {
style.min_size.width = Dimension::length(mm_to_pt(default_w));
}
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "barcode".to_string(),
content: Some(ResolvedContent::Barcode {
format: e.format.clone(),
value,
}),
style: ResolvedStyle {
barcode_color: e.style.color.clone(),
barcode_include_text: e.style.include_text,
..Default::default()
},
children_ids: vec![],
},
);
Ok(node)
}
TemplateElement::RepeatingTable(e) => {
// Tabloyu container ağacına expand et (measurer ile auto sütun genişlikleri hesaplanır)
let expanded = table_layout::expand_table(e, resolved, measurer, page_width_mm);
// Expand edilmiş tablo cell'lerinin text'lerini resolved'a ekle
// (expand_table StaticText'ler üretir, bunların text'leri zaten content'te)
let mut table_resolved = resolved.clone();
register_expanded_texts(
&TemplateElement::Container(expanded.clone()),
&mut table_resolved,
);
// Container olarak build et
build_container(
&expanded,
taffy,
node_map,
&table_resolved,
parent_direction,
measurer,
page_width_mm,
)
}
TemplateElement::Shape(e) => {
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "shape".to_string(),
content: Some(ResolvedContent::Shape {
shape_type: e.shape_type.clone(),
}),
style: ResolvedStyle {
background_color: e.style.background_color.clone(),
border_color: e.style.border_color.clone(),
border_width: e.style.border_width,
border_radius: e.style.border_radius,
..Default::default()
},
children_ids: vec![],
},
);
Ok(node)
}
TemplateElement::Checkbox(e) => {
let checked_str = resolved.texts.get(&e.id).map(|s| s.as_str()).unwrap_or("false");
let checked = checked_str == "true";
let box_size_mm = e.style.size.unwrap_or(4.0);
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
// Auto size → square based on style.size
let mut leaf_style = style;
if matches!(e.size.width, SizeValue::Auto) {
leaf_style.size.width = Dimension::length(mm_to_pt(box_size_mm));
}
if matches!(e.size.height, SizeValue::Auto) {
leaf_style.size.height = Dimension::length(mm_to_pt(box_size_mm));
}
let node = taffy.new_leaf(leaf_style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "checkbox".to_string(),
content: Some(ResolvedContent::Checkbox { checked }),
style: ResolvedStyle {
color: e.style.check_color.clone(),
border_color: e.style.border_color.clone(),
border_width: e.style.border_width,
..Default::default()
},
children_ids: vec![],
},
);
Ok(node)
}
TemplateElement::RichText(e) => {
let spans = resolved.rich_texts.get(&e.id).cloned().unwrap_or_default();
let rich_span_measures: Vec<crate::text_measure::RichSpanMeasure> = spans
.iter()
.map(|s| crate::text_measure::RichSpanMeasure {
text: s.text.clone(),
font_family: s.font_family.clone(),
font_size_pt: s.font_size.unwrap_or(11.0) as f32,
font_weight: s.font_weight.clone(),
})
.collect();
let max_font_size_pt = rich_span_measures
.iter()
.map(|s| s.font_size_pt)
.fold(11.0f32, f32::max);
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
let context = MeasureContext {
text: String::new(),
font_family: None,
font_size_pt: max_font_size_pt,
font_weight: None,
rich_spans: Some(rich_span_measures),
};
let node = taffy.new_leaf_with_context(style, context)?;
// ResolvedContent::RichText span'ları oluştur
let resolved_spans: Vec<crate::ResolvedRichSpan> = spans
.iter()
.map(|s| crate::ResolvedRichSpan {
text: s.text.clone(),
font_size: s.font_size,
font_weight: s.font_weight.clone(),
font_family: s.font_family.clone(),
color: s.color.clone(),
})
.collect();
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "rich_text".to_string(),
content: Some(ResolvedContent::RichText { spans: resolved_spans }),
style: ResolvedStyle {
font_size: e.style.font_size,
font_weight: e.style.font_weight.clone(),
font_family: e.style.font_family.clone(),
color: e.style.color.clone(),
text_align: e.style.align.clone(),
..Default::default()
},
children_ids: vec![],
},
);
Ok(node)
}
TemplateElement::Chart(e) => {
let mut style = sizing::leaf_style(&e.size, &e.position, parent_direction);
// Default minimum boyut — Auto ise chart cok kucuk olmasin
if matches!(e.size.width, SizeValue::Auto) {
style.min_size.width = Dimension::length(mm_to_pt(80.0));
}
if matches!(e.size.height, SizeValue::Auto) {
style.min_size.height = Dimension::length(mm_to_pt(60.0));
}
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "chart".to_string(),
content: None, // SVG collect_layout'ta uretilecek
style: ResolvedStyle::default(),
children_ids: vec![],
},
);
Ok(node)
}
TemplateElement::PageBreak(e) => {
// Küçük yükseklik — editörde görünür olması için (0.5mm ≈ 1.4pt)
let style = Style {
size: Size {
width: Dimension::auto(),
height: Dimension::length(mm_to_pt(0.5)),
},
..Default::default()
};
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "page_break".to_string(),
content: None,
style: ResolvedStyle::default(),
children_ids: vec![],
},
);
Ok(node)
}
}
}
/// Expand edilmiş tablo cell'lerinin text'lerini ResolvedData'ya kaydet
fn register_expanded_texts(el: &TemplateElement, resolved: &mut ResolvedData) {
match el {
TemplateElement::StaticText(e) => {
resolved.texts.insert(e.id.clone(), e.content.clone());
}
TemplateElement::Container(e) => {
for child in &e.children {
register_expanded_texts(child, resolved);
}
}
_ => {}
}
}
/// Text leaf node oluştur (static_text, text, page_number için ortak)
fn build_text_leaf(
taffy: &mut TaffyTree<MeasureContext>,
node_map: &mut HashMap<NodeId, NodeInfo>,
id: &str,
element_type: &str,
text: &str,
text_style: &TextStyle,
size: &SizeConstraint,
position: &PositionMode,
parent_direction: Option<&str>,
) -> Result<NodeId, LayoutError> {
let style = sizing::leaf_style(size, position, parent_direction);
let font_size_pt = text_style.font_size.unwrap_or(11.0) as f32;
let context = MeasureContext {
text: text.to_string(),
font_family: text_style.font_family.clone(),
font_size_pt,
font_weight: text_style.font_weight.clone(),
rich_spans: None,
};
let node = taffy.new_leaf_with_context(style, context)?;
node_map.insert(
node,
NodeInfo {
element_id: id.to_string(),
element_type: element_type.to_string(),
content: Some(ResolvedContent::Text {
value: text.to_string(),
}),
style: ResolvedStyle {
font_size: text_style.font_size,
font_weight: text_style.font_weight.clone(),
font_style: text_style.font_style.clone(),
font_family: text_style.font_family.clone(),
color: text_style.color.clone(),
text_align: text_style.align.clone(),
..Default::default()
},
children_ids: vec![],
},
);
Ok(node)
}
/// Taffy MeasureFunc: text leaf node'ları ölç
fn measure_leaf(
known_dimensions: Size<Option<f32>>,
available_space: Size<AvailableSpace>,
context: Option<&mut MeasureContext>,
measurer: &mut TextMeasurer,
) -> Size<f32> {
let Some(ctx) = context else {
// Context yoksa (line, image vs.) → taffy style'daki boyutu kullan
return Size {
width: known_dimensions.width.unwrap_or(0.0),
height: known_dimensions.height.unwrap_or(0.0),
};
};
// Bilinen boyutlar varsa onları kullan
if let (Some(w), Some(h)) = (known_dimensions.width, known_dimensions.height) {
return Size {
width: w,
height: h,
};
}
let available_width = match available_space.width {
AvailableSpace::Definite(w) => Some(w),
AvailableSpace::MaxContent => None,
AvailableSpace::MinContent => Some(0.0),
};
let (measured_w, measured_h) = if let Some(ref rich_spans) = ctx.rich_spans {
measurer.measure_rich_text(rich_spans, available_width)
} else {
measurer.measure(
&ctx.text,
ctx.font_family.as_deref(),
ctx.font_size_pt,
ctx.font_weight.as_deref(),
available_width,
)
};
Size {
width: known_dimensions.width.unwrap_or(measured_w),
height: known_dimensions.height.unwrap_or(measured_h),
}
}
/// Taffy layout sonuçlarını ElementLayout listesine dönüştür (recursive).
/// Pozisyon biriktirmesi f64 (mm) cinsinde yapılır — f32'de toplama hassasiyet kaybına yol açar.
fn collect_layout(
taffy: &TaffyTree<MeasureContext>,
node: NodeId,
node_map: &HashMap<NodeId, NodeInfo>,
resolved: &ResolvedData,
parent_x_mm: f64,
parent_y_mm: f64,
) -> Result<Vec<ElementLayout>, LayoutError> {
let mut elements = Vec::new();
let Some(info) = node_map.get(&node) else {
return Ok(elements);
};
let layout = taffy.layout(node)?;
let x_mm = parent_x_mm + pt_to_mm(layout.location.x);
let y_mm = parent_y_mm + pt_to_mm(layout.location.y);
let w_mm = pt_to_mm(layout.size.width);
let h_mm = pt_to_mm(layout.size.height);
// Chart elementleri icin SVG uret (boyutlar artik belli)
let content = if info.element_type == "chart" {
resolved.charts.get(&info.element_id).map(|cd| {
use crate::{ChartRenderData, ChartSeriesData};
use crate::chart_layout::DEFAULT_COLORS;
// Renk paleti olustur
let n_colors = cd.categories.len().max(cd.series.len()).max(1);
let colors: Vec<String> = (0..n_colors)
.map(|i| {
cd.style.colors.as_ref()
.and_then(|c| c.get(i).cloned())
.unwrap_or_else(|| DEFAULT_COLORS[i % DEFAULT_COLORS.len()].to_string())
})
.collect();
ResolvedContent::Chart {
svg: crate::chart_render::render_svg(cd, w_mm, h_mm),
chart_data: ChartRenderData {
chart_type: cd.chart_type.clone(),
categories: cd.categories.clone(),
series: cd.series.iter().map(|s| ChartSeriesData {
name: s.name.clone(),
values: s.values.clone(),
}).collect(),
title_text: cd.title.as_ref().map(|t| t.text.clone()),
title_font_size: cd.title.as_ref().and_then(|t| t.font_size),
title_color: cd.title.as_ref().and_then(|t| t.color.clone()),
colors,
show_labels: cd.labels.as_ref().is_some_and(|l| l.show),
label_font_size: cd.labels.as_ref().and_then(|l| l.font_size),
show_grid: cd.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true),
grid_color: cd.axis.as_ref().and_then(|a| a.grid_color.clone()),
bar_gap: cd.style.bar_gap,
stacked: matches!(cd.group_mode, Some(dreport_core::models::GroupMode::Stacked)),
inner_radius: cd.style.inner_radius,
show_points: cd.style.show_points,
line_width: cd.style.line_width,
background_color: cd.style.background_color.clone(),
label_color: cd.labels.as_ref().and_then(|l| l.color.clone()),
legend_show: cd.legend.as_ref().is_some_and(|l| l.show),
legend_position: cd.legend.as_ref().and_then(|l| l.position.clone()),
legend_font_size: cd.legend.as_ref().and_then(|l| l.font_size),
x_label: cd.axis.as_ref().and_then(|a| a.x_label.clone()),
y_label: cd.axis.as_ref().and_then(|a| a.y_label.clone()),
title_align: cd.title.as_ref().and_then(|t| t.align.clone()),
},
}
})
} else {
info.content.clone()
};
elements.push(ElementLayout {
id: info.element_id.clone(),
x_mm,
y_mm,
width_mm: w_mm,
height_mm: h_mm,
element_type: info.element_type.clone(),
content,
style: info.style.clone(),
children: info.children_ids.clone(),
});
// Child node'ları da topla
let children = taffy.children(node)?;
for child_node in children {
let child_elements = collect_layout(taffy, child_node, node_map, resolved, x_mm, y_mm)?;
elements.extend(child_elements);
}
Ok(elements)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
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()],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Auto,
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
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(),
break_inside: "auto".to_string(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
style: TextStyle {
font_size: Some(18.0),
font_weight: Some("bold".to_string()),
..Default::default()
},
content: "FATURA".to_string(),
}),
TemplateElement::Line(LineElement {
id: "line1".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
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,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
style: TextStyle {
font_size: Some(11.0),
..Default::default()
},
content: "Bu bir test belgesidir.".to_string(),
}),
],
},
}
}
#[test]
fn test_basic_layout() {
let template = simple_template();
let data = json!({});
let resolved = crate::data_resolve::resolve_template(&template, &data);
let fonts = crate::text_measure::load_test_fonts();
let mut measurer = TextMeasurer::new(&fonts);
let result = compute(&template, &resolved, &mut measurer).unwrap();
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);
println!("Layout sonuçları:");
for el in &page.elements {
println!(
" {} ({}): x={:.1}mm y={:.1}mm w={:.1}mm h={:.1}mm",
el.id, el.element_type, el.x_mm, el.y_mm, el.width_mm, el.height_mm
);
}
// Root container + 3 children = en az 4 element
assert!(page.elements.len() >= 4);
// Root container pozisyonu: (0, 0)
let root = page.elements.iter().find(|e| e.id == "root").unwrap();
assert!(root.x_mm.abs() < 0.1);
assert!(root.y_mm.abs() < 0.1);
// Title: padding'in içinde olmalı (x ≈ 15mm, y ≈ 15mm)
let title = page.elements.iter().find(|e| e.id == "title").unwrap();
assert!((title.x_mm - 15.0).abs() < 1.0);
assert!((title.y_mm - 15.0).abs() < 1.0);
assert!(
title.width_mm > 100.0,
"title width={:.1}mm, expected > 100mm",
title.width_mm
);
// Line: title'dan sonra (y > title.y)
let line = page.elements.iter().find(|e| e.id == "line1").unwrap();
assert!(line.y_mm > title.y_mm);
assert!(line.height_mm < 2.0);
// Body: line'dan sonra
let body = page.elements.iter().find(|e| e.id == "body").unwrap();
assert!(body.y_mm > line.y_mm);
}
#[test]
fn test_row_container() {
let template = Template {
id: "test".to_string(),
name: "Test".to_string(),
page: PageSettings {
width: 210.0,
height: 297.0,
},
fonts: vec![],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Auto,
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
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(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::Container(ContainerElement {
id: "row".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
direction: "row".to_string(),
gap: 5.0,
padding: Padding {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: 0.0,
},
align: "start".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "left".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
style: TextStyle {
font_size: Some(11.0),
..Default::default()
},
content: "Sol".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "right".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
style: TextStyle {
font_size: Some(11.0),
..Default::default()
},
content: "Sağ".to_string(),
}),
],
})],
},
};
let data = json!({});
let resolved = crate::data_resolve::resolve_template(&template, &data);
let fonts = crate::text_measure::load_test_fonts();
let mut measurer = TextMeasurer::new(&fonts);
let result = compute(&template, &resolved, &mut measurer).unwrap();
let page = &result.pages[0];
let left = page.elements.iter().find(|e| e.id == "left").unwrap();
let right = page.elements.iter().find(|e| e.id == "right").unwrap();
// İki eleman eşit genişlikte olmalı (fr: 1 + fr: 1)
assert!((left.width_mm - right.width_mm).abs() < 1.0);
// Right, left'in sağında olmalı
assert!(right.x_mm > left.x_mm + left.width_mm - 1.0);
// İkisinin toplam genişliği ≈ 190mm (210 - 10 - 10 padding, - 5mm gap)
let total = left.width_mm + right.width_mm + 5.0; // gap
assert!((total - 190.0).abs() < 2.0);
println!("Row layout:");
for el in &page.elements {
println!(
" {} ({}): x={:.1}mm y={:.1}mm w={:.1}mm h={:.1}mm",
el.id, el.element_type, el.x_mm, el.y_mm, el.width_mm, el.height_mm
);
}
}
#[test]
fn test_absolute_positioning() {
let template = Template {
id: "test".to_string(),
name: "Test".to_string(),
page: PageSettings {
width: 210.0,
height: 297.0,
},
fonts: vec![],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Auto,
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
direction: "column".to_string(),
gap: 0.0,
padding: Padding {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: 0.0,
},
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "abs_text".to_string(),
position: PositionMode::Absolute { x: 50.0, y: 80.0 },
size: SizeConstraint {
width: SizeValue::Fixed { value: 100.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
style: TextStyle {
font_size: Some(14.0),
..Default::default()
},
content: "Absolute".to_string(),
})],
},
};
let data = json!({});
let resolved = crate::data_resolve::resolve_template(&template, &data);
let fonts = crate::text_measure::load_test_fonts();
let mut measurer = TextMeasurer::new(&fonts);
let result = compute(&template, &resolved, &mut measurer).unwrap();
let page = &result.pages[0];
let abs = page.elements.iter().find(|e| e.id == "abs_text").unwrap();
// Absolute pozisyon: x ≈ 50mm, y ≈ 80mm
assert!((abs.x_mm - 50.0).abs() < 1.0);
assert!((abs.y_mm - 80.0).abs() < 1.0);
assert!((abs.width_mm - 100.0).abs() < 1.0);
println!(
"Absolute: x={:.1}mm y={:.1}mm w={:.1}mm h={:.1}mm",
abs.x_mm, abs.y_mm, abs.width_mm, abs.height_mm
);
}
#[test]
fn test_invoice_header_overflow() {
// App.vue'daki fatura şablonunun header kısmını birebir test et
let sz_auto = SizeConstraint {
width: SizeValue::Auto,
height: SizeValue::Auto,
..Default::default()
};
let sz_fr_auto = SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
};
let p0 = Padding::default();
let template = Template {
id: "test".to_string(),
name: "Test".to_string(),
page: PageSettings {
width: 210.0,
height: 297.0,
},
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: sz_auto.clone(),
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(),
break_inside: "auto".to_string(),
children: vec![
// Header row
TemplateElement::Container(ContainerElement {
id: "c_header".to_string(),
position: PositionMode::Flow,
size: sz_fr_auto.clone(),
direction: "row".to_string(),
gap: 5.0,
padding: p0.clone(),
align: "start".to_string(),
justify: "space-between".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![
// Sol: firma bilgileri
TemplateElement::Container(ContainerElement {
id: "c_firma".to_string(),
position: PositionMode::Flow,
size: sz_fr_auto.clone(),
direction: "column".to_string(),
gap: 1.0,
padding: p0.clone(),
align: "start".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_unvan".to_string(),
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
font_size: Some(14.0),
font_weight: Some("bold".to_string()),
..Default::default()
},
content: "Teknova Yazılım ve Danışmanlık A.Ş.".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_adres".to_string(),
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
font_size: Some(9.0),
..Default::default()
},
content: "Levent Mah. Inovasyon Sk. No:42 Kat:5"
.to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_il".to_string(),
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
font_size: Some(9.0),
..Default::default()
},
content: "Istanbul".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_tel".to_string(),
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
font_size: Some(9.0),
..Default::default()
},
content: "Tel: +90 212 555 0042".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_vd".to_string(),
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
font_size: Some(9.0),
..Default::default()
},
content: "VD: Levent VD".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_vn".to_string(),
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
font_size: Some(9.0),
..Default::default()
},
content: "VN: 1234567890".to_string(),
}),
],
}),
// Sağ: fatura başlığı
TemplateElement::Container(ContainerElement {
id: "c_fatura_baslik".to_string(),
position: PositionMode::Flow,
size: sz_auto.clone(),
direction: "column".to_string(),
gap: 2.0,
padding: p0.clone(),
align: "end".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "el_fatura_baslik".to_string(),
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
font_size: Some(18.0),
font_weight: Some("bold".to_string()),
..Default::default()
},
content: "FATURA".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_fatura_no".to_string(),
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
font_size: Some(10.0),
..Default::default()
},
content: "No: FTR-2026-001547".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_fatura_tarih".to_string(),
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
font_size: Some(10.0),
..Default::default()
},
content: "Tarih: 2026-03-29".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_fatura_vade".to_string(),
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
font_size: Some(10.0),
..Default::default()
},
content: "Vade: 2026-04-28".to_string(),
}),
],
}),
],
}),
],
},
};
let data = json!({});
let resolved = crate::data_resolve::resolve_template(&template, &data);
let fonts = crate::text_measure::load_test_fonts();
let mut measurer = TextMeasurer::new(&fonts);
let result = compute(&template, &resolved, &mut measurer).unwrap();
let page = &result.pages[0];
println!("\n=== FATURA HEADER LAYOUT ===");
for el in &page.elements {
println!(
" {:20} ({:12}): x={:9.4}mm y={:9.4}mm w={:9.4}mm h={:9.4}mm",
el.id, el.element_type, el.x_mm, el.y_mm, el.width_mm, el.height_mm
);
}
// c_header sınırlarını kontrol et
let header = page.elements.iter().find(|e| e.id == "c_header").unwrap();
let fatura_baslik = page
.elements
.iter()
.find(|e| e.id == "c_fatura_baslik")
.unwrap();
let header_right = header.x_mm + header.width_mm;
let header_bottom = header.y_mm + header.height_mm;
let fb_right = fatura_baslik.x_mm + fatura_baslik.width_mm;
let fb_bottom = fatura_baslik.y_mm + fatura_baslik.height_mm;
println!(
"\n c_header sağ kenar: {:.1}mm, alt kenar: {:.1}mm",
header_right, header_bottom
);
println!(
" c_fatura_baslik sağ kenar: {:.1}mm, alt kenar: {:.1}mm",
fb_right, fb_bottom
);
println!(" Yatay taşma: {:.1}mm", fb_right - header_right);
println!(" Dikey taşma: {:.1}mm", fb_bottom - header_bottom);
// c_fatura_baslik, c_header'ın dışına taşmamalı
assert!(
fb_right <= header_right + 0.5,
"c_fatura_baslik sağ kenarı ({:.1}mm) c_header sağ kenarını ({:.1}mm) aşıyor!",
fb_right,
header_right
);
assert!(
fb_bottom <= header_bottom + 0.5,
"c_fatura_baslik alt kenarı ({:.1}mm) c_header alt kenarını ({:.1}mm) aşıyor!",
fb_bottom,
header_bottom
);
}
}