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::{self, TableExpandCache}; 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, style: ResolvedStyle, children_ids: Vec, } /// Taffy leaf node'lar için ölçüm context'i struct MeasureContext { text: String, font_family: Option, font_size_pt: f32, font_weight: Option, /// Rich text span'ları (varsa text/font_family/font_size_pt/font_weight yok sayılır) rich_spans: Option>, } /// Ana layout hesaplama fonksiyonu. pub fn compute( template: &Template, resolved: &ResolvedData, measurer: &mut TextMeasurer, ) -> Result { 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::::new(); taffy.disable_rounding(); let mut node_map: HashMap = HashMap::new(); let mut table_cache = TableExpandCache::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, &mut table_cache, )?; // 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, f64), LayoutError> { let mut taffy = TaffyTree::::new(); taffy.disable_rounding(); let mut node_map: HashMap = HashMap::new(); let mut table_cache = TableExpandCache::new(); let section_node = build_container( container, &mut taffy, &mut node_map, resolved, None, measurer, page_width_mm, &mut table_cache, )?; 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 { 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) { if let TemplateElement::Container(c) = el { modes.insert(c.base.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 { 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) { 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.base.id.clone()); } } _ => {} } } /// Container element'ini taffy node ağacına ekle (recursive) #[allow(clippy::too_many_arguments)] fn build_container( el: &ContainerElement, taffy: &mut TaffyTree, node_map: &mut HashMap, resolved: &ResolvedData, parent_direction: Option<&str>, measurer: &mut TextMeasurer, page_width_mm: f64, table_cache: &mut TableExpandCache, ) -> Result { 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.base.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 { // Koşullu render: hidden_elements'te olan elemanları atla if resolved.hidden_elements.contains(child.id()) { continue; } let child_node = build_element( child, taffy, node_map, resolved, Some(direction), measurer, content_width_mm, table_cache, )?; 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.base.id.clone(), element_type: el.type_str().to_string(), content: None, style: (&el.style).into(), children_ids, }, ); Ok(node) } /// Leaf node oluştur ve node_map'e kaydet (tekrarlayan boilerplate'i ortadan kaldırır). fn register_leaf( taffy: &mut TaffyTree, node_map: &mut HashMap, style: Style, id: &str, element_type: &str, content: Option, resolved_style: ResolvedStyle, ) -> Result { let node = taffy.new_leaf(style)?; node_map.insert( node, NodeInfo { element_id: id.to_string(), element_type: element_type.to_string(), content, style: resolved_style, children_ids: vec![], }, ); Ok(node) } /// Herhangi bir element tipini taffy node'a çevir #[allow(clippy::too_many_arguments)] fn build_element( el: &TemplateElement, taffy: &mut TaffyTree, node_map: &mut HashMap, resolved: &ResolvedData, parent_direction: Option<&str>, measurer: &mut TextMeasurer, page_width_mm: f64, table_cache: &mut TableExpandCache, ) -> Result { match el { TemplateElement::Container(e) => build_container( e, taffy, node_map, resolved, parent_direction, measurer, page_width_mm, table_cache, ), TemplateElement::StaticText(e) => build_resolved_text_leaf( &e.base, e.type_str(), &e.style, taffy, node_map, resolved, parent_direction, &e.content, ), TemplateElement::Text(e) => build_resolved_text_leaf( &e.base, e.type_str(), &e.style, taffy, node_map, resolved, parent_direction, "", ), TemplateElement::PageNumber(e) => build_resolved_text_leaf( &e.base, e.type_str(), &e.style, taffy, node_map, resolved, parent_direction, "1 / 1", ), TemplateElement::CurrentDate(e) => build_resolved_text_leaf( &e.base, e.type_str(), &e.style, taffy, node_map, resolved, parent_direction, "", ), TemplateElement::CalculatedText(e) => build_resolved_text_leaf( &e.base, e.type_str(), &e.style, taffy, node_map, resolved, parent_direction, "", ), TemplateElement::Line(e) => { let stroke_w = e.style.stroke_width.unwrap_or(0.5); let mut style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction); if matches!(e.base.size.height, SizeValue::Auto) { style.size.height = Dimension::length(mm_to_pt(stroke_w)); } let mut rs: ResolvedStyle = (&e.style).into(); rs.stroke_width = Some(stroke_w); register_leaf( taffy, node_map, style, &e.base.id, e.type_str(), Some(ResolvedContent::Line), rs, ) } TemplateElement::Image(e) => { let style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction); let src = resolved.images.get(&e.base.id).cloned().unwrap_or_default(); register_leaf( taffy, node_map, style, &e.base.id, e.type_str(), Some(ResolvedContent::Image { src }), (&e.style).into(), ) } TemplateElement::Barcode(e) => { let mut style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction); let value = resolved.barcodes.get(&e.base.id).cloned().unwrap_or_default(); let is_qr = e.format == "qr"; if matches!(e.base.size.height, SizeValue::Auto) { style.min_size.height = Dimension::length(mm_to_pt(if is_qr { 20.0 } else { 15.0 })); } if matches!(e.base.size.width, SizeValue::Auto) { style.min_size.width = Dimension::length(mm_to_pt(if is_qr { 20.0 } else { 40.0 })); } register_leaf( taffy, node_map, style, &e.base.id, e.type_str(), Some(ResolvedContent::Barcode { format: e.format.clone(), value }), (&e.style).into(), ) } TemplateElement::RepeatingTable(e) => { // Tabloyu container ağacına expand et (cache ile) let expanded = table_layout::expand_table_cached( e, resolved, measurer, page_width_mm, table_cache, ); // 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, table_cache, ) } TemplateElement::Shape(e) => { let style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction); register_leaf( taffy, node_map, style, &e.base.id, e.type_str(), Some(ResolvedContent::Shape { shape_type: e.shape_type.clone() }), (&e.style).into(), ) } TemplateElement::Checkbox(e) => { let checked = resolved .texts .get(&e.base.id) .map(|s| s == "true") .unwrap_or(false); let box_size_mm = e.style.size.unwrap_or(4.0); let mut style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction); if matches!(e.base.size.width, SizeValue::Auto) { style.size.width = Dimension::length(mm_to_pt(box_size_mm)); } if matches!(e.base.size.height, SizeValue::Auto) { style.size.height = Dimension::length(mm_to_pt(box_size_mm)); } register_leaf( taffy, node_map, style, &e.base.id, e.type_str(), Some(ResolvedContent::Checkbox { checked }), (&e.style).into(), ) } TemplateElement::RichText(e) => { let spans = resolved.rich_texts.get(&e.base.id).cloned().unwrap_or_default(); let rich_span_measures: Vec = 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.base.size, &e.base.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 = 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.base.id.clone(), element_type: e.type_str().to_string(), content: Some(ResolvedContent::RichText { spans: resolved_spans, }), style: (&e.style).into(), children_ids: vec![], }, ); Ok(node) } TemplateElement::Chart(e) => { let mut style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction); if matches!(e.base.size.width, SizeValue::Auto) { style.min_size.width = Dimension::length(mm_to_pt(80.0)); } if matches!(e.base.size.height, SizeValue::Auto) { style.min_size.height = Dimension::length(mm_to_pt(60.0)); } register_leaf( taffy, node_map, style, &e.base.id, e.type_str(), None, // SVG collect_layout'ta üretilecek ResolvedStyle::default(), ) } TemplateElement::PageBreak(e) => { let style = Style { size: Size { width: Dimension::auto(), height: Dimension::length(mm_to_pt(0.5)), }, ..Default::default() }; register_leaf( taffy, node_map, style, &e.base.id, e.type_str(), None, ResolvedStyle::default(), ) } } } /// 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.base.id.clone(), e.content.clone()); } TemplateElement::Container(e) => { for child in &e.children { register_expanded_texts(child, resolved); } } _ => {} } } /// Generic text leaf builder — HasTextStyle trait ile text-benzeri elementleri tek yerde build eder fn build_resolved_text_leaf( el_base: &ElementBase, el_type_str: &str, text_style: &TextStyle, taffy: &mut TaffyTree, node_map: &mut HashMap, resolved: &ResolvedData, parent_direction: Option<&str>, fallback_text: &str, ) -> Result { let text = resolved .texts .get(&el_base.id) .map(|s| s.as_str()) .unwrap_or(fallback_text); build_text_leaf( taffy, node_map, &el_base.id, el_type_str, text, text_style, &el_base.size, &el_base.position, parent_direction, ) } /// Text leaf node oluştur (static_text, text, page_number için ortak) #[allow(clippy::too_many_arguments)] fn build_text_leaf( taffy: &mut TaffyTree, node_map: &mut HashMap, id: &str, element_type: &str, text: &str, text_style: &TextStyle, size: &SizeConstraint, position: &PositionMode, parent_direction: Option<&str>, ) -> Result { 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: text_style.into(), children_ids: vec![], }, ); Ok(node) } /// Taffy MeasureFunc: text leaf node'ları ölç fn measure_leaf( known_dimensions: Size>, available_space: Size, context: Option<&mut MeasureContext>, measurer: &mut TextMeasurer, ) -> Size { 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, node: NodeId, node_map: &HashMap, resolved: &ResolvedData, parent_x_mm: f64, parent_y_mm: f64, ) -> Result, 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 için SVG üret (boyutlar artık belli) let content = if info.element_type == "chart" { resolved.charts.get(&info.element_id).map(|cd| { ResolvedContent::Chart { svg: crate::chart_render::render_svg(cd, w_mm, h_mm), chart_data: Box::new(crate::ChartRenderData::from(cd)), } }) } 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, locale: None, root: ContainerElement { base: ElementBase::flow("root".to_string(), 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 { base: ElementBase::flow("title".to_string(), 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 { base: ElementBase::flow("line1".to_string(), 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 { base: ElementBase::flow("body".to_string(), 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, locale: None, root: ContainerElement { base: ElementBase::flow("root".to_string(), 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 { base: ElementBase::flow("row".to_string(), 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 { base: ElementBase::flow("left".to_string(), 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 { base: ElementBase::flow("right".to_string(), 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, locale: None, root: ContainerElement { base: ElementBase::flow("root".to_string(), 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 { base: ElementBase { id: "abs_text".to_string(), condition: None, 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, locale: None, root: ContainerElement { base: ElementBase::flow("root".to_string(), 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 { base: ElementBase::flow("c_header".to_string(), 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 { base: ElementBase::flow("c_firma".to_string(), 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 { base: ElementBase::flow("el_firma_unvan".to_string(), 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 { base: ElementBase::flow("el_firma_adres".to_string(), 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 { base: ElementBase::flow("el_firma_il".to_string(), sz_auto.clone()), style: TextStyle { font_size: Some(9.0), ..Default::default() }, content: "Istanbul".to_string(), }), TemplateElement::StaticText(StaticTextElement { base: ElementBase::flow("el_firma_tel".to_string(), sz_auto.clone()), style: TextStyle { font_size: Some(9.0), ..Default::default() }, content: "Tel: +90 212 555 0042".to_string(), }), TemplateElement::StaticText(StaticTextElement { base: ElementBase::flow("el_firma_vd".to_string(), sz_auto.clone()), style: TextStyle { font_size: Some(9.0), ..Default::default() }, content: "VD: Levent VD".to_string(), }), TemplateElement::StaticText(StaticTextElement { base: ElementBase::flow("el_firma_vn".to_string(), sz_auto.clone()), style: TextStyle { font_size: Some(9.0), ..Default::default() }, content: "VN: 1234567890".to_string(), }), ], }), // Sağ: fatura başlığı TemplateElement::Container(ContainerElement { base: ElementBase::flow("c_fatura_baslik".to_string(), 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 { base: ElementBase::flow("el_fatura_baslik".to_string(), 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 { base: ElementBase::flow("el_fatura_no".to_string(), sz_auto.clone()), style: TextStyle { font_size: Some(10.0), ..Default::default() }, content: "No: FTR-2026-001547".to_string(), }), TemplateElement::StaticText(StaticTextElement { base: ElementBase::flow("el_fatura_tarih".to_string(), sz_auto.clone()), style: TextStyle { font_size: Some(10.0), ..Default::default() }, content: "Tarih: 2026-03-29".to_string(), }), TemplateElement::StaticText(StaticTextElement { base: ElementBase::flow("el_fatura_vade".to_string(), 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 ); } }