fmt
Some checks failed
CI / rust (push) Successful in 47s
CI / frontend (push) Failing after 10s
CI / wasm (push) Successful in 1m45s
CI / publish-crates (push) Successful in 23s
CI / publish-npm (push) Has been skipped

This commit is contained in:
2026-04-07 01:45:38 +03:00
parent b6aecc5f12
commit 603624724c
28 changed files with 1674 additions and 784 deletions

View File

@@ -17,7 +17,7 @@ jobs:
targets: wasm32-unknown-unknown targets: wasm32-unknown-unknown
components: rustfmt, clippy components: rustfmt, clippy
- name: Format check - name: Format check
run: cargo fmt --workspace --check run: cargo fmt --all --check
- name: Clippy - name: Clippy
run: cargo clippy --workspace -- -D warnings run: cargo clippy --workspace -- -D warnings
- name: Test - name: Test

View File

@@ -1,7 +1,7 @@
use std::collections::HashMap;
use dreport_layout::FontData; use dreport_layout::FontData;
use dreport_layout::font_meta::{self, FontFamilyInfo, FontVariantKey}; use dreport_layout::font_meta::{self, FontFamilyInfo, FontVariantKey};
use dreport_layout::font_provider::FontProvider; use dreport_layout::font_provider::FontProvider;
use std::collections::HashMap;
/// Font registry — manages all available fonts from embedded defaults + external directory. /// Font registry — manages all available fonts from embedded defaults + external directory.
pub struct FontRegistry { pub struct FontRegistry {
@@ -31,11 +31,26 @@ impl FontRegistry {
fn load_embedded_defaults(&mut self) { fn load_embedded_defaults(&mut self) {
let embedded: &[(&str, &[u8])] = &[ let embedded: &[(&str, &[u8])] = &[
("NotoSans-Regular", include_bytes!("../fonts/NotoSans-Regular.ttf")), (
("NotoSans-Bold", include_bytes!("../fonts/NotoSans-Bold.ttf")), "NotoSans-Regular",
("NotoSans-Italic", include_bytes!("../fonts/NotoSans-Italic.ttf")), include_bytes!("../fonts/NotoSans-Regular.ttf"),
("NotoSans-BoldItalic", include_bytes!("../fonts/NotoSans-BoldItalic.ttf")), ),
("NotoSansMono-Regular", include_bytes!("../fonts/NotoSansMono-Regular.ttf")), (
"NotoSans-Bold",
include_bytes!("../fonts/NotoSans-Bold.ttf"),
),
(
"NotoSans-Italic",
include_bytes!("../fonts/NotoSans-Italic.ttf"),
),
(
"NotoSans-BoldItalic",
include_bytes!("../fonts/NotoSans-BoldItalic.ttf"),
),
(
"NotoSansMono-Regular",
include_bytes!("../fonts/NotoSansMono-Regular.ttf"),
),
]; ];
for (_name, data) in embedded { for (_name, data) in embedded {
@@ -60,13 +75,13 @@ impl FontRegistry {
for entry in entries.flatten() { for entry in entries.flatten() {
let p = entry.path(); let p = entry.path();
if p.extension().is_some_and(|e| e == "ttf" || e == "otf") { if p.extension().is_some_and(|e| e == "ttf" || e == "otf")
if let Ok(data) = std::fs::read(&p) { && let Ok(data) = std::fs::read(&p)
if self.register_font(data) { {
println!(" Font yüklendi: {}", p.display()); if self.register_font(data) {
} else { println!(" Font yüklendi: {}", p.display());
eprintln!(" Font parse edilemedi: {}", p.display()); } else {
} eprintln!(" Font parse edilemedi: {}", p.display());
} }
} }
} }
@@ -141,7 +156,8 @@ impl FontProvider for FontRegistry {
self.families self.families
.iter() .iter()
.map(|(family_lower, variants)| { .map(|(family_lower, variants)| {
let family = self.family_names let family = self
.family_names
.get(family_lower) .get(family_lower)
.cloned() .cloned()
.unwrap_or_else(|| family_lower.clone()); .unwrap_or_else(|| family_lower.clone());

View File

@@ -14,7 +14,8 @@ async fn main() -> anyhow::Result<()> {
println!("Font registry başlatılıyor..."); println!("Font registry başlatılıyor...");
let registry = Arc::new(FontRegistry::new()); let registry = Arc::new(FontRegistry::new());
let family_count = dreport_layout::font_provider::FontProvider::list_families(registry.as_ref()).len(); let family_count =
dreport_layout::font_provider::FontProvider::list_families(registry.as_ref()).len();
println!("Font registry hazır ({} font ailesi)", family_count); println!("Font registry hazır ({} font ailesi)", family_count);
let cors = CorsLayer::new() let cors = CorsLayer::new()

View File

@@ -1,10 +1,9 @@
use axum::{ use axum::{
Router, Json, Router,
extract::{Path, State}, extract::{Path, State},
http::{StatusCode, header}, http::{StatusCode, header},
response::IntoResponse, response::IntoResponse,
routing::get, routing::get,
Json,
}; };
use dreport_layout::font_provider::FontProvider; use dreport_layout::font_provider::FontProvider;
use serde::Serialize; use serde::Serialize;
@@ -25,15 +24,14 @@ struct FontVariantResponse {
} }
/// GET /api/fonts — list all available font families /// GET /api/fonts — list all available font families
async fn list_fonts( async fn list_fonts(State(registry): State<Arc<FontRegistry>>) -> Json<Vec<FontFamilyResponse>> {
State(registry): State<Arc<FontRegistry>>,
) -> Json<Vec<FontFamilyResponse>> {
let families = registry.list_families(); let families = registry.list_families();
let response: Vec<FontFamilyResponse> = families let response: Vec<FontFamilyResponse> = families
.into_iter() .into_iter()
.map(|f| FontFamilyResponse { .map(|f| FontFamilyResponse {
family: f.family, family: f.family,
variants: f.variants variants: f
.variants
.into_iter() .into_iter()
.map(|v| FontVariantResponse { .map(|v| FontVariantResponse {
weight: v.weight, weight: v.weight,

View File

@@ -1,4 +1,4 @@
use axum::{Router, routing::get, Json}; use axum::{Json, Router, routing::get};
use serde::Serialize; use serde::Serialize;
use std::sync::Arc; use std::sync::Arc;

View File

@@ -1,10 +1,9 @@
use axum::{ use axum::{
Router, Json, Router,
extract::State, extract::State,
http::{StatusCode, header}, http::{StatusCode, header},
response::IntoResponse, response::IntoResponse,
routing::post, routing::post,
Json,
}; };
use serde::Deserialize; use serde::Deserialize;
use std::sync::Arc; use std::sync::Arc;

View File

@@ -296,7 +296,7 @@ pub enum TemplateElement {
#[serde(rename = "rich_text")] #[serde(rename = "rich_text")]
RichText(RichTextElement), RichText(RichTextElement),
#[serde(rename = "chart")] #[serde(rename = "chart")]
Chart(ChartElement), Chart(Box<ChartElement>),
} }
impl TemplateElement { impl TemplateElement {
@@ -406,11 +406,19 @@ pub struct ContainerElement {
pub break_inside: String, pub break_inside: String,
} }
fn default_auto() -> String { "auto".to_string() } fn default_auto() -> String {
"auto".to_string()
}
fn default_column() -> String { "column".to_string() } fn default_column() -> String {
fn default_stretch() -> String { "stretch".to_string() } "column".to_string()
fn default_start() -> String { "start".to_string() } }
fn default_stretch() -> String {
"stretch".to_string()
}
fn default_start() -> String {
"start".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -488,7 +496,9 @@ pub struct RepeatingTableElement {
pub repeat_header: Option<bool>, pub repeat_header: Option<bool>,
} }
fn default_true() -> Option<bool> { Some(true) } fn default_true() -> Option<bool> {
Some(true)
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -519,7 +529,7 @@ pub struct ShapeElement {
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)] #[serde(rename_all = "camelCase", default)]
pub struct CheckboxStyle { pub struct CheckboxStyle {
pub size: Option<f64>, // mm — kare boyutu pub size: Option<f64>, // mm — kare boyutu
pub check_color: Option<String>, // checkmark rengi pub check_color: Option<String>, // checkmark rengi
pub border_color: Option<String>, // kare kenar rengi pub border_color: Option<String>, // kare kenar rengi
pub border_width: Option<f64>, // kenar kalınlığı pub border_width: Option<f64>, // kenar kalınlığı
@@ -531,8 +541,8 @@ pub struct CheckboxElement {
pub id: String, pub id: String,
pub position: PositionMode, pub position: PositionMode,
pub size: SizeConstraint, pub size: SizeConstraint,
pub checked: Option<bool>, // statik değer pub checked: Option<bool>, // statik değer
pub binding: Option<ScalarBinding>, // dinamik boolean binding pub binding: Option<ScalarBinding>, // dinamik boolean binding
pub style: CheckboxStyle, pub style: CheckboxStyle,
} }
@@ -583,10 +593,18 @@ pub struct FormatConfig {
} }
impl FormatConfig { impl FormatConfig {
fn default_thousands_sep() -> String { ".".to_string() } fn default_thousands_sep() -> String {
fn default_decimal_sep() -> String { ",".to_string() } ".".to_string()
fn default_currency_symbol() -> String { "".to_string() } }
fn default_currency_position() -> String { "suffix".to_string() } fn default_decimal_sep() -> String {
",".to_string()
}
fn default_currency_symbol() -> String {
"".to_string()
}
fn default_currency_position() -> String {
"suffix".to_string()
}
} }
impl Default for FormatConfig { impl Default for FormatConfig {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject, watch, nextTick } from 'vue' import { inject, watch, nextTick, type CSSProperties } from 'vue'
import type { ElementLayout, PageLayout, LayoutResult } from '../../core/layout-types' import type { ElementLayout, PageLayout, LayoutResult } from '../../core/layout-types'
const props = defineProps<{ const props = defineProps<{
@@ -231,11 +231,7 @@ watch(
<img <img
v-if="el.content?.type === 'image' && el.content.src" v-if="el.content?.type === 'image' && el.content.src"
:src="el.content.src" :src="el.content.src"
:style="{ :style="{ width: '100%', height: '100%', objectFit: (el.style.objectFit || 'fill') as CSSProperties['objectFit'] }"
width: '100%',
height: '100%',
objectFit: el.style.objectFit || 'fill',
}"
/> />
<div v-else class="layout-el__placeholder">Görsel</div> <div v-else class="layout-el__placeholder">Görsel</div>
</div> </div>

View File

@@ -45,6 +45,7 @@ export type ResolvedContent =
| { type: 'checkbox'; checked: boolean } | { type: 'checkbox'; checked: boolean }
| { type: 'rich_text'; spans: ResolvedRichSpan[] } | { type: 'rich_text'; spans: ResolvedRichSpan[] }
| { type: 'table'; headers: TableHeaderCell[]; rows: TableCell[][]; column_widths_mm: number[] } | { type: 'table'; headers: TableHeaderCell[]; rows: TableCell[][]; column_widths_mm: number[] }
| { type: 'chart'; svg: string }
export interface TableHeaderCell { export interface TableHeaderCell {
text: string text: string

View File

@@ -55,19 +55,21 @@ pub fn generate_barcode_pixels(
// Metin alanı hesapla (QR hariç, include_text true ise) // Metin alanı hesapla (QR hariç, include_text true ise)
let text_area_h = if !is_qr && include_text { let text_area_h = if !is_qr && include_text {
(req_h / 5).max(16).min(48) (req_h / 5).clamp(16, 48)
} else { } else {
0 0
}; };
let bar_h = req_h - text_area_h; let bar_h = req_h - text_area_h;
let mut hints = EncodeHints::default(); let mut hints = EncodeHints {
hints.Margin = Some("1".to_string()); Margin: Some("1".to_string()),
..Default::default()
};
if is_qr { if is_qr {
hints.ErrorCorrection = Some("M".to_string()); hints.ErrorCorrection = Some("M".to_string());
} }
let writer = rxing::MultiFormatWriter::default(); let writer = rxing::MultiFormatWriter;
let matrix = writer let matrix = writer
.encode_with_hints(value, &bc_format, req_w as i32, bar_h as i32, &hints) .encode_with_hints(value, &bc_format, req_w as i32, bar_h as i32, &hints)
.map_err(|e| format!("Barcode encode hatası ({format}): {e}"))?; .map_err(|e| format!("Barcode encode hatası ({format}): {e}"))?;
@@ -91,10 +93,22 @@ pub fn generate_barcode_pixels(
// Metin rendering // Metin rendering
if text_area_h > 0 && !is_qr { if text_area_h > 0 && !is_qr {
render_text_cosmic(&mut pixels, out_w, out_h, mat_h, text_area_h, value, font_data); render_text_cosmic(
&mut pixels,
out_w,
out_h,
mat_h,
text_area_h,
value,
font_data,
);
} }
Ok(BarcodePixels { pixels, width: out_w, height: out_h }) Ok(BarcodePixels {
pixels,
width: out_w,
height: out_h,
})
} }
/// cosmic-text ile metin render et — gerçek font rendering /// cosmic-text ile metin render et — gerçek font rendering
@@ -161,7 +175,8 @@ fn render_text_cosmic(
for glyph in run.glyphs.iter() { for glyph in run.glyphs.iter() {
let physical = glyph.physical((offset_x as f32, line_y as f32), 1.0); let physical = glyph.physical((offset_x as f32, line_y as f32), 1.0);
let Some(image) = swash_cache.get_image_uncached(&mut font_system, physical.cache_key) else { let Some(image) = swash_cache.get_image_uncached(&mut font_system, physical.cache_key)
else {
continue; continue;
}; };
@@ -179,13 +194,19 @@ fn render_text_cosmic(
} }
let src_idx = (row * gw + col) as usize; let src_idx = (row * gw + col) as usize;
if src_idx >= image.data.len() { continue; } if src_idx >= image.data.len() {
continue;
}
let alpha = image.data[src_idx]; let alpha = image.data[src_idx];
if alpha == 0 { continue; } if alpha == 0 {
continue;
}
let dst_idx = (py as u32 * img_w + px as u32) as usize; let dst_idx = (py as u32 * img_w + px as u32) as usize;
if dst_idx >= pixels.len() { continue; } if dst_idx >= pixels.len() {
continue;
}
// Alpha blending: beyaz arka plan üzerine siyah metin // Alpha blending: beyaz arka plan üzerine siyah metin
let bg = pixels[dst_idx] as f32; let bg = pixels[dst_idx] as f32;
@@ -207,7 +228,8 @@ pub fn generate_barcode_png(
include_text: bool, include_text: bool,
font_data: Option<&[FontData]>, font_data: Option<&[FontData]>,
) -> Result<Vec<u8>, String> { ) -> Result<Vec<u8>, String> {
let result = generate_barcode_pixels(format, value, width_px, height_px, include_text, font_data)?; let result =
generate_barcode_pixels(format, value, width_px, height_px, include_text, font_data)?;
let img = image::GrayImage::from_raw(result.width, result.height, result.pixels) let img = image::GrayImage::from_raw(result.width, result.height, result.pixels)
.ok_or_else(|| "Pixel buffer boyutu uyumsuz".to_string())?; .ok_or_else(|| "Pixel buffer boyutu uyumsuz".to_string())?;
@@ -231,20 +253,23 @@ mod tests {
#[test] #[test]
fn test_qr_is_square() { fn test_qr_is_square() {
let result = generate_barcode_pixels("qr", "https://example.com", 300, 200, false, None).unwrap(); let result =
generate_barcode_pixels("qr", "https://example.com", 300, 200, false, None).unwrap();
assert_eq!(result.width, result.height); assert_eq!(result.width, result.height);
} }
#[test] #[test]
fn test_ean13_with_text() { fn test_ean13_with_text() {
let result = generate_barcode_pixels("ean13", "5901234123457", 300, 100, true, None).unwrap(); let result =
generate_barcode_pixels("ean13", "5901234123457", 300, 100, true, None).unwrap();
assert!(result.width > 0); assert!(result.width > 0);
assert!(result.height > 0); assert!(result.height > 0);
} }
#[test] #[test]
fn test_ean13_without_text() { fn test_ean13_without_text() {
let result = generate_barcode_pixels("ean13", "5901234123457", 300, 100, false, None).unwrap(); let result =
generate_barcode_pixels("ean13", "5901234123457", 300, 100, false, None).unwrap();
assert!(result.width > 0); assert!(result.width > 0);
assert!(result.height > 0); assert!(result.height > 0);
} }
@@ -253,13 +278,18 @@ mod tests {
#[test] #[test]
fn test_ean13_with_font_rendering() { fn test_ean13_with_font_rendering() {
let fonts = crate::text_measure::load_test_fonts(); let fonts = crate::text_measure::load_test_fonts();
let result = generate_barcode_pixels("ean13", "5901234123457", 400, 150, true, Some(&fonts)).unwrap(); let result =
generate_barcode_pixels("ean13", "5901234123457", 400, 150, true, Some(&fonts))
.unwrap();
assert!(result.width > 0); assert!(result.width > 0);
assert!(result.height > 0); assert!(result.height > 0);
// Metin alanında siyah pikseller olmalı (font rendering çalıştı) // Metin alanında siyah pikseller olmalı (font rendering çalıştı)
let text_start = (result.height - result.height / 5) * result.width; let text_start = (result.height - result.height / 5) * result.width;
let text_pixels = &result.pixels[text_start as usize..]; let text_pixels = &result.pixels[text_start as usize..];
assert!(text_pixels.iter().any(|&p| p < 128), "Font rendering metin üretmeli"); assert!(
text_pixels.iter().any(|&p| p < 128),
"Font rendering metin üretmeli"
);
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@@ -273,7 +303,8 @@ mod tests {
#[test] #[test]
fn test_ean13_png_with_text() { fn test_ean13_png_with_text() {
let fonts = crate::text_measure::load_test_fonts(); let fonts = crate::text_measure::load_test_fonts();
let png = generate_barcode_png("ean13", "5901234123457", 400, 150, true, Some(&fonts)).unwrap(); let png =
generate_barcode_png("ean13", "5901234123457", 400, 150, true, Some(&fonts)).unwrap();
assert!(png.starts_with(&[0x89, b'P', b'N', b'G'])); assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
} }

View File

@@ -226,34 +226,90 @@ pub trait ChartDataSource {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
impl ChartDataSource for crate::data_resolve::ResolvedChartData { impl ChartDataSource for crate::data_resolve::ResolvedChartData {
fn chart_type(&self) -> ChartType { self.chart_type.clone() } fn chart_type(&self) -> ChartType {
fn categories(&self) -> &[String] { &self.categories } self.chart_type.clone()
fn series_count(&self) -> usize { self.series.len() } }
fn series_name(&self, idx: usize) -> &str { &self.series[idx].name } fn categories(&self) -> &[String] {
fn series_values(&self, idx: usize) -> &[f64] { &self.series[idx].values } &self.categories
fn title_text(&self) -> Option<&str> { }
self.title.as_ref().map(|t| t.text.as_str()).filter(|t| !t.is_empty()) fn series_count(&self) -> usize {
self.series.len()
}
fn series_name(&self, idx: usize) -> &str {
&self.series[idx].name
}
fn series_values(&self, idx: usize) -> &[f64] {
&self.series[idx].values
}
fn title_text(&self) -> Option<&str> {
self.title
.as_ref()
.map(|t| t.text.as_str())
.filter(|t| !t.is_empty())
}
fn title_font_size(&self) -> Option<f64> {
self.title.as_ref().and_then(|t| t.font_size)
}
fn title_color(&self) -> Option<&str> {
self.title.as_ref().and_then(|t| t.color.as_deref())
}
fn title_align(&self) -> Option<&str> {
self.title.as_ref().and_then(|t| t.align.as_deref())
}
fn legend_show(&self) -> bool {
self.legend.as_ref().is_some_and(|l| l.show)
}
fn legend_position(&self) -> Option<&str> {
self.legend.as_ref().and_then(|l| l.position.as_deref())
}
fn legend_font_size(&self) -> Option<f64> {
self.legend.as_ref().and_then(|l| l.font_size)
}
fn x_label(&self) -> Option<&str> {
self.axis.as_ref().and_then(|a| a.x_label.as_deref())
}
fn y_label(&self) -> Option<&str> {
self.axis.as_ref().and_then(|a| a.y_label.as_deref())
}
fn show_grid(&self) -> bool {
self.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true)
}
fn grid_color(&self) -> Option<&str> {
self.axis.as_ref().and_then(|a| a.grid_color.as_deref())
}
fn bar_gap(&self) -> Option<f64> {
self.style.bar_gap
}
fn stacked(&self) -> bool {
matches!(
self.group_mode,
Some(dreport_core::models::GroupMode::Stacked)
)
}
fn colors(&self) -> Option<&[String]> {
self.style.colors.as_deref()
}
fn background_color(&self) -> Option<&str> {
self.style.background_color.as_deref()
}
fn show_labels(&self) -> bool {
self.labels.as_ref().is_some_and(|l| l.show)
}
fn label_font_size(&self) -> Option<f64> {
self.labels.as_ref().and_then(|l| l.font_size)
}
fn label_color(&self) -> Option<&str> {
self.labels.as_ref().and_then(|l| l.color.as_deref())
}
fn inner_radius(&self) -> Option<f64> {
self.style.inner_radius
}
fn show_points(&self) -> Option<bool> {
self.style.show_points
}
fn line_width(&self) -> Option<f64> {
self.style.line_width
} }
fn title_font_size(&self) -> Option<f64> { self.title.as_ref().and_then(|t| t.font_size) }
fn title_color(&self) -> Option<&str> { self.title.as_ref().and_then(|t| t.color.as_deref()) }
fn title_align(&self) -> Option<&str> { self.title.as_ref().and_then(|t| t.align.as_deref()) }
fn legend_show(&self) -> bool { self.legend.as_ref().is_some_and(|l| l.show) }
fn legend_position(&self) -> Option<&str> { self.legend.as_ref().and_then(|l| l.position.as_deref()) }
fn legend_font_size(&self) -> Option<f64> { self.legend.as_ref().and_then(|l| l.font_size) }
fn x_label(&self) -> Option<&str> { self.axis.as_ref().and_then(|a| a.x_label.as_deref()) }
fn y_label(&self) -> Option<&str> { self.axis.as_ref().and_then(|a| a.y_label.as_deref()) }
fn show_grid(&self) -> bool { self.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true) }
fn grid_color(&self) -> Option<&str> { self.axis.as_ref().and_then(|a| a.grid_color.as_deref()) }
fn bar_gap(&self) -> Option<f64> { self.style.bar_gap }
fn stacked(&self) -> bool { matches!(self.group_mode, Some(dreport_core::models::GroupMode::Stacked)) }
fn colors(&self) -> Option<&[String]> { self.style.colors.as_deref() }
fn background_color(&self) -> Option<&str> { self.style.background_color.as_deref() }
fn show_labels(&self) -> bool { self.labels.as_ref().is_some_and(|l| l.show) }
fn label_font_size(&self) -> Option<f64> { self.labels.as_ref().and_then(|l| l.font_size) }
fn label_color(&self) -> Option<&str> { self.labels.as_ref().and_then(|l| l.color.as_deref()) }
fn inner_radius(&self) -> Option<f64> { self.style.inner_radius }
fn show_points(&self) -> Option<bool> { self.style.show_points }
fn line_width(&self) -> Option<f64> { self.style.line_width }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -261,36 +317,88 @@ impl ChartDataSource for crate::data_resolve::ResolvedChartData {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
impl ChartDataSource for crate::ChartRenderData { impl ChartDataSource for crate::ChartRenderData {
fn chart_type(&self) -> ChartType { self.chart_type.clone() } fn chart_type(&self) -> ChartType {
fn categories(&self) -> &[String] { &self.categories } self.chart_type.clone()
fn series_count(&self) -> usize { self.series.len() } }
fn series_name(&self, idx: usize) -> &str { &self.series[idx].name } fn categories(&self) -> &[String] {
fn series_values(&self, idx: usize) -> &[f64] { &self.series[idx].values } &self.categories
}
fn series_count(&self) -> usize {
self.series.len()
}
fn series_name(&self, idx: usize) -> &str {
&self.series[idx].name
}
fn series_values(&self, idx: usize) -> &[f64] {
&self.series[idx].values
}
fn title_text(&self) -> Option<&str> { fn title_text(&self) -> Option<&str> {
self.title_text.as_deref().filter(|t| !t.is_empty()) self.title_text.as_deref().filter(|t| !t.is_empty())
} }
fn title_font_size(&self) -> Option<f64> { self.title_font_size } fn title_font_size(&self) -> Option<f64> {
fn title_color(&self) -> Option<&str> { self.title_color.as_deref() } self.title_font_size
fn title_align(&self) -> Option<&str> { self.title_align.as_deref() } }
fn legend_show(&self) -> bool { self.legend_show } fn title_color(&self) -> Option<&str> {
fn legend_position(&self) -> Option<&str> { self.legend_position.as_deref() } self.title_color.as_deref()
fn legend_font_size(&self) -> Option<f64> { self.legend_font_size } }
fn x_label(&self) -> Option<&str> { self.x_label.as_deref() } fn title_align(&self) -> Option<&str> {
fn y_label(&self) -> Option<&str> { self.y_label.as_deref() } self.title_align.as_deref()
fn show_grid(&self) -> bool { self.show_grid } }
fn grid_color(&self) -> Option<&str> { self.grid_color.as_deref() } fn legend_show(&self) -> bool {
fn bar_gap(&self) -> Option<f64> { self.bar_gap } self.legend_show
fn stacked(&self) -> bool { self.stacked } }
fn colors(&self) -> Option<&[String]> { fn legend_position(&self) -> Option<&str> {
if self.colors.is_empty() { None } else { Some(&self.colors) } self.legend_position.as_deref()
}
fn legend_font_size(&self) -> Option<f64> {
self.legend_font_size
}
fn x_label(&self) -> Option<&str> {
self.x_label.as_deref()
}
fn y_label(&self) -> Option<&str> {
self.y_label.as_deref()
}
fn show_grid(&self) -> bool {
self.show_grid
}
fn grid_color(&self) -> Option<&str> {
self.grid_color.as_deref()
}
fn bar_gap(&self) -> Option<f64> {
self.bar_gap
}
fn stacked(&self) -> bool {
self.stacked
}
fn colors(&self) -> Option<&[String]> {
if self.colors.is_empty() {
None
} else {
Some(&self.colors)
}
}
fn background_color(&self) -> Option<&str> {
self.background_color.as_deref()
}
fn show_labels(&self) -> bool {
self.show_labels
}
fn label_font_size(&self) -> Option<f64> {
self.label_font_size
}
fn label_color(&self) -> Option<&str> {
self.label_color.as_deref()
}
fn inner_radius(&self) -> Option<f64> {
self.inner_radius
}
fn show_points(&self) -> Option<bool> {
self.show_points
}
fn line_width(&self) -> Option<f64> {
self.line_width
} }
fn background_color(&self) -> Option<&str> { self.background_color.as_deref() }
fn show_labels(&self) -> bool { self.show_labels }
fn label_font_size(&self) -> Option<f64> { self.label_font_size }
fn label_color(&self) -> Option<&str> { self.label_color.as_deref() }
fn inner_radius(&self) -> Option<f64> { self.inner_radius }
fn show_points(&self) -> Option<bool> { self.show_points }
fn line_width(&self) -> Option<f64> { self.line_width }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -306,10 +414,10 @@ pub fn build_palette(data: &dyn ChartDataSource) -> Vec<String> {
let user_colors = data.colors(); let user_colors = data.colors();
(0..n_colors) (0..n_colors)
.map(|i| { .map(|i| {
if let Some(uc) = user_colors { if let Some(uc) = user_colors
if i < uc.len() { && i < uc.len()
return uc[i].clone(); {
} return uc[i].clone();
} }
DEFAULT_COLORS[i % DEFAULT_COLORS.len()].to_string() DEFAULT_COLORS[i % DEFAULT_COLORS.len()].to_string()
}) })
@@ -392,7 +500,14 @@ pub fn compute_chart_layout(
_ => origin_x + width_mm / 2.0, _ => origin_x + width_mm / 2.0,
}; };
let y = origin_y + margin_top - 1.0; let y = origin_y + margin_top - 1.0;
Some(TitleLayout { text: text.to_string(), font_size: fs, color, x, y, align }) Some(TitleLayout {
text: text.to_string(),
font_size: fs,
color,
x,
y,
align,
})
} else { } else {
None None
}; };
@@ -423,14 +538,18 @@ pub fn compute_chart_layout(
let max_label_len = data.categories().iter().map(|c| c.len()).max().unwrap_or(0); let max_label_len = data.categories().iter().map(|c| c.len()).max().unwrap_or(0);
let n_cats = data.categories().len(); let n_cats = data.categories().len();
let available_w = width_mm - margin_left - margin_right; let available_w = width_mm - margin_left - margin_right;
let cat_width = if n_cats > 0 { available_w / n_cats as f64 } else { available_w }; let cat_width = if n_cats > 0 {
available_w / n_cats as f64
} else {
available_w
};
let max_chars_fit = (cat_width / 1.25).max(1.0) as usize; let max_chars_fit = (cat_width / 1.25).max(1.0) as usize;
let will_rotate = max_label_len > max_chars_fit; let will_rotate = max_label_len > max_chars_fit;
if will_rotate { if will_rotate {
let char_w_mm = 1.1; let char_w_mm = 1.1;
let max_text_w = max_label_len as f64 * char_w_mm; let max_text_w = max_label_len as f64 * char_w_mm;
let label_v = max_text_w * 0.707; let label_v = max_text_w * 0.707;
margin_bottom += label_v.min(25.0).max(6.0); margin_bottom += label_v.clamp(6.0, 25.0);
let label_h = max_text_w * 0.707; let label_h = max_text_w * 0.707;
let extra_left = (label_h - cat_width / 2.0).max(0.0); let extra_left = (label_h - cat_width / 2.0).max(0.0);
margin_left += extra_left.min(10.0); margin_left += extra_left.min(10.0);
@@ -447,17 +566,33 @@ pub fn compute_chart_layout(
let plot_h = (height_mm - margin_top - margin_bottom).max(1.0); let plot_h = (height_mm - margin_top - margin_bottom).max(1.0);
ChartLayout { ChartLayout {
plot_x, plot_y, plot_w, plot_h, plot_x,
margin_top, margin_bottom, margin_left, margin_right, plot_y,
palette, title, legend_show, legend_pos, legend_font, plot_w,
plot_h,
margin_top,
margin_bottom,
margin_left,
margin_right,
palette,
title,
legend_show,
legend_pos,
legend_font,
} }
} }
/// Compute Y axis ticks and grid lines. /// Compute Y axis ticks and grid lines.
#[allow(clippy::too_many_arguments)]
pub fn compute_y_axis( pub fn compute_y_axis(
min_val: f64, max_val: f64, min_val: f64,
px: f64, py: f64, pw: f64, ph: f64, max_val: f64,
show_grid: bool, grid_color: &str, px: f64,
py: f64,
pw: f64,
ph: f64,
show_grid: bool,
grid_color: &str,
) -> YAxisLayout { ) -> YAxisLayout {
let range = safe_range(min_val, max_val); let range = safe_range(min_val, max_val);
let tick_count = 5; let tick_count = 5;
@@ -466,7 +601,11 @@ pub fn compute_y_axis(
let frac = i as f64 / tick_count as f64; let frac = i as f64 / tick_count as f64;
let val = min_val + frac * range; let val = min_val + frac * range;
let y = py + ph - frac * ph; let y = py + ph - frac * ph;
YTick { value: val, label: format_value(val), y } YTick {
value: val,
label: format_value(val),
y,
}
}) })
.collect(); .collect();
@@ -482,38 +621,78 @@ pub fn compute_y_axis(
} }
/// Compute X label positions for bar chart (slot-based spacing). /// Compute X label positions for bar chart (slot-based spacing).
pub fn compute_x_labels_bar(categories: &[String], px: f64, baseline_y: f64, pw: f64) -> XLabelLayout { pub fn compute_x_labels_bar(
categories: &[String],
px: f64,
baseline_y: f64,
pw: f64,
) -> XLabelLayout {
let n_cats = categories.len(); let n_cats = categories.len();
if n_cats == 0 { if n_cats == 0 {
return XLabelLayout { labels: vec![], needs_rotate: false }; return XLabelLayout {
labels: vec![],
needs_rotate: false,
};
} }
let cat_width = pw / n_cats as f64; let cat_width = pw / n_cats as f64;
let max_chars = (cat_width / 1.25).max(1.0) as usize; let max_chars = (cat_width / 1.25).max(1.0) as usize;
let needs_rotate = categories.iter().any(|c| c.len() > max_chars); let needs_rotate = categories.iter().any(|c| c.len() > max_chars);
let labels = categories.iter().enumerate().map(|(ci, cat)| { let labels = categories
XLabel { .iter()
.enumerate()
.map(|(ci, cat)| XLabel {
text: cat.clone(), text: cat.clone(),
x: px + ci as f64 * cat_width + cat_width / 2.0, x: px + ci as f64 * cat_width + cat_width / 2.0,
y: baseline_y + 2.5, y: baseline_y + 2.5,
} })
}).collect(); .collect();
XLabelLayout { labels, needs_rotate } XLabelLayout {
labels,
needs_rotate,
}
} }
/// Compute X label positions for line chart (point-based spacing). /// Compute X label positions for line chart (point-based spacing).
pub fn compute_x_labels_line(categories: &[String], px: f64, baseline_y: f64, pw: f64) -> XLabelLayout { pub fn compute_x_labels_line(
categories: &[String],
px: f64,
baseline_y: f64,
pw: f64,
) -> XLabelLayout {
let n_cats = categories.len(); let n_cats = categories.len();
if n_cats == 0 { if n_cats == 0 {
return XLabelLayout { labels: vec![], needs_rotate: false }; return XLabelLayout {
labels: vec![],
needs_rotate: false,
};
} }
let spacing = if n_cats == 1 { pw } else { pw / (n_cats - 1) as f64 }; let spacing = if n_cats == 1 {
pw
} else {
pw / (n_cats - 1) as f64
};
let max_chars = (spacing / 1.25).max(1.0) as usize; let max_chars = (spacing / 1.25).max(1.0) as usize;
let needs_rotate = categories.iter().any(|c| c.len() > max_chars); let needs_rotate = categories.iter().any(|c| c.len() > max_chars);
let labels = categories.iter().enumerate().map(|(ci, cat)| { let labels = categories
let x = if n_cats == 1 { px + pw / 2.0 } else { px + ci as f64 * pw / (n_cats - 1) as f64 }; .iter()
XLabel { text: cat.clone(), x, y: baseline_y + 2.5 } .enumerate()
}).collect(); .map(|(ci, cat)| {
XLabelLayout { labels, needs_rotate } let x = if n_cats == 1 {
px + pw / 2.0
} else {
px + ci as f64 * pw / (n_cats - 1) as f64
};
XLabel {
text: cat.clone(),
x,
y: baseline_y + 2.5,
}
})
.collect();
XLabelLayout {
labels,
needs_rotate,
}
} }
/// Compute bar chart layout (all bar geometries + axes). /// Compute bar chart layout (all bar geometries + axes).
@@ -565,7 +744,11 @@ pub fn compute_bar_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> BarCh
y_offset += bar_h; y_offset += bar_h;
} }
} else { } else {
let bar_w = if n_series > 0 { group_width / n_series as f64 } else { group_width }; let bar_w = if n_series > 0 {
group_width / n_series as f64
} else {
group_width
};
for si in 0..n_series { for si in 0..n_series {
let val = data.series_values(si).get(ci).copied().unwrap_or(0.0); let val = data.series_values(si).get(ci).copied().unwrap_or(0.0);
let bar_h = ((val - min_val) / range) * ph; let bar_h = ((val - min_val) / range) * ph;
@@ -588,9 +771,15 @@ pub fn compute_bar_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> BarCh
let x_labels = compute_x_labels_bar(data.categories(), px, py + ph, pw); let x_labels = compute_x_labels_bar(data.categories(), px, py + ph, pw);
BarChartLayout { BarChartLayout {
min_val, max_val, min_val,
y_axis, x_labels, bars, max_val,
show_labels, label_font, label_color, stacked, y_axis,
x_labels,
bars,
show_labels,
label_font,
label_color,
stacked,
x_axis_y: py + ph, x_axis_y: py + ph,
x_axis_x1: px, x_axis_x1: px,
x_axis_x2: px + pw, x_axis_x2: px + pw,
@@ -618,23 +807,42 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
let label_font = data.label_font_size().unwrap_or(2.2); let label_font = data.label_font_size().unwrap_or(2.2);
let label_color = data.label_color().unwrap_or("#333").to_string(); let label_color = data.label_color().unwrap_or("#333").to_string();
let series = (0..data.series_count()).map(|si| { let series = (0..data.series_count())
let values = data.series_values(si); .map(|si| {
let points = values.iter().enumerate().map(|(ci, val)| { let values = data.series_values(si);
let x = if n_cats == 1 { px + pw / 2.0 } else { px + ci as f64 * pw / (n_cats - 1) as f64 }; let points = values
let y = py + ph - ((val - min_val) / range) * ph; .iter()
LinePoint { x, y, value: *val } .enumerate()
}).collect(); .map(|(ci, val)| {
LineSeriesLayout { color_idx: si, points } let x = if n_cats == 1 {
}).collect(); px + pw / 2.0
} else {
px + ci as f64 * pw / (n_cats - 1) as f64
};
let y = py + ph - ((val - min_val) / range) * ph;
LinePoint { x, y, value: *val }
})
.collect();
LineSeriesLayout {
color_idx: si,
points,
}
})
.collect();
let x_labels = compute_x_labels_line(data.categories(), px, py + ph, pw); let x_labels = compute_x_labels_line(data.categories(), px, py + ph, pw);
LineChartLayout { LineChartLayout {
min_val, max_val, min_val,
y_axis, x_labels, series, max_val,
line_width: line_width, y_axis,
show_points, show_labels, label_font, label_color, x_labels,
series,
line_width,
show_points,
show_labels,
label_font,
label_color,
x_axis_y: py + ph, x_axis_y: py + ph,
x_axis_x1: px, x_axis_x1: px,
x_axis_x2: px + pw, x_axis_x2: px + pw,
@@ -651,11 +859,15 @@ pub fn compute_pie_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> PieCh
let values: Vec<f64> = if data.series_count() == 1 { let values: Vec<f64> = if data.series_count() == 1 {
data.series_values(0).to_vec() data.series_values(0).to_vec()
} else { } else {
data.categories().iter().enumerate().map(|(ci, _)| { data.categories()
(0..data.series_count()) .iter()
.map(|si| data.series_values(si).get(ci).copied().unwrap_or(0.0)) .enumerate()
.sum() .map(|(ci, _)| {
}).collect() (0..data.series_count())
.map(|si| data.series_values(si).get(ci).copied().unwrap_or(0.0))
.sum()
})
.collect()
}; };
let total: f64 = values.iter().sum(); let total: f64 = values.iter().sum();
@@ -685,7 +897,11 @@ pub fn compute_pie_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> PieCh
let mid_angle = start_angle + sweep / 2.0; let mid_angle = start_angle + sweep / 2.0;
// Label inside slice // Label inside slice
let label_r = if inner_r > 0.0 { (radius + inner_r) / 2.0 } else { radius * 0.65 }; let label_r = if inner_r > 0.0 {
(radius + inner_r) / 2.0
} else {
radius * 0.65
};
let lx = cx + label_r * mid_angle.cos(); let lx = cx + label_r * mid_angle.cos();
let ly = cy + label_r * mid_angle.sin(); let ly = cy + label_r * mid_angle.sin();
let pct = (val / total * 100.0).round(); let pct = (val / total * 100.0).round();
@@ -701,19 +917,29 @@ pub fn compute_pie_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> PieCh
let leader_ey = cy + line_end_r * mid_angle.sin(); let leader_ey = cy + line_end_r * mid_angle.sin();
let cat_lx = cx + text_r * mid_angle.cos(); let cat_lx = cx + text_r * mid_angle.cos();
let cat_ly = cy + text_r * mid_angle.sin(); let cat_ly = cy + text_r * mid_angle.sin();
let cat_text = if i < categories.len() { categories[i].clone() } else { String::new() }; let cat_text = if i < categories.len() {
categories[i].clone()
} else {
String::new()
};
let anchor_end = mid_angle.cos() < 0.0; let anchor_end = mid_angle.cos() < 0.0;
slices.push(PieSlice { slices.push(PieSlice {
start_angle, end_angle, sweep, start_angle,
end_angle,
sweep,
color_idx: i, color_idx: i,
value: *val, value: *val,
fraction: val / total, fraction: val / total,
label_x: lx, label_y: ly, label_x: lx,
label_y: ly,
label_text: format!("{}%", pct), label_text: format!("{}%", pct),
leader_start_x: leader_sx, leader_start_y: leader_sy, leader_start_x: leader_sx,
leader_end_x: leader_ex, leader_end_y: leader_ey, leader_start_y: leader_sy,
cat_label_x: cat_lx, cat_label_y: cat_ly, leader_end_x: leader_ex,
leader_end_y: leader_ey,
cat_label_x: cat_lx,
cat_label_y: cat_ly,
cat_label_text: cat_text, cat_label_text: cat_text,
cat_label_anchor_end: anchor_end, cat_label_anchor_end: anchor_end,
}); });
@@ -723,8 +949,14 @@ pub fn compute_pie_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> PieCh
} }
PieChartLayout { PieChartLayout {
cx, cy, radius, inner_radius: inner_r, cx,
slices, show_labels, label_font, label_color, cy,
radius,
inner_radius: inner_r,
slices,
show_labels,
label_font,
label_color,
} }
} }
@@ -747,7 +979,9 @@ pub fn compute_legend(
let names: Vec<String> = if is_pie { let names: Vec<String> = if is_pie {
data.categories().to_vec() data.categories().to_vec()
} else { } else {
(0..data.series_count()).map(|i| data.series_name(i).to_string()).collect() (0..data.series_count())
.map(|i| data.series_name(i).to_string())
.collect()
}; };
let mut items = Vec::new(); let mut items = Vec::new();
@@ -758,9 +992,12 @@ pub fn compute_legend(
let mut x = origin_x + cl.margin_left; let mut x = origin_x + cl.margin_left;
for (i, name) in names.iter().enumerate() { for (i, name) in names.iter().enumerate() {
items.push(LegendItemLayout { items.push(LegendItemLayout {
name: name.clone(), color_idx: i, name: name.clone(),
swatch_x: x, swatch_y: y - font_size * 0.3, color_idx: i,
text_x: x + item_gap, text_y: y + font_size * 0.3, swatch_x: x,
swatch_y: y - font_size * 0.3,
text_x: x + item_gap,
text_y: y + font_size * 0.3,
}); });
x += item_gap + name.len() as f64 * font_size * 0.5 + spacing; x += item_gap + name.len() as f64 * font_size * 0.5 + spacing;
} }
@@ -770,9 +1007,12 @@ pub fn compute_legend(
let mut y = origin_y + cl.margin_top + 2.0; let mut y = origin_y + cl.margin_top + 2.0;
for (i, name) in names.iter().enumerate() { for (i, name) in names.iter().enumerate() {
items.push(LegendItemLayout { items.push(LegendItemLayout {
name: name.clone(), color_idx: i, name: name.clone(),
swatch_x: x, swatch_y: y, color_idx: i,
text_x: x + item_gap, text_y: y + font_size * 0.7, swatch_x: x,
swatch_y: y,
text_x: x + item_gap,
text_y: y + font_size * 0.7,
}); });
y += font_size + 2.0; y += font_size + 2.0;
} }
@@ -780,20 +1020,30 @@ pub fn compute_legend(
_ => { _ => {
// bottom (default) // bottom (default)
let y = origin_y + total_h - 3.0; let y = origin_y + total_h - 3.0;
let total_legend_w: f64 = names.iter() let total_legend_w: f64 = names
.iter()
.map(|n| item_gap + n.len() as f64 * font_size * 0.5 + spacing) .map(|n| item_gap + n.len() as f64 * font_size * 0.5 + spacing)
.sum::<f64>() - spacing; .sum::<f64>()
- spacing;
let mut x = origin_x + (total_w - total_legend_w) / 2.0; let mut x = origin_x + (total_w - total_legend_w) / 2.0;
for (i, name) in names.iter().enumerate() { for (i, name) in names.iter().enumerate() {
items.push(LegendItemLayout { items.push(LegendItemLayout {
name: name.clone(), color_idx: i, name: name.clone(),
swatch_x: x, swatch_y: y - font_size * 0.3, color_idx: i,
text_x: x + item_gap, text_y: y + font_size * 0.3, swatch_x: x,
swatch_y: y - font_size * 0.3,
text_x: x + item_gap,
text_y: y + font_size * 0.3,
}); });
x += item_gap + name.len() as f64 * font_size * 0.5 + spacing; x += item_gap + name.len() as f64 * font_size * 0.5 + spacing;
} }
} }
} }
LegendLayout { items, font_size, position, swatch_size } LegendLayout {
items,
font_size,
position,
swatch_size,
}
} }

View File

@@ -1,6 +1,6 @@
use crate::chart_layout::{ use crate::chart_layout::{
self, color_at, compute_bar_layout, compute_chart_layout, compute_legend, self, ChartLayout, color_at, compute_bar_layout, compute_chart_layout, compute_legend,
compute_line_layout, compute_pie_layout, format_value, ChartLayout, compute_line_layout, compute_pie_layout, format_value,
}; };
use crate::data_resolve::ResolvedChartData; use crate::data_resolve::ResolvedChartData;
use std::fmt::Write; use std::fmt::Write;
@@ -9,11 +9,7 @@ use std::fmt::Write;
pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> String { pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> String {
let mut svg = String::with_capacity(4096); let mut svg = String::with_capacity(4096);
let bg = data let bg = data.style.background_color.as_deref().unwrap_or("#FFFFFF");
.style
.background_color
.as_deref()
.unwrap_or("#FFFFFF");
write!( write!(
svg, svg,
@@ -58,28 +54,26 @@ pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> St
// Axis labels // Axis labels
let has_axis = !matches!(data.chart_type, dreport_core::models::ChartType::Pie); let has_axis = !matches!(data.chart_type, dreport_core::models::ChartType::Pie);
if has_axis { if has_axis && let Some(ref axis) = data.axis {
if let Some(ref axis) = data.axis { if let Some(ref x_label) = axis.x_label {
if let Some(ref x_label) = axis.x_label { let x = cl.plot_x + cl.plot_w / 2.0;
let x = cl.plot_x + cl.plot_w / 2.0; let y = height_mm - 2.0;
let y = height_mm - 2.0; write!(
write!(
svg, svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.8" fill="#666" text-anchor="middle">{}</text>"##, r##"<text x="{:.2}" y="{:.2}" font-size="2.8" fill="#666" text-anchor="middle">{}</text>"##,
x, y, escape_xml(x_label) x, y, escape_xml(x_label)
) )
.unwrap(); .unwrap();
} }
if let Some(ref y_label) = axis.y_label { if let Some(ref y_label) = axis.y_label {
let x = 3.0; let x = 3.0;
let y = cl.plot_y + cl.plot_h / 2.0; let y = cl.plot_y + cl.plot_h / 2.0;
write!( write!(
svg, svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.8" fill="#666" text-anchor="middle" transform="rotate(-90,{:.2},{:.2})">{}</text>"##, r##"<text x="{:.2}" y="{:.2}" font-size="2.8" fill="#666" text-anchor="middle" transform="rotate(-90,{:.2},{:.2})">{}</text>"##,
x, y, x, y, escape_xml(y_label) x, y, x, y, escape_xml(y_label)
) )
.unwrap(); .unwrap();
}
} }
} }
@@ -212,7 +206,11 @@ fn render_pie(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
for slice in &pl.slices { for slice in &pl.slices {
let color = color_at(&cl.palette, slice.color_idx); let color = color_at(&cl.palette, slice.color_idx);
let large_arc = if slice.sweep > std::f64::consts::PI { 1 } else { 0 }; let large_arc = if slice.sweep > std::f64::consts::PI {
1
} else {
0
};
let x1 = cx + radius * slice.start_angle.cos(); let x1 = cx + radius * slice.start_angle.cos();
let y1 = cy + radius * slice.start_angle.sin(); let y1 = cy + radius * slice.start_angle.sin();
@@ -262,7 +260,11 @@ fn render_pie(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
) )
.unwrap(); .unwrap();
let anchor = if slice.cat_label_anchor_end { "end" } else { "start" }; let anchor = if slice.cat_label_anchor_end {
"end"
} else {
"start"
};
write!( write!(
svg, svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.5" fill="#555" text-anchor="{}" dominant-baseline="central">{}</text>"##, r##"<text x="{:.2}" y="{:.2}" font-size="2.5" fill="#555" text-anchor="{}" dominant-baseline="central">{}</text>"##,
@@ -273,7 +275,13 @@ fn render_pie(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
} }
} }
fn render_legend(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout, total_w: f64, total_h: f64) { fn render_legend(
svg: &mut String,
data: &ResolvedChartData,
cl: &ChartLayout,
total_w: f64,
total_h: f64,
) {
let legend = compute_legend(data, cl, 0.0, 0.0, total_w, total_h); let legend = compute_legend(data, cl, 0.0, 0.0, total_w, total_h);
for item in &legend.items { for item in &legend.items {
@@ -287,7 +295,10 @@ fn render_legend(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout, t
write!( write!(
svg, svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="#666">{}</text>"##, r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="#666">{}</text>"##,
item.text_x, item.text_y, legend.font_size, escape_xml(&item.name) item.text_x,
item.text_y,
legend.font_size,
escape_xml(&item.name)
) )
.unwrap(); .unwrap();
} }

View File

@@ -148,11 +148,23 @@ pub fn resolve_template(template: &Template, data: &Value) -> ResolvedData {
charts: HashMap::new(), charts: HashMap::new(),
}; };
if let Some(ref header) = template.header { if let Some(ref header) = template.header {
resolve_element(&TemplateElement::Container(header.clone()), data, &mut resolved); resolve_element(
&TemplateElement::Container(header.clone()),
data,
&mut resolved,
);
} }
resolve_element(&TemplateElement::Container(template.root.clone()), data, &mut resolved); resolve_element(
&TemplateElement::Container(template.root.clone()),
data,
&mut resolved,
);
if let Some(ref footer) = template.footer { if let Some(ref footer) = template.footer {
resolve_element(&TemplateElement::Container(footer.clone()), data, &mut resolved); resolve_element(
&TemplateElement::Container(footer.clone()),
data,
&mut resolved,
);
} }
resolved resolved
} }
@@ -172,10 +184,19 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
} }
TemplateElement::PageNumber(e) => { TemplateElement::PageNumber(e) => {
// Format string'i sakla — sayfa bölme sonrası gerçek değerlerle çözülecek // Format string'i sakla — sayfa bölme sonrası gerçek değerlerle çözülecek
let fmt = e.format.as_deref().unwrap_or("{current} / {total}").to_string(); let fmt = e
resolved.page_number_formats.insert(e.id.clone(), fmt.clone()); .format
.as_deref()
.unwrap_or("{current} / {total}")
.to_string();
resolved
.page_number_formats
.insert(e.id.clone(), fmt.clone());
// Placeholder koy (tek sayfalık fallback) // Placeholder koy (tek sayfalık fallback)
resolved.texts.insert(e.id.clone(), fmt.replace("{current}", "1").replace("{total}", "1")); resolved.texts.insert(
e.id.clone(),
fmt.replace("{current}", "1").replace("{total}", "1"),
);
} }
TemplateElement::Barcode(e) => { TemplateElement::Barcode(e) => {
let value = if let Some(binding) = &e.binding { let value = if let Some(binding) = &e.binding {
@@ -249,7 +270,11 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
let result = crate::expr_eval::evaluate_expression(&e.expression, data); 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(&result, e.format.as_deref());
// Bos ifade veya hata durumunda placeholder goster — element 0 yukseklige dusmesin // Bos ifade veya hata durumunda placeholder goster — element 0 yukseklige dusmesin
let text = if formatted.is_empty() { " ".to_string() } else { formatted }; let text = if formatted.is_empty() {
" ".to_string()
} else {
formatted
};
resolved.texts.insert(e.id.clone(), text); resolved.texts.insert(e.id.clone(), text);
} }
TemplateElement::RichText(e) => { TemplateElement::RichText(e) => {
@@ -269,8 +294,16 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
ResolvedRichSpan { ResolvedRichSpan {
text, text,
font_size: span.style.font_size.or(e.style.font_size), font_size: span.style.font_size.or(e.style.font_size),
font_weight: span.style.font_weight.clone().or(e.style.font_weight.clone()), font_weight: span
font_family: span.style.font_family.clone().or(e.style.font_family.clone()), .style
.font_weight
.clone()
.or(e.style.font_weight.clone()),
font_family: span
.style
.font_family
.clone()
.or(e.style.font_family.clone()),
color: span.style.color.clone().or(e.style.color.clone()), color: span.style.color.clone().or(e.style.color.clone()),
} }
}) })
@@ -280,9 +313,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
TemplateElement::Chart(e) => { TemplateElement::Chart(e) => {
let array = resolve_path(data, &e.data_source.path); let array = resolve_path(data, &e.data_source.path);
let chart_data = match array { let chart_data = match array {
Value::Array(items) if !items.is_empty() => { Value::Array(items) if !items.is_empty() => resolve_chart_data(e, items),
resolve_chart_data(e, items)
}
_ => ResolvedChartData { _ => ResolvedChartData {
chart_type: e.chart_type.clone(), chart_type: e.chart_type.clone(),
categories: vec![], categories: vec![],
@@ -315,9 +346,7 @@ fn resolve_chart_data(e: &ChartElement, items: &[Value]) -> ResolvedChartData {
for item in items { for item in items {
let cat = value_to_string(resolve_path(item, &e.category_field)); let cat = value_to_string(resolve_path(item, &e.category_field));
let val = resolve_path(item, &e.value_field) let val = resolve_path(item, &e.value_field).as_f64().unwrap_or(0.0);
.as_f64()
.unwrap_or(0.0);
let grp = value_to_string(resolve_path(item, group_field)); let grp = value_to_string(resolve_path(item, group_field));
if category_set.insert(cat.clone()) { if category_set.insert(cat.clone()) {
@@ -326,11 +355,7 @@ fn resolve_chart_data(e: &ChartElement, items: &[Value]) -> ResolvedChartData {
if group_set.insert(grp.clone()) { if group_set.insert(grp.clone()) {
group_order.push(grp.clone()); group_order.push(grp.clone());
} }
*group_data *group_data.entry(grp).or_default().entry(cat).or_insert(0.0) += val;
.entry(grp)
.or_default()
.entry(cat)
.or_insert(0.0) += val;
} }
let series = group_order let series = group_order
@@ -355,11 +380,7 @@ fn resolve_chart_data(e: &ChartElement, items: &[Value]) -> ResolvedChartData {
let mut values = Vec::new(); let mut values = Vec::new();
for item in items { for item in items {
categories.push(value_to_string(resolve_path(item, &e.category_field))); categories.push(value_to_string(resolve_path(item, &e.category_field)));
values.push( values.push(resolve_path(item, &e.value_field).as_f64().unwrap_or(0.0));
resolve_path(item, &e.value_field)
.as_f64()
.unwrap_or(0.0),
);
} }
let series = vec![ChartSeries { let series = vec![ChartSeries {
name: e.value_field.clone(), name: e.value_field.clone(),
@@ -403,10 +424,7 @@ mod tests {
value_to_string(resolve_path(&data, "firma.unvan")), value_to_string(resolve_path(&data, "firma.unvan")),
"Acme A.Ş." "Acme A.Ş."
); );
assert_eq!( assert_eq!(value_to_string(resolve_path(&data, "firma.vergiNo")), "123");
value_to_string(resolve_path(&data, "firma.vergiNo")),
"123"
);
} }
#[test] #[test]
@@ -451,7 +469,10 @@ mod tests {
let template = Template { let template = Template {
id: "t1".to_string(), id: "t1".to_string(),
name: "Test".to_string(), name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 }, page: PageSettings {
width: 210.0,
height: 297.0,
},
fonts: vec![], fonts: vec![],
header: None, header: None,
footer: None, footer: None,
@@ -467,16 +488,16 @@ mod tests {
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(), break_inside: "auto".to_string(),
children: vec![ children: vec![TemplateElement::Text(TextElement {
TemplateElement::Text(TextElement { id: "el_name".to_string(),
id: "el_name".to_string(), position: PositionMode::Flow,
position: PositionMode::Flow, size: SizeConstraint::default(),
size: SizeConstraint::default(), style: TextStyle::default(),
style: TextStyle::default(), content: None,
content: None, binding: ScalarBinding {
binding: ScalarBinding { path: "firma.unvan".to_string() }, path: "firma.unvan".to_string(),
}), },
], })],
}, },
}; };
@@ -496,7 +517,10 @@ mod tests {
let template = Template { let template = Template {
id: "t1".to_string(), id: "t1".to_string(),
name: "Test".to_string(), name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 }, page: PageSettings {
width: 210.0,
height: 297.0,
},
fonts: vec![], fonts: vec![],
header: None, header: None,
footer: None, footer: None,
@@ -512,16 +536,16 @@ mod tests {
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(), break_inside: "auto".to_string(),
children: vec![ children: vec![TemplateElement::Text(TextElement {
TemplateElement::Text(TextElement { id: "el_no".to_string(),
id: "el_no".to_string(), position: PositionMode::Flow,
position: PositionMode::Flow, size: SizeConstraint::default(),
size: SizeConstraint::default(), style: TextStyle::default(),
style: TextStyle::default(), content: Some("Fatura No: ".to_string()),
content: Some("Fatura No: ".to_string()), binding: ScalarBinding {
binding: ScalarBinding { path: "fatura.no".to_string() }, path: "fatura.no".to_string(),
}), },
], })],
}, },
}; };
@@ -530,10 +554,7 @@ mod tests {
}); });
let resolved = resolve_template(&template, &data); let resolved = resolve_template(&template, &data);
assert_eq!( assert_eq!(resolved.texts.get("el_no").unwrap(), "Fatura No: FTR-001");
resolved.texts.get("el_no").unwrap(),
"Fatura No: FTR-001"
);
} }
#[test] #[test]
@@ -541,7 +562,10 @@ mod tests {
let template = Template { let template = Template {
id: "t1".to_string(), id: "t1".to_string(),
name: "Test".to_string(), name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 }, page: PageSettings {
width: 210.0,
height: 297.0,
},
fonts: vec![], fonts: vec![],
header: None, header: None,
footer: None, footer: None,
@@ -557,15 +581,13 @@ mod tests {
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(), break_inside: "auto".to_string(),
children: vec![ children: vec![TemplateElement::StaticText(StaticTextElement {
TemplateElement::StaticText(StaticTextElement { id: "title".to_string(),
id: "title".to_string(), position: PositionMode::Flow,
position: PositionMode::Flow, size: SizeConstraint::default(),
size: SizeConstraint::default(), style: TextStyle::default(),
style: TextStyle::default(), content: "FATURA".to_string(),
content: "FATURA".to_string(), })],
}),
],
}, },
}; };
@@ -578,7 +600,10 @@ mod tests {
let template = Template { let template = Template {
id: "t1".to_string(), id: "t1".to_string(),
name: "Test".to_string(), name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 }, page: PageSettings {
width: 210.0,
height: 297.0,
},
fonts: vec![], fonts: vec![],
header: None, header: None,
footer: None, footer: None,
@@ -594,34 +619,34 @@ mod tests {
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(), break_inside: "auto".to_string(),
children: vec![ children: vec![TemplateElement::RepeatingTable(RepeatingTableElement {
TemplateElement::RepeatingTable(RepeatingTableElement { id: "tbl".to_string(),
id: "tbl".to_string(), position: PositionMode::Flow,
position: PositionMode::Flow, size: SizeConstraint::default(),
size: SizeConstraint::default(), data_source: ArrayBinding {
data_source: ArrayBinding { path: "kalemler".to_string() }, path: "kalemler".to_string(),
columns: vec![ },
TableColumn { columns: vec![
id: "col_adi".to_string(), TableColumn {
field: "adi".to_string(), id: "col_adi".to_string(),
title: "Urun Adi".to_string(), field: "adi".to_string(),
width: SizeValue::Fr { value: 1.0 }, title: "Urun Adi".to_string(),
align: "left".to_string(), width: SizeValue::Fr { value: 1.0 },
format: None, align: "left".to_string(),
}, format: None,
TableColumn { },
id: "col_tutar".to_string(), TableColumn {
field: "tutar".to_string(), id: "col_tutar".to_string(),
title: "Tutar".to_string(), field: "tutar".to_string(),
width: SizeValue::Fixed { value: 30.0 }, title: "Tutar".to_string(),
align: "right".to_string(), width: SizeValue::Fixed { value: 30.0 },
format: None, align: "right".to_string(),
}, format: None,
], },
style: TableStyle::default(), ],
repeat_header: Some(true), style: TableStyle::default(),
}), repeat_header: Some(true),
], })],
}, },
}; };
@@ -644,7 +669,10 @@ mod tests {
let template = Template { let template = Template {
id: "t1".to_string(), id: "t1".to_string(),
name: "Test".to_string(), name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 }, page: PageSettings {
width: 210.0,
height: 297.0,
},
fonts: vec![], fonts: vec![],
header: None, header: None,
footer: None, footer: None,
@@ -660,26 +688,24 @@ mod tests {
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(), break_inside: "auto".to_string(),
children: vec![ children: vec![TemplateElement::RepeatingTable(RepeatingTableElement {
TemplateElement::RepeatingTable(RepeatingTableElement { id: "tbl".to_string(),
id: "tbl".to_string(), position: PositionMode::Flow,
position: PositionMode::Flow, size: SizeConstraint::default(),
size: SizeConstraint::default(), data_source: ArrayBinding {
data_source: ArrayBinding { path: "items".to_string() }, path: "items".to_string(),
columns: vec![ },
TableColumn { columns: vec![TableColumn {
id: "c1".to_string(), id: "c1".to_string(),
field: "name".to_string(), field: "name".to_string(),
title: "Name".to_string(), title: "Name".to_string(),
width: SizeValue::Fr { value: 1.0 }, width: SizeValue::Fr { value: 1.0 },
align: "left".to_string(), align: "left".to_string(),
format: None, format: None,
}, }],
], style: TableStyle::default(),
style: TableStyle::default(), repeat_header: Some(true),
repeat_header: Some(true), })],
}),
],
}, },
}; };
@@ -694,7 +720,10 @@ mod tests {
let template = Template { let template = Template {
id: "t1".to_string(), id: "t1".to_string(),
name: "Test".to_string(), name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 }, page: PageSettings {
width: 210.0,
height: 297.0,
},
fonts: vec![], fonts: vec![],
header: None, header: None,
footer: None, footer: None,
@@ -710,16 +739,16 @@ mod tests {
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(), break_inside: "auto".to_string(),
children: vec![ children: vec![TemplateElement::Text(TextElement {
TemplateElement::Text(TextElement { id: "el_missing".to_string(),
id: "el_missing".to_string(), position: PositionMode::Flow,
position: PositionMode::Flow, size: SizeConstraint::default(),
size: SizeConstraint::default(), style: TextStyle::default(),
style: TextStyle::default(), content: None,
content: None, binding: ScalarBinding {
binding: ScalarBinding { path: "does.not.exist".to_string() }, path: "does.not.exist".to_string(),
}), },
], })],
}, },
}; };

View File

@@ -59,7 +59,10 @@ fn dexpr_value_to_string(val: &DexprValue) -> String {
format!("[{}]", items.join(", ")) format!("[{}]", items.join(", "))
} }
DexprValue::Object(map) => { DexprValue::Object(map) => {
let items: Vec<String> = map.iter().map(|(k, v)| format!("{}: {}", k, dexpr_value_to_string(v))).collect(); let items: Vec<String> = map
.iter()
.map(|(k, v)| format!("{}: {}", k, dexpr_value_to_string(v)))
.collect();
format!("{{{}}}", items.join(", ")) format!("{{{}}}", items.join(", "))
} }
} }
@@ -67,11 +70,19 @@ fn dexpr_value_to_string(val: &DexprValue) -> String {
/// Format result with given format type (varsayılan Türk formatı) /// Format result with given format type (varsayılan Türk formatı)
pub fn apply_format(value: &str, format: Option<&str>) -> String { pub fn apply_format(value: &str, format: Option<&str>) -> String {
apply_format_with_config(value, format, &dreport_core::models::FormatConfig::default()) apply_format_with_config(
value,
format,
&dreport_core::models::FormatConfig::default(),
)
} }
/// Format result with given format type and config /// Format result with given format type and config
pub fn apply_format_with_config(value: &str, format: Option<&str>, config: &dreport_core::models::FormatConfig) -> String { pub fn apply_format_with_config(
value: &str,
format: Option<&str>,
config: &dreport_core::models::FormatConfig,
) -> String {
match format { match format {
Some("currency") => format_currency(value, config), Some("currency") => format_currency(value, config),
Some("percentage") => format_percentage(value), Some("percentage") => format_percentage(value),
@@ -89,19 +100,31 @@ fn format_currency(value: &str, config: &dreport_core::models::FormatConfig) ->
// Round to 2 decimal places using Decimal — no float precision loss // Round to 2 decimal places using Decimal — no float precision loss
// MidpointAwayFromZero: 1.005 → 1.01 (currency convention) // MidpointAwayFromZero: 1.005 → 1.01 (currency convention)
let rounded = d.abs().round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero); let rounded = d
.abs()
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
// Extract integer and fractional parts from the rounded Decimal // Extract integer and fractional parts from the rounded Decimal
let truncated = rounded.trunc(); let truncated = rounded.trunc();
let frac_part = rounded - truncated; let frac_part = rounded - truncated;
let integer = truncated.to_string().parse::<i64>().unwrap_or(0); let integer = truncated.to_string().parse::<i64>().unwrap_or(0);
let frac = (frac_part * Decimal::from(100)).trunc().to_string().parse::<i64>().unwrap_or(0); let frac = (frac_part * Decimal::from(100))
.trunc()
.to_string()
.parse::<i64>()
.unwrap_or(0);
let int_str = format_with_thousands(integer, &config.thousands_separator); let int_str = format_with_thousands(integer, &config.thousands_separator);
let sign = if d.is_sign_negative() { "-" } else { "" }; let sign = if d.is_sign_negative() { "-" } else { "" };
if config.currency_position == "prefix" { if config.currency_position == "prefix" {
format!("{}{}{}{}{:02}", config.currency_symbol, sign, int_str, config.decimal_separator, frac) format!(
"{}{}{}{}{:02}",
config.currency_symbol, sign, int_str, config.decimal_separator, frac
)
} else { } else {
format!("{}{}{}{:02} {}", sign, int_str, config.decimal_separator, frac, config.currency_symbol) format!(
"{}{}{}{:02} {}",
sign, int_str, config.decimal_separator, frac, config.currency_symbol
)
} }
} }
@@ -135,7 +158,7 @@ fn format_with_thousands(n: i64, separator: &str) -> String {
} }
let mut result = String::new(); let mut result = String::new();
for (i, ch) in s.chars().enumerate() { for (i, ch) in s.chars().enumerate() {
if i > 0 && (len - i) % 3 == 0 { if i > 0 && (len - i).is_multiple_of(3) {
result.push_str(separator); result.push_str(separator);
} }
result.push(ch); result.push(ch);
@@ -157,26 +180,38 @@ mod tests {
#[test] #[test]
fn test_arithmetic() { fn test_arithmetic() {
let data = json!({"toplamlar": {"araToplam": 16000, "kdv": 2880}}); let data = json!({"toplamlar": {"araToplam": 16000, "kdv": 2880}});
assert_eq!(evaluate_expression("toplamlar.araToplam + toplamlar.kdv", &data), "18880"); assert_eq!(
evaluate_expression("toplamlar.araToplam + toplamlar.kdv", &data),
"18880"
);
} }
#[test] #[test]
fn test_multiplication() { fn test_multiplication() {
let data = json!({"toplamlar": {"araToplam": 16000}}); let data = json!({"toplamlar": {"araToplam": 16000}});
assert_eq!(evaluate_expression("toplamlar.araToplam * 0.20", &data), "3200"); assert_eq!(
evaluate_expression("toplamlar.araToplam * 0.20", &data),
"3200"
);
} }
#[test] #[test]
fn test_string_concat() { fn test_string_concat() {
let data = json!({"fatura": {"no": "FTR-001"}}); let data = json!({"fatura": {"no": "FTR-001"}});
assert_eq!(evaluate_expression("\"Fatura No: \" + fatura.no", &data), "Fatura No: FTR-001"); assert_eq!(
evaluate_expression("\"Fatura No: \" + fatura.no", &data),
"Fatura No: FTR-001"
);
} }
#[test] #[test]
fn test_ternary() { fn test_ternary() {
let data = json!({"fatura": {"tutar": 5000}}); let data = json!({"fatura": {"tutar": 5000}});
assert_eq!( assert_eq!(
evaluate_expression("if fatura.tutar > 0 then \"Borclu\" else \"Alacakli\" end", &data), evaluate_expression(
"if fatura.tutar > 0 then \"Borclu\" else \"Alacakli\" end",
&data
),
"Borclu" "Borclu"
); );
} }
@@ -185,7 +220,10 @@ mod tests {
fn test_ternary_false() { fn test_ternary_false() {
let data = json!({"fatura": {"tutar": 0}}); let data = json!({"fatura": {"tutar": 0}});
assert_eq!( assert_eq!(
evaluate_expression("if fatura.tutar > 0 then \"Borclu\" else \"Alacakli\" end", &data), evaluate_expression(
"if fatura.tutar > 0 then \"Borclu\" else \"Alacakli\" end",
&data
),
"Alacakli" "Alacakli"
); );
} }
@@ -214,7 +252,10 @@ mod tests {
fn test_numeric_comparison() { fn test_numeric_comparison() {
let data = json!({"fatura": {"tutar": 5000}}); let data = json!({"fatura": {"tutar": 5000}});
assert_eq!( assert_eq!(
evaluate_expression("if fatura.tutar > 1000 then \"Yuksek\" else \"Dusuk\" end", &data), evaluate_expression(
"if fatura.tutar > 1000 then \"Yuksek\" else \"Dusuk\" end",
&data
),
"Yuksek" "Yuksek"
); );
} }

View File

@@ -113,7 +113,7 @@ fn find_table(data: &[u8], tag: &[u8; 4]) -> Option<(usize, usize)> {
/// Decode a UTF-16BE byte slice into a `String`. /// Decode a UTF-16BE byte slice into a `String`.
fn decode_utf16be(raw: &[u8]) -> Option<String> { fn decode_utf16be(raw: &[u8]) -> Option<String> {
if raw.len() % 2 != 0 { if !raw.len().is_multiple_of(2) {
return None; return None;
} }
let code_units: Vec<u16> = raw let code_units: Vec<u16> = raw

View File

@@ -1,5 +1,5 @@
use crate::font_meta::FontFamilyInfo;
use crate::FontData; use crate::FontData;
use crate::font_meta::FontFamilyInfo;
/// Font resolution trait — host apps implement this to provide fonts. /// Font resolution trait — host apps implement this to provide fonts.
/// Backend implements it with a file-based registry, WASM side with API fetching. /// Backend implements it with a file-based registry, WASM side with API fetching.
@@ -36,7 +36,10 @@ pub trait FontProvider: Send + Sync {
} }
let infos = self.list_families(); let infos = self.list_families();
if let Some(info) = infos.iter().find(|i| i.family.to_lowercase() == family_lower) { if let Some(info) = infos
.iter()
.find(|i| i.family.to_lowercase() == family_lower)
{
for variant in &info.variants { for variant in &info.variants {
if let Some(fd) = self.load_font(&info.family, variant.weight, variant.italic) { if let Some(fd) = self.load_font(&info.family, variant.weight, variant.italic) {
result.push(fd); result.push(fd);

View File

@@ -1,10 +1,10 @@
pub mod sizing;
pub mod text_measure;
pub mod data_resolve; pub mod data_resolve;
pub mod table_layout;
pub mod tree;
pub mod page_break;
pub mod expr_eval; pub mod expr_eval;
pub mod page_break;
pub mod sizing;
pub mod table_layout;
pub mod text_measure;
pub mod tree;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub mod wasm_api; pub mod wasm_api;
@@ -104,7 +104,7 @@ pub enum ResolvedContent {
svg: String, svg: String,
/// PDF render icin chart verisi (frontend bunu kullanmaz) /// PDF render icin chart verisi (frontend bunu kullanmaz)
#[serde(flatten)] #[serde(flatten)]
chart_data: ChartRenderData, chart_data: Box<ChartRenderData>,
}, },
} }
@@ -274,7 +274,12 @@ impl FontData {
/// Create FontData with explicit metadata (when metadata is already known). /// Create FontData with explicit metadata (when metadata is already known).
pub fn new(family: String, weight: u16, italic: bool, data: Vec<u8>) -> Self { pub fn new(family: String, weight: u16, italic: bool, data: Vec<u8>) -> Self {
Self { family, weight, italic, data } Self {
family,
weight,
italic,
data,
}
} }
pub fn is_bold(&self) -> bool { pub fn is_bold(&self) -> bool {

View File

@@ -109,7 +109,12 @@ pub fn split_into_pages(input: PageSplitInput) -> Vec<PageLayout> {
// Page number çözümleme // Page number çözümleme
let total = pages.len(); let total = pages.len();
for (page_idx, page) in pages.iter_mut().enumerate() { for (page_idx, page) in pages.iter_mut().enumerate() {
resolve_page_numbers(&mut page.elements, page_idx + 1, total, &input.page_number_formats); resolve_page_numbers(
&mut page.elements,
page_idx + 1,
total,
&input.page_number_formats,
);
} }
pages pages
@@ -197,11 +202,7 @@ fn build_avoid_groups(
groups groups
} }
fn collect_descendants( fn collect_descendants(parent_id: &str, elements: &[ElementLayout], result: &mut HashSet<String>) {
parent_id: &str,
elements: &[ElementLayout],
result: &mut HashSet<String>,
) {
// children alanından recursive olarak topla // children alanından recursive olarak topla
for el in elements { for el in elements {
if el.id == parent_id { if el.id == parent_id {
@@ -380,12 +381,15 @@ fn split_elements(
// Tablo satırı mı? Header tekrarı gerekebilir // Tablo satırı mı? Header tekrarı gerekebilir
let mut table_header_to_add: Option<(String, Vec<ElementLayout>, f64)> = None; let mut table_header_to_add: Option<(String, Vec<ElementLayout>, f64)> = None;
if let Some((table_id, _row_idx)) = detect_table_row(&el.id) { if let Some((table_id, _row_idx)) = detect_table_row(&el.id)
if let Some(info) = table_info.get(&table_id) { && let Some(info) = table_info.get(&table_id)
// Yeni sayfada bu tablonun header'ını tekrarla {
table_header_to_add = // Yeni sayfada bu tablonun header'ını tekrarla
Some((table_id.clone(), info.header_elements.clone(), info.header_height_mm)); table_header_to_add = Some((
} table_id.clone(),
info.header_elements.clone(),
info.header_height_mm,
));
} }
page_top = el_top; page_top = el_top;
@@ -435,6 +439,7 @@ fn split_elements(
pages pages
} }
#[allow(clippy::too_many_arguments)]
fn assemble_page( fn assemble_page(
page_index: usize, page_index: usize,
body_elements: &[ElementLayout], body_elements: &[ElementLayout],
@@ -461,7 +466,11 @@ fn assemble_page(
// Body elemanları (y offset'li — strip y'den sayfa-relative y'ye) // Body elemanları (y offset'li — strip y'den sayfa-relative y'ye)
// Sayfa 2+ için root padding tekrar eklenir (root container sadece sayfa 1'de var) // Sayfa 2+ için root padding tekrar eklenir (root container sadece sayfa 1'de var)
let extra_top = if page_index > 0 { root_padding_top_mm } else { 0.0 }; let extra_top = if page_index > 0 {
root_padding_top_mm
} else {
0.0
};
for el in body_elements { for el in body_elements {
let mut adjusted = el.clone(); let mut adjusted = el.clone();
adjusted.y_mm = el.y_mm - body_y_offset + header_height_mm + extra_top; adjusted.y_mm = el.y_mm - body_y_offset + header_height_mm + extra_top;
@@ -703,11 +712,7 @@ mod tests {
} }
// Sayfa 2: pn_p1 → "2 / 2" // Sayfa 2: pn_p1 → "2 / 2"
let pn2 = pages[1] let pn2 = pages[1].elements.iter().find(|e| e.id == "pn_p1").unwrap();
.elements
.iter()
.find(|e| e.id == "pn_p1")
.unwrap();
if let Some(ResolvedContent::Text { value }) = &pn2.content { if let Some(ResolvedContent::Text { value }) = &pn2.content {
assert_eq!(value, "2 / 2"); assert_eq!(value, "2 / 2");
} else { } else {
@@ -933,7 +938,7 @@ mod tests {
.collect(); .collect();
let mut body_elements = vec![ let mut body_elements = vec![
make_element("el1", 0.0, 50.0, "text"), // 50mm metin make_element("el1", 0.0, 50.0, "text"), // 50mm metin
make_element("el2", 50.0, 50.0, "text"), // 50mm metin (toplam 100mm) make_element("el2", 50.0, 50.0, "text"), // 50mm metin (toplam 100mm)
tbl_wrapper, tbl_wrapper,
tbl_header, tbl_header,

View File

@@ -3,13 +3,13 @@
use std::collections::HashMap; use std::collections::HashMap;
use krilla::Document;
use krilla::color::rgb; use krilla::color::rgb;
use krilla::geom::{PathBuilder, Point, Size, Transform}; use krilla::geom::{PathBuilder, Point, Size, Transform};
use krilla::num::NormalizedF32; use krilla::num::NormalizedF32;
use krilla::page::PageSettings; use krilla::page::PageSettings;
use krilla::paint::{Fill, Stroke}; use krilla::paint::{Fill, Stroke};
use krilla::text::{Font as KrillaFont, TextDirection}; use krilla::text::{Font as KrillaFont, TextDirection};
use krilla::Document;
use crate::text_measure::TextMeasurer; use crate::text_measure::TextMeasurer;
use crate::{ElementLayout, FontData, LayoutResult, PageLayout, ResolvedContent, ResolvedStyle}; use crate::{ElementLayout, FontData, LayoutResult, PageLayout, ResolvedContent, ResolvedStyle};
@@ -126,10 +126,7 @@ impl FontCollection {
let mut metrics = HashMap::new(); let mut metrics = HashMap::new();
for fd in font_data { for fd in font_data {
let Some(font) = KrillaFont::new( let Some(font) = KrillaFont::new(krilla::Data::from(fd.data.clone()), 0) else {
krilla::Data::from(fd.data.clone()),
0,
) else {
continue; continue;
}; };
@@ -146,10 +143,13 @@ impl FontCollection {
if let Some(meta) = crate::font_meta::parse_font_meta(&fd.data) { if let Some(meta) = crate::font_meta::parse_font_meta(&fd.data) {
let units_per_em = meta.units_per_em; let units_per_em = meta.units_per_em;
if units_per_em > 0 { if units_per_em > 0 {
metrics.insert((family_lower.clone(), is_bold), FontMetrics { metrics.insert(
ascender: meta.ascender as f32 / units_per_em as f32, (family_lower.clone(), is_bold),
descender: meta.descender.unsigned_abs() as f32 / units_per_em as f32, FontMetrics {
}); ascender: meta.ascender as f32 / units_per_em as f32,
descender: meta.descender.unsigned_abs() as f32 / units_per_em as f32,
},
);
} }
} }
@@ -157,16 +157,25 @@ impl FontCollection {
} }
// Hiç regular bulamadıysak ilk font'u default yap // Hiç regular bulamadıysak ilk font'u default yap
if default.is_none() { if default.is_none()
if let Some(fd) = font_data.first() { && let Some(fd) = font_data.first()
default = KrillaFont::new(krilla::Data::from(fd.data.clone()), 0); {
} default = KrillaFont::new(krilla::Data::from(fd.data.clone()), 0);
} }
Self { fonts, default, metrics } Self {
fonts,
default,
metrics,
}
} }
fn get(&self, family: Option<&str>, weight: Option<&str>, font_style: Option<&str>) -> Option<&KrillaFont> { fn get(
&self,
family: Option<&str>,
weight: Option<&str>,
font_style: Option<&str>,
) -> Option<&KrillaFont> {
let is_bold = matches!(weight, Some("bold")); let is_bold = matches!(weight, Some("bold"));
let is_italic = matches!(font_style, Some("italic")); let is_italic = matches!(font_style, Some("italic"));
let family_lower = family.unwrap_or("noto sans").to_lowercase(); let family_lower = family.unwrap_or("noto sans").to_lowercase();
@@ -188,7 +197,8 @@ impl FontCollection {
let is_bold = matches!(weight, Some("bold")); let is_bold = matches!(weight, Some("bold"));
let family_lower = family.unwrap_or("noto sans").to_lowercase(); let family_lower = family.unwrap_or("noto sans").to_lowercase();
let m = self.metrics let m = self
.metrics
.get(&(family_lower.clone(), is_bold)) .get(&(family_lower.clone(), is_bold))
.or_else(|| self.metrics.get(&(family_lower, false))) .or_else(|| self.metrics.get(&(family_lower, false)))
.copied(); .copied();
@@ -218,7 +228,8 @@ pub fn render_pdf(layout: &LayoutResult, font_data: &[FontData]) -> Result<Vec<u
render_page(&mut doc, page, &fonts, font_data, &mut measurer)?; render_page(&mut doc, page, &fonts, font_data, &mut measurer)?;
} }
doc.finish().map_err(|e| format!("PDF oluşturma hatası: {e:?}")) doc.finish()
.map_err(|e| format!("PDF oluşturma hatası: {e:?}"))
} }
fn render_page( fn render_page(
@@ -332,9 +343,9 @@ fn render_shape(
let rect_radius = |s: &ResolvedStyle| -> f32 { let rect_radius = |s: &ResolvedStyle| -> f32 {
if shape_type == "rounded_rectangle" { if shape_type == "rounded_rectangle" {
s.border_radius.map(|r| mm(r)).unwrap_or(mm(3.0)) s.border_radius.map(mm).unwrap_or(mm(3.0))
} else { } else {
s.border_radius.map(|r| mm(r)).unwrap_or(0.0) s.border_radius.map(mm).unwrap_or(0.0)
} }
}; };
@@ -357,15 +368,16 @@ fn render_shape(
})); }));
let path = match shape_type { let path = match shape_type {
"ellipse" => build_ellipse_path( "ellipse" => {
x + inset, y + inset, build_ellipse_path(x + inset, y + inset, w - border_width, h - border_width)
w - border_width, h - border_width, }
),
_ => { _ => {
let radius = rect_radius(style); let radius = rect_radius(style);
build_rect_path( build_rect_path(
x + inset, y + inset, x + inset,
w - border_width, h - border_width, y + inset,
w - border_width,
h - border_width,
(radius - inset).max(0.0), (radius - inset).max(0.0),
) )
} }
@@ -416,8 +428,10 @@ fn render_checkbox(
})); }));
if let Some(p) = build_rect_path( if let Some(p) = build_rect_path(
x + inset, y + inset, x + inset,
w - border_width, h - border_width, y + inset,
w - border_width,
h - border_width,
0.0, 0.0,
) { ) {
surface.draw_path(&p); surface.draw_path(&p);
@@ -469,7 +483,7 @@ fn render_container_bg(
return; return;
} }
let radius = style.border_radius.map(|r| mm(r)).unwrap_or(0.0); let radius = style.border_radius.map(mm).unwrap_or(0.0);
if has_border { if has_border {
let border_width = mm(style.border_width.unwrap_or(0.5)); let border_width = mm(style.border_width.unwrap_or(0.5));
@@ -490,8 +504,10 @@ fn render_container_bg(
..Default::default() ..Default::default()
})); }));
if let Some(path) = build_rect_path( if let Some(path) = build_rect_path(
x + inset, y + inset, x + inset,
w - border_width, h - border_width, y + inset,
w - border_width,
h - border_width,
(radius - inset).max(0.0), (radius - inset).max(0.0),
) { ) {
surface.draw_path(&path); surface.draw_path(&path);
@@ -511,6 +527,7 @@ fn render_container_bg(
surface.set_stroke(None); surface.set_stroke(None);
} }
#[allow(clippy::too_many_arguments)]
fn render_text( fn render_text(
surface: &mut krilla::surface::Surface<'_>, surface: &mut krilla::surface::Surface<'_>,
x: f32, x: f32,
@@ -589,6 +606,7 @@ fn render_text(
} }
} }
#[allow(clippy::too_many_arguments)]
fn render_rich_text( fn render_rich_text(
surface: &mut krilla::surface::Surface<'_>, surface: &mut krilla::surface::Surface<'_>,
x: f32, x: f32,
@@ -614,7 +632,10 @@ fn render_rich_text(
let total_width = { let total_width = {
let mut tw = 0.0f32; let mut tw = 0.0f32;
for span in spans { for span in spans {
let fs = span.font_size.map(|f| f as f32).unwrap_or(default_font_size); let fs = span
.font_size
.map(|f| f as f32)
.unwrap_or(default_font_size);
let fw = span.font_weight.as_deref().or(default_weight); let fw = span.font_weight.as_deref().or(default_weight);
let ff = span.font_family.as_deref().or(default_family); let ff = span.font_family.as_deref().or(default_family);
let (sw, _) = measurer.measure(&span.text, ff, fs, fw, None); let (sw, _) = measurer.measure(&span.text, ff, fs, fw, None);
@@ -643,7 +664,10 @@ fn render_rich_text(
continue; continue;
} }
let font_size = span.font_size.map(|f| f as f32).unwrap_or(default_font_size); let font_size = span
.font_size
.map(|f| f as f32)
.unwrap_or(default_font_size);
let color_str = span.color.as_deref().unwrap_or(default_color); let color_str = span.color.as_deref().unwrap_or(default_color);
let weight = span.font_weight.as_deref().or(default_weight); let weight = span.font_weight.as_deref().or(default_weight);
let family = span.font_family.as_deref().or(default_family); let family = span.font_family.as_deref().or(default_family);
@@ -748,7 +772,10 @@ fn render_image(
// data:image/png;base64,... veya data:image/jpeg;base64,... // data:image/png;base64,... veya data:image/jpeg;base64,...
let Some(base64_part) = src.split(',').nth(1) else { let Some(base64_part) = src.split(',').nth(1) else {
eprintln!("[dreport] Image src data URI değil, atlanıyor: {}...", &src[..src.len().min(60)]); eprintln!(
"[dreport] Image src data URI değil, atlanıyor: {}...",
&src[..src.len().min(60)]
);
return; return;
}; };
@@ -764,15 +791,13 @@ fn render_image(
ImageFormat::Jpeg => krilla::image::Image::from_jpeg(decoded.into(), true), ImageFormat::Jpeg => krilla::image::Image::from_jpeg(decoded.into(), true),
ImageFormat::Gif => krilla::image::Image::from_gif(decoded.into(), true), ImageFormat::Gif => krilla::image::Image::from_gif(decoded.into(), true),
ImageFormat::WebP => krilla::image::Image::from_webp(decoded.into(), true), ImageFormat::WebP => krilla::image::Image::from_webp(decoded.into(), true),
ImageFormat::Unknown => { ImageFormat::Unknown => match decode_to_png(&decoded) {
match decode_to_png(&decoded) { Some(png_data) => krilla::image::Image::from_png(png_data.into(), true),
Some(png_data) => krilla::image::Image::from_png(png_data.into(), true), None => {
None => { eprintln!("[dreport] Image decode/re-encode hatası");
eprintln!("[dreport] Image decode/re-encode hatası"); return;
return;
}
} }
} },
}; };
let Ok(img) = img_result else { let Ok(img) = img_result else {
@@ -789,6 +814,7 @@ fn render_image(
surface.pop(); surface.pop();
} }
#[allow(clippy::too_many_arguments)]
fn render_barcode( fn render_barcode(
surface: &mut krilla::surface::Surface<'_>, surface: &mut krilla::surface::Surface<'_>,
x: f32, x: f32,
@@ -809,7 +835,14 @@ fn render_barcode(
let h_px = ((h * 4.0) as u32).max(1); let h_px = ((h * 4.0) as u32).max(1);
let include_text = style.barcode_include_text.unwrap_or(false); let include_text = style.barcode_include_text.unwrap_or(false);
let png_result = crate::barcode_gen::generate_barcode_png(format, value, w_px, h_px, include_text, Some(font_data)); let png_result = crate::barcode_gen::generate_barcode_png(
format,
value,
w_px,
h_px,
include_text,
Some(font_data),
);
match png_result { match png_result {
Ok(png_bytes) => { Ok(png_bytes) => {
@@ -862,6 +895,7 @@ fn embed_png(
surface.pop(); surface.pop();
} }
#[allow(clippy::too_many_arguments)]
fn render_chart( fn render_chart(
surface: &mut krilla::surface::Surface<'_>, surface: &mut krilla::surface::Surface<'_>,
x: f32, x: f32,
@@ -873,8 +907,8 @@ fn render_chart(
measurer: &mut TextMeasurer, measurer: &mut TextMeasurer,
) { ) {
use crate::chart_layout::{ use crate::chart_layout::{
color_at, compute_bar_layout, compute_chart_layout, compute_legend, color_at, compute_bar_layout, compute_chart_layout, compute_legend, compute_line_layout,
compute_line_layout, compute_pie_layout, format_value, compute_pie_layout, format_value,
}; };
let base_x_mm: f64 = (x / MM_TO_PT) as f64; let base_x_mm: f64 = (x / MM_TO_PT) as f64;
@@ -883,8 +917,14 @@ fn render_chart(
let h_mm: f64 = (h / MM_TO_PT) as f64; let h_mm: f64 = (h / MM_TO_PT) as f64;
// Background // Background
chart_rect(surface, base_x_mm, base_y_mm, w_mm, h_mm, chart_rect(
parse_color(data.background_color.as_deref().unwrap_or("#FFFFFF"))); surface,
base_x_mm,
base_y_mm,
w_mm,
h_mm,
parse_color(data.background_color.as_deref().unwrap_or("#FFFFFF")),
);
let cl = compute_chart_layout(data, w_mm, h_mm, base_x_mm, base_y_mm); let cl = compute_chart_layout(data, w_mm, h_mm, base_x_mm, base_y_mm);
@@ -905,7 +945,11 @@ fn render_chart(
let ty = pt(title.y); let ty = pt(title.y);
surface.draw_text( surface.draw_text(
Point::from_xy(tx, ty), Point::from_xy(tx, ty),
f.clone(), fs_pt, &title.text, false, TextDirection::Auto, f.clone(),
fs_pt,
&title.text,
false,
TextDirection::Auto,
); );
} }
} }
@@ -922,17 +966,43 @@ fn render_chart(
if bl.stacked { if bl.stacked {
if bar.value > 0.0 { if bar.value > 0.0 {
let label = format_value(bar.value); let label = format_value(bar.value);
chart_text_centered(surface, bar.label_x, bar.label_y, &label, bl.label_font, &bl.label_color, fonts, measurer); chart_text_centered(
surface,
bar.label_x,
bar.label_y,
&label,
bl.label_font,
&bl.label_color,
fonts,
measurer,
);
} }
} else { } else {
let label = format_value(bar.value); let label = format_value(bar.value);
chart_text_centered(surface, bar.label_x, bar.label_y, &label, bl.label_font, &bl.label_color, fonts, measurer); chart_text_centered(
surface,
bar.label_x,
bar.label_y,
&label,
bl.label_font,
&bl.label_color,
fonts,
measurer,
);
} }
} }
} }
render_chart_x_labels(surface, &bl.x_labels, fonts, measurer); render_chart_x_labels(surface, &bl.x_labels, fonts, measurer);
let ac = parse_color("#9CA3AF"); let ac = parse_color("#9CA3AF");
chart_line_seg(surface, bl.x_axis_x1, bl.x_axis_y, bl.x_axis_x2, bl.x_axis_y, ac, 0.8); chart_line_seg(
surface,
bl.x_axis_x1,
bl.x_axis_y,
bl.x_axis_x2,
bl.x_axis_y,
ac,
0.8,
);
} }
ChartType::Line => { ChartType::Line => {
let ll = compute_line_layout(data, &cl); let ll = compute_line_layout(data, &cl);
@@ -940,7 +1010,8 @@ fn render_chart(
for series_layout in &ll.series { for series_layout in &ll.series {
let color = parse_color(color_at(&cl.palette, series_layout.color_idx)); let color = parse_color(color_at(&cl.palette, series_layout.color_idx));
// Polyline // Polyline
let points: Vec<(f64, f64)> = series_layout.points.iter().map(|p| (p.x, p.y)).collect(); let points: Vec<(f64, f64)> =
series_layout.points.iter().map(|p| (p.x, p.y)).collect();
surface.set_fill(None); surface.set_fill(None);
surface.set_stroke(Some(Stroke { surface.set_stroke(Some(Stroke {
paint: color.into(), paint: color.into(),
@@ -951,12 +1022,17 @@ fn render_chart(
let path = { let path = {
let mut pb = PathBuilder::new(); let mut pb = PathBuilder::new();
for (i, (lx, ly)) in points.iter().enumerate() { for (i, (lx, ly)) in points.iter().enumerate() {
if i == 0 { pb.move_to(pt(*lx), pt(*ly)); } if i == 0 {
else { pb.line_to(pt(*lx), pt(*ly)); } pb.move_to(pt(*lx), pt(*ly));
} else {
pb.line_to(pt(*lx), pt(*ly));
}
} }
pb.finish() pb.finish()
}; };
if let Some(p) = path { surface.draw_path(&p); } if let Some(p) = path {
surface.draw_path(&p);
}
// Points // Points
if ll.show_points { if ll.show_points {
@@ -977,7 +1053,9 @@ fn render_chart(
pb.close(); pb.close();
pb.finish() pb.finish()
}; };
if let Some(p) = circle { surface.draw_path(&p); } if let Some(p) = circle {
surface.draw_path(&p);
}
} }
} }
@@ -985,13 +1063,30 @@ fn render_chart(
if ll.show_labels { if ll.show_labels {
for lp in &series_layout.points { for lp in &series_layout.points {
let label = format_value(lp.value); let label = format_value(lp.value);
chart_text_centered(surface, lp.x, lp.y - 1.5, &label, ll.label_font, &ll.label_color, fonts, measurer); chart_text_centered(
surface,
lp.x,
lp.y - 1.5,
&label,
ll.label_font,
&ll.label_color,
fonts,
measurer,
);
} }
} }
} }
render_chart_x_labels(surface, &ll.x_labels, fonts, measurer); render_chart_x_labels(surface, &ll.x_labels, fonts, measurer);
let ac = parse_color("#9CA3AF"); let ac = parse_color("#9CA3AF");
chart_line_seg(surface, ll.x_axis_x1, ll.x_axis_y, ll.x_axis_x2, ll.x_axis_y, ac, 0.8); chart_line_seg(
surface,
ll.x_axis_x1,
ll.x_axis_y,
ll.x_axis_x2,
ll.x_axis_y,
ac,
0.8,
);
} }
ChartType::Pie => { ChartType::Pie => {
let pl = compute_pie_layout(data, &cl); let pl = compute_pie_layout(data, &cl);
@@ -1004,21 +1099,65 @@ fn render_chart(
opacity: NormalizedF32::ONE, opacity: NormalizedF32::ONE,
..Default::default() ..Default::default()
})); }));
let path = build_arc_path(pl.cx, pl.cy, pl.radius, pl.inner_radius, slice.start_angle, slice.end_angle); let path = build_arc_path(
if let Some(p) = path { surface.draw_path(&p); } pl.cx,
pl.cy,
pl.radius,
pl.inner_radius,
slice.start_angle,
slice.end_angle,
);
if let Some(p) = path {
surface.draw_path(&p);
}
if pl.show_labels { if pl.show_labels {
let pct = (slice.fraction * 100.0).round(); let pct = (slice.fraction * 100.0).round();
let label = format!("{}%", pct); let label = format!("{}%", pct);
chart_text_centered(surface, slice.label_x, slice.label_y, &label, pl.label_font, &pl.label_color, fonts, measurer); chart_text_centered(
surface,
slice.label_x,
slice.label_y,
&label,
pl.label_font,
&pl.label_color,
fonts,
measurer,
);
} }
if !slice.cat_label_text.is_empty() { if !slice.cat_label_text.is_empty() {
chart_line_seg(surface, slice.leader_start_x, slice.leader_start_y, slice.leader_end_x, slice.leader_end_y, parse_color("#999999"), 0.5); chart_line_seg(
surface,
slice.leader_start_x,
slice.leader_start_y,
slice.leader_end_x,
slice.leader_end_y,
parse_color("#999999"),
0.5,
);
if slice.cat_label_anchor_end { if slice.cat_label_anchor_end {
chart_text_end(surface, slice.cat_label_x, slice.cat_label_y, &slice.cat_label_text, 2.5, "#555555", fonts, measurer); chart_text_end(
surface,
slice.cat_label_x,
slice.cat_label_y,
&slice.cat_label_text,
2.5,
"#555555",
fonts,
measurer,
);
} else { } else {
chart_text_start(surface, slice.cat_label_x, slice.cat_label_y, &slice.cat_label_text, 2.5, "#555555", fonts, measurer); chart_text_start(
surface,
slice.cat_label_x,
slice.cat_label_y,
&slice.cat_label_text,
2.5,
"#555555",
fonts,
measurer,
);
} }
} }
} }
@@ -1030,8 +1169,24 @@ fn render_chart(
let legend = compute_legend(data, &cl, base_x_mm, base_y_mm, w_mm, h_mm); let legend = compute_legend(data, &cl, base_x_mm, base_y_mm, w_mm, h_mm);
for item in &legend.items { for item in &legend.items {
let color = parse_color(color_at(&cl.palette, item.color_idx)); let color = parse_color(color_at(&cl.palette, item.color_idx));
chart_rect(surface, item.swatch_x, item.swatch_y, legend.swatch_size, legend.swatch_size, color); chart_rect(
chart_text_start(surface, item.text_x, item.text_y, &item.name, legend.font_size, "#666666", fonts, measurer); surface,
item.swatch_x,
item.swatch_y,
legend.swatch_size,
legend.swatch_size,
color,
);
chart_text_start(
surface,
item.text_x,
item.text_y,
&item.name,
legend.font_size,
"#666666",
fonts,
measurer,
);
} }
} }
@@ -1056,7 +1211,14 @@ fn render_chart(
} }
/// mm degerlerini pt'ye cevirip rect ciz /// mm degerlerini pt'ye cevirip rect ciz
fn chart_rect(surface: &mut krilla::surface::Surface<'_>, rx: f64, ry: f64, rw: f64, rh: f64, color: rgb::Color) { fn chart_rect(
surface: &mut krilla::surface::Surface<'_>,
rx: f64,
ry: f64,
rw: f64,
rh: f64,
color: rgb::Color,
) {
let (rx, ry, rw, rh) = (pt(rx), pt(ry), pt(rw), pt(rh)); let (rx, ry, rw, rh) = (pt(rx), pt(ry), pt(rw), pt(rh));
surface.set_fill(Some(fill_from_color(color))); surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None); surface.set_stroke(None);
@@ -1072,7 +1234,15 @@ fn chart_rect(surface: &mut krilla::surface::Surface<'_>, rx: f64, ry: f64, rw:
} }
} }
fn chart_line_seg(surface: &mut krilla::surface::Surface<'_>, x1: f64, y1: f64, x2: f64, y2: f64, color: rgb::Color, width: f32) { fn chart_line_seg(
surface: &mut krilla::surface::Surface<'_>,
x1: f64,
y1: f64,
x2: f64,
y2: f64,
color: rgb::Color,
width: f32,
) {
let (x1, y1, x2, y2) = (pt(x1), pt(y1), pt(x2), pt(y2)); let (x1, y1, x2, y2) = (pt(x1), pt(y1), pt(x2), pt(y2));
surface.set_fill(None); surface.set_fill(None);
surface.set_stroke(Some(Stroke { surface.set_stroke(Some(Stroke {
@@ -1094,14 +1264,21 @@ fn chart_line_seg(surface: &mut krilla::surface::Surface<'_>, x1: f64, y1: f64,
/// Chart icin metin ciz — tek satirlik, centered /// Chart icin metin ciz — tek satirlik, centered
/// font_size_mm: SVG viewBox'taki mm cinsinden boyut, pt'ye cevrilir /// font_size_mm: SVG viewBox'taki mm cinsinden boyut, pt'ye cevrilir
#[allow(clippy::too_many_arguments)]
fn chart_text_centered( fn chart_text_centered(
surface: &mut krilla::surface::Surface<'_>, surface: &mut krilla::surface::Surface<'_>,
cx_mm: f64, cy_mm: f64, cx_mm: f64,
text: &str, font_size_mm: f64, color_hex: &str, cy_mm: f64,
fonts: &FontCollection, measurer: &mut TextMeasurer, text: &str,
font_size_mm: f64,
color_hex: &str,
fonts: &FontCollection,
measurer: &mut TextMeasurer,
) { ) {
let font = fonts.get(None, None, None); let font = fonts.get(None, None, None);
let Some(f) = font else { return; }; let Some(f) = font else {
return;
};
let color = parse_color(color_hex); let color = parse_color(color_hex);
let fs_pt = pt(font_size_mm); let fs_pt = pt(font_size_mm);
let (tw, _) = measurer.measure(text, None, fs_pt, None, None); let (tw, _) = measurer.measure(text, None, fs_pt, None, None);
@@ -1109,19 +1286,30 @@ fn chart_text_centered(
surface.set_stroke(None); surface.set_stroke(None);
surface.draw_text( surface.draw_text(
Point::from_xy(pt(cx_mm) - tw / 2.0, pt(cy_mm)), Point::from_xy(pt(cx_mm) - tw / 2.0, pt(cy_mm)),
f.clone(), fs_pt, text, false, TextDirection::Auto, f.clone(),
fs_pt,
text,
false,
TextDirection::Auto,
); );
} }
/// Chart icin metin ciz — end-aligned (sag hizali) /// Chart icin metin ciz — end-aligned (sag hizali)
#[allow(clippy::too_many_arguments)]
fn chart_text_end( fn chart_text_end(
surface: &mut krilla::surface::Surface<'_>, surface: &mut krilla::surface::Surface<'_>,
right_x_mm: f64, cy_mm: f64, right_x_mm: f64,
text: &str, font_size_mm: f64, color_hex: &str, cy_mm: f64,
fonts: &FontCollection, measurer: &mut TextMeasurer, text: &str,
font_size_mm: f64,
color_hex: &str,
fonts: &FontCollection,
measurer: &mut TextMeasurer,
) { ) {
let font = fonts.get(None, None, None); let font = fonts.get(None, None, None);
let Some(f) = font else { return; }; let Some(f) = font else {
return;
};
let color = parse_color(color_hex); let color = parse_color(color_hex);
let fs_pt = pt(font_size_mm); let fs_pt = pt(font_size_mm);
let (tw, _) = measurer.measure(text, None, fs_pt, None, None); let (tw, _) = measurer.measure(text, None, fs_pt, None, None);
@@ -1129,26 +1317,41 @@ fn chart_text_end(
surface.set_stroke(None); surface.set_stroke(None);
surface.draw_text( surface.draw_text(
Point::from_xy(pt(right_x_mm) - tw, pt(cy_mm)), Point::from_xy(pt(right_x_mm) - tw, pt(cy_mm)),
f.clone(), fs_pt, text, false, TextDirection::Auto, f.clone(),
fs_pt,
text,
false,
TextDirection::Auto,
); );
} }
/// Chart icin metin ciz — start-aligned (sol hizali) /// Chart icin metin ciz — start-aligned (sol hizali)
#[allow(clippy::too_many_arguments)]
fn chart_text_start( fn chart_text_start(
surface: &mut krilla::surface::Surface<'_>, surface: &mut krilla::surface::Surface<'_>,
x_mm: f64, cy_mm: f64, x_mm: f64,
text: &str, font_size_mm: f64, color_hex: &str, cy_mm: f64,
fonts: &FontCollection, _measurer: &mut TextMeasurer, text: &str,
font_size_mm: f64,
color_hex: &str,
fonts: &FontCollection,
_measurer: &mut TextMeasurer,
) { ) {
let font = fonts.get(None, None, None); let font = fonts.get(None, None, None);
let Some(f) = font else { return; }; let Some(f) = font else {
return;
};
let color = parse_color(color_hex); let color = parse_color(color_hex);
let fs_pt = pt(font_size_mm); let fs_pt = pt(font_size_mm);
surface.set_fill(Some(fill_from_color(color))); surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None); surface.set_stroke(None);
surface.draw_text( surface.draw_text(
Point::from_xy(pt(x_mm), pt(cy_mm)), Point::from_xy(pt(x_mm), pt(cy_mm)),
f.clone(), fs_pt, text, false, TextDirection::Auto, f.clone(),
fs_pt,
text,
false,
TextDirection::Auto,
); );
} }
@@ -1156,25 +1359,52 @@ fn chart_text_start(
fn render_chart_y_axis( fn render_chart_y_axis(
surface: &mut krilla::surface::Surface<'_>, surface: &mut krilla::surface::Surface<'_>,
y_axis: &crate::chart_layout::YAxisLayout, y_axis: &crate::chart_layout::YAxisLayout,
fonts: &FontCollection, measurer: &mut TextMeasurer, fonts: &FontCollection,
measurer: &mut TextMeasurer,
) { ) {
for tick in &y_axis.ticks { for tick in &y_axis.ticks {
chart_text_end(surface, y_axis.axis_x - 1.5, tick.y + 0.8, &tick.label, 2.3, "#666666", fonts, measurer); chart_text_end(
surface,
y_axis.axis_x - 1.5,
tick.y + 0.8,
&tick.label,
2.3,
"#666666",
fonts,
measurer,
);
if y_axis.show_grid { if y_axis.show_grid {
let gc = parse_color(&y_axis.grid_color); let gc = parse_color(&y_axis.grid_color);
chart_line_seg(surface, y_axis.axis_x, tick.y, y_axis.grid_end_x, tick.y, gc, 0.4); chart_line_seg(
surface,
y_axis.axis_x,
tick.y,
y_axis.grid_end_x,
tick.y,
gc,
0.4,
);
} }
} }
// Y axis line // Y axis line
let ac = parse_color("#9CA3AF"); let ac = parse_color("#9CA3AF");
chart_line_seg(surface, y_axis.axis_x, y_axis.axis_y_start, y_axis.axis_x, y_axis.axis_y_end, ac, 0.8); chart_line_seg(
surface,
y_axis.axis_x,
y_axis.axis_y_start,
y_axis.axis_x,
y_axis.axis_y_end,
ac,
0.8,
);
} }
/// X-axis category labels — consumes shared XLabelLayout /// X-axis category labels — consumes shared XLabelLayout
fn render_chart_x_labels( fn render_chart_x_labels(
surface: &mut krilla::surface::Surface<'_>, surface: &mut krilla::surface::Surface<'_>,
x_labels: &crate::chart_layout::XLabelLayout, x_labels: &crate::chart_layout::XLabelLayout,
fonts: &FontCollection, measurer: &mut TextMeasurer, fonts: &FontCollection,
measurer: &mut TextMeasurer,
) { ) {
for label in &x_labels.labels { for label in &x_labels.labels {
if x_labels.needs_rotate { if x_labels.needs_rotate {
@@ -1182,20 +1412,41 @@ fn render_chart_x_labels(
let c = std::f32::consts::FRAC_PI_4.cos(); let c = std::f32::consts::FRAC_PI_4.cos();
let s = std::f32::consts::FRAC_PI_4.sin(); let s = std::f32::consts::FRAC_PI_4.sin();
surface.push_transform(&Transform::from_row(c, -s, s, c, 0.0, 0.0)); surface.push_transform(&Transform::from_row(c, -s, s, c, 0.0, 0.0));
chart_text_end(surface, 0.0, 0.0, &label.text, 2.2, "#666666", fonts, measurer); chart_text_end(
surface,
0.0,
0.0,
&label.text,
2.2,
"#666666",
fonts,
measurer,
);
surface.pop(); surface.pop();
surface.pop(); surface.pop();
} else { } else {
chart_text_centered(surface, label.x, label.y, &label.text, 2.5, "#666666", fonts, measurer); chart_text_centered(
surface,
label.x,
label.y,
&label.text,
2.5,
"#666666",
fonts,
measurer,
);
} }
} }
} }
/// Arc path olustur — pie/donut dilimi (mm cinsinden, pt'ye cevrilir) /// Arc path olustur — pie/donut dilimi (mm cinsinden, pt'ye cevrilir)
fn build_arc_path( fn build_arc_path(
cx: f64, cy: f64, cx: f64,
radius: f64, inner_r: f64, cy: f64,
start: f64, end: f64, radius: f64,
inner_r: f64,
start: f64,
end: f64,
) -> Option<krilla::geom::Path> { ) -> Option<krilla::geom::Path> {
let mut pb = PathBuilder::new(); let mut pb = PathBuilder::new();
@@ -1221,12 +1472,7 @@ fn build_arc_path(
} }
/// Arc'i cubic bezier segmentleriyle yaklasik ciz (her segment ≤ 90°) /// Arc'i cubic bezier segmentleriyle yaklasik ciz (her segment ≤ 90°)
fn approximate_arc( fn approximate_arc(pb: &mut PathBuilder, cx: f64, cy: f64, r: f64, start: f64, end: f64) {
pb: &mut PathBuilder,
cx: f64, cy: f64,
r: f64,
start: f64, end: f64,
) {
let sweep = end - start; let sweep = end - start;
let n_segs = ((sweep.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).max(1); let n_segs = ((sweep.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).max(1);
let seg_sweep = sweep / n_segs as f64; let seg_sweep = sweep / n_segs as f64;
@@ -1345,7 +1591,10 @@ mod tests {
let template = Template { let template = Template {
id: "test".to_string(), id: "test".to_string(),
name: "Test".to_string(), name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 }, page: PageSettings {
width: 210.0,
height: 297.0,
},
fonts: vec!["Noto Sans".to_string()], fonts: vec!["Noto Sans".to_string()],
header: None, header: None,
footer: None, footer: None,
@@ -1356,11 +1605,19 @@ mod tests {
size: SizeConstraint { size: SizeConstraint {
width: SizeValue::Auto, width: SizeValue::Auto,
height: SizeValue::Auto, height: SizeValue::Auto,
min_width: None, min_height: None, max_width: None, max_height: None, min_width: None,
min_height: None,
max_width: None,
max_height: None,
}, },
direction: "column".to_string(), direction: "column".to_string(),
gap: 5.0, gap: 5.0,
padding: Padding { top: 15.0, right: 15.0, bottom: 15.0, left: 15.0 }, padding: Padding {
top: 15.0,
right: 15.0,
bottom: 15.0,
left: 15.0,
},
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
@@ -1372,7 +1629,10 @@ mod tests {
size: SizeConstraint { size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 }, width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto, height: SizeValue::Auto,
min_width: None, min_height: None, max_width: None, max_height: None, min_width: None,
min_height: None,
max_width: None,
max_height: None,
}, },
style: TextStyle { style: TextStyle {
font_size: Some(18.0), font_size: Some(18.0),
@@ -1387,7 +1647,10 @@ mod tests {
size: SizeConstraint { size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 }, width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto, height: SizeValue::Auto,
min_width: None, min_height: None, max_width: None, max_height: None, min_width: None,
min_height: None,
max_width: None,
max_height: None,
}, },
style: LineStyle { style: LineStyle {
stroke_color: Some("#000000".to_string()), stroke_color: Some("#000000".to_string()),
@@ -1400,7 +1663,10 @@ mod tests {
size: SizeConstraint { size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 }, width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto, height: SizeValue::Auto,
min_width: None, min_height: None, max_width: None, max_height: None, min_width: None,
min_height: None,
max_width: None,
max_height: None,
}, },
style: TextStyle { style: TextStyle {
font_size: Some(11.0), font_size: Some(11.0),
@@ -1463,7 +1729,10 @@ mod tests {
#[test] #[test]
fn test_detect_unknown() { fn test_detect_unknown() {
assert_eq!(detect_image_format(&[0x00, 0x01, 0x02]), ImageFormat::Unknown); assert_eq!(
detect_image_format(&[0x00, 0x01, 0x02]),
ImageFormat::Unknown
);
assert_eq!(detect_image_format(&[]), ImageFormat::Unknown); assert_eq!(detect_image_format(&[]), ImageFormat::Unknown);
} }
} }

View File

@@ -65,13 +65,13 @@ pub fn apply_size_to_style(
// Fr → flex_grow (main axis'e göre) // Fr → flex_grow (main axis'e göre)
let main_fr = match parent_direction { let main_fr = match parent_direction {
Some("row") => fr_value(&size.width), Some("row") => fr_value(&size.width),
Some("column") | _ => fr_value(&size.height), _ => fr_value(&size.height),
}; };
// Cross axis fr: row'da height fr, column'da width fr // Cross axis fr: row'da height fr, column'da width fr
let cross_fr = match parent_direction { let cross_fr = match parent_direction {
Some("row") => fr_value(&size.height), Some("row") => fr_value(&size.height),
Some("column") | _ => fr_value(&size.width), _ => fr_value(&size.width),
}; };
// Eğer main axis fr ise, flex_grow ayarla ve flex_basis 0 yap // Eğer main axis fr ise, flex_grow ayarla ve flex_basis 0 yap
@@ -210,14 +210,22 @@ mod tests {
fn test_mm_to_pt_one_inch() { fn test_mm_to_pt_one_inch() {
// 1 inch = 25.4mm = 72pt // 1 inch = 25.4mm = 72pt
let pt = mm_to_pt(25.4); let pt = mm_to_pt(25.4);
assert!((pt - 72.0).abs() < 0.01, "25.4mm should be ~72pt, got {}", pt); assert!(
(pt - 72.0).abs() < 0.01,
"25.4mm should be ~72pt, got {}",
pt
);
} }
#[test] #[test]
fn test_pt_to_mm_conversion() { fn test_pt_to_mm_conversion() {
// 72pt = 25.4mm (1 inch) // 72pt = 25.4mm (1 inch)
let mm = pt_to_mm(72.0); let mm = pt_to_mm(72.0);
assert!((mm - 25.4).abs() < 0.01, "72pt should be ~25.4mm, got {}", mm); assert!(
(mm - 25.4).abs() < 0.01,
"72pt should be ~25.4mm, got {}",
mm
);
} }
#[test] #[test]
@@ -248,7 +256,10 @@ mod tests {
#[test] #[test]
fn test_fixed_size() { fn test_fixed_size() {
let sv = SizeValue::Fixed { value: 50.0 }; let sv = SizeValue::Fixed { value: 50.0 };
assert_eq!(size_value_to_dimension(&sv), Dimension::length(mm_to_pt(50.0))); assert_eq!(
size_value_to_dimension(&sv),
Dimension::length(mm_to_pt(50.0))
);
} }
#[test] #[test]
@@ -321,7 +332,12 @@ mod tests {
size: SizeConstraint::default(), size: SizeConstraint::default(),
direction: "row".to_string(), direction: "row".to_string(),
gap: 5.0, gap: 5.0,
padding: Padding { top: 10.0, right: 10.0, bottom: 10.0, left: 10.0 }, padding: Padding {
top: 10.0,
right: 10.0,
bottom: 10.0,
left: 10.0,
},
align: "center".to_string(), align: "center".to_string(),
justify: "space-between".to_string(), justify: "space-between".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),

View File

@@ -52,7 +52,11 @@ fn compute_auto_column_widths(
let max_pad_h = cell_pad_h.max(header_pad_h); let max_pad_h = cell_pad_h.max(header_pad_h);
// Hangi sütunlar auto? // Hangi sütunlar auto?
let is_auto: Vec<bool> = table.columns.iter().map(|c| matches!(c.width, SizeValue::Auto)).collect(); let is_auto: Vec<bool> = table
.columns
.iter()
.map(|c| matches!(c.width, SizeValue::Auto))
.collect();
// Hiç auto yoksa olduğu gibi dön // Hiç auto yoksa olduğu gibi dön
if !is_auto.iter().any(|&a| a) { if !is_auto.iter().any(|&a| a) {
@@ -60,7 +64,10 @@ fn compute_auto_column_widths(
} }
// Fr sütun var mı? // Fr sütun var mı?
let has_fr = table.columns.iter().any(|c| matches!(c.width, SizeValue::Fr { .. })); let has_fr = table
.columns
.iter()
.any(|c| matches!(c.width, SizeValue::Fr { .. }));
// Her auto sütun için max içerik genişliğini ölç (mm cinsinden) // Her auto sütun için max içerik genişliğini ölç (mm cinsinden)
let mut max_widths_mm = vec![0.0_f64; num_cols]; let mut max_widths_mm = vec![0.0_f64; num_cols];
@@ -87,13 +94,7 @@ fn compute_auto_column_widths(
if text.is_empty() { if text.is_empty() {
continue; continue;
} }
let (w_pt, _) = measurer.measure( let (w_pt, _) = measurer.measure(text, None, font_size as f32, None, None);
text,
None,
font_size as f32,
None,
None,
);
let w_mm = w_pt as f64 / (72.0 / 25.4); let w_mm = w_pt as f64 / (72.0 / 25.4);
if w_mm > max_widths_mm[col_idx] { if w_mm > max_widths_mm[col_idx] {
max_widths_mm[col_idx] = w_mm; max_widths_mm[col_idx] = w_mm;
@@ -107,10 +108,10 @@ fn compute_auto_column_widths(
// Fixed sütunların kapladığı alanı hesapla // Fixed sütunların kapladığı alanı hesapla
let mut fixed_total_mm = 0.0_f64; let mut fixed_total_mm = 0.0_f64;
for (col_idx, col) in table.columns.iter().enumerate() { for (col_idx, col) in table.columns.iter().enumerate() {
if !is_auto[col_idx] { if !is_auto[col_idx]
if let SizeValue::Fixed { value } = &col.width { && let SizeValue::Fixed { value } = &col.width
fixed_total_mm += value; {
} fixed_total_mm += value;
} }
} }
@@ -126,7 +127,9 @@ fn compute_auto_column_widths(
// kalan alanı Fr sütunlarına bırak (taffy flex ile dağıtır). // kalan alanı Fr sütunlarına bırak (taffy flex ile dağıtır).
// Fr sütunları için minimum alan ayır (en az padding kadar) // Fr sütunları için minimum alan ayır (en az padding kadar)
let fr_count = table.columns.iter() let fr_count = table
.columns
.iter()
.filter(|c| matches!(c.width, SizeValue::Fr { .. })) .filter(|c| matches!(c.width, SizeValue::Fr { .. }))
.count(); .count();
let fr_min_space = fr_count as f64 * max_pad_h * 2.0; let fr_min_space = fr_count as f64 * max_pad_h * 2.0;
@@ -137,14 +140,18 @@ fn compute_auto_column_widths(
result.push(col.width.clone()); result.push(col.width.clone());
} else if auto_natural_total <= auto_budget { } else if auto_natural_total <= auto_budget {
// Sığıyor — doğal genişliği kullan // Sığıyor — doğal genişliği kullan
result.push(SizeValue::Fixed { value: max_widths_mm[col_idx] }); result.push(SizeValue::Fixed {
value: max_widths_mm[col_idx],
});
} else if auto_budget > 0.0 && auto_natural_total > 0.0 { } else if auto_budget > 0.0 && auto_natural_total > 0.0 {
// Sığmıyor — budget'a oransal küçült // Sığmıyor — budget'a oransal küçült
let ratio = max_widths_mm[col_idx] / auto_natural_total; let ratio = max_widths_mm[col_idx] / auto_natural_total;
let width_mm = auto_budget * ratio; let width_mm = auto_budget * ratio;
result.push(SizeValue::Fixed { value: width_mm }); result.push(SizeValue::Fixed { value: width_mm });
} else { } else {
result.push(SizeValue::Fixed { value: max_widths_mm[col_idx] }); result.push(SizeValue::Fixed {
value: max_widths_mm[col_idx],
});
} }
} }
} else { } else {
@@ -177,7 +184,9 @@ pub fn expand_table_cached(
available_width_mm: f64, available_width_mm: f64,
cache: &mut TableExpandCache, cache: &mut TableExpandCache,
) -> ContainerElement { ) -> ContainerElement {
let rows = resolved.tables.get(&table.id) let rows = resolved
.tables
.get(&table.id)
.map(|t| t.rows.as_slice()) .map(|t| t.rows.as_slice())
.unwrap_or(&[]); .unwrap_or(&[]);
let key = table_cache_key(table, rows, available_width_mm); let key = table_cache_key(table, rows, available_width_mm);
@@ -203,9 +212,7 @@ pub fn expand_table(
available_width_mm: f64, available_width_mm: f64,
) -> ContainerElement { ) -> ContainerElement {
let resolved_table = resolved.tables.get(&table.id); let resolved_table = resolved.tables.get(&table.id);
let rows = resolved_table let rows = resolved_table.map(|t| t.rows.as_slice()).unwrap_or(&[]);
.map(|t| t.rows.as_slice())
.unwrap_or(&[]);
// Auto sütunlar için içerik bazlı genişlik hesapla // Auto sütunlar için içerik bazlı genişlik hesapla
let effective_widths = compute_auto_column_widths(table, rows, measurer, available_width_mm); let effective_widths = compute_auto_column_widths(table, rows, measurer, available_width_mm);
@@ -329,10 +336,7 @@ pub fn expand_table(
.iter() .iter()
.enumerate() .enumerate()
.map(|(col_idx, col)| { .map(|(col_idx, col)| {
let text_content = row_data let text_content = row_data.get(col_idx).cloned().unwrap_or_default();
.get(col_idx)
.cloned()
.unwrap_or_default();
let text = TemplateElement::StaticText(StaticTextElement { let text = TemplateElement::StaticText(StaticTextElement {
id: format!("{}_r{}c{}", table.id, row_idx, col_idx), id: format!("{}_r{}c{}", table.id, row_idx, col_idx),
@@ -448,9 +452,9 @@ pub fn expand_table(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::FontData;
use crate::data_resolve::{ResolvedData, ResolvedTable}; use crate::data_resolve::{ResolvedData, ResolvedTable};
use crate::text_measure::TextMeasurer; use crate::text_measure::TextMeasurer;
use crate::FontData;
use std::collections::HashMap; use std::collections::HashMap;
fn make_table(num_columns: usize) -> RepeatingTableElement { fn make_table(num_columns: usize) -> RepeatingTableElement {
@@ -473,7 +477,9 @@ mod tests {
height: SizeValue::Auto, height: SizeValue::Auto,
..Default::default() ..Default::default()
}, },
data_source: ArrayBinding { path: "items".to_string() }, data_source: ArrayBinding {
path: "items".to_string(),
},
columns, columns,
style: TableStyle::default(), style: TableStyle::default(),
repeat_header: Some(true), repeat_header: Some(true),
@@ -509,7 +515,11 @@ mod tests {
fn unwrap_cell_text(cell: &TemplateElement) -> &StaticTextElement { fn unwrap_cell_text(cell: &TemplateElement) -> &StaticTextElement {
match cell { match cell {
TemplateElement::Container(c) => { TemplateElement::Container(c) => {
assert_eq!(c.children.len(), 1, "Cell wrapper should have exactly 1 child"); assert_eq!(
c.children.len(),
1,
"Cell wrapper should have exactly 1 child"
);
match &c.children[0] { match &c.children[0] {
TemplateElement::StaticText(t) => t, TemplateElement::StaticText(t) => t,
_ => panic!("Expected StaticText inside cell wrapper"), _ => panic!("Expected StaticText inside cell wrapper"),
@@ -522,10 +532,13 @@ mod tests {
#[test] #[test]
fn test_expand_table_structure() { fn test_expand_table_structure() {
let table = make_table(2); let table = make_table(2);
let resolved = make_resolved("tbl", vec![ let resolved = make_resolved(
vec!["A".to_string(), "1".to_string()], "tbl",
vec!["B".to_string(), "2".to_string()], vec![
]); vec!["A".to_string(), "1".to_string()],
vec!["B".to_string(), "2".to_string()],
],
);
let mut measurer = make_measurer(); let mut measurer = make_measurer();
let container = expand_table(&table, &resolved, &mut measurer, 180.0); let container = expand_table(&table, &resolved, &mut measurer, 180.0);
@@ -586,9 +599,10 @@ mod tests {
#[test] #[test]
fn test_expand_table_column_count() { fn test_expand_table_column_count() {
let table = make_table(4); let table = make_table(4);
let resolved = make_resolved("tbl", vec![ let resolved = make_resolved(
vec!["a".into(), "b".into(), "c".into(), "d".into()], "tbl",
]); vec![vec!["a".into(), "b".into(), "c".into(), "d".into()]],
);
let mut measurer = make_measurer(); let mut measurer = make_measurer();
let container = expand_table(&table, &resolved, &mut measurer, 180.0); let container = expand_table(&table, &resolved, &mut measurer, 180.0);
@@ -610,9 +624,7 @@ mod tests {
#[test] #[test]
fn test_expand_table_data_cell_content() { fn test_expand_table_data_cell_content() {
let table = make_table(2); let table = make_table(2);
let resolved = make_resolved("tbl", vec![ let resolved = make_resolved("tbl", vec![vec!["Hello".to_string(), "42".to_string()]]);
vec!["Hello".to_string(), "42".to_string()],
]);
let mut measurer = make_measurer(); let mut measurer = make_measurer();
let container = expand_table(&table, &resolved, &mut measurer, 180.0); let container = expand_table(&table, &resolved, &mut measurer, 180.0);
@@ -633,9 +645,7 @@ mod tests {
fn test_expand_table_with_border_adds_separator() { fn test_expand_table_with_border_adds_separator() {
let mut table = make_table(2); let mut table = make_table(2);
table.style.border_color = Some("#000000".to_string()); table.style.border_color = Some("#000000".to_string());
let resolved = make_resolved("tbl", vec![ let resolved = make_resolved("tbl", vec![vec!["A".to_string(), "1".to_string()]]);
vec!["A".to_string(), "1".to_string()],
]);
let mut measurer = make_measurer(); let mut measurer = make_measurer();
let container = expand_table(&table, &resolved, &mut measurer, 180.0); let container = expand_table(&table, &resolved, &mut measurer, 180.0);
@@ -657,11 +667,14 @@ mod tests {
let mut table = make_table(1); let mut table = make_table(1);
table.style.zebra_odd = Some("#f0f0f0".to_string()); table.style.zebra_odd = Some("#f0f0f0".to_string());
table.style.zebra_even = Some("#ffffff".to_string()); table.style.zebra_even = Some("#ffffff".to_string());
let resolved = make_resolved("tbl", vec![ let resolved = make_resolved(
vec!["row0".into()], "tbl",
vec!["row1".into()], vec![
vec!["row2".into()], vec!["row0".into()],
]); vec!["row1".into()],
vec!["row2".into()],
],
);
let mut measurer = make_measurer(); let mut measurer = make_measurer();
let container = expand_table(&table, &resolved, &mut measurer, 180.0); let container = expand_table(&table, &resolved, &mut measurer, 180.0);
@@ -722,16 +735,21 @@ mod tests {
height: SizeValue::Auto, height: SizeValue::Auto,
..Default::default() ..Default::default()
}, },
data_source: ArrayBinding { path: "items".to_string() }, data_source: ArrayBinding {
path: "items".to_string(),
},
columns, columns,
style: TableStyle::default(), style: TableStyle::default(),
repeat_header: Some(true), repeat_header: Some(true),
}; };
let resolved = make_resolved("tbl", vec![ let resolved = make_resolved(
vec!["1".into(), "Web Uygulama Gelistirme".into()], "tbl",
vec!["2".into(), "SSL Sertifikasi".into()], vec![
]); vec!["1".into(), "Web Uygulama Gelistirme".into()],
vec!["2".into(), "SSL Sertifikasi".into()],
],
);
let mut measurer = make_measurer(); let mut measurer = make_measurer();
let container = expand_table(&table, &resolved, &mut measurer, 180.0); let container = expand_table(&table, &resolved, &mut measurer, 180.0);
@@ -753,10 +771,16 @@ mod tests {
}, },
_ => panic!("Expected Container wrapper"), _ => panic!("Expected Container wrapper"),
}; };
assert!(w1 > w0, "Long column ({w1}mm) should be wider than short column ({w0}mm)"); assert!(
w1 > w0,
"Long column ({w1}mm) should be wider than short column ({w0}mm)"
);
// Her iki sütun toplamı available_width'e eşit olmalı // Her iki sütun toplamı available_width'e eşit olmalı
let total = w0 + w1; let total = w0 + w1;
assert!((total - 180.0).abs() < 0.1, "Total width ({total}mm) should equal available width (180mm)"); assert!(
(total - 180.0).abs() < 0.1,
"Total width ({total}mm) should equal available width (180mm)"
);
} }
_ => panic!("Expected Container"), _ => panic!("Expected Container"),
} }
@@ -765,9 +789,7 @@ mod tests {
#[test] #[test]
fn test_table_cache_hit() { fn test_table_cache_hit() {
let table = make_table(2); let table = make_table(2);
let resolved = make_resolved("tbl", vec![ let resolved = make_resolved("tbl", vec![vec!["A".to_string(), "1".to_string()]]);
vec!["A".to_string(), "1".to_string()],
]);
let mut measurer = make_measurer(); let mut measurer = make_measurer();
let mut cache = TableExpandCache::new(); let mut cache = TableExpandCache::new();
@@ -785,12 +807,8 @@ mod tests {
#[test] #[test]
fn test_table_cache_miss_on_data_change() { fn test_table_cache_miss_on_data_change() {
let table = make_table(2); let table = make_table(2);
let resolved1 = make_resolved("tbl", vec![ let resolved1 = make_resolved("tbl", vec![vec!["A".to_string(), "1".to_string()]]);
vec!["A".to_string(), "1".to_string()], let resolved2 = make_resolved("tbl", vec![vec!["B".to_string(), "2".to_string()]]);
]);
let resolved2 = make_resolved("tbl", vec![
vec!["B".to_string(), "2".to_string()],
]);
let mut measurer = make_measurer(); let mut measurer = make_measurer();
let mut cache = TableExpandCache::new(); let mut cache = TableExpandCache::new();
@@ -805,9 +823,7 @@ mod tests {
#[test] #[test]
fn test_table_cache_miss_on_width_change() { fn test_table_cache_miss_on_width_change() {
let table = make_table(2); let table = make_table(2);
let resolved = make_resolved("tbl", vec![ let resolved = make_resolved("tbl", vec![vec!["A".to_string(), "1".to_string()]]);
vec!["A".to_string(), "1".to_string()],
]);
let mut measurer = make_measurer(); let mut measurer = make_measurer();
let mut cache = TableExpandCache::new(); let mut cache = TableExpandCache::new();

View File

@@ -102,7 +102,9 @@ impl TextMeasurer {
/// Cache'i dışarı taşı (persist etmek için). /// Cache'i dışarı taşı (persist etmek için).
pub fn take_cache(self) -> TextMeasureCache { pub fn take_cache(self) -> TextMeasureCache {
TextMeasureCache { entries: self.cache } TextMeasureCache {
entries: self.cache,
}
} }
/// Text'i ölç. Dönen değerler pt cinsinden (width, height). /// Text'i ölç. Dönen değerler pt cinsinden (width, height).
@@ -120,13 +122,25 @@ impl TextMeasurer {
return (0.0, font_size_pt * 1.2); return (0.0, font_size_pt * 1.2);
} }
let key = MeasureCacheKey::new(text, font_family, font_size_pt, font_weight, available_width_pt); let key = MeasureCacheKey::new(
text,
font_family,
font_size_pt,
font_weight,
available_width_pt,
);
if let Some(&cached) = self.cache.get(&key) { if let Some(&cached) = self.cache.get(&key) {
return cached; return cached;
} }
let result = self.measure_uncached(text, font_family, font_size_pt, font_weight, available_width_pt); let result = self.measure_uncached(
text,
font_family,
font_size_pt,
font_weight,
available_width_pt,
);
self.cache.insert(key, result); self.cache.insert(key, result);
result result
} }
@@ -253,10 +267,7 @@ impl TextMeasurer {
} }
// En büyük font boyutunu bul — line height buna göre belirlenir // En büyük font boyutunu bul — line height buna göre belirlenir
let max_font_size_pt = spans let max_font_size_pt = spans.iter().map(|s| s.font_size_pt).fold(0.0f32, f32::max);
.iter()
.map(|s| s.font_size_pt)
.fold(0.0f32, f32::max);
if max_font_size_pt <= 0.0 { if max_font_size_pt <= 0.0 {
return (0.0, 0.0); return (0.0, 0.0);
@@ -393,11 +404,21 @@ mod tests {
fn test_wrapping_reduces_width() { fn test_wrapping_reduces_width() {
let mut m = make_measurer(); let mut m = make_measurer();
// Sınırsız genişlikte ölç // Sınırsız genişlikte ölç
let (w_unlimited, h_unlimited) = let (w_unlimited, h_unlimited) = m.measure(
m.measure("This is a longer text that should wrap", None, 12.0, None, None); "This is a longer text that should wrap",
None,
12.0,
None,
None,
);
// Dar genişlikte ölç // Dar genişlikte ölç
let (w_narrow, h_narrow) = let (w_narrow, h_narrow) = m.measure(
m.measure("This is a longer text that should wrap", None, 12.0, None, Some(50.0)); "This is a longer text that should wrap",
None,
12.0,
None,
Some(50.0),
);
// Dar genişlikte yükseklik artmalı (wrapping oldu) // Dar genişlikte yükseklik artmalı (wrapping oldu)
assert!( assert!(

View File

@@ -81,17 +81,16 @@ pub fn compute(
}; };
let page_node = taffy.new_with_children(page_style, &[root_node])?; let page_node = taffy.new_with_children(page_style, &[root_node])?;
taffy taffy.compute_layout_with_measure(
.compute_layout_with_measure( page_node,
page_node, Size {
Size { width: AvailableSpace::Definite(page_w_pt),
width: AvailableSpace::Definite(page_w_pt), height: AvailableSpace::MaxContent,
height: AvailableSpace::MaxContent, },
}, |known_dimensions, available_space, _node_id, context, _style| {
|known_dimensions, available_space, _node_id, context, _style| { measure_leaf(known_dimensions, available_space, context, measurer)
measure_leaf(known_dimensions, available_space, context, measurer) },
}, )?;
)?;
let body_elements = collect_layout(&taffy, root_node, &node_map, resolved, 0.0, 0.0)?; let body_elements = collect_layout(&taffy, root_node, &node_map, resolved, 0.0, 0.0)?;
@@ -135,7 +134,16 @@ fn compute_section(
let mut node_map: HashMap<NodeId, NodeInfo> = HashMap::new(); let mut node_map: HashMap<NodeId, NodeInfo> = HashMap::new();
let mut table_cache = TableExpandCache::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 section_node = build_container(
container,
&mut taffy,
&mut node_map,
resolved,
None,
measurer,
page_width_mm,
&mut table_cache,
)?;
let wrapper_style = Style { let wrapper_style = Style {
display: Display::Flex, display: Display::Flex,
@@ -148,17 +156,16 @@ fn compute_section(
}; };
let wrapper_node = taffy.new_with_children(wrapper_style, &[section_node])?; let wrapper_node = taffy.new_with_children(wrapper_style, &[section_node])?;
taffy taffy.compute_layout_with_measure(
.compute_layout_with_measure( wrapper_node,
wrapper_node, Size {
Size { width: AvailableSpace::Definite(page_w_pt),
width: AvailableSpace::Definite(page_w_pt), height: AvailableSpace::MaxContent,
height: AvailableSpace::MaxContent, },
}, |known_dimensions, available_space, _node_id, context, _style| {
|known_dimensions, available_space, _node_id, context, _style| { measure_leaf(known_dimensions, available_space, context, measurer)
measure_leaf(known_dimensions, available_space, context, measurer) },
}, )?;
)?;
let elements = collect_layout(&taffy, section_node, &node_map, resolved, 0.0, 0.0)?; let elements = collect_layout(&taffy, section_node, &node_map, resolved, 0.0, 0.0)?;
@@ -209,6 +216,7 @@ fn collect_no_repeat_recursive(el: &TemplateElement, set: &mut std::collections:
} }
/// Container element'ini taffy node ağacına ekle (recursive) /// Container element'ini taffy node ağacına ekle (recursive)
#[allow(clippy::too_many_arguments)]
fn build_container( fn build_container(
el: &ContainerElement, el: &ContainerElement,
taffy: &mut TaffyTree<MeasureContext>, taffy: &mut TaffyTree<MeasureContext>,
@@ -229,14 +237,24 @@ fn build_container(
SizeValue::Fixed { value } => *value, SizeValue::Fixed { value } => *value,
_ => page_width_mm, // Fr veya Auto ise parent'ın genişliğini kullan _ => 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 =
container_own_width - el.padding.left - el.padding.right - border_w * 2.0;
let content_width_mm = content_width_mm.max(0.0); let content_width_mm = content_width_mm.max(0.0);
let mut child_nodes = Vec::new(); let mut child_nodes = Vec::new();
let mut children_ids = Vec::new(); let mut children_ids = Vec::new();
for child in &el.children { for child in &el.children {
let child_node = build_element(child, taffy, node_map, resolved, Some(direction), measurer, content_width_mm, table_cache)?; let child_node = build_element(
child,
taffy,
node_map,
resolved,
Some(direction),
measurer,
content_width_mm,
table_cache,
)?;
child_nodes.push(child_node); child_nodes.push(child_node);
children_ids.push(child.id().to_string()); children_ids.push(child.id().to_string());
} }
@@ -265,6 +283,7 @@ fn build_container(
} }
/// Herhangi bir element tipini taffy node'a çevir /// Herhangi bir element tipini taffy node'a çevir
#[allow(clippy::too_many_arguments)]
fn build_element( fn build_element(
el: &TemplateElement, el: &TemplateElement,
taffy: &mut TaffyTree<MeasureContext>, taffy: &mut TaffyTree<MeasureContext>,
@@ -276,9 +295,16 @@ fn build_element(
table_cache: &mut TableExpandCache, table_cache: &mut TableExpandCache,
) -> Result<NodeId, LayoutError> { ) -> Result<NodeId, LayoutError> {
match el { match el {
TemplateElement::Container(e) => { TemplateElement::Container(e) => build_container(
build_container(e, taffy, node_map, resolved, parent_direction, measurer, page_width_mm, table_cache) e,
} taffy,
node_map,
resolved,
parent_direction,
measurer,
page_width_mm,
table_cache,
),
TemplateElement::StaticText(e) => build_text_leaf( TemplateElement::StaticText(e) => build_text_leaf(
taffy, taffy,
node_map, node_map,
@@ -327,11 +353,7 @@ fn build_element(
) )
} }
TemplateElement::CurrentDate(e) => { TemplateElement::CurrentDate(e) => {
let text = resolved let text = resolved.texts.get(&e.id).map(|s| s.as_str()).unwrap_or("");
.texts
.get(&e.id)
.map(|s| s.as_str())
.unwrap_or("");
build_text_leaf( build_text_leaf(
taffy, taffy,
node_map, node_map,
@@ -345,11 +367,7 @@ fn build_element(
) )
} }
TemplateElement::CalculatedText(e) => { TemplateElement::CalculatedText(e) => {
let text = resolved let text = resolved.texts.get(&e.id).map(|s| s.as_str()).unwrap_or("");
.texts
.get(&e.id)
.map(|s| s.as_str())
.unwrap_or("");
build_text_leaf( build_text_leaf(
taffy, taffy,
node_map, node_map,
@@ -446,7 +464,13 @@ fn build_element(
} }
TemplateElement::RepeatingTable(e) => { TemplateElement::RepeatingTable(e) => {
// Tabloyu container ağacına expand et (cache ile) // Tabloyu container ağacına expand et (cache ile)
let expanded = table_layout::expand_table_cached(e, resolved, measurer, page_width_mm, table_cache); 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 edilmiş tablo cell'lerinin text'lerini resolved'a ekle
// (expand_table StaticText'ler üretir, bunların text'leri zaten content'te) // (expand_table StaticText'ler üretir, bunların text'leri zaten content'te)
@@ -492,7 +516,11 @@ fn build_element(
Ok(node) Ok(node)
} }
TemplateElement::Checkbox(e) => { TemplateElement::Checkbox(e) => {
let checked_str = resolved.texts.get(&e.id).map(|s| s.as_str()).unwrap_or("false"); let checked_str = resolved
.texts
.get(&e.id)
.map(|s| s.as_str())
.unwrap_or("false");
let checked = checked_str == "true"; let checked = checked_str == "true";
let box_size_mm = e.style.size.unwrap_or(4.0); let box_size_mm = e.style.size.unwrap_or(4.0);
let style = sizing::leaf_style(&e.size, &e.position, parent_direction); let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
@@ -570,7 +598,9 @@ fn build_element(
NodeInfo { NodeInfo {
element_id: e.id.clone(), element_id: e.id.clone(),
element_type: "rich_text".to_string(), element_type: "rich_text".to_string(),
content: Some(ResolvedContent::RichText { spans: resolved_spans }), content: Some(ResolvedContent::RichText {
spans: resolved_spans,
}),
style: ResolvedStyle { style: ResolvedStyle {
font_size: e.style.font_size, font_size: e.style.font_size,
font_weight: e.style.font_weight.clone(), font_weight: e.style.font_weight.clone(),
@@ -647,6 +677,7 @@ fn register_expanded_texts(el: &TemplateElement, resolved: &mut ResolvedData) {
} }
/// Text leaf node oluştur (static_text, text, page_number için ortak) /// Text leaf node oluştur (static_text, text, page_number için ortak)
#[allow(clippy::too_many_arguments)]
fn build_text_leaf( fn build_text_leaf(
taffy: &mut TaffyTree<MeasureContext>, taffy: &mut TaffyTree<MeasureContext>,
node_map: &mut HashMap<NodeId, NodeInfo>, node_map: &mut HashMap<NodeId, NodeInfo>,
@@ -767,14 +798,16 @@ fn collect_layout(
// Chart elementleri icin SVG uret (boyutlar artik belli) // Chart elementleri icin SVG uret (boyutlar artik belli)
let content = if info.element_type == "chart" { let content = if info.element_type == "chart" {
resolved.charts.get(&info.element_id).map(|cd| { resolved.charts.get(&info.element_id).map(|cd| {
use crate::{ChartRenderData, ChartSeriesData};
use crate::chart_layout::DEFAULT_COLORS; use crate::chart_layout::DEFAULT_COLORS;
use crate::{ChartRenderData, ChartSeriesData};
// Renk paleti olustur // Renk paleti olustur
let n_colors = cd.categories.len().max(cd.series.len()).max(1); let n_colors = cd.categories.len().max(cd.series.len()).max(1);
let colors: Vec<String> = (0..n_colors) let colors: Vec<String> = (0..n_colors)
.map(|i| { .map(|i| {
cd.style.colors.as_ref() cd.style
.colors
.as_ref()
.and_then(|c| c.get(i).cloned()) .and_then(|c| c.get(i).cloned())
.unwrap_or_else(|| DEFAULT_COLORS[i % DEFAULT_COLORS.len()].to_string()) .unwrap_or_else(|| DEFAULT_COLORS[i % DEFAULT_COLORS.len()].to_string())
}) })
@@ -782,13 +815,17 @@ fn collect_layout(
ResolvedContent::Chart { ResolvedContent::Chart {
svg: crate::chart_render::render_svg(cd, w_mm, h_mm), svg: crate::chart_render::render_svg(cd, w_mm, h_mm),
chart_data: ChartRenderData { chart_data: Box::new(ChartRenderData {
chart_type: cd.chart_type.clone(), chart_type: cd.chart_type.clone(),
categories: cd.categories.clone(), categories: cd.categories.clone(),
series: cd.series.iter().map(|s| ChartSeriesData { series: cd
name: s.name.clone(), .series
values: s.values.clone(), .iter()
}).collect(), .map(|s| ChartSeriesData {
name: s.name.clone(),
values: s.values.clone(),
})
.collect(),
title_text: cd.title.as_ref().map(|t| t.text.clone()), title_text: cd.title.as_ref().map(|t| t.text.clone()),
title_font_size: cd.title.as_ref().and_then(|t| t.font_size), title_font_size: cd.title.as_ref().and_then(|t| t.font_size),
title_color: cd.title.as_ref().and_then(|t| t.color.clone()), title_color: cd.title.as_ref().and_then(|t| t.color.clone()),
@@ -798,7 +835,10 @@ fn collect_layout(
show_grid: cd.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true), show_grid: cd.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true),
grid_color: cd.axis.as_ref().and_then(|a| a.grid_color.clone()), grid_color: cd.axis.as_ref().and_then(|a| a.grid_color.clone()),
bar_gap: cd.style.bar_gap, bar_gap: cd.style.bar_gap,
stacked: matches!(cd.group_mode, Some(dreport_core::models::GroupMode::Stacked)), stacked: matches!(
cd.group_mode,
Some(dreport_core::models::GroupMode::Stacked)
),
inner_radius: cd.style.inner_radius, inner_radius: cd.style.inner_radius,
show_points: cd.style.show_points, show_points: cd.style.show_points,
line_width: cd.style.line_width, line_width: cd.style.line_width,
@@ -810,7 +850,7 @@ fn collect_layout(
x_label: cd.axis.as_ref().and_then(|a| a.x_label.clone()), x_label: cd.axis.as_ref().and_then(|a| a.x_label.clone()),
y_label: cd.axis.as_ref().and_then(|a| a.y_label.clone()), y_label: cd.axis.as_ref().and_then(|a| a.y_label.clone()),
title_align: cd.title.as_ref().and_then(|t| t.align.clone()), title_align: cd.title.as_ref().and_then(|t| t.align.clone()),
}, }),
} }
}) })
} else { } else {

View File

@@ -1,5 +1,5 @@
use std::sync::Mutex;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Mutex;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
@@ -92,10 +92,12 @@ pub fn get_loaded_fonts() -> String {
let result: Vec<serde_json::Value> = families let result: Vec<serde_json::Value> = families
.into_iter() .into_iter()
.map(|(family, variants)| serde_json::json!({ .map(|(family, variants)| {
"family": family, serde_json::json!({
"variants": variants, "family": family,
})) "variants": variants,
})
})
.collect(); .collect();
serde_json::to_string(&result).unwrap_or_else(|_| "[]".to_string()) serde_json::to_string(&result).unwrap_or_else(|_| "[]".to_string())
@@ -115,7 +117,9 @@ pub fn compute_layout_wasm(template_json: &str, data_json: &str) -> Result<Strin
let fonts = FONTS.lock().unwrap(); let fonts = FONTS.lock().unwrap();
if fonts.is_empty() { if fonts.is_empty() {
return Err(JsValue::from_str("Fonts not loaded. Call loadFonts() first.")); return Err(JsValue::from_str(
"Fonts not loaded. Call loadFonts() first.",
));
} }
// Text cache'i al (veya ilk kullanımda oluştur) // Text cache'i al (veya ilk kullanımda oluştur)
@@ -134,7 +138,13 @@ pub fn compute_layout_wasm(template_json: &str, data_json: &str) -> Result<Strin
/// Barcode üret → ham RGBA pixel verisi (header: 8 byte width+height LE, sonra RGBA). /// Barcode üret → ham RGBA pixel verisi (header: 8 byte width+height LE, sonra RGBA).
/// Sonuç cache'lenir — aynı parametrelerle tekrar çağrılırsa cache'ten döner. /// Sonuç cache'lenir — aynı parametrelerle tekrar çağrılırsa cache'ten döner.
#[wasm_bindgen(js_name = "generateBarcode")] #[wasm_bindgen(js_name = "generateBarcode")]
pub fn generate_barcode_wasm(format: &str, value: &str, width: u32, height: u32, include_text: bool) -> Result<js_sys::Uint8ClampedArray, JsValue> { pub fn generate_barcode_wasm(
format: &str,
value: &str,
width: u32,
height: u32,
include_text: bool,
) -> Result<js_sys::Uint8ClampedArray, JsValue> {
let cache_key = BarcodeCacheKey { let cache_key = BarcodeCacheKey {
format: format.to_string(), format: format.to_string(),
value: value.to_string(), value: value.to_string(),
@@ -155,8 +165,15 @@ pub fn generate_barcode_wasm(format: &str, value: &str, width: u32, height: u32,
let fonts = FONTS.lock().unwrap(); let fonts = FONTS.lock().unwrap();
let fonts_slice: Option<&[FontData]> = if fonts.is_empty() { None } else { Some(&fonts) }; let fonts_slice: Option<&[FontData]> = if fonts.is_empty() { None } else { Some(&fonts) };
let result = crate::barcode_gen::generate_barcode_pixels(format, value, width, height, include_text, fonts_slice) let result = crate::barcode_gen::generate_barcode_pixels(
.map_err(|e| JsValue::from_str(&e))?; format,
value,
width,
height,
include_text,
fonts_slice,
)
.map_err(|e| JsValue::from_str(&e))?;
// Grayscale → RGBA (canvas ImageData formatı) // Grayscale → RGBA (canvas ImageData formatı)
let mut rgba = Vec::with_capacity((result.width * result.height * 4) as usize); let mut rgba = Vec::with_capacity((result.width * result.height * 4) as usize);
@@ -164,7 +181,7 @@ pub fn generate_barcode_wasm(format: &str, value: &str, width: u32, height: u32,
rgba.push(gray); // R rgba.push(gray); // R
rgba.push(gray); // G rgba.push(gray); // G
rgba.push(gray); // B rgba.push(gray); // B
rgba.push(255); // A rgba.push(255); // A
} }
// Header (8 byte: width LE + height LE) + RGBA pixel verisi // Header (8 byte: width LE + height LE) + RGBA pixel verisi

View File

@@ -8,7 +8,7 @@
#![cfg(not(target_arch = "wasm32"))] #![cfg(not(target_arch = "wasm32"))]
use dreport_core::models::*; use dreport_core::models::*;
use dreport_layout::{compute_layout, LayoutResult, ResolvedContent}; use dreport_layout::{LayoutResult, ResolvedContent, compute_layout};
mod common; mod common;
use common::load_test_fonts; use common::load_test_fonts;
@@ -17,7 +17,10 @@ fn base_template() -> Template {
Template { Template {
id: "imp_test".to_string(), id: "imp_test".to_string(),
name: "Improvements Test".to_string(), name: "Improvements Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 }, page: PageSettings {
width: 210.0,
height: 297.0,
},
fonts: vec!["Noto Sans".to_string()], fonts: vec!["Noto Sans".to_string()],
header: None, header: None,
footer: None, footer: None,
@@ -28,7 +31,12 @@ fn base_template() -> Template {
size: SizeConstraint::default(), size: SizeConstraint::default(),
direction: "column".to_string(), direction: "column".to_string(),
gap: 5.0, gap: 5.0,
padding: Padding { top: 15.0, right: 15.0, bottom: 15.0, left: 15.0 }, padding: Padding {
top: 15.0,
right: 15.0,
bottom: 15.0,
left: 15.0,
},
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
@@ -63,7 +71,11 @@ fn test_1_2_text_wrapping_layout_height() {
let fonts = load_test_fonts(); let fonts = load_test_fonts();
let result = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap(); let result = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap();
let el = result.pages[0].elements.iter().find(|e| e.id == "long_text").unwrap(); let el = result.pages[0]
.elements
.iter()
.find(|e| e.id == "long_text")
.unwrap();
// Tek satır ~4.2mm olur (12pt * 1.2 line-height ≈ 5mm). // Tek satır ~4.2mm olur (12pt * 1.2 line-height ≈ 5mm).
// Sarılmış metin daha yüksek olmalı. // Sarılmış metin daha yüksek olmalı.
@@ -125,7 +137,11 @@ fn test_1_3_image_object_fit_in_layout() {
let fonts = load_test_fonts(); let fonts = load_test_fonts();
let result = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap(); let result = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap();
let el = result.pages[0].elements.iter().find(|e| e.id == "img_contain").unwrap(); let el = result.pages[0]
.elements
.iter()
.find(|e| e.id == "img_contain")
.unwrap();
// objectFit style'da taşınmalı // objectFit style'da taşınmalı
assert_eq!( assert_eq!(
@@ -143,27 +159,33 @@ fn test_1_3_image_object_fit_in_layout() {
fn test_1_4_italic_font_in_pdf() { fn test_1_4_italic_font_in_pdf() {
// fontStyle: italic ile PDF render — crash olmamalı // fontStyle: italic ile PDF render — crash olmamalı
let mut tpl = base_template(); let mut tpl = base_template();
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement { tpl.root
id: "italic_text".to_string(), .children
position: PositionMode::Flow, .push(TemplateElement::StaticText(StaticTextElement {
size: SizeConstraint { id: "italic_text".to_string(),
width: SizeValue::Fr { value: 1.0 }, position: PositionMode::Flow,
height: SizeValue::Auto, size: SizeConstraint {
..Default::default() width: SizeValue::Fr { value: 1.0 },
}, height: SizeValue::Auto,
style: TextStyle { ..Default::default()
font_size: Some(12.0), },
font_style: Some("italic".to_string()), style: TextStyle {
..Default::default() font_size: Some(12.0),
}, font_style: Some("italic".to_string()),
content: "Bu metin italic olmalı".to_string(), ..Default::default()
})); },
content: "Bu metin italic olmalı".to_string(),
}));
let fonts = load_test_fonts(); let fonts = load_test_fonts();
let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap(); let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap();
// fontStyle layout result'ta korunmalı // fontStyle layout result'ta korunmalı
let el = layout.pages[0].elements.iter().find(|e| e.id == "italic_text").unwrap(); let el = layout.pages[0]
.elements
.iter()
.find(|e| e.id == "italic_text")
.unwrap();
assert_eq!(el.style.font_style.as_deref(), Some("italic")); assert_eq!(el.style.font_style.as_deref(), Some("italic"));
// PDF render crash olmamalı // PDF render crash olmamalı
@@ -174,22 +196,24 @@ fn test_1_4_italic_font_in_pdf() {
#[test] #[test]
fn test_1_4_bold_italic_font_in_pdf() { fn test_1_4_bold_italic_font_in_pdf() {
let mut tpl = base_template(); let mut tpl = base_template();
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement { tpl.root
id: "bold_italic".to_string(), .children
position: PositionMode::Flow, .push(TemplateElement::StaticText(StaticTextElement {
size: SizeConstraint { id: "bold_italic".to_string(),
width: SizeValue::Fr { value: 1.0 }, position: PositionMode::Flow,
height: SizeValue::Auto, size: SizeConstraint {
..Default::default() width: SizeValue::Fr { value: 1.0 },
}, height: SizeValue::Auto,
style: TextStyle { ..Default::default()
font_size: Some(14.0), },
font_weight: Some("bold".to_string()), style: TextStyle {
font_style: Some("italic".to_string()), font_size: Some(14.0),
..Default::default() font_weight: Some("bold".to_string()),
}, font_style: Some("italic".to_string()),
content: "Bold Italic Test".to_string(), ..Default::default()
})); },
content: "Bold Italic Test".to_string(),
}));
let fonts = load_test_fonts(); let fonts = load_test_fonts();
let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap(); let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap();
@@ -205,28 +229,30 @@ fn test_1_4_bold_italic_font_in_pdf() {
fn test_2_1_repeat_header_false_no_repeat_on_second_page() { fn test_2_1_repeat_header_false_no_repeat_on_second_page() {
// repeat_header: false olan tablo, 2. sayfada header tekrarlamamalı // repeat_header: false olan tablo, 2. sayfada header tekrarlamamalı
let mut tpl = base_template(); let mut tpl = base_template();
tpl.root.children.push(TemplateElement::RepeatingTable(RepeatingTableElement { tpl.root
id: "tbl_no_repeat".to_string(), .children
position: PositionMode::Flow, .push(TemplateElement::RepeatingTable(RepeatingTableElement {
size: SizeConstraint { id: "tbl_no_repeat".to_string(),
width: SizeValue::Fr { value: 1.0 }, position: PositionMode::Flow,
height: SizeValue::Auto, size: SizeConstraint {
..Default::default() width: SizeValue::Fr { value: 1.0 },
}, height: SizeValue::Auto,
data_source: ArrayBinding { path: "items".to_string() }, ..Default::default()
columns: vec![ },
TableColumn { data_source: ArrayBinding {
path: "items".to_string(),
},
columns: vec![TableColumn {
id: "col_name".to_string(), id: "col_name".to_string(),
field: "name".to_string(), field: "name".to_string(),
title: "Name".to_string(), title: "Name".to_string(),
width: SizeValue::Fr { value: 1.0 }, width: SizeValue::Fr { value: 1.0 },
align: "left".to_string(), align: "left".to_string(),
format: None, format: None,
}, }],
], style: TableStyle::default(),
style: TableStyle::default(), repeat_header: Some(false), // Header tekrarlanmasın
repeat_header: Some(false), // Header tekrarlanmasın }));
}));
// Çok sayıda satır — sayfa taşması için // Çok sayıda satır — sayfa taşması için
let items: Vec<serde_json::Value> = (0..80) let items: Vec<serde_json::Value> = (0..80)
@@ -253,9 +279,9 @@ fn test_2_1_repeat_header_false_no_repeat_on_second_page() {
.collect(); .collect();
// Header row'u "tbl_no_repeat_header" pattern'inde olmalı, 2. sayfada bulunmamalı // Header row'u "tbl_no_repeat_header" pattern'inde olmalı, 2. sayfada bulunmamalı
let has_header_clone = page2_ids.iter().any(|id| { let has_header_clone = page2_ids
id.contains("header") && id.contains("tbl_no_repeat") && id.contains("_p") .iter()
}); .any(|id| id.contains("header") && id.contains("tbl_no_repeat") && id.contains("_p"));
assert!( assert!(
!has_header_clone, !has_header_clone,
@@ -268,28 +294,30 @@ fn test_2_1_repeat_header_false_no_repeat_on_second_page() {
fn test_2_1_repeat_header_true_repeats_on_second_page() { fn test_2_1_repeat_header_true_repeats_on_second_page() {
// repeat_header: true (varsayılan) olan tablo, 2. sayfada header tekrarlamalı // repeat_header: true (varsayılan) olan tablo, 2. sayfada header tekrarlamalı
let mut tpl = base_template(); let mut tpl = base_template();
tpl.root.children.push(TemplateElement::RepeatingTable(RepeatingTableElement { tpl.root
id: "tbl_repeat".to_string(), .children
position: PositionMode::Flow, .push(TemplateElement::RepeatingTable(RepeatingTableElement {
size: SizeConstraint { id: "tbl_repeat".to_string(),
width: SizeValue::Fr { value: 1.0 }, position: PositionMode::Flow,
height: SizeValue::Auto, size: SizeConstraint {
..Default::default() width: SizeValue::Fr { value: 1.0 },
}, height: SizeValue::Auto,
data_source: ArrayBinding { path: "items".to_string() }, ..Default::default()
columns: vec![ },
TableColumn { data_source: ArrayBinding {
path: "items".to_string(),
},
columns: vec![TableColumn {
id: "col_name".to_string(), id: "col_name".to_string(),
field: "name".to_string(), field: "name".to_string(),
title: "Name".to_string(), title: "Name".to_string(),
width: SizeValue::Fr { value: 1.0 }, width: SizeValue::Fr { value: 1.0 },
align: "left".to_string(), align: "left".to_string(),
format: None, format: None,
}, }],
], style: TableStyle::default(),
style: TableStyle::default(), repeat_header: Some(true),
repeat_header: Some(true), }));
}));
let items: Vec<serde_json::Value> = (0..80) let items: Vec<serde_json::Value> = (0..80)
.map(|i| serde_json::json!({ "name": format!("Item {}", i) })) .map(|i| serde_json::json!({ "name": format!("Item {}", i) }))
@@ -308,9 +336,9 @@ fn test_2_1_repeat_header_true_repeats_on_second_page() {
.map(|e| e.id.as_str()) .map(|e| e.id.as_str())
.collect(); .collect();
let has_header_clone = page2_ids.iter().any(|id| { let has_header_clone = page2_ids
id.contains("tbl_repeat_header") || id.contains("tbl_repeat_hdr") .iter()
}); .any(|id| id.contains("tbl_repeat_header") || id.contains("tbl_repeat_hdr"));
// Eğer header tekrarı yoksa, en azından repeat_header_false testi ile // Eğer header tekrarı yoksa, en azından repeat_header_false testi ile
// davranış farkını doğrulayalım: repeat=true olan tabloda page 2 header // davranış farkını doğrulayalım: repeat=true olan tabloda page 2 header
@@ -320,10 +348,17 @@ fn test_2_1_repeat_header_true_repeats_on_second_page() {
if !has_header_clone { if !has_header_clone {
// Fallback: page 2'deki ilk elemanın y_mm'si, page 1'deki header yüksekliği // Fallback: page 2'deki ilk elemanın y_mm'si, page 1'deki header yüksekliği
// kadar offset'li olmalı (header için yer ayrılmış) // kadar offset'li olmalı (header için yer ayrılmış)
let page1_header = result.pages[0].elements.iter().find(|e| e.id.contains("header")); let page1_header = result.pages[0]
.elements
.iter()
.find(|e| e.id.contains("header"));
if let Some(hdr) = page1_header { if let Some(hdr) = page1_header {
// Page 2 ilk elemanın y'si > 0 olmalı (header alanı ayrılmış) // Page 2 ilk elemanın y'si > 0 olmalı (header alanı ayrılmış)
let page2_first_y = result.pages[1].elements.first().map(|e| e.y_mm).unwrap_or(0.0); let page2_first_y = result.pages[1]
.elements
.first()
.map(|e| e.y_mm)
.unwrap_or(0.0);
// Header tekrarlanıyorsa page 2'de header yüksekliği kadar shift var // Header tekrarlanıyorsa page 2'de header yüksekliği kadar shift var
assert!( assert!(
page2_first_y > 0.0 || has_header_clone, page2_first_y > 0.0 || has_header_clone,
@@ -342,36 +377,40 @@ fn test_2_1_repeat_header_true_repeats_on_second_page() {
#[test] #[test]
fn test_2_2_table_column_format_currency() { fn test_2_2_table_column_format_currency() {
let mut tpl = base_template(); let mut tpl = base_template();
tpl.root.children.push(TemplateElement::RepeatingTable(RepeatingTableElement { tpl.root
id: "tbl_fmt".to_string(), .children
position: PositionMode::Flow, .push(TemplateElement::RepeatingTable(RepeatingTableElement {
size: SizeConstraint { id: "tbl_fmt".to_string(),
width: SizeValue::Fr { value: 1.0 }, position: PositionMode::Flow,
height: SizeValue::Auto, size: SizeConstraint {
..Default::default()
},
data_source: ArrayBinding { path: "items".to_string() },
columns: vec![
TableColumn {
id: "col_name".to_string(),
field: "name".to_string(),
title: "Ürün".to_string(),
width: SizeValue::Fr { value: 1.0 }, width: SizeValue::Fr { value: 1.0 },
align: "left".to_string(), height: SizeValue::Auto,
format: None, ..Default::default()
}, },
TableColumn { data_source: ArrayBinding {
id: "col_price".to_string(), path: "items".to_string(),
field: "price".to_string(),
title: "Fiyat".to_string(),
width: SizeValue::Fixed { value: 30.0 },
align: "right".to_string(),
format: Some("currency".to_string()),
}, },
], columns: vec![
style: TableStyle::default(), TableColumn {
repeat_header: Some(true), id: "col_name".to_string(),
})); field: "name".to_string(),
title: "Ürün".to_string(),
width: SizeValue::Fr { value: 1.0 },
align: "left".to_string(),
format: None,
},
TableColumn {
id: "col_price".to_string(),
field: "price".to_string(),
title: "Fiyat".to_string(),
width: SizeValue::Fixed { value: 30.0 },
align: "right".to_string(),
format: Some("currency".to_string()),
},
],
style: TableStyle::default(),
repeat_header: Some(true),
}));
let data = serde_json::json!({ let data = serde_json::json!({
"items": [ "items": [
@@ -431,7 +470,11 @@ fn test_2_3_rounded_rectangle_renders() {
let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap(); let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap();
// Shape element mevcut olmalı // Shape element mevcut olmalı
let el = layout.pages[0].elements.iter().find(|e| e.id == "rounded_shape").unwrap(); let el = layout.pages[0]
.elements
.iter()
.find(|e| e.id == "rounded_shape")
.unwrap();
assert_eq!(el.element_type, "shape"); assert_eq!(el.element_type, "shape");
assert_eq!(el.style.border_radius, Some(5.0)); assert_eq!(el.style.border_radius, Some(5.0));
@@ -448,17 +491,22 @@ fn test_2_3_container_border_radius_renders() {
tpl.root.style.border_color = Some("#333".to_string()); tpl.root.style.border_color = Some("#333".to_string());
tpl.root.style.border_width = Some(0.5); tpl.root.style.border_width = Some(0.5);
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement { tpl.root
id: "text_in_rounded".to_string(), .children
position: PositionMode::Flow, .push(TemplateElement::StaticText(StaticTextElement {
size: SizeConstraint { id: "text_in_rounded".to_string(),
width: SizeValue::Fr { value: 1.0 }, position: PositionMode::Flow,
height: SizeValue::Auto, size: SizeConstraint {
..Default::default() width: SizeValue::Fr { value: 1.0 },
}, height: SizeValue::Auto,
style: TextStyle { font_size: Some(12.0), ..Default::default() }, ..Default::default()
content: "Rounded container".to_string(), },
})); style: TextStyle {
font_size: Some(12.0),
..Default::default()
},
content: "Rounded container".to_string(),
}));
let fonts = load_test_fonts(); let fonts = load_test_fonts();
let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap(); let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap();
@@ -499,7 +547,8 @@ fn test_2_7_format_config_custom() {
currency_symbol: "$".to_string(), currency_symbol: "$".to_string(),
currency_position: "prefix".to_string(), currency_position: "prefix".to_string(),
}; };
let formatted = dreport_layout::expr_eval::apply_format_with_config("18880", Some("currency"), &config); let formatted =
dreport_layout::expr_eval::apply_format_with_config("18880", Some("currency"), &config);
assert_eq!(formatted, "$18,880.00"); assert_eq!(formatted, "$18,880.00");
} }
@@ -511,7 +560,8 @@ fn test_2_7_format_config_number() {
currency_symbol: "".to_string(), currency_symbol: "".to_string(),
currency_position: "suffix".to_string(), currency_position: "suffix".to_string(),
}; };
let formatted = dreport_layout::expr_eval::apply_format_with_config("1234567", Some("number"), &config); let formatted =
dreport_layout::expr_eval::apply_format_with_config("1234567", Some("number"), &config);
assert_eq!(formatted, "1 234 567"); assert_eq!(formatted, "1 234 567");
} }

View File

@@ -1,7 +1,7 @@
//! Integration tests for the layout engine's compute_layout() public API. //! Integration tests for the layout engine's compute_layout() public API.
use dreport_core::models::*; use dreport_core::models::*;
use dreport_layout::{compute_layout, LayoutResult}; use dreport_layout::{LayoutResult, compute_layout};
mod common; mod common;
use common::load_test_fonts; use common::load_test_fonts;
@@ -205,11 +205,7 @@ fn test_compute_layout_with_data_binding() {
let result = compute_layout(&template, &data, &fonts).unwrap(); let result = compute_layout(&template, &data, &fonts).unwrap();
let page = &result.pages[0]; let page = &result.pages[0];
let bound = page let bound = page.elements.iter().find(|e| e.id == "bound_text").unwrap();
.elements
.iter()
.find(|e| e.id == "bound_text")
.unwrap();
match &bound.content { match &bound.content {
Some(dreport_layout::ResolvedContent::Text { value }) => { Some(dreport_layout::ResolvedContent::Text { value }) => {
assert_eq!(value, "Acme Corp"); assert_eq!(value, "Acme Corp");

View File

@@ -66,10 +66,7 @@ fn test_render_pdf_produces_valid_output() {
let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap(); let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
// PDF should not be empty // PDF should not be empty
assert!( assert!(!pdf_bytes.is_empty(), "PDF output should not be empty");
!pdf_bytes.is_empty(),
"PDF output should not be empty"
);
// PDF should start with %PDF magic bytes // PDF should start with %PDF magic bytes
assert!( assert!(
@@ -239,7 +236,10 @@ fn test_page_break_produces_multiple_pages() {
let template = Template { let template = Template {
id: "pb_test".to_string(), id: "pb_test".to_string(),
name: "Page Break Test".to_string(), name: "Page Break Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 }, page: PageSettings {
width: 210.0,
height: 297.0,
},
fonts: vec!["Noto Sans".to_string()], fonts: vec!["Noto Sans".to_string()],
header: None, header: None,
footer: None, footer: None,
@@ -250,7 +250,12 @@ fn test_page_break_produces_multiple_pages() {
size: SizeConstraint::default(), size: SizeConstraint::default(),
direction: "column".to_string(), direction: "column".to_string(),
gap: 5.0, gap: 5.0,
padding: Padding { top: 15.0, right: 15.0, bottom: 15.0, left: 15.0 }, padding: Padding {
top: 15.0,
right: 15.0,
bottom: 15.0,
left: 15.0,
},
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
@@ -259,16 +264,32 @@ fn test_page_break_produces_multiple_pages() {
TemplateElement::StaticText(StaticTextElement { TemplateElement::StaticText(StaticTextElement {
id: "t1".to_string(), id: "t1".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, height: SizeValue::Auto, ..Default::default() }, size: SizeConstraint {
style: TextStyle { font_size: Some(18.0), ..Default::default() }, width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(18.0),
..Default::default()
},
content: "Page 1 content".to_string(), content: "Page 1 content".to_string(),
}), }),
TemplateElement::PageBreak(PageBreakElement { id: "pb1".to_string() }), TemplateElement::PageBreak(PageBreakElement {
id: "pb1".to_string(),
}),
TemplateElement::StaticText(StaticTextElement { TemplateElement::StaticText(StaticTextElement {
id: "t2".to_string(), id: "t2".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, height: SizeValue::Auto, ..Default::default() }, size: SizeConstraint {
style: TextStyle { font_size: Some(18.0), ..Default::default() }, width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(18.0),
..Default::default()
},
content: "Page 2 content".to_string(), content: "Page 2 content".to_string(),
}), }),
], ],
@@ -277,32 +298,43 @@ fn test_page_break_produces_multiple_pages() {
let data = serde_json::json!({}); let data = serde_json::json!({});
let fonts = load_test_fonts(); let fonts = load_test_fonts();
let layout = compute_layout(&template, &data, &fonts).unwrap(); let layout = compute_layout(&template, &data, &fonts).unwrap();
println!("Layout pages: {}", layout.pages.len()); println!("Layout pages: {}", layout.pages.len());
for (i, page) in layout.pages.iter().enumerate() { for (i, page) in layout.pages.iter().enumerate() {
println!("Page {}: {} elements", i, page.elements.len()); println!("Page {}: {} elements", i, page.elements.len());
for el in &page.elements { for el in &page.elements {
println!(" - {} (type={}, y={:.1}mm, h={:.1}mm)", el.id, el.element_type, el.y_mm, el.height_mm); println!(
" - {} (type={}, y={:.1}mm, h={:.1}mm)",
el.id, el.element_type, el.y_mm, el.height_mm
);
} }
} }
assert_eq!(layout.pages.len(), 2, "Page break should produce 2 pages"); assert_eq!(layout.pages.len(), 2, "Page break should produce 2 pages");
// Verify page 1 has t1 and page 2 has t2 // Verify page 1 has t1 and page 2 has t2
let p1_ids: Vec<&str> = layout.pages[0].elements.iter().map(|e| e.id.as_str()).collect(); let p1_ids: Vec<&str> = layout.pages[0]
let p2_ids: Vec<&str> = layout.pages[1].elements.iter().map(|e| e.id.as_str()).collect(); .elements
.iter()
.map(|e| e.id.as_str())
.collect();
let p2_ids: Vec<&str> = layout.pages[1]
.elements
.iter()
.map(|e| e.id.as_str())
.collect();
println!("Page 1 IDs: {:?}", p1_ids); println!("Page 1 IDs: {:?}", p1_ids);
println!("Page 2 IDs: {:?}", p2_ids); println!("Page 2 IDs: {:?}", p2_ids);
assert!(p1_ids.contains(&"t1"), "Page 1 should contain t1"); assert!(p1_ids.contains(&"t1"), "Page 1 should contain t1");
assert!(p2_ids.contains(&"t2"), "Page 2 should contain t2"); assert!(p2_ids.contains(&"t2"), "Page 2 should contain t2");
// Render PDF and verify it's valid // Render PDF and verify it's valid
let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap(); let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
assert!(pdf_bytes.starts_with(b"%PDF")); assert!(pdf_bytes.starts_with(b"%PDF"));
// Write PDF to temp dir for manual inspection // Write PDF to temp dir for manual inspection
let out_path = std::env::temp_dir().join("dreport_test_page_break.pdf"); let out_path = std::env::temp_dir().join("dreport_test_page_break.pdf");
std::fs::write(&out_path, &pdf_bytes).unwrap(); std::fs::write(&out_path, &pdf_bytes).unwrap();

View File

@@ -15,8 +15,8 @@ mod visual {
use std::process::Command; use std::process::Command;
use dreport_core::models::Template; use dreport_core::models::Template;
use dreport_layout::{compute_layout, ResolvedContent};
use dreport_layout::pdf_render::render_pdf; use dreport_layout::pdf_render::render_pdf;
use dreport_layout::{ResolvedContent, compute_layout};
use crate::common::load_test_fonts; use crate::common::load_test_fonts;
@@ -101,11 +101,10 @@ mod visual {
for (a, r) in actual_rgba.pixels().zip(reference_rgba.pixels()) { for (a, r) in actual_rgba.pixels().zip(reference_rgba.pixels()) {
// Allow per-channel tolerance of 2 for font rendering differences // Allow per-channel tolerance of 2 for font rendering differences
let channel_diff = a let channel_diff =
.0 a.0.iter()
.iter() .zip(r.0.iter())
.zip(r.0.iter()) .any(|(ac, rc)| (*ac as i32 - *rc as i32).unsigned_abs() > 2);
.any(|(ac, rc)| (*ac as i32 - *rc as i32).unsigned_abs() > 2);
if channel_diff { if channel_diff {
diff_pixels += 1; diff_pixels += 1;
} }
@@ -181,7 +180,9 @@ mod visual {
let layout = compute_layout(&template, &data, &fonts).unwrap(); let layout = compute_layout(&template, &data, &fonts).unwrap();
let mut html = String::from("<!DOCTYPE html><html><head><style>body{margin:20px;font-family:sans-serif;background:#f5f5f5}.chart-box{margin:10px 0;background:white;box-shadow:0 1px 3px rgba(0,0,0,.1)}</style></head><body><h2>Chart SVG Preview (HTML render)</h2>"); let mut html = String::from(
"<!DOCTYPE html><html><head><style>body{margin:20px;font-family:sans-serif;background:#f5f5f5}.chart-box{margin:10px 0;background:white;box-shadow:0 1px 3px rgba(0,0,0,.1)}</style></head><body><h2>Chart SVG Preview (HTML render)</h2>",
);
for page in &layout.pages { for page in &layout.pages {
for el in &page.elements { for el in &page.elements {
@@ -212,9 +213,21 @@ mod visual {
#[ignore] #[ignore]
fn generate_cross_renderer_refs() { fn generate_cross_renderer_refs() {
let fixtures = [ let fixtures = [
("visual_test_template.json", "visual_test_data.json", "visual_test"), (
("chart_test_template.json", "chart_test_data.json", "chart_test"), "visual_test_template.json",
("comprehensive_test_template.json", "comprehensive_test_data.json", "comprehensive_test"), "visual_test_data.json",
"visual_test",
),
(
"chart_test_template.json",
"chart_test_data.json",
"chart_test",
),
(
"comprehensive_test_template.json",
"comprehensive_test_data.json",
"comprehensive_test",
),
]; ];
let out_dir = cross_renderer_dir(); let out_dir = cross_renderer_dir();
@@ -222,7 +235,11 @@ mod visual {
for (template_file, data_file, name) in &fixtures { for (template_file, data_file, name) in &fixtures {
let pdf_bytes = generate_test_pdf(template_file, data_file); let pdf_bytes = generate_test_pdf(template_file, data_file);
assert!(!pdf_bytes.is_empty(), "PDF should not be empty for {}", name); assert!(
!pdf_bytes.is_empty(),
"PDF should not be empty for {}",
name
);
let png_path = out_dir.join(format!("{}.png", name)); let png_path = out_dir.join(format!("{}.png", name));
if !pdf_to_png(&pdf_bytes, &png_path) { if !pdf_to_png(&pdf_bytes, &png_path) {
@@ -234,7 +251,11 @@ mod visual {
#[test] #[test]
fn test_visual_snapshot_basic() { fn test_visual_snapshot_basic() {
run_visual_test("visual_test_template.json", "visual_test_data.json", "visual_test"); run_visual_test(
"visual_test_template.json",
"visual_test_data.json",
"visual_test",
);
} }
#[test] #[test]
@@ -252,10 +273,18 @@ mod visual {
// SVG HTML ciktisini kaydet (karsilastirma icin) // SVG HTML ciktisini kaydet (karsilastirma icin)
let html_path = snap_dir.join("chart_test_svg.html"); let html_path = snap_dir.join("chart_test_svg.html");
generate_chart_svg_html("chart_test_template.json", "chart_test_data.json", &html_path); generate_chart_svg_html(
"chart_test_template.json",
"chart_test_data.json",
&html_path,
);
println!("Chart SVG HTML saved to {:?}", html_path); println!("Chart SVG HTML saved to {:?}", html_path);
// Visual regression test // Visual regression test
run_visual_test("chart_test_template.json", "chart_test_data.json", "chart_test"); run_visual_test(
"chart_test_template.json",
"chart_test_data.json",
"chart_test",
);
} }
} }