From 09dc2b4ecd06e82cb50a36554a90f83cd556fdbc Mon Sep 17 00:00:00 2001 From: Duhan BALCI Date: Tue, 7 Apr 2026 02:55:16 +0300 Subject: [PATCH] improvements --- IMPROVEMENTS.md | 10 +- core/src/models.rs | 118 +++++ .../__tests__/useSnapGuides.test.ts | 189 ++++++++ .../composables/__tests__/useUndoRedo.test.ts | 152 ++++++ layout-engine/src/chart_layout.rs | 3 + layout-engine/src/chart_render.rs | 251 +++++++++- layout-engine/src/data_resolve.rs | 89 +++- layout-engine/src/page_break.rs | 255 ++++++++++ layout-engine/src/pdf_render.rs | 303 +++++++++++- layout-engine/src/sizing.rs | 2 + layout-engine/src/table_layout.rs | 11 + layout-engine/src/tree.rs | 32 ++ layout-engine/tests/improvements_test.rs | 447 ++++++++++++++++++ layout-engine/tests/layout_integration.rs | 10 + layout-engine/tests/pdf_render_test.rs | 16 + .../tests/snapshots/chart_test_svg.html | 2 +- 16 files changed, 1876 insertions(+), 14 deletions(-) create mode 100644 frontend/src/composables/__tests__/useSnapGuides.test.ts create mode 100644 frontend/src/composables/__tests__/useUndoRedo.test.ts diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index 6f30503..1a6c55f 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -657,7 +657,7 @@ pub fn load_test_fonts() -> Vec { ... } ## 7. Yeni Ozellik Onerileri -### 7.1 Conditional Rendering `[IMPLEMENTE EDILMEDI]` +### 7.1 Conditional Rendering `[IMPLEMENTE EDILDI]` **Aciklama:** Template'te `v-if` benzeri kosullu gosterim. Data'daki bir alana gore eleman goster/gizle. @@ -714,7 +714,7 @@ Tablo disinda array verisiyle tekrarlayan serbest-form container. Ornegin bir ka --- -### 7.5 Coklu Dil / Lokalizasyon Destegi `[IMPLEMENTE EDILMEDI]` +### 7.5 Coklu Dil / Lokalizasyon Destegi `[IMPLEMENTE EDILDI]` **Aciklama:** Currency, date ve sayi formatlama icin lokalizasyon. Su an Turk lokali hardcoded. @@ -745,7 +745,7 @@ Template'te header/footer tanimi icin `condition` alani: --- -### 7.7 QR Code Eleman Tipi `[IMPLEMENTE EDILMEDI]` +### 7.7 QR Code Eleman Tipi `[bu var zaten, barcode özelliklerinden barkod tipi seçilebiliyor qr olarak]` **Mevcut Durum:** `rxing` crate'i barcode uretimi icin zaten kullaniliyor ve QR Code destegi var. Ancak UI tarafinda ayri bir QR Code eleman tipi tanimlanmamis. @@ -773,7 +773,7 @@ Hazir sablon galerisi — kullanici sifirdan tasarlamak yerine bir sablon secip ## 8. Kucuk Ama Degerli Iyilestirmeler -### 8.1 Chart Legend Tek Seri Durumu `[IMPLEMENTE EDILMEDI]` +### 8.1 Chart Legend Tek Seri Durumu `[IMPLEMENTE EDILDI]` **Dosya:** `layout-engine/src/chart_render.rs` @@ -781,7 +781,7 @@ Hazir sablon galerisi — kullanici sifirdan tasarlamak yerine bir sablon secip --- -### 8.2 Pie Chart Label Kontrolu `[IMPLEMENTE EDILMEDI]` +### 8.2 Pie Chart Label Kontrolu `[IMPLEMENTE EDILDI]` **Dosya:** `layout-engine/src/chart_render.rs` (satirlar 521-551) diff --git a/core/src/models.rs b/core/src/models.rs index 6b28c9d..1a2c82a 100644 --- a/core/src/models.rs +++ b/core/src/models.rs @@ -97,6 +97,20 @@ pub struct ContainerStyle { pub border_style: Option, } +// --- Condition (v-if benzeri koşullu gösterim) --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Condition { + /// Data JSON'daki alan yolu (ör: "toplamlar.iskonto") + pub path: String, + /// Karşılaştırma operatörü: eq, neq, gt, gte, lt, lte, empty, not_empty + pub operator: String, + /// Karşılaştırılacak değer (empty/not_empty için gerekmez) + #[serde(default)] + pub value: Option, +} + // --- Binding --- #[derive(Debug, Clone, Serialize, Deserialize)] @@ -234,6 +248,8 @@ pub struct ChartStyle { #[serde(rename_all = "camelCase")] pub struct ChartElement { pub id: String, + #[serde(default)] + pub condition: Option, pub position: PositionMode, pub size: SizeConstraint, pub chart_type: ChartType, @@ -340,6 +356,26 @@ impl TemplateElement { } } + pub fn condition(&self) -> Option<&Condition> { + match self { + Self::Container(e) => e.condition.as_ref(), + Self::StaticText(e) => e.condition.as_ref(), + Self::Text(e) => e.condition.as_ref(), + Self::Line(e) => e.condition.as_ref(), + Self::RepeatingTable(e) => e.condition.as_ref(), + Self::Image(e) => e.condition.as_ref(), + Self::PageNumber(e) => e.condition.as_ref(), + Self::Barcode(e) => e.condition.as_ref(), + Self::PageBreak(e) => e.condition.as_ref(), + Self::CurrentDate(e) => e.condition.as_ref(), + Self::Shape(e) => e.condition.as_ref(), + Self::Checkbox(e) => e.condition.as_ref(), + Self::CalculatedText(e) => e.condition.as_ref(), + Self::RichText(e) => e.condition.as_ref(), + Self::Chart(e) => e.condition.as_ref(), + } + } + pub fn size(&self) -> &SizeConstraint { static DEFAULT_SIZE: SizeConstraint = SizeConstraint { width: SizeValue::Auto, @@ -373,6 +409,8 @@ impl TemplateElement { #[serde(rename_all = "camelCase")] pub struct RichTextElement { pub id: String, + #[serde(default)] + pub condition: Option, pub position: PositionMode, pub size: SizeConstraint, #[serde(default)] @@ -385,6 +423,8 @@ pub struct RichTextElement { pub struct ContainerElement { pub id: String, #[serde(default)] + pub condition: Option, + #[serde(default)] pub position: PositionMode, #[serde(default)] pub size: SizeConstraint, @@ -424,6 +464,8 @@ fn default_start() -> String { #[serde(rename_all = "camelCase")] pub struct StaticTextElement { pub id: String, + #[serde(default)] + pub condition: Option, pub position: PositionMode, pub size: SizeConstraint, pub style: TextStyle, @@ -434,6 +476,8 @@ pub struct StaticTextElement { #[serde(rename_all = "camelCase")] pub struct TextElement { pub id: String, + #[serde(default)] + pub condition: Option, pub position: PositionMode, pub size: SizeConstraint, pub style: TextStyle, @@ -445,6 +489,8 @@ pub struct TextElement { #[serde(rename_all = "camelCase")] pub struct LineElement { pub id: String, + #[serde(default)] + pub condition: Option, pub position: PositionMode, pub size: SizeConstraint, pub style: LineStyle, @@ -454,6 +500,8 @@ pub struct LineElement { #[serde(rename_all = "camelCase")] pub struct ImageElement { pub id: String, + #[serde(default)] + pub condition: Option, pub position: PositionMode, pub size: SizeConstraint, pub src: Option, @@ -465,6 +513,8 @@ pub struct ImageElement { #[serde(rename_all = "camelCase")] pub struct PageNumberElement { pub id: String, + #[serde(default)] + pub condition: Option, pub position: PositionMode, pub size: SizeConstraint, pub style: TextStyle, @@ -475,6 +525,8 @@ pub struct PageNumberElement { #[serde(rename_all = "camelCase")] pub struct BarcodeElement { pub id: String, + #[serde(default)] + pub condition: Option, pub position: PositionMode, pub size: SizeConstraint, pub format: String, // qr, ean13, ean8, code128, code39 @@ -487,6 +539,8 @@ pub struct BarcodeElement { #[serde(rename_all = "camelCase")] pub struct RepeatingTableElement { pub id: String, + #[serde(default)] + pub condition: Option, pub position: PositionMode, pub size: SizeConstraint, pub data_source: ArrayBinding, @@ -504,12 +558,16 @@ fn default_true() -> Option { #[serde(rename_all = "camelCase")] pub struct PageBreakElement { pub id: String, + #[serde(default)] + pub condition: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CurrentDateElement { pub id: String, + #[serde(default)] + pub condition: Option, pub position: PositionMode, pub size: SizeConstraint, pub style: TextStyle, @@ -520,6 +578,8 @@ pub struct CurrentDateElement { #[serde(rename_all = "camelCase")] pub struct ShapeElement { pub id: String, + #[serde(default)] + pub condition: Option, pub position: PositionMode, pub size: SizeConstraint, pub shape_type: String, // rectangle, ellipse, rounded_rectangle @@ -539,6 +599,8 @@ pub struct CheckboxStyle { #[serde(rename_all = "camelCase")] pub struct CheckboxElement { pub id: String, + #[serde(default)] + pub condition: Option, pub position: PositionMode, pub size: SizeConstraint, pub checked: Option, // statik değer @@ -550,6 +612,8 @@ pub struct CheckboxElement { #[serde(rename_all = "camelCase")] pub struct CalculatedTextElement { pub id: String, + #[serde(default)] + pub condition: Option, pub position: PositionMode, pub size: SizeConstraint, pub style: TextStyle, @@ -572,6 +636,10 @@ pub struct Template { pub root: ContainerElement, #[serde(default)] pub format_config: Option, + /// Lokalizasyon: "tr-TR", "en-US", "de-DE", "fr-FR" vb. + /// Belirtilirse ve format_config yoksa, locale'den FormatConfig türetilir. + #[serde(default)] + pub locale: Option, } /// Sayı/para birimi formatlama ayarları. @@ -617,3 +685,53 @@ impl Default for FormatConfig { } } } + +impl FormatConfig { + /// Locale string'inden FormatConfig türet. + /// Desteklenen locale'ler: tr-TR, en-US, de-DE, fr-FR. + /// Bilinmeyen locale → Türk formatı (varsayılan). + pub fn from_locale(locale: &str) -> Self { + match locale { + "en-US" | "en" => Self { + thousands_separator: ",".to_string(), + decimal_separator: ".".to_string(), + currency_symbol: "$".to_string(), + currency_position: "prefix".to_string(), + }, + "de-DE" | "de" => Self { + thousands_separator: ".".to_string(), + decimal_separator: ",".to_string(), + currency_symbol: "€".to_string(), + currency_position: "suffix".to_string(), + }, + "fr-FR" | "fr" => Self { + thousands_separator: " ".to_string(), + decimal_separator: ",".to_string(), + currency_symbol: "€".to_string(), + currency_position: "suffix".to_string(), + }, + "en-GB" | "gb" => Self { + thousands_separator: ",".to_string(), + decimal_separator: ".".to_string(), + currency_symbol: "£".to_string(), + currency_position: "prefix".to_string(), + }, + // tr-TR veya bilinmeyen → Türk formatı + _ => Self::default(), + } + } +} + +impl Template { + /// Template'in etkin FormatConfig'ini döndür. + /// Öncelik: format_config > locale > varsayılan (tr-TR). + pub fn effective_format_config(&self) -> FormatConfig { + if let Some(ref fc) = self.format_config { + fc.clone() + } else if let Some(ref locale) = self.locale { + FormatConfig::from_locale(locale) + } else { + FormatConfig::default() + } + } +} diff --git a/frontend/src/composables/__tests__/useSnapGuides.test.ts b/frontend/src/composables/__tests__/useSnapGuides.test.ts new file mode 100644 index 0000000..87ac55b --- /dev/null +++ b/frontend/src/composables/__tests__/useSnapGuides.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useSnapGuides } from '../useSnapGuides' +import type { ElementLayout } from '../../core/layout-types' + +function makeLayout( + id: string, + x: number, + y: number, + w: number, + h: number, +): ElementLayout { + return { + id, + x_mm: x, + y_mm: y, + width_mm: w, + height_mm: h, + element_type: 'static_text', + style: {}, + } as ElementLayout +} + +describe('useSnapGuides', () => { + let guides: ReturnType + + beforeEach(() => { + guides = useSnapGuides() + }) + + describe('collectEdges', () => { + it('collects page edges and element edges', () => { + const layoutMap: Record = { + el1: makeLayout('el1', 10, 20, 50, 30), + } + + guides.collectEdges(layoutMap, 'excluded', 210, 297) + + // After collecting, calculateSnap should work + const result = guides.calculateSnap(0, 0, 10, 10) + expect(result).toBeDefined() + }) + + it('excludes the dragged element', () => { + const layoutMap: Record = { + dragged: makeLayout('dragged', 50, 50, 20, 20), + other: makeLayout('other', 100, 100, 30, 30), + } + + guides.collectEdges(layoutMap, 'dragged', 210, 297) + + // Snap to "other" element's left edge (100mm) + const result = guides.calculateSnap(99.5, 50, 20, 20) + expect(result.snappedX_mm).toBe(100) // snaps to other's left edge + }) + }) + + describe('calculateSnap', () => { + it('returns proposed position when no edges cached', () => { + const result = guides.calculateSnap(42, 73, 10, 10) + + expect(result.snappedX_mm).toBe(42) + expect(result.snappedY_mm).toBe(73) + expect(result.guides).toHaveLength(0) + }) + + it('snaps left edge to page left (0)', () => { + guides.collectEdges({}, 'none', 210, 297) + + // Proposed x=0.5 → should snap to 0 (within 1.5mm threshold) + const result = guides.calculateSnap(0.5, 50, 20, 20) + expect(result.snappedX_mm).toBe(0) + expect(result.guides).toContainEqual({ type: 'vertical', position_mm: 0 }) + }) + + it('snaps right edge to page right', () => { + guides.collectEdges({}, 'none', 210, 297) + + // Element 20mm wide, proposed x=189 → right edge = 209, should snap to 210 + const result = guides.calculateSnap(189, 50, 20, 20) + expect(result.snappedX_mm).toBe(190) // 210 - 20 = 190 + expect(result.guides).toContainEqual({ type: 'vertical', position_mm: 210 }) + }) + + it('snaps center to page center', () => { + guides.collectEdges({}, 'none', 210, 297) + + // Element 20mm wide, center at 105mm → x = 95 + // Proposed x=94.5 → center = 104.5, should snap to 105 → x = 95 + const result = guides.calculateSnap(94.5, 50, 20, 20) + expect(result.snappedX_mm).toBe(95) // 105 - 10 = 95 + }) + + it('snaps top edge to page top', () => { + guides.collectEdges({}, 'none', 210, 297) + + const result = guides.calculateSnap(50, 1.0, 20, 20) + expect(result.snappedY_mm).toBe(0) + expect(result.guides).toContainEqual({ type: 'horizontal', position_mm: 0 }) + }) + + it('does not snap when outside threshold', () => { + guides.collectEdges({}, 'none', 210, 297) + + // Proposed x=50, far from any edge → no snap + const result = guides.calculateSnap(50, 50, 20, 20) + expect(result.snappedX_mm).toBe(50) + expect(result.snappedY_mm).toBe(50) + }) + + it('snaps to other element edges', () => { + const layoutMap: Record = { + ref: makeLayout('ref', 30, 40, 50, 20), + } + guides.collectEdges(layoutMap, 'dragged', 210, 297) + + // Snap dragged element's left to ref's right (30+50=80) + const result = guides.calculateSnap(79.5, 50, 20, 20) + expect(result.snappedX_mm).toBe(80) + }) + + it('snaps both axes simultaneously', () => { + guides.collectEdges({}, 'none', 210, 297) + + // Near page origin + const result = guides.calculateSnap(0.5, 0.5, 20, 20) + expect(result.snappedX_mm).toBe(0) + expect(result.snappedY_mm).toBe(0) + expect(result.guides).toHaveLength(2) + }) + + it('updates activeGuides ref', () => { + guides.collectEdges({}, 'none', 210, 297) + + guides.calculateSnap(0.5, 0.5, 20, 20) + expect(guides.activeGuides.value.length).toBeGreaterThan(0) + }) + }) + + describe('calculateResizeSnap', () => { + it('returns proposed value when no edges', () => { + const result = guides.calculateResizeSnap('right', 42) + expect(result).toBe(42) + }) + + it('snaps right edge to nearest vertical', () => { + const layoutMap: Record = { + ref: makeLayout('ref', 100, 50, 40, 20), + } + guides.collectEdges(layoutMap, 'resizing', 210, 297) + + // Snap to ref's left edge (100mm) + const result = guides.calculateResizeSnap('right', 99.5) + expect(result).toBe(100) + }) + + it('snaps bottom edge to nearest horizontal', () => { + const layoutMap: Record = { + ref: makeLayout('ref', 50, 80, 40, 20), + } + guides.collectEdges(layoutMap, 'resizing', 210, 297) + + // Snap to ref's top edge (80mm) + const result = guides.calculateResizeSnap('bottom', 79.5) + expect(result).toBe(80) + }) + + it('does not snap when outside threshold', () => { + guides.collectEdges({}, 'none', 210, 297) + + const result = guides.calculateResizeSnap('right', 50) + expect(result).toBe(50) // no edge near 50mm + }) + }) + + describe('clearGuides', () => { + it('clears active guides and cached edges', () => { + guides.collectEdges({}, 'none', 210, 297) + guides.calculateSnap(0.5, 0.5, 10, 10) + expect(guides.activeGuides.value.length).toBeGreaterThan(0) + + guides.clearGuides() + expect(guides.activeGuides.value).toHaveLength(0) + + // After clear, calculateSnap should return unsnapped + const result = guides.calculateSnap(0.5, 0.5, 10, 10) + expect(result.snappedX_mm).toBe(0.5) + }) + }) +}) diff --git a/frontend/src/composables/__tests__/useUndoRedo.test.ts b/frontend/src/composables/__tests__/useUndoRedo.test.ts new file mode 100644 index 0000000..6f0cfbc --- /dev/null +++ b/frontend/src/composables/__tests__/useUndoRedo.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { ref } from 'vue' +import { useUndoRedo } from '../useUndoRedo' + +describe('useUndoRedo', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('starts with initial snapshot', () => { + const source = ref({ value: 1 }) + const { canUndo, canRedo } = useUndoRedo(source) + + expect(canUndo()).toBe(false) // only 1 snapshot (initial) + expect(canRedo()).toBe(false) + }) + + it('records snapshot after debounce', async () => { + const source = ref({ value: 1 }) + const { canUndo } = useUndoRedo(source) + + source.value = { value: 2 } + await vi.advanceTimersByTimeAsync(350) // debounce = 300ms + + expect(canUndo()).toBe(true) + }) + + it('undo restores previous state', async () => { + const source = ref({ count: 0 }) + const { undo, canUndo } = useUndoRedo(source) + + source.value = { count: 1 } + await vi.advanceTimersByTimeAsync(350) + + source.value = { count: 2 } + await vi.advanceTimersByTimeAsync(350) + + expect(source.value.count).toBe(2) + + undo() + expect(source.value.count).toBe(1) + + undo() + expect(source.value.count).toBe(0) + }) + + it('redo restores undone state', async () => { + const source = ref({ count: 0 }) + const { undo, redo, canRedo } = useUndoRedo(source) + + source.value = { count: 1 } + await vi.advanceTimersByTimeAsync(350) + + undo() + expect(source.value.count).toBe(0) + expect(canRedo()).toBe(true) + + redo() + expect(source.value.count).toBe(1) + expect(canRedo()).toBe(false) + }) + + it('new mutation clears redo stack', async () => { + const source = ref({ v: 'a' }) + const { undo, redo, canRedo } = useUndoRedo(source) + + source.value = { v: 'b' } + await vi.advanceTimersByTimeAsync(350) + + undo() + expect(canRedo()).toBe(true) + + // New mutation after undo → clears redo + source.value = { v: 'c' } + await vi.advanceTimersByTimeAsync(350) + + expect(canRedo()).toBe(false) + }) + + it('respects maxHistory limit', async () => { + const source = ref({ n: 0 }) + const { canUndo, undo } = useUndoRedo(source, 3) // max 3 snapshots + + source.value = { n: 1 } + await vi.advanceTimersByTimeAsync(350) + + source.value = { n: 2 } + await vi.advanceTimersByTimeAsync(350) + + source.value = { n: 3 } + await vi.advanceTimersByTimeAsync(350) + + // Stack: [1, 2, 3] (initial 0 was shifted out) + // 3 snapshots, can undo twice (back to 1) + undo() + expect(source.value.n).toBe(2) + + undo() + expect(source.value.n).toBe(1) + + // Can't undo further (stack has only 1 left) + expect(canUndo()).toBe(false) + }) + + it('skips duplicate snapshots', async () => { + const source = ref({ x: 1 }) + const { canUndo } = useUndoRedo(source) + + // Set same value + source.value = { x: 1 } + await vi.advanceTimersByTimeAsync(350) + + expect(canUndo()).toBe(false) // no new snapshot since value same + }) + + it('debounces rapid changes into one snapshot', async () => { + const source = ref({ n: 0 }) + const { undo } = useUndoRedo(source) + + // Rapid changes within debounce window + source.value = { n: 1 } + await vi.advanceTimersByTimeAsync(100) + source.value = { n: 2 } + await vi.advanceTimersByTimeAsync(100) + source.value = { n: 3 } + await vi.advanceTimersByTimeAsync(350) // trigger debounce + + // Only one snapshot recorded (n=3), so one undo goes to initial + undo() + expect(source.value.n).toBe(0) + }) + + it('undo with only initial snapshot does nothing', () => { + const source = ref({ v: 'init' }) + const { undo } = useUndoRedo(source) + + undo() // should not crash + expect(source.value.v).toBe('init') + }) + + it('redo with empty redo stack does nothing', () => { + const source = ref({ v: 'init' }) + const { redo } = useUndoRedo(source) + + redo() // should not crash + expect(source.value.v).toBe('init') + }) +}) diff --git a/layout-engine/src/chart_layout.rs b/layout-engine/src/chart_layout.rs index 8af84a0..6955aa1 100644 --- a/layout-engine/src/chart_layout.rs +++ b/layout-engine/src/chart_layout.rs @@ -162,6 +162,8 @@ pub struct PieChartLayout { pub inner_radius: f64, pub slices: Vec, pub show_labels: bool, + /// Category name labels + leader lines outside slices + pub show_cat_labels: bool, pub label_font: f64, pub label_color: String, } @@ -955,6 +957,7 @@ pub fn compute_pie_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> PieCh inner_radius: inner_r, slices, show_labels, + show_cat_labels: show_labels, label_font, label_color, } diff --git a/layout-engine/src/chart_render.rs b/layout-engine/src/chart_render.rs index ccf648d..a024c07 100644 --- a/layout-engine/src/chart_render.rs +++ b/layout-engine/src/chart_render.rs @@ -48,7 +48,7 @@ pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> St } // Legend render - if cl.legend_show && data.series.len() > 1 { + if cl.legend_show { render_legend(&mut svg, data, &cl, width_mm, height_mm); } @@ -251,7 +251,7 @@ fn render_pie(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) { } // Category name label outside slice with leader line - if !slice.cat_label_text.is_empty() { + if pl.show_cat_labels && !slice.cat_label_text.is_empty() { write!( svg, r##""##, @@ -362,3 +362,250 @@ fn escape_xml(s: &str) -> String { .replace('>', ">") .replace('"', """) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::data_resolve::{ChartSeries, ResolvedChartData}; + use dreport_core::models::{ChartAxis, ChartLabels, ChartLegend, ChartStyle, ChartTitle, ChartType}; + + fn make_bar_data(categories: Vec<&str>, series: Vec<(&str, Vec)>) -> ResolvedChartData { + ResolvedChartData { + chart_type: ChartType::Bar, + categories: categories.into_iter().map(|s| s.to_string()).collect(), + series: series + .into_iter() + .map(|(name, values)| ChartSeries { + name: name.to_string(), + values, + }) + .collect(), + title: None, + legend: None, + labels: None, + axis: None, + style: ChartStyle::default(), + group_mode: None, + } + } + + fn make_line_data(categories: Vec<&str>, series: Vec<(&str, Vec)>) -> ResolvedChartData { + let mut data = make_bar_data(categories, series); + data.chart_type = ChartType::Line; + data + } + + fn make_pie_data(categories: Vec<&str>, values: Vec) -> ResolvedChartData { + ResolvedChartData { + chart_type: ChartType::Pie, + categories: categories.into_iter().map(|s| s.to_string()).collect(), + series: vec![ChartSeries { + name: "data".to_string(), + values, + }], + title: None, + legend: None, + labels: None, + axis: None, + style: ChartStyle::default(), + group_mode: None, + } + } + + #[test] + fn test_bar_chart_svg_structure() { + let data = make_bar_data(vec!["A", "B", "C"], vec![("Sales", vec![10.0, 20.0, 30.0])]); + let svg = render_svg(&data, 100.0, 60.0); + + assert!(svg.starts_with("")); + // 3 categories × 1 series = 3 bars (each with rx="0.5") + let bar_count = svg.matches(r#"rx="0.5""#).count(); + assert_eq!(bar_count, 3, "expected 3 bars for 3 categories, got {}", bar_count); + } + + #[test] + fn test_bar_chart_with_labels() { + let mut data = make_bar_data(vec!["A", "B"], vec![("S1", vec![10.0, 20.0])]); + data.labels = Some(ChartLabels { + show: true, + font_size: None, + color: None, + }); + let svg = render_svg(&data, 100.0, 60.0); + + // Labels shown → should contain text elements with formatted values + assert!(svg.contains("")); + } + + #[test] + fn test_empty_series_bar_chart() { + let data = make_bar_data(vec!["A", "B"], vec![]); + let svg = render_svg(&data, 100.0, 60.0); + + assert!(svg.starts_with("")); + } + + #[test] + fn test_empty_pie_chart() { + let data = make_pie_data(vec![], vec![]); + let svg = render_svg(&data, 80.0, 80.0); + + assert!(svg.starts_with("")); + // No slices + assert!(!svg.contains(""), "<script>"); + assert_eq!(escape_xml(r#"say "hi""#), "say "hi""); + assert_eq!(escape_xml("normal"), "normal"); + } + + #[test] + fn test_donut_chart_inner_radius() { + let mut data = make_pie_data(vec!["A", "B"], vec![60.0, 40.0]); + data.style.inner_radius = Some(0.5); + let svg = render_svg(&data, 80.0, 80.0); + + // Donut chart uses arc paths with inner radius → the path should contain "A" commands + // for both outer and inner arcs + let path_count = svg.matches(">, /// element_id → çözümlenmiş chart verisi pub charts: HashMap, + /// Koşulu sağlamayan (gizlenmesi gereken) element ID'leri + pub hidden_elements: std::collections::HashSet, } #[derive(Debug, Clone)] @@ -146,30 +148,91 @@ pub fn resolve_template(template: &Template, data: &Value) -> ResolvedData { page_number_formats: HashMap::new(), rich_texts: HashMap::new(), charts: HashMap::new(), + hidden_elements: std::collections::HashSet::new(), }; + let fc = template.effective_format_config(); if let Some(ref header) = template.header { resolve_element( &TemplateElement::Container(header.clone()), data, &mut resolved, + &fc, ); } resolve_element( &TemplateElement::Container(template.root.clone()), data, &mut resolved, + &fc, ); if let Some(ref footer) = template.footer { resolve_element( &TemplateElement::Container(footer.clone()), data, &mut resolved, + &fc, ); } resolved } -fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedData) { +/// Koşul değerlendirme: Condition struct'ındaki path, operator, value ile data'yı karşılaştır. +fn evaluate_condition(condition: &dreport_core::models::Condition, data: &Value) -> bool { + let actual = resolve_path(data, &condition.path); + match condition.operator.as_str() { + "empty" => matches!(actual, Value::Null) || actual.as_str().is_some_and(|s| s.is_empty()), + "not_empty" => !matches!(actual, Value::Null) && !actual.as_str().is_some_and(|s| s.is_empty()), + "eq" => { + if let Some(ref expected) = condition.value { + json_values_eq(actual, expected) + } else { + actual.is_null() + } + } + "neq" => { + if let Some(ref expected) = condition.value { + !json_values_eq(actual, expected) + } else { + !actual.is_null() + } + } + op @ ("gt" | "gte" | "lt" | "lte") => { + let a = actual.as_f64().unwrap_or(0.0); + let b = condition.value.as_ref().and_then(|v| v.as_f64()).unwrap_or(0.0); + match op { + "gt" => a > b, + "gte" => a >= b, + "lt" => a < b, + "lte" => a <= b, + _ => unreachable!(), + } + } + _ => true, // bilinmeyen operator → göster + } +} + +/// İki JSON değerini karşılaştır (tip dönüşümlü). +fn json_values_eq(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(a), Value::Number(b)) => a.as_f64() == b.as_f64(), + (Value::String(a), Value::String(b)) => a == b, + (Value::Bool(a), Value::Bool(b)) => a == b, + (Value::Null, Value::Null) => true, + // Çapraz tip karşılaştırma: sayı string vs sayı + (Value::String(s), Value::Number(n)) | (Value::Number(n), Value::String(s)) => { + s.parse::().ok() == n.as_f64() + } + _ => a == b, + } +} + +fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedData, format_config: &dreport_core::models::FormatConfig) { + // Koşul kontrolü: condition varsa ve sağlanmıyorsa, hidden olarak işaretle ve çık + if let Some(condition) = el.condition() && !evaluate_condition(condition, data) { + resolved.hidden_elements.insert(el.id().to_string()); + return; + } + match el { TemplateElement::StaticText(e) => { resolved.texts.insert(e.id.clone(), e.content.clone()); @@ -228,7 +291,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa let raw = value_to_string(v); // Sütun formatı varsa uygula (currency, percentage, number, date) if let Some(ref fmt) = col.format { - crate::expr_eval::apply_format(&raw, Some(fmt.as_str())) + crate::expr_eval::apply_format_with_config(&raw, Some(fmt.as_str()), format_config) } else { raw } @@ -243,7 +306,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa } TemplateElement::Container(e) => { for child in &e.children { - resolve_element(child, data, resolved); + resolve_element(child, data, resolved, format_config); } } TemplateElement::CurrentDate(e) => { @@ -268,7 +331,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa } TemplateElement::CalculatedText(e) => { let result = crate::expr_eval::evaluate_expression(&e.expression, data); - let formatted = crate::expr_eval::apply_format(&result, e.format.as_deref()); + let formatted = crate::expr_eval::apply_format_with_config(&result, e.format.as_deref(), format_config); // Bos ifade veya hata durumunda placeholder goster — element 0 yukseklige dusmesin let text = if formatted.is_empty() { " ".to_string() @@ -477,8 +540,10 @@ mod tests { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), direction: "column".to_string(), @@ -490,6 +555,7 @@ mod tests { break_inside: "auto".to_string(), children: vec![TemplateElement::Text(TextElement { id: "el_name".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), style: TextStyle::default(), @@ -525,8 +591,10 @@ mod tests { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), direction: "column".to_string(), @@ -538,6 +606,7 @@ mod tests { break_inside: "auto".to_string(), children: vec![TemplateElement::Text(TextElement { id: "el_no".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), style: TextStyle::default(), @@ -570,8 +639,10 @@ mod tests { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), direction: "column".to_string(), @@ -583,6 +654,7 @@ mod tests { break_inside: "auto".to_string(), children: vec![TemplateElement::StaticText(StaticTextElement { id: "title".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), style: TextStyle::default(), @@ -608,8 +680,10 @@ mod tests { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), direction: "column".to_string(), @@ -621,6 +695,7 @@ mod tests { break_inside: "auto".to_string(), children: vec![TemplateElement::RepeatingTable(RepeatingTableElement { id: "tbl".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), data_source: ArrayBinding { @@ -677,8 +752,10 @@ mod tests { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), direction: "column".to_string(), @@ -690,6 +767,7 @@ mod tests { break_inside: "auto".to_string(), children: vec![TemplateElement::RepeatingTable(RepeatingTableElement { id: "tbl".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), data_source: ArrayBinding { @@ -728,8 +806,10 @@ mod tests { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), direction: "column".to_string(), @@ -741,6 +821,7 @@ mod tests { break_inside: "auto".to_string(), children: vec![TemplateElement::Text(TextElement { id: "el_missing".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), style: TextStyle::default(), diff --git a/layout-engine/src/page_break.rs b/layout-engine/src/page_break.rs index b88cc7c..580d057 100644 --- a/layout-engine/src/page_break.rs +++ b/layout-engine/src/page_break.rs @@ -896,6 +896,261 @@ mod tests { ); } + #[test] + fn test_break_inside_avoid_group_moves_to_new_page() { + // break_inside: avoid olan container grubunun mevcut sayfaya sığmadığında + // komple yeni sayfaya taşınması gerekir. + let mut break_modes = HashMap::new(); + break_modes.insert("group".to_string(), "avoid".to_string()); + + // group container: y=250, h=80 → bottom=330 > 297 (sayfa yüksekliği) + // ama 80mm tek sayfaya sığar → yeni sayfaya geçmeli + let mut group = make_element("group", 250.0, 80.0, "container"); + group.children = vec!["g_child1".to_string(), "g_child2".to_string()]; + + let input = PageSplitInput { + body_elements: vec![ + make_element("el1", 0.0, 250.0, "text"), + group, + make_element("g_child1", 250.0, 40.0, "text"), + make_element("g_child2", 290.0, 40.0, "text"), + ], + page_height_mm: 297.0, + header_height_mm: 0.0, + footer_height_mm: 0.0, + header_elements: vec![], + footer_elements: vec![], + page_width_mm: 210.0, + break_modes, + page_number_formats: HashMap::new(), + root_padding_top_mm: 0.0, + no_repeat_header_tables: HashSet::new(), + }; + + let pages = split_into_pages(input); + assert_eq!(pages.len(), 2, "avoid group should cause a new page"); + // el1 sayfada 1, group + children sayfada 2 + assert!(pages[0].elements.iter().any(|e| e.id == "el1")); + assert!(pages[1].elements.iter().any(|e| e.id == "group")); + assert!(pages[1].elements.iter().any(|e| e.id == "g_child1")); + assert!(pages[1].elements.iter().any(|e| e.id == "g_child2")); + } + + #[test] + fn test_avoid_group_larger_than_page_stays_in_flow() { + // break_inside: avoid olan container sayfadan büyükse, normal akışa devam etmeli. + // Yeni sayfaya atlamamalı çünkü zaten sığmıyor. + let mut break_modes = HashMap::new(); + break_modes.insert("big_group".to_string(), "avoid".to_string()); + + let mut big = make_element("big_group", 0.0, 400.0, "container"); + big.children = vec!["bg_child".to_string()]; + + let input = PageSplitInput { + body_elements: vec![ + big, + make_element("bg_child", 0.0, 400.0, "text"), + ], + page_height_mm: 297.0, + header_height_mm: 0.0, + footer_height_mm: 0.0, + header_elements: vec![], + footer_elements: vec![], + page_width_mm: 210.0, + break_modes, + page_number_formats: HashMap::new(), + root_padding_top_mm: 0.0, + no_repeat_header_tables: HashSet::new(), + }; + + let pages = split_into_pages(input); + // Sayfa 1'de grup başlamalı (sığmasa da mecbur) + assert!(pages[0].elements.iter().any(|e| e.id == "big_group")); + } + + #[test] + fn test_element_exactly_at_page_boundary() { + // Eleman tam sayfa sınırına denk geldiğinde doğru sayfalanmalı. + // İki eleman: 148.5mm + 148.5mm = 297mm → tam sığar, tek sayfa. + let input = PageSplitInput { + body_elements: vec![ + make_element("el1", 0.0, 148.5, "text"), + make_element("el2", 148.5, 148.5, "text"), + ], + page_height_mm: 297.0, + header_height_mm: 0.0, + footer_height_mm: 0.0, + header_elements: vec![], + footer_elements: vec![], + page_width_mm: 210.0, + break_modes: HashMap::new(), + page_number_formats: HashMap::new(), + root_padding_top_mm: 0.0, + no_repeat_header_tables: HashSet::new(), + }; + + let pages = split_into_pages(input); + assert_eq!(pages.len(), 1, "elements exactly filling page should fit in 1 page"); + assert_eq!(pages[0].elements.len(), 2); + } + + #[test] + fn test_element_one_mm_over_page_boundary() { + // Eleman sayfa sınırını 1mm aşıyorsa yeni sayfaya geçmeli. + let input = PageSplitInput { + body_elements: vec![ + make_element("el1", 0.0, 148.5, "text"), + make_element("el2", 148.5, 149.5, "text"), // bottom = 298 > 297 + ], + page_height_mm: 297.0, + header_height_mm: 0.0, + footer_height_mm: 0.0, + header_elements: vec![], + footer_elements: vec![], + page_width_mm: 210.0, + break_modes: HashMap::new(), + page_number_formats: HashMap::new(), + root_padding_top_mm: 0.0, + no_repeat_header_tables: HashSet::new(), + }; + + let pages = split_into_pages(input); + assert_eq!(pages.len(), 2, "element exceeding page by 1mm should go to page 2"); + assert!(pages[0].elements.iter().any(|e| e.id == "el1")); + assert!(pages[1].elements.iter().any(|e| e.id == "el2")); + } + + #[test] + fn test_single_element_larger_than_page() { + // Sayfadan büyük tek eleman — mecburen sayfa 1'de kalmalı. + let input = PageSplitInput { + body_elements: vec![ + make_element("huge", 0.0, 500.0, "text"), + ], + page_height_mm: 297.0, + header_height_mm: 0.0, + footer_height_mm: 0.0, + header_elements: vec![], + footer_elements: vec![], + page_width_mm: 210.0, + break_modes: HashMap::new(), + page_number_formats: HashMap::new(), + root_padding_top_mm: 0.0, + no_repeat_header_tables: HashSet::new(), + }; + + let pages = split_into_pages(input); + assert_eq!(pages.len(), 1, "single oversized element should stay on page 1"); + assert_eq!(pages[0].elements[0].id, "huge"); + } + + #[test] + fn test_no_repeat_header_tables_suppresses_header() { + // no_repeat_header_tables'a eklenen tablonun header'ı tekrarlanmamalı. + let mut tbl_wrapper = make_element("tbl", 0.0, 200.0, "container"); + tbl_wrapper.children = vec![ + "tbl_header".to_string(), + "tbl_row_0".to_string(), + "tbl_row_1".to_string(), + "tbl_row_2".to_string(), + "tbl_row_3".to_string(), + "tbl_row_4".to_string(), + ]; + + let tbl_header = { + let mut el = make_element("tbl_header", 0.0, 20.0, "container"); + el.children = vec!["tbl_hdr_0".to_string()]; + el + }; + let tbl_hdr_0 = make_element("tbl_hdr_0", 0.0, 20.0, "static_text"); + + let rows: Vec = (0..5) + .flat_map(|i| { + let y = 20.0 + (i as f64) * 30.0; + let mut row = make_element(&format!("tbl_row_{}", i), y, 30.0, "container"); + row.children = vec![format!("tbl_r{}c0", i)]; + let cell = make_element(&format!("tbl_r{}c0", i), y, 30.0, "static_text"); + vec![row, cell] + }) + .collect(); + + let mut body_elements = vec![tbl_wrapper, tbl_header, tbl_hdr_0]; + body_elements.extend(rows); + + let mut no_repeat = HashSet::new(); + no_repeat.insert("tbl".to_string()); + + let input = PageSplitInput { + body_elements, + page_height_mm: 120.0, + header_height_mm: 0.0, + footer_height_mm: 0.0, + header_elements: vec![], + footer_elements: vec![], + page_width_mm: 210.0, + break_modes: HashMap::new(), + page_number_formats: HashMap::new(), + root_padding_top_mm: 0.0, + no_repeat_header_tables: no_repeat, + }; + + let pages = split_into_pages(input); + assert!(pages.len() >= 2, "table should split across pages"); + + // Sayfa 2'de tekrarlanan header OLMAMALI + let page2_has_repeated_header = pages[1] + .elements + .iter() + .any(|e| e.id.starts_with("tbl_header") && e.id != "tbl_header"); + assert!( + !page2_has_repeated_header, + "no_repeat_header_tables should suppress header repetition on page 2" + ); + } + + #[test] + fn test_empty_body_produces_single_page() { + let input = PageSplitInput { + body_elements: vec![], + page_height_mm: 297.0, + header_height_mm: 0.0, + footer_height_mm: 0.0, + header_elements: vec![], + footer_elements: vec![], + page_width_mm: 210.0, + break_modes: HashMap::new(), + page_number_formats: HashMap::new(), + root_padding_top_mm: 0.0, + no_repeat_header_tables: HashSet::new(), + }; + + let pages = split_into_pages(input); + assert_eq!(pages.len(), 1, "empty body should produce 1 page"); + } + + #[test] + fn test_content_height_zero_returns_single_page() { + // Header + footer sayfayı dolduruyor → content_height <= 0 + let input = PageSplitInput { + body_elements: vec![ + make_element("el1", 0.0, 50.0, "text"), + ], + page_height_mm: 100.0, + header_height_mm: 60.0, + footer_height_mm: 50.0, + header_elements: vec![make_element("hdr", 0.0, 60.0, "text")], + footer_elements: vec![make_element("ftr", 0.0, 50.0, "text")], + page_width_mm: 210.0, + break_modes: HashMap::new(), + page_number_formats: HashMap::new(), + root_padding_top_mm: 0.0, + no_repeat_header_tables: HashSet::new(), + }; + + let pages = split_into_pages(input); + assert_eq!(pages.len(), 1, "zero content height should produce 1 page"); + } + #[test] fn test_repeated_header_no_gap_with_rows() { // Tekrarlanan header ile ilk satır arasında boşluk olmamalı. diff --git a/layout-engine/src/pdf_render.rs b/layout-engine/src/pdf_render.rs index e5110bc..19f5f7f 100644 --- a/layout-engine/src/pdf_render.rs +++ b/layout-engine/src/pdf_render.rs @@ -1126,7 +1126,7 @@ fn render_chart( ); } - if !slice.cat_label_text.is_empty() { + if pl.show_cat_labels && !slice.cat_label_text.is_empty() { chart_line_seg( surface, slice.leader_start_x, @@ -1165,7 +1165,7 @@ fn render_chart( } // Legend render - if cl.legend_show && data.series.len() > 1 { + if cl.legend_show { let legend = compute_legend(data, &cl, base_x_mm, base_y_mm, w_mm, h_mm); for item in &legend.items { let color = parse_color(color_at(&cl.palette, item.color_idx)); @@ -1599,8 +1599,10 @@ mod tests { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Auto, @@ -1625,6 +1627,7 @@ mod tests { children: vec![ TemplateElement::StaticText(StaticTextElement { id: "title".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -1643,6 +1646,7 @@ mod tests { }), TemplateElement::Line(LineElement { id: "line1".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -1659,6 +1663,7 @@ mod tests { }), TemplateElement::Text(TextElement { id: "firma".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -1700,6 +1705,300 @@ mod tests { println!("Full pipeline PDF: {}", out_path.display()); } + // --- parse_color tests --- + + #[test] + fn test_parse_color_6_digit_hex() { + let c = parse_color("#FF8800"); + assert_eq!(c, rgb::Color::new(255, 136, 0)); + } + + #[test] + fn test_parse_color_3_digit_hex() { + let c = parse_color("#F80"); + assert_eq!(c, rgb::Color::new(255, 136, 0)); // F*17=255, 8*17=136, 0*17=0 + } + + #[test] + fn test_parse_color_without_hash() { + let c = parse_color("00FF00"); + assert_eq!(c, rgb::Color::new(0, 255, 0)); + } + + #[test] + fn test_parse_color_black() { + let c = parse_color("#000000"); + assert_eq!(c, rgb::Color::new(0, 0, 0)); + } + + #[test] + fn test_parse_color_white() { + let c = parse_color("#FFFFFF"); + assert_eq!(c, rgb::Color::new(255, 255, 255)); + } + + #[test] + fn test_parse_color_invalid_length() { + // Invalid length → defaults to (0,0,0) + let c = parse_color("#ABCD"); + assert_eq!(c, rgb::Color::new(0, 0, 0)); + } + + #[test] + fn test_parse_color_empty() { + let c = parse_color(""); + assert_eq!(c, rgb::Color::new(0, 0, 0)); + } + + // --- build_rect_path tests --- + + #[test] + fn test_build_rect_path_no_radius() { + let path = build_rect_path(10.0, 20.0, 100.0, 50.0, 0.0); + assert!(path.is_some(), "should produce valid rect path with no radius"); + } + + #[test] + fn test_build_rect_path_with_radius() { + let path = build_rect_path(0.0, 0.0, 100.0, 50.0, 5.0); + assert!(path.is_some(), "should produce valid rounded rect path"); + } + + #[test] + fn test_build_rect_path_radius_clamped() { + // Radius larger than half the smaller dimension → should be clamped + let path = build_rect_path(0.0, 0.0, 20.0, 10.0, 100.0); + assert!(path.is_some(), "should clamp radius and produce valid path"); + } + + // --- build_ellipse_path tests --- + + #[test] + fn test_build_ellipse_path() { + let path = build_ellipse_path(10.0, 20.0, 60.0, 40.0); + assert!(path.is_some(), "should produce valid ellipse path"); + } + + #[test] + fn test_build_ellipse_path_circle() { + // Equal width and height → circle + let path = build_ellipse_path(0.0, 0.0, 50.0, 50.0); + assert!(path.is_some(), "should produce valid circle path"); + } + + // --- mm/pt conversion tests --- + + #[test] + fn test_mm_to_pt_conversion() { + // 25.4mm = 72pt (1 inch) + let result = mm(25.4); + assert!((result - 72.0).abs() < 0.01, "25.4mm should be ~72pt, got {}", result); + } + + #[test] + fn test_mm_zero() { + assert_eq!(mm(0.0), 0.0); + } + + #[test] + fn test_pt_conversion() { + let result = pt(25.4); + assert!((result - 72.0).abs() < 0.01); + } + + // --- render_pdf integration with various element types --- + + #[test] + fn test_render_pdf_with_line_element() { + let layout = LayoutResult { + pages: vec![PageLayout { + page_index: 0, + width_mm: 210.0, + height_mm: 297.0, + elements: vec![ElementLayout { + id: "line1".to_string(), + x_mm: 15.0, + y_mm: 50.0, + width_mm: 180.0, + height_mm: 0.5, + element_type: "line".to_string(), + content: Some(ResolvedContent::Line), + style: ResolvedStyle { + stroke_color: Some("#FF0000".to_string()), + stroke_width: Some(1.0), + ..Default::default() + }, + children: vec![], + }], + }], + }; + let fonts = test_fonts(); + let pdf = render_pdf(&layout, &fonts).expect("should render line element"); + assert!(pdf.starts_with(b"%PDF")); + } + + #[test] + fn test_render_pdf_with_container_background() { + let layout = LayoutResult { + pages: vec![PageLayout { + page_index: 0, + width_mm: 210.0, + height_mm: 297.0, + elements: vec![ElementLayout { + id: "box".to_string(), + x_mm: 20.0, + y_mm: 20.0, + width_mm: 170.0, + height_mm: 100.0, + element_type: "container".to_string(), + content: None, + style: ResolvedStyle { + background_color: Some("#E0E0E0".to_string()), + border_color: Some("#333333".to_string()), + border_width: Some(0.5), + border_radius: Some(3.0), + ..Default::default() + }, + children: vec![], + }], + }], + }; + let fonts = test_fonts(); + let pdf = render_pdf(&layout, &fonts).expect("should render container bg"); + assert!(pdf.starts_with(b"%PDF")); + } + + #[test] + fn test_render_pdf_with_shape_element() { + let layout = LayoutResult { + pages: vec![PageLayout { + page_index: 0, + width_mm: 210.0, + height_mm: 297.0, + elements: vec![ElementLayout { + id: "shape1".to_string(), + x_mm: 50.0, + y_mm: 50.0, + width_mm: 40.0, + height_mm: 40.0, + element_type: "shape".to_string(), + content: Some(ResolvedContent::Shape { + shape_type: "ellipse".to_string(), + }), + style: ResolvedStyle { + background_color: Some("#3366FF".to_string()), + border_color: Some("#000000".to_string()), + border_width: Some(1.0), + ..Default::default() + }, + children: vec![], + }], + }], + }; + let fonts = test_fonts(); + let pdf = render_pdf(&layout, &fonts).expect("should render shape element"); + assert!(pdf.starts_with(b"%PDF")); + } + + #[test] + fn test_render_pdf_with_checkbox() { + let layout = LayoutResult { + pages: vec![PageLayout { + page_index: 0, + width_mm: 210.0, + height_mm: 297.0, + elements: vec![ + ElementLayout { + id: "cb_checked".to_string(), + x_mm: 15.0, + y_mm: 15.0, + width_mm: 5.0, + height_mm: 5.0, + element_type: "checkbox".to_string(), + content: Some(ResolvedContent::Checkbox { checked: true }), + style: ResolvedStyle::default(), + children: vec![], + }, + ElementLayout { + id: "cb_unchecked".to_string(), + x_mm: 15.0, + y_mm: 25.0, + width_mm: 5.0, + height_mm: 5.0, + element_type: "checkbox".to_string(), + content: Some(ResolvedContent::Checkbox { checked: false }), + style: ResolvedStyle::default(), + children: vec![], + }, + ], + }], + }; + let fonts = test_fonts(); + let pdf = render_pdf(&layout, &fonts).expect("should render checkbox elements"); + assert!(pdf.starts_with(b"%PDF")); + } + + #[test] + fn test_render_pdf_empty_page() { + let layout = LayoutResult { + pages: vec![PageLayout { + page_index: 0, + width_mm: 210.0, + height_mm: 297.0, + elements: vec![], + }], + }; + let fonts = test_fonts(); + let pdf = render_pdf(&layout, &fonts).expect("empty page should still render"); + assert!(pdf.starts_with(b"%PDF")); + } + + #[test] + fn test_render_pdf_multi_page() { + let layout = LayoutResult { + pages: vec![ + PageLayout { + page_index: 0, + width_mm: 210.0, + height_mm: 297.0, + elements: vec![ElementLayout { + id: "p1".to_string(), + x_mm: 15.0, + y_mm: 15.0, + width_mm: 180.0, + height_mm: 10.0, + element_type: "static_text".to_string(), + content: Some(ResolvedContent::Text { value: "Page 1".to_string() }), + style: ResolvedStyle { font_size: Some(12.0), ..Default::default() }, + children: vec![], + }], + }, + PageLayout { + page_index: 1, + width_mm: 210.0, + height_mm: 297.0, + elements: vec![ElementLayout { + id: "p2".to_string(), + x_mm: 15.0, + y_mm: 15.0, + width_mm: 180.0, + height_mm: 10.0, + element_type: "static_text".to_string(), + content: Some(ResolvedContent::Text { value: "Page 2".to_string() }), + style: ResolvedStyle { font_size: Some(12.0), ..Default::default() }, + children: vec![], + }], + }, + ], + }; + let fonts = test_fonts(); + let pdf = render_pdf(&layout, &fonts).expect("multi-page should render"); + assert!(pdf.starts_with(b"%PDF")); + assert!(pdf.len() > 200, "multi-page PDF should have reasonable size"); + } + + // --- detect_image_format tests --- + #[test] fn test_detect_png() { let data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; diff --git a/layout-engine/src/sizing.rs b/layout-engine/src/sizing.rs index 26f690e..d2576a4 100644 --- a/layout-engine/src/sizing.rs +++ b/layout-engine/src/sizing.rs @@ -328,6 +328,7 @@ mod tests { fn test_container_to_style_direction() { let el = ContainerElement { id: "test".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), direction: "row".to_string(), @@ -354,6 +355,7 @@ mod tests { fn test_container_to_style_absolute() { let el = ContainerElement { id: "test".to_string(), + condition: None, position: PositionMode::Absolute { x: 20.0, y: 30.0 }, size: SizeConstraint::default(), direction: "column".to_string(), diff --git a/layout-engine/src/table_layout.rs b/layout-engine/src/table_layout.rs index 1cb449c..b2d2f74 100644 --- a/layout-engine/src/table_layout.rs +++ b/layout-engine/src/table_layout.rs @@ -233,6 +233,7 @@ pub fn expand_table( .map(|(i, col)| { let text = TemplateElement::StaticText(StaticTextElement { id: format!("{}_hdr_{}", table.id, i), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -254,6 +255,7 @@ pub fn expand_table( }); TemplateElement::Container(ContainerElement { id: format!("{}_hdr_{}_wrap", table.id, i), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: effective_widths[i].clone(), @@ -282,6 +284,7 @@ pub fn expand_table( children.push(TemplateElement::Container(ContainerElement { id: format!("{}_header", table.id), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -313,6 +316,7 @@ pub fn expand_table( if table.style.border_color.is_some() { children.push(TemplateElement::Line(LineElement { id: format!("{}_header_line", table.id), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -340,6 +344,7 @@ pub fn expand_table( let text = TemplateElement::StaticText(StaticTextElement { id: format!("{}_r{}c{}", table.id, row_idx, col_idx), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -361,6 +366,7 @@ pub fn expand_table( }); TemplateElement::Container(ContainerElement { id: format!("{}_r{}c{}_wrap", table.id, row_idx, col_idx), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: effective_widths[col_idx].clone(), @@ -396,6 +402,7 @@ pub fn expand_table( children.push(TemplateElement::Container(ContainerElement { id: format!("{}_row_{}", table.id, row_idx), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -427,6 +434,7 @@ pub fn expand_table( // Wrapper container (column direction, tüm tablo) ContainerElement { id: table.id.clone(), + condition: None, position: table.position.clone(), size: table.size.clone(), direction: "column".to_string(), @@ -471,6 +479,7 @@ mod tests { RepeatingTableElement { id: "tbl".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -497,6 +506,7 @@ mod tests { page_number_formats: HashMap::new(), rich_texts: HashMap::new(), charts: HashMap::new(), + hidden_elements: std::collections::HashSet::new(), } } @@ -729,6 +739,7 @@ mod tests { let table = RepeatingTableElement { id: "tbl".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, diff --git a/layout-engine/src/tree.rs b/layout-engine/src/tree.rs index 13afa24..0a0e848 100644 --- a/layout-engine/src/tree.rs +++ b/layout-engine/src/tree.rs @@ -245,6 +245,10 @@ fn build_container( 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, @@ -896,8 +900,10 @@ mod tests { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Auto, @@ -922,6 +928,7 @@ mod tests { children: vec![ TemplateElement::StaticText(StaticTextElement { id: "title".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -940,6 +947,7 @@ mod tests { }), TemplateElement::Line(LineElement { id: "line1".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -956,6 +964,7 @@ mod tests { }), TemplateElement::StaticText(StaticTextElement { id: "body".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -1040,8 +1049,10 @@ mod tests { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Auto, @@ -1065,6 +1076,7 @@ mod tests { break_inside: "auto".to_string(), children: vec![TemplateElement::Container(ContainerElement { id: "row".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -1089,6 +1101,7 @@ mod tests { children: vec![ TemplateElement::StaticText(StaticTextElement { id: "left".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -1106,6 +1119,7 @@ mod tests { }), TemplateElement::StaticText(StaticTextElement { id: "right".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -1168,8 +1182,10 @@ mod tests { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Auto, @@ -1193,6 +1209,7 @@ mod tests { break_inside: "auto".to_string(), children: vec![TemplateElement::StaticText(StaticTextElement { id: "abs_text".to_string(), + condition: None, position: PositionMode::Absolute { x: 50.0, y: 80.0 }, size: SizeConstraint { width: SizeValue::Fixed { value: 100.0 }, @@ -1257,8 +1274,10 @@ mod tests { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), + condition: None, position: PositionMode::Flow, size: sz_auto.clone(), direction: "column".to_string(), @@ -1277,6 +1296,7 @@ mod tests { // Header row TemplateElement::Container(ContainerElement { id: "c_header".to_string(), + condition: None, position: PositionMode::Flow, size: sz_fr_auto.clone(), direction: "row".to_string(), @@ -1290,6 +1310,7 @@ mod tests { // Sol: firma bilgileri TemplateElement::Container(ContainerElement { id: "c_firma".to_string(), + condition: None, position: PositionMode::Flow, size: sz_fr_auto.clone(), direction: "column".to_string(), @@ -1302,6 +1323,7 @@ mod tests { children: vec![ TemplateElement::StaticText(StaticTextElement { id: "el_firma_unvan".to_string(), + condition: None, position: PositionMode::Flow, size: sz_auto.clone(), style: TextStyle { @@ -1313,6 +1335,7 @@ mod tests { }), TemplateElement::StaticText(StaticTextElement { id: "el_firma_adres".to_string(), + condition: None, position: PositionMode::Flow, size: sz_auto.clone(), style: TextStyle { @@ -1324,6 +1347,7 @@ mod tests { }), TemplateElement::StaticText(StaticTextElement { id: "el_firma_il".to_string(), + condition: None, position: PositionMode::Flow, size: sz_auto.clone(), style: TextStyle { @@ -1334,6 +1358,7 @@ mod tests { }), TemplateElement::StaticText(StaticTextElement { id: "el_firma_tel".to_string(), + condition: None, position: PositionMode::Flow, size: sz_auto.clone(), style: TextStyle { @@ -1344,6 +1369,7 @@ mod tests { }), TemplateElement::StaticText(StaticTextElement { id: "el_firma_vd".to_string(), + condition: None, position: PositionMode::Flow, size: sz_auto.clone(), style: TextStyle { @@ -1354,6 +1380,7 @@ mod tests { }), TemplateElement::StaticText(StaticTextElement { id: "el_firma_vn".to_string(), + condition: None, position: PositionMode::Flow, size: sz_auto.clone(), style: TextStyle { @@ -1367,6 +1394,7 @@ mod tests { // Sağ: fatura başlığı TemplateElement::Container(ContainerElement { id: "c_fatura_baslik".to_string(), + condition: None, position: PositionMode::Flow, size: sz_auto.clone(), direction: "column".to_string(), @@ -1379,6 +1407,7 @@ mod tests { children: vec![ TemplateElement::StaticText(StaticTextElement { id: "el_fatura_baslik".to_string(), + condition: None, position: PositionMode::Flow, size: sz_auto.clone(), style: TextStyle { @@ -1390,6 +1419,7 @@ mod tests { }), TemplateElement::StaticText(StaticTextElement { id: "el_fatura_no".to_string(), + condition: None, position: PositionMode::Flow, size: sz_auto.clone(), style: TextStyle { @@ -1400,6 +1430,7 @@ mod tests { }), TemplateElement::StaticText(StaticTextElement { id: "el_fatura_tarih".to_string(), + condition: None, position: PositionMode::Flow, size: sz_auto.clone(), style: TextStyle { @@ -1410,6 +1441,7 @@ mod tests { }), TemplateElement::StaticText(StaticTextElement { id: "el_fatura_vade".to_string(), + condition: None, position: PositionMode::Flow, size: sz_auto.clone(), style: TextStyle { diff --git a/layout-engine/tests/improvements_test.rs b/layout-engine/tests/improvements_test.rs index 9722f81..6927edd 100644 --- a/layout-engine/tests/improvements_test.rs +++ b/layout-engine/tests/improvements_test.rs @@ -25,12 +25,14 @@ fn base_template() -> Template { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), position: PositionMode::Flow, size: SizeConstraint::default(), direction: "column".to_string(), gap: 5.0, + condition: None, padding: Padding { top: 15.0, right: 15.0, @@ -57,6 +59,7 @@ fn test_1_2_text_wrapping_layout_height() { tpl.root.children.push(TemplateElement::StaticText(StaticTextElement { id: "long_text".to_string(), position: PositionMode::Flow, + condition: None, size: SizeConstraint { width: SizeValue::Fixed { value: 40.0 }, // 40mm genişlik — kısa height: SizeValue::Auto, @@ -92,6 +95,7 @@ fn test_1_2_text_wrapping_pdf_renders() { let mut tpl = base_template(); tpl.root.children.push(TemplateElement::StaticText(StaticTextElement { id: "wrap_pdf".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fixed { value: 50.0 }, @@ -123,6 +127,7 @@ fn test_1_3_image_object_fit_in_layout() { tpl.root.children.push(TemplateElement::Image(ImageElement { id: "img_contain".to_string(), position: PositionMode::Flow, + condition: None, size: SizeConstraint { width: SizeValue::Fixed { value: 40.0 }, height: SizeValue::Fixed { value: 30.0 }, @@ -164,6 +169,7 @@ fn test_1_4_italic_font_in_pdf() { .push(TemplateElement::StaticText(StaticTextElement { id: "italic_text".to_string(), position: PositionMode::Flow, + condition: None, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, height: SizeValue::Auto, @@ -201,6 +207,7 @@ fn test_1_4_bold_italic_font_in_pdf() { .push(TemplateElement::StaticText(StaticTextElement { id: "bold_italic".to_string(), position: PositionMode::Flow, + condition: None, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, height: SizeValue::Auto, @@ -234,6 +241,7 @@ fn test_2_1_repeat_header_false_no_repeat_on_second_page() { .push(TemplateElement::RepeatingTable(RepeatingTableElement { id: "tbl_no_repeat".to_string(), position: PositionMode::Flow, + condition: None, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, height: SizeValue::Auto, @@ -299,6 +307,7 @@ fn test_2_1_repeat_header_true_repeats_on_second_page() { .push(TemplateElement::RepeatingTable(RepeatingTableElement { id: "tbl_repeat".to_string(), position: PositionMode::Flow, + condition: None, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, height: SizeValue::Auto, @@ -382,6 +391,7 @@ fn test_2_2_table_column_format_currency() { .push(TemplateElement::RepeatingTable(RepeatingTableElement { id: "tbl_fmt".to_string(), position: PositionMode::Flow, + condition: None, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, height: SizeValue::Auto, @@ -451,6 +461,7 @@ fn test_2_3_rounded_rectangle_renders() { tpl.root.children.push(TemplateElement::Shape(ShapeElement { id: "rounded_shape".to_string(), position: PositionMode::Flow, + condition: None, size: SizeConstraint { width: SizeValue::Fixed { value: 50.0 }, height: SizeValue::Fixed { value: 30.0 }, @@ -496,6 +507,7 @@ fn test_2_3_container_border_radius_renders() { .push(TemplateElement::StaticText(StaticTextElement { id: "text_in_rounded".to_string(), position: PositionMode::Flow, + condition: None, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, height: SizeValue::Auto, @@ -594,6 +606,7 @@ fn test_ellipse_shape_renders() { tpl.root.children.push(TemplateElement::Shape(ShapeElement { id: "ellipse".to_string(), position: PositionMode::Flow, + condition: None, size: SizeConstraint { width: SizeValue::Fixed { value: 40.0 }, height: SizeValue::Fixed { value: 20.0 }, @@ -613,3 +626,437 @@ fn test_ellipse_shape_renders() { let pdf = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap(); assert!(pdf.starts_with(b"%PDF")); } + +// ============================================================================= +// 7.1 Conditional Rendering +// ============================================================================= + +#[test] +fn test_7_1_condition_gt_hides_element() { + let mut tpl = base_template(); + tpl.root.children.push(TemplateElement::StaticText(StaticTextElement { + id: "always_visible".to_string(), + condition: None, + position: PositionMode::Flow, + size: SizeConstraint::default(), + style: TextStyle { font_size: Some(10.0), ..Default::default() }, + content: "Visible".to_string(), + })); + tpl.root.children.push(TemplateElement::Text(TextElement { + id: "conditional_text".to_string(), + condition: Some(Condition { + path: "toplamlar.iskonto".to_string(), + operator: "gt".to_string(), + value: Some(serde_json::json!(0)), + }), + position: PositionMode::Flow, + size: SizeConstraint::default(), + style: TextStyle { font_size: Some(10.0), ..Default::default() }, + content: None, + binding: ScalarBinding { path: "toplamlar.iskonto".to_string() }, + })); + + let fonts = load_test_fonts(); + + // iskonto = 0 → koşul sağlanmaz, element gizlenmeli + let data_no_iskonto = serde_json::json!({ "toplamlar": { "iskonto": 0 } }); + let layout = compute_layout(&tpl, &data_no_iskonto, &fonts).unwrap(); + let page = &layout.pages[0]; + assert!( + !page.elements.iter().any(|e| e.id == "conditional_text"), + "iskonto=0 durumunda conditional_text gizlenmeli" + ); + assert!( + page.elements.iter().any(|e| e.id == "always_visible"), + "koşulsuz eleman her zaman görünmeli" + ); +} + +#[test] +fn test_7_1_condition_gt_shows_element() { + let mut tpl = base_template(); + tpl.root.children.push(TemplateElement::Text(TextElement { + id: "conditional_text".to_string(), + condition: Some(Condition { + path: "toplamlar.iskonto".to_string(), + operator: "gt".to_string(), + value: Some(serde_json::json!(0)), + }), + position: PositionMode::Flow, + size: SizeConstraint::default(), + style: TextStyle { font_size: Some(10.0), ..Default::default() }, + content: None, + binding: ScalarBinding { path: "toplamlar.iskonto".to_string() }, + })); + + let fonts = load_test_fonts(); + + // iskonto = 500 → koşul sağlanır, element görünmeli + let data_with_iskonto = serde_json::json!({ "toplamlar": { "iskonto": 500 } }); + let layout = compute_layout(&tpl, &data_with_iskonto, &fonts).unwrap(); + let page = &layout.pages[0]; + assert!( + page.elements.iter().any(|e| e.id == "conditional_text"), + "iskonto>0 durumunda conditional_text görünmeli" + ); +} + +#[test] +fn test_7_1_condition_eq_operator() { + let mut tpl = base_template(); + tpl.root.children.push(TemplateElement::StaticText(StaticTextElement { + id: "status_text".to_string(), + condition: Some(Condition { + path: "durum".to_string(), + operator: "eq".to_string(), + value: Some(serde_json::json!("aktif")), + }), + position: PositionMode::Flow, + size: SizeConstraint::default(), + style: TextStyle { font_size: Some(10.0), ..Default::default() }, + content: "Aktif".to_string(), + })); + + let fonts = load_test_fonts(); + + // durum = "aktif" → görünür + let layout = compute_layout(&tpl, &serde_json::json!({"durum": "aktif"}), &fonts).unwrap(); + assert!(layout.pages[0].elements.iter().any(|e| e.id == "status_text")); + + // durum = "pasif" → gizli + let layout = compute_layout(&tpl, &serde_json::json!({"durum": "pasif"}), &fonts).unwrap(); + assert!(!layout.pages[0].elements.iter().any(|e| e.id == "status_text")); +} + +#[test] +fn test_7_1_condition_empty_not_empty() { + let mut tpl = base_template(); + tpl.root.children.push(TemplateElement::StaticText(StaticTextElement { + id: "show_if_exists".to_string(), + condition: Some(Condition { + path: "note".to_string(), + operator: "not_empty".to_string(), + value: None, + }), + position: PositionMode::Flow, + size: SizeConstraint::default(), + style: TextStyle { font_size: Some(10.0), ..Default::default() }, + content: "Has note".to_string(), + })); + + let fonts = load_test_fonts(); + + // note yok → gizli + let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap(); + assert!(!layout.pages[0].elements.iter().any(|e| e.id == "show_if_exists")); + + // note var → görünür + let layout = compute_layout(&tpl, &serde_json::json!({"note": "merhaba"}), &fonts).unwrap(); + assert!(layout.pages[0].elements.iter().any(|e| e.id == "show_if_exists")); + + // note boş string → gizli + let layout = compute_layout(&tpl, &serde_json::json!({"note": ""}), &fonts).unwrap(); + assert!(!layout.pages[0].elements.iter().any(|e| e.id == "show_if_exists")); +} + +#[test] +fn test_7_1_condition_on_container_hides_children() { + let mut tpl = base_template(); + tpl.root.children.push(TemplateElement::Container(ContainerElement { + id: "cond_container".to_string(), + condition: Some(Condition { + path: "show".to_string(), + operator: "eq".to_string(), + value: Some(serde_json::json!(true)), + }), + 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(), + break_inside: "auto".to_string(), + children: vec![TemplateElement::StaticText(StaticTextElement { + id: "child_text".to_string(), + condition: None, + position: PositionMode::Flow, + size: SizeConstraint::default(), + style: TextStyle { font_size: Some(10.0), ..Default::default() }, + content: "Child".to_string(), + })], + })); + + let fonts = load_test_fonts(); + + // show=false → container ve çocukları gizli + let layout = compute_layout(&tpl, &serde_json::json!({"show": false}), &fonts).unwrap(); + assert!(!layout.pages[0].elements.iter().any(|e| e.id == "cond_container")); + assert!(!layout.pages[0].elements.iter().any(|e| e.id == "child_text")); + + // show=true → container ve çocukları görünür + let layout = compute_layout(&tpl, &serde_json::json!({"show": true}), &fonts).unwrap(); + assert!(layout.pages[0].elements.iter().any(|e| e.id == "cond_container")); + assert!(layout.pages[0].elements.iter().any(|e| e.id == "child_text")); +} + +// ============================================================================= +// 7.5 Localization / FormatConfig from locale +// ============================================================================= + +#[test] +fn test_7_5_locale_en_us_currency() { + let config = FormatConfig::from_locale("en-US"); + assert_eq!(config.thousands_separator, ","); + assert_eq!(config.decimal_separator, "."); + assert_eq!(config.currency_symbol, "$"); + assert_eq!(config.currency_position, "prefix"); +} + +#[test] +fn test_7_5_locale_de_de_currency() { + let config = FormatConfig::from_locale("de-DE"); + assert_eq!(config.thousands_separator, "."); + assert_eq!(config.decimal_separator, ","); + assert_eq!(config.currency_symbol, "€"); + assert_eq!(config.currency_position, "suffix"); +} + +#[test] +fn test_7_5_locale_fr_fr_currency() { + let config = FormatConfig::from_locale("fr-FR"); + assert_eq!(config.thousands_separator, " "); + assert_eq!(config.decimal_separator, ","); + assert_eq!(config.currency_symbol, "€"); +} + +#[test] +fn test_7_5_locale_tr_default() { + let config = FormatConfig::from_locale("tr-TR"); + assert_eq!(config, FormatConfig::default()); +} + +#[test] +fn test_7_5_unknown_locale_falls_back_to_default() { + let config = FormatConfig::from_locale("xx-XX"); + assert_eq!(config, FormatConfig::default()); +} + +#[test] +fn test_7_5_effective_format_config_priority() { + // format_config set → onu kullan + let tpl = Template { + id: "t1".to_string(), + name: "Test".to_string(), + page: PageSettings { width: 210.0, height: 297.0 }, + fonts: vec![], + header: None, + footer: None, + root: ContainerElement { + id: "root".to_string(), + condition: None, + 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(), + break_inside: "auto".to_string(), + children: vec![], + }, + format_config: Some(FormatConfig { + thousands_separator: ",".to_string(), + decimal_separator: ".".to_string(), + currency_symbol: "$".to_string(), + currency_position: "prefix".to_string(), + }), + locale: Some("de-DE".to_string()), + }; + let fc = tpl.effective_format_config(); + assert_eq!(fc.currency_symbol, "$"); // format_config kullanılır, de-DE değil +} + +#[test] +fn test_7_5_effective_format_config_locale_fallback() { + let tpl = Template { + id: "t1".to_string(), + name: "Test".to_string(), + page: PageSettings { width: 210.0, height: 297.0 }, + fonts: vec![], + header: None, + footer: None, + root: ContainerElement { + id: "root".to_string(), + condition: None, + 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(), + break_inside: "auto".to_string(), + children: vec![], + }, + format_config: None, + locale: Some("en-US".to_string()), + }; + let fc = tpl.effective_format_config(); + assert_eq!(fc.currency_symbol, "$"); + assert_eq!(fc.currency_position, "prefix"); +} + +#[test] +fn test_7_5_locale_affects_table_currency_format() { + let mut tpl = base_template(); + tpl.locale = Some("en-US".to_string()); + tpl.root.children.push(TemplateElement::RepeatingTable(RepeatingTableElement { + id: "tbl_locale".to_string(), + condition: None, + position: PositionMode::Flow, + size: SizeConstraint { + width: SizeValue::Fr { value: 1.0 }, + height: SizeValue::Auto, + ..Default::default() + }, + data_source: ArrayBinding { path: "items".to_string() }, + columns: vec![ + TableColumn { + id: "col_price".to_string(), + field: "price".to_string(), + title: "Price".to_string(), + width: SizeValue::Fr { value: 1.0 }, + align: "right".to_string(), + format: Some("currency".to_string()), + }, + ], + style: TableStyle::default(), + repeat_header: Some(true), + })); + + let data = serde_json::json!({ + "items": [ + { "price": 1500 } + ] + }); + + // data_resolve seviyesinde kontrol: locale en-US → $ prefix, comma thousands + let resolved = dreport_layout::data_resolve::resolve_template(&tpl, &data); + let table = resolved.tables.get("tbl_locale").expect("tbl_locale should be resolved"); + assert_eq!(table.rows.len(), 1); + assert_eq!(table.rows[0][0], "$1,500.00"); +} + +// ============================================================================= +// 8.1 Chart Legend — tek seri durumunda da render edilmeli +// ============================================================================= + +#[test] +fn test_8_1_legend_renders_for_single_series() { + use dreport_layout::chart_render::render_svg; + use dreport_layout::data_resolve::{ChartSeries, ResolvedChartData}; + + let data = ResolvedChartData { + chart_type: ChartType::Bar, + categories: vec!["A".to_string(), "B".to_string()], + series: vec![ChartSeries { + name: "Revenue".to_string(), + values: vec![100.0, 200.0], + }], + title: None, + legend: Some(ChartLegend { show: true, position: None, font_size: None }), + labels: None, + axis: None, + style: ChartStyle::default(), + group_mode: None, + }; + + let svg = render_svg(&data, 100.0, 60.0); + let has_swatch = svg.contains(r#"width="2.5" height="2.5""#); + assert!(has_swatch, "tek serili chart'ta legend.show=true olunca legend render edilmeli"); + assert!(svg.contains("Revenue"), "legend seri adını göstermeli"); +} + +#[test] +fn test_8_1_legend_hidden_when_show_false() { + use dreport_layout::chart_render::render_svg; + use dreport_layout::data_resolve::{ChartSeries, ResolvedChartData}; + + let data = ResolvedChartData { + chart_type: ChartType::Bar, + categories: vec!["A".to_string()], + series: vec![ChartSeries { + name: "Sales".to_string(), + values: vec![50.0], + }], + title: None, + legend: Some(ChartLegend { show: false, position: None, font_size: None }), + labels: None, + axis: None, + style: ChartStyle::default(), + group_mode: None, + }; + + let svg = render_svg(&data, 100.0, 60.0); + let has_swatch = svg.contains(r#"width="2.5" height="2.5""#); + assert!(!has_swatch, "legend.show=false olunca legend render edilmemeli"); +} + +// ============================================================================= +// 8.2 Pie Chart Label Kontrolü +// ============================================================================= + +#[test] +fn test_8_2_pie_labels_hidden_when_show_false() { + use dreport_layout::chart_render::render_svg; + use dreport_layout::data_resolve::{ChartSeries, ResolvedChartData}; + + let data = ResolvedChartData { + chart_type: ChartType::Pie, + categories: vec!["Gida".to_string(), "Ulasim".to_string(), "Kira".to_string()], + series: vec![ChartSeries { + name: "data".to_string(), + values: vec![50.0, 30.0, 20.0], + }], + title: None, + legend: None, + labels: Some(ChartLabels { show: false, font_size: None, color: None }), + axis: None, + style: ChartStyle::default(), + group_mode: None, + }; + + let svg = render_svg(&data, 80.0, 80.0); + assert!(!svg.contains("Gida"), "labels.show=false iken kategori adı görünmemeli"); + assert!(!svg.contains("Ulasim"), "labels.show=false iken kategori adı görünmemeli"); + assert!(!svg.contains("50%"), "labels.show=false iken yüzde etiketi görünmemeli"); +} + +#[test] +fn test_8_2_pie_labels_shown_when_show_true() { + use dreport_layout::chart_render::render_svg; + use dreport_layout::data_resolve::{ChartSeries, ResolvedChartData}; + + let data = ResolvedChartData { + chart_type: ChartType::Pie, + categories: vec!["Gida".to_string(), "Ulasim".to_string()], + series: vec![ChartSeries { + name: "data".to_string(), + values: vec![75.0, 25.0], + }], + title: None, + legend: None, + labels: Some(ChartLabels { show: true, font_size: None, color: None }), + axis: None, + style: ChartStyle::default(), + group_mode: None, + }; + + let svg = render_svg(&data, 80.0, 80.0); + assert!(svg.contains("Gida"), "labels.show=true iken kategori adı görünmeli"); + assert!(svg.contains("75%"), "labels.show=true iken yüzde etiketi görünmeli"); +} diff --git a/layout-engine/tests/layout_integration.rs b/layout-engine/tests/layout_integration.rs index 302ba2e..c750d9a 100644 --- a/layout-engine/tests/layout_integration.rs +++ b/layout-engine/tests/layout_integration.rs @@ -18,8 +18,10 @@ fn simple_template() -> Template { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), direction: "column".to_string(), @@ -36,6 +38,7 @@ fn simple_template() -> Template { break_inside: "auto".to_string(), children: vec![TemplateElement::StaticText(StaticTextElement { id: "title".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -161,8 +164,10 @@ fn test_compute_layout_with_data_binding() { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), direction: "column".to_string(), @@ -179,6 +184,7 @@ fn test_compute_layout_with_data_binding() { break_inside: "auto".to_string(), children: vec![TemplateElement::Text(TextElement { id: "bound_text".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -227,8 +233,10 @@ fn test_compute_layout_multiple_children_ordering() { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), direction: "column".to_string(), @@ -246,6 +254,7 @@ fn test_compute_layout_multiple_children_ordering() { children: vec![ TemplateElement::StaticText(StaticTextElement { id: "first".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -260,6 +269,7 @@ fn test_compute_layout_multiple_children_ordering() { }), TemplateElement::StaticText(StaticTextElement { id: "second".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, diff --git a/layout-engine/tests/pdf_render_test.rs b/layout-engine/tests/pdf_render_test.rs index 69852b4..940db25 100644 --- a/layout-engine/tests/pdf_render_test.rs +++ b/layout-engine/tests/pdf_render_test.rs @@ -21,8 +21,10 @@ fn simple_template() -> Template { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), direction: "column".to_string(), @@ -39,6 +41,7 @@ fn simple_template() -> Template { break_inside: "auto".to_string(), children: vec![TemplateElement::StaticText(StaticTextElement { id: "title".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -89,8 +92,10 @@ fn test_render_pdf_with_multiple_elements() { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), direction: "column".to_string(), @@ -108,6 +113,7 @@ fn test_render_pdf_with_multiple_elements() { children: vec![ TemplateElement::StaticText(StaticTextElement { id: "header".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -123,6 +129,7 @@ fn test_render_pdf_with_multiple_elements() { }), TemplateElement::Line(LineElement { id: "sep".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -136,6 +143,7 @@ fn test_render_pdf_with_multiple_elements() { }), TemplateElement::StaticText(StaticTextElement { id: "body".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -182,8 +190,10 @@ fn test_render_pdf_with_container_styles() { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), direction: "column".to_string(), @@ -205,6 +215,7 @@ fn test_render_pdf_with_container_styles() { break_inside: "auto".to_string(), children: vec![TemplateElement::StaticText(StaticTextElement { id: "text".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -244,8 +255,10 @@ fn test_page_break_produces_multiple_pages() { header: None, footer: None, format_config: None, + locale: None, root: ContainerElement { id: "root".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint::default(), direction: "column".to_string(), @@ -263,6 +276,7 @@ fn test_page_break_produces_multiple_pages() { children: vec![ TemplateElement::StaticText(StaticTextElement { id: "t1".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, @@ -277,9 +291,11 @@ fn test_page_break_produces_multiple_pages() { }), TemplateElement::PageBreak(PageBreakElement { id: "pb1".to_string(), + condition: None, }), TemplateElement::StaticText(StaticTextElement { id: "t2".to_string(), + condition: None, position: PositionMode::Flow, size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, diff --git a/layout-engine/tests/snapshots/chart_test_svg.html b/layout-engine/tests/snapshots/chart_test_svg.html index a999e0a..77cdfd8 100644 --- a/layout-engine/tests/snapshots/chart_test_svg.html +++ b/layout-engine/tests/snapshots/chart_test_svg.html @@ -1 +1 @@ -

Chart SVG Preview (HTML render)

Aylik Satis Geliri04.6K9.2K13.9K18.5K23.1K15.0K8.0K3.0K18.0K9.5K4.2K22.0K11.0K5.1K19.5K10.2K4.8KOcakSubatMartNisanOnlineMagazaToptanAylarGelir (TL)
Haftalik Ziyaretci Trendi03787561.1K1.5K1.9KH1H2H3H4OrganikReklam
Kategori Dagilimi35%Elektronik25%Giyim20%Gida12%Kozmetik8%Diger
\ No newline at end of file +

Chart SVG Preview (HTML render)

Aylik Satis Geliri04.6K9.2K13.9K18.5K23.1K15.0K8.0K3.0K18.0K9.5K4.2K22.0K11.0K5.1K19.5K10.2K4.8KOcakSubatMartNisanOnlineMagazaToptanAylarGelir (TL)
Haftalik Ziyaretci Trendi03787561.1K1.5K1.9KH1H2H3H4OrganikReklam
Kategori Dagilimi35%Elektronik25%Giyim20%Gida12%Kozmetik8%DigerElektronikGiyimGidaKozmetikDiger
\ No newline at end of file