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
components: rustfmt, clippy
- name: Format check
run: cargo fmt --workspace --check
run: cargo fmt --all --check
- name: Clippy
run: cargo clippy --workspace -- -D warnings
- name: Test

View File

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

View File

@@ -14,7 +14,8 @@ async fn main() -> anyhow::Result<()> {
println!("Font registry başlatılıyor...");
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);
let cors = CorsLayer::new()

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<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'
const props = defineProps<{
@@ -231,11 +231,7 @@ watch(
<img
v-if="el.content?.type === 'image' && el.content.src"
:src="el.content.src"
:style="{
width: '100%',
height: '100%',
objectFit: el.style.objectFit || 'fill',
}"
:style="{ width: '100%', height: '100%', objectFit: (el.style.objectFit || 'fill') as CSSProperties['objectFit'] }"
/>
<div v-else class="layout-el__placeholder">Görsel</div>
</div>

View File

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

View File

@@ -55,19 +55,21 @@ pub fn generate_barcode_pixels(
// Metin alanı hesapla (QR hariç, include_text true ise)
let text_area_h = if !is_qr && include_text {
(req_h / 5).max(16).min(48)
(req_h / 5).clamp(16, 48)
} else {
0
};
let bar_h = req_h - text_area_h;
let mut hints = EncodeHints::default();
hints.Margin = Some("1".to_string());
let mut hints = EncodeHints {
Margin: Some("1".to_string()),
..Default::default()
};
if is_qr {
hints.ErrorCorrection = Some("M".to_string());
}
let writer = rxing::MultiFormatWriter::default();
let writer = rxing::MultiFormatWriter;
let matrix = writer
.encode_with_hints(value, &bc_format, req_w as i32, bar_h as i32, &hints)
.map_err(|e| format!("Barcode encode hatası ({format}): {e}"))?;
@@ -91,10 +93,22 @@ pub fn generate_barcode_pixels(
// Metin rendering
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
@@ -161,7 +175,8 @@ fn render_text_cosmic(
for glyph in run.glyphs.iter() {
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;
};
@@ -179,13 +194,19 @@ fn render_text_cosmic(
}
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];
if alpha == 0 { continue; }
if alpha == 0 {
continue;
}
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
let bg = pixels[dst_idx] as f32;
@@ -207,7 +228,8 @@ pub fn generate_barcode_png(
include_text: bool,
font_data: Option<&[FontData]>,
) -> 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)
.ok_or_else(|| "Pixel buffer boyutu uyumsuz".to_string())?;
@@ -231,20 +253,23 @@ mod tests {
#[test]
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);
}
#[test]
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.height > 0);
}
#[test]
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.height > 0);
}
@@ -253,13 +278,18 @@ mod tests {
#[test]
fn test_ean13_with_font_rendering() {
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.height > 0);
// Metin alanında siyah pikseller olmalı (font rendering çalıştı)
let text_start = (result.height - result.height / 5) * result.width;
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"))]
@@ -273,7 +303,8 @@ mod tests {
#[test]
fn test_ean13_png_with_text() {
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']));
}

View File

@@ -226,34 +226,90 @@ pub trait ChartDataSource {
// ---------------------------------------------------------------------------
impl ChartDataSource for crate::data_resolve::ResolvedChartData {
fn chart_type(&self) -> ChartType { self.chart_type.clone() }
fn categories(&self) -> &[String] { &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> {
self.title.as_ref().map(|t| t.text.as_str()).filter(|t| !t.is_empty())
fn chart_type(&self) -> ChartType {
self.chart_type.clone()
}
fn categories(&self) -> &[String] {
&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> {
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 {
fn chart_type(&self) -> ChartType { self.chart_type.clone() }
fn categories(&self) -> &[String] { &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 chart_type(&self) -> ChartType {
self.chart_type.clone()
}
fn categories(&self) -> &[String] {
&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> {
self.title_text.as_deref().filter(|t| !t.is_empty())
}
fn title_font_size(&self) -> Option<f64> { self.title_font_size }
fn title_color(&self) -> Option<&str> { self.title_color.as_deref() }
fn title_align(&self) -> Option<&str> { self.title_align.as_deref() }
fn legend_show(&self) -> bool { self.legend_show }
fn legend_position(&self) -> Option<&str> { 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 title_font_size(&self) -> Option<f64> {
self.title_font_size
}
fn title_color(&self) -> Option<&str> {
self.title_color.as_deref()
}
fn title_align(&self) -> Option<&str> {
self.title_align.as_deref()
}
fn legend_show(&self) -> bool {
self.legend_show
}
fn legend_position(&self) -> Option<&str> {
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();
(0..n_colors)
.map(|i| {
if let Some(uc) = user_colors {
if i < uc.len() {
return uc[i].clone();
}
if let Some(uc) = user_colors
&& i < uc.len()
{
return uc[i].clone();
}
DEFAULT_COLORS[i % DEFAULT_COLORS.len()].to_string()
})
@@ -392,7 +500,14 @@ pub fn compute_chart_layout(
_ => origin_x + width_mm / 2.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 {
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 n_cats = data.categories().len();
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 will_rotate = max_label_len > max_chars_fit;
if will_rotate {
let char_w_mm = 1.1;
let max_text_w = max_label_len as f64 * char_w_mm;
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 extra_left = (label_h - cat_width / 2.0).max(0.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);
ChartLayout {
plot_x, plot_y, plot_w, plot_h,
margin_top, margin_bottom, margin_left, margin_right,
palette, title, legend_show, legend_pos, legend_font,
plot_x,
plot_y,
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.
#[allow(clippy::too_many_arguments)]
pub fn compute_y_axis(
min_val: f64, max_val: f64,
px: f64, py: f64, pw: f64, ph: f64,
show_grid: bool, grid_color: &str,
min_val: f64,
max_val: f64,
px: f64,
py: f64,
pw: f64,
ph: f64,
show_grid: bool,
grid_color: &str,
) -> YAxisLayout {
let range = safe_range(min_val, max_val);
let tick_count = 5;
@@ -466,7 +601,11 @@ pub fn compute_y_axis(
let frac = i as f64 / tick_count as f64;
let val = min_val + frac * range;
let y = py + ph - frac * ph;
YTick { value: val, label: format_value(val), y }
YTick {
value: val,
label: format_value(val),
y,
}
})
.collect();
@@ -482,38 +621,78 @@ pub fn compute_y_axis(
}
/// 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();
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 max_chars = (cat_width / 1.25).max(1.0) as usize;
let needs_rotate = categories.iter().any(|c| c.len() > max_chars);
let labels = categories.iter().enumerate().map(|(ci, cat)| {
XLabel {
let labels = categories
.iter()
.enumerate()
.map(|(ci, cat)| XLabel {
text: cat.clone(),
x: px + ci as f64 * cat_width + cat_width / 2.0,
y: baseline_y + 2.5,
}
}).collect();
XLabelLayout { labels, needs_rotate }
})
.collect();
XLabelLayout {
labels,
needs_rotate,
}
}
/// 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();
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 needs_rotate = categories.iter().any(|c| c.len() > max_chars);
let labels = categories.iter().enumerate().map(|(ci, cat)| {
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 }
let labels = categories
.iter()
.enumerate()
.map(|(ci, cat)| {
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).
@@ -565,7 +744,11 @@ pub fn compute_bar_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> BarCh
y_offset += bar_h;
}
} 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 {
let val = data.series_values(si).get(ci).copied().unwrap_or(0.0);
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);
BarChartLayout {
min_val, max_val,
y_axis, x_labels, bars,
show_labels, label_font, label_color, stacked,
min_val,
max_val,
y_axis,
x_labels,
bars,
show_labels,
label_font,
label_color,
stacked,
x_axis_y: py + ph,
x_axis_x1: px,
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_color = data.label_color().unwrap_or("#333").to_string();
let series = (0..data.series_count()).map(|si| {
let values = data.series_values(si);
let points = values.iter().enumerate().map(|(ci, val)| {
let x = if n_cats == 1 { 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 series = (0..data.series_count())
.map(|si| {
let values = data.series_values(si);
let points = values
.iter()
.enumerate()
.map(|(ci, val)| {
let x = if n_cats == 1 {
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);
LineChartLayout {
min_val, max_val,
y_axis, x_labels, series,
line_width: line_width,
show_points, show_labels, label_font, label_color,
min_val,
max_val,
y_axis,
x_labels,
series,
line_width,
show_points,
show_labels,
label_font,
label_color,
x_axis_y: py + ph,
x_axis_x1: px,
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 {
data.series_values(0).to_vec()
} else {
data.categories().iter().enumerate().map(|(ci, _)| {
(0..data.series_count())
.map(|si| data.series_values(si).get(ci).copied().unwrap_or(0.0))
.sum()
}).collect()
data.categories()
.iter()
.enumerate()
.map(|(ci, _)| {
(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();
@@ -685,7 +897,11 @@ pub fn compute_pie_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> PieCh
let mid_angle = start_angle + sweep / 2.0;
// 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 ly = cy + label_r * mid_angle.sin();
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 cat_lx = cx + text_r * mid_angle.cos();
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;
slices.push(PieSlice {
start_angle, end_angle, sweep,
start_angle,
end_angle,
sweep,
color_idx: i,
value: *val,
fraction: val / total,
label_x: lx, label_y: ly,
label_x: lx,
label_y: ly,
label_text: format!("{}%", pct),
leader_start_x: leader_sx, leader_start_y: leader_sy,
leader_end_x: leader_ex, leader_end_y: leader_ey,
cat_label_x: cat_lx, cat_label_y: cat_ly,
leader_start_x: leader_sx,
leader_start_y: leader_sy,
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_anchor_end: anchor_end,
});
@@ -723,8 +949,14 @@ pub fn compute_pie_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> PieCh
}
PieChartLayout {
cx, cy, radius, inner_radius: inner_r,
slices, show_labels, label_font, label_color,
cx,
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 {
data.categories().to_vec()
} 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();
@@ -758,9 +992,12 @@ pub fn compute_legend(
let mut x = origin_x + cl.margin_left;
for (i, name) in names.iter().enumerate() {
items.push(LegendItemLayout {
name: name.clone(), color_idx: i,
swatch_x: x, swatch_y: y - font_size * 0.3,
text_x: x + item_gap, text_y: y + font_size * 0.3,
name: name.clone(),
color_idx: i,
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;
}
@@ -770,9 +1007,12 @@ pub fn compute_legend(
let mut y = origin_y + cl.margin_top + 2.0;
for (i, name) in names.iter().enumerate() {
items.push(LegendItemLayout {
name: name.clone(), color_idx: i,
swatch_x: x, swatch_y: y,
text_x: x + item_gap, text_y: y + font_size * 0.7,
name: name.clone(),
color_idx: i,
swatch_x: x,
swatch_y: y,
text_x: x + item_gap,
text_y: y + font_size * 0.7,
});
y += font_size + 2.0;
}
@@ -780,20 +1020,30 @@ pub fn compute_legend(
_ => {
// bottom (default)
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)
.sum::<f64>() - spacing;
.sum::<f64>()
- spacing;
let mut x = origin_x + (total_w - total_legend_w) / 2.0;
for (i, name) in names.iter().enumerate() {
items.push(LegendItemLayout {
name: name.clone(), color_idx: i,
swatch_x: x, swatch_y: y - font_size * 0.3,
text_x: x + item_gap, text_y: y + font_size * 0.3,
name: name.clone(),
color_idx: i,
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;
}
}
}
LegendLayout { items, font_size, position, swatch_size }
LegendLayout {
items,
font_size,
position,
swatch_size,
}
}

View File

@@ -1,6 +1,6 @@
use crate::chart_layout::{
self, color_at, compute_bar_layout, compute_chart_layout, compute_legend,
compute_line_layout, compute_pie_layout, format_value, ChartLayout,
self, ChartLayout, color_at, compute_bar_layout, compute_chart_layout, compute_legend,
compute_line_layout, compute_pie_layout, format_value,
};
use crate::data_resolve::ResolvedChartData;
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 {
let mut svg = String::with_capacity(4096);
let bg = data
.style
.background_color
.as_deref()
.unwrap_or("#FFFFFF");
let bg = data.style.background_color.as_deref().unwrap_or("#FFFFFF");
write!(
svg,
@@ -58,28 +54,26 @@ pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> St
// Axis labels
let has_axis = !matches!(data.chart_type, dreport_core::models::ChartType::Pie);
if has_axis {
if let Some(ref axis) = data.axis {
if let Some(ref x_label) = axis.x_label {
let x = cl.plot_x + cl.plot_w / 2.0;
let y = height_mm - 2.0;
write!(
if has_axis && let Some(ref axis) = data.axis {
if let Some(ref x_label) = axis.x_label {
let x = cl.plot_x + cl.plot_w / 2.0;
let y = height_mm - 2.0;
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.8" fill="#666" text-anchor="middle">{}</text>"##,
x, y, escape_xml(x_label)
)
.unwrap();
}
if let Some(ref y_label) = axis.y_label {
let x = 3.0;
let y = cl.plot_y + cl.plot_h / 2.0;
write!(
}
if let Some(ref y_label) = axis.y_label {
let x = 3.0;
let y = cl.plot_y + cl.plot_h / 2.0;
write!(
svg,
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)
)
.unwrap();
}
}
}
@@ -212,7 +206,11 @@ fn render_pie(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
for slice in &pl.slices {
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 y1 = cy + radius * slice.start_angle.sin();
@@ -262,7 +260,11 @@ fn render_pie(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
)
.unwrap();
let anchor = if slice.cat_label_anchor_end { "end" } else { "start" };
let anchor = if slice.cat_label_anchor_end {
"end"
} else {
"start"
};
write!(
svg,
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);
for item in &legend.items {
@@ -287,7 +295,10 @@ fn render_legend(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout, t
write!(
svg,
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();
}

View File

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

View File

@@ -59,7 +59,10 @@ fn dexpr_value_to_string(val: &DexprValue) -> String {
format!("[{}]", items.join(", "))
}
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(", "))
}
}
@@ -67,11 +70,19 @@ fn dexpr_value_to_string(val: &DexprValue) -> String {
/// Format result with given format type (varsayılan Türk formatı)
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
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 {
Some("currency") => format_currency(value, config),
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
// 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
let truncated = rounded.trunc();
let frac_part = rounded - truncated;
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 sign = if d.is_sign_negative() { "-" } else { "" };
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 {
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();
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(ch);
@@ -157,26 +180,38 @@ mod tests {
#[test]
fn test_arithmetic() {
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]
fn test_multiplication() {
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]
fn test_string_concat() {
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]
fn test_ternary() {
let data = json!({"fatura": {"tutar": 5000}});
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"
);
}
@@ -185,7 +220,10 @@ mod tests {
fn test_ternary_false() {
let data = json!({"fatura": {"tutar": 0}});
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"
);
}
@@ -214,7 +252,10 @@ mod tests {
fn test_numeric_comparison() {
let data = json!({"fatura": {"tutar": 5000}});
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"
);
}

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`.
fn decode_utf16be(raw: &[u8]) -> Option<String> {
if raw.len() % 2 != 0 {
if !raw.len().is_multiple_of(2) {
return None;
}
let code_units: Vec<u16> = raw

View File

@@ -1,5 +1,5 @@
use crate::font_meta::FontFamilyInfo;
use crate::FontData;
use crate::font_meta::FontFamilyInfo;
/// Font resolution trait — host apps implement this to provide fonts.
/// 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();
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 {
if let Some(fd) = self.load_font(&info.family, variant.weight, variant.italic) {
result.push(fd);

View File

@@ -1,10 +1,10 @@
pub mod sizing;
pub mod text_measure;
pub mod data_resolve;
pub mod table_layout;
pub mod tree;
pub mod page_break;
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")]
pub mod wasm_api;
@@ -104,7 +104,7 @@ pub enum ResolvedContent {
svg: String,
/// PDF render icin chart verisi (frontend bunu kullanmaz)
#[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).
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 {

View File

@@ -109,7 +109,12 @@ pub fn split_into_pages(input: PageSplitInput) -> Vec<PageLayout> {
// Page number çözümleme
let total = pages.len();
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
@@ -197,11 +202,7 @@ fn build_avoid_groups(
groups
}
fn collect_descendants(
parent_id: &str,
elements: &[ElementLayout],
result: &mut HashSet<String>,
) {
fn collect_descendants(parent_id: &str, elements: &[ElementLayout], result: &mut HashSet<String>) {
// children alanından recursive olarak topla
for el in elements {
if el.id == parent_id {
@@ -380,12 +381,15 @@ fn split_elements(
// Tablo satırı mı? Header tekrarı gerekebilir
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(info) = table_info.get(&table_id) {
// Yeni sayfada bu tablonun header'ını tekrarla
table_header_to_add =
Some((table_id.clone(), info.header_elements.clone(), info.header_height_mm));
}
if let Some((table_id, _row_idx)) = detect_table_row(&el.id)
&& let Some(info) = table_info.get(&table_id)
{
// Yeni sayfada bu tablonun header'ını tekrarla
table_header_to_add = Some((
table_id.clone(),
info.header_elements.clone(),
info.header_height_mm,
));
}
page_top = el_top;
@@ -435,6 +439,7 @@ fn split_elements(
pages
}
#[allow(clippy::too_many_arguments)]
fn assemble_page(
page_index: usize,
body_elements: &[ElementLayout],
@@ -461,7 +466,11 @@ fn assemble_page(
// 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)
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 {
let mut adjusted = el.clone();
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"
let pn2 = pages[1]
.elements
.iter()
.find(|e| e.id == "pn_p1")
.unwrap();
let pn2 = pages[1].elements.iter().find(|e| e.id == "pn_p1").unwrap();
if let Some(ResolvedContent::Text { value }) = &pn2.content {
assert_eq!(value, "2 / 2");
} else {
@@ -933,7 +938,7 @@ mod tests {
.collect();
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)
tbl_wrapper,
tbl_header,

View File

@@ -3,13 +3,13 @@
use std::collections::HashMap;
use krilla::Document;
use krilla::color::rgb;
use krilla::geom::{PathBuilder, Point, Size, Transform};
use krilla::num::NormalizedF32;
use krilla::page::PageSettings;
use krilla::paint::{Fill, Stroke};
use krilla::text::{Font as KrillaFont, TextDirection};
use krilla::Document;
use crate::text_measure::TextMeasurer;
use crate::{ElementLayout, FontData, LayoutResult, PageLayout, ResolvedContent, ResolvedStyle};
@@ -126,10 +126,7 @@ impl FontCollection {
let mut metrics = HashMap::new();
for fd in font_data {
let Some(font) = KrillaFont::new(
krilla::Data::from(fd.data.clone()),
0,
) else {
let Some(font) = KrillaFont::new(krilla::Data::from(fd.data.clone()), 0) else {
continue;
};
@@ -146,10 +143,13 @@ impl FontCollection {
if let Some(meta) = crate::font_meta::parse_font_meta(&fd.data) {
let units_per_em = meta.units_per_em;
if units_per_em > 0 {
metrics.insert((family_lower.clone(), is_bold), FontMetrics {
ascender: meta.ascender as f32 / units_per_em as f32,
descender: meta.descender.unsigned_abs() as f32 / units_per_em as f32,
});
metrics.insert(
(family_lower.clone(), is_bold),
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
if default.is_none() {
if let Some(fd) = font_data.first() {
default = KrillaFont::new(krilla::Data::from(fd.data.clone()), 0);
}
if default.is_none()
&& let Some(fd) = font_data.first()
{
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_italic = matches!(font_style, Some("italic"));
let family_lower = family.unwrap_or("noto sans").to_lowercase();
@@ -188,7 +197,8 @@ impl FontCollection {
let is_bold = matches!(weight, Some("bold"));
let family_lower = family.unwrap_or("noto sans").to_lowercase();
let m = self.metrics
let m = self
.metrics
.get(&(family_lower.clone(), is_bold))
.or_else(|| self.metrics.get(&(family_lower, false)))
.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)?;
}
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(
@@ -332,9 +343,9 @@ fn render_shape(
let rect_radius = |s: &ResolvedStyle| -> f32 {
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 {
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 {
"ellipse" => build_ellipse_path(
x + inset, y + inset,
w - border_width, h - border_width,
),
"ellipse" => {
build_ellipse_path(x + inset, y + inset, w - border_width, h - border_width)
}
_ => {
let radius = rect_radius(style);
build_rect_path(
x + inset, y + inset,
w - border_width, h - border_width,
x + inset,
y + inset,
w - border_width,
h - border_width,
(radius - inset).max(0.0),
)
}
@@ -416,8 +428,10 @@ fn render_checkbox(
}));
if let Some(p) = build_rect_path(
x + inset, y + inset,
w - border_width, h - border_width,
x + inset,
y + inset,
w - border_width,
h - border_width,
0.0,
) {
surface.draw_path(&p);
@@ -469,7 +483,7 @@ fn render_container_bg(
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 {
let border_width = mm(style.border_width.unwrap_or(0.5));
@@ -490,8 +504,10 @@ fn render_container_bg(
..Default::default()
}));
if let Some(path) = build_rect_path(
x + inset, y + inset,
w - border_width, h - border_width,
x + inset,
y + inset,
w - border_width,
h - border_width,
(radius - inset).max(0.0),
) {
surface.draw_path(&path);
@@ -511,6 +527,7 @@ fn render_container_bg(
surface.set_stroke(None);
}
#[allow(clippy::too_many_arguments)]
fn render_text(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
@@ -589,6 +606,7 @@ fn render_text(
}
}
#[allow(clippy::too_many_arguments)]
fn render_rich_text(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
@@ -614,7 +632,10 @@ fn render_rich_text(
let total_width = {
let mut tw = 0.0f32;
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 ff = span.font_family.as_deref().or(default_family);
let (sw, _) = measurer.measure(&span.text, ff, fs, fw, None);
@@ -643,7 +664,10 @@ fn render_rich_text(
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 weight = span.font_weight.as_deref().or(default_weight);
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,...
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;
};
@@ -764,15 +791,13 @@ fn render_image(
ImageFormat::Jpeg => krilla::image::Image::from_jpeg(decoded.into(), true),
ImageFormat::Gif => krilla::image::Image::from_gif(decoded.into(), true),
ImageFormat::WebP => krilla::image::Image::from_webp(decoded.into(), true),
ImageFormat::Unknown => {
match decode_to_png(&decoded) {
Some(png_data) => krilla::image::Image::from_png(png_data.into(), true),
None => {
eprintln!("[dreport] Image decode/re-encode hatası");
return;
}
ImageFormat::Unknown => match decode_to_png(&decoded) {
Some(png_data) => krilla::image::Image::from_png(png_data.into(), true),
None => {
eprintln!("[dreport] Image decode/re-encode hatası");
return;
}
}
},
};
let Ok(img) = img_result else {
@@ -789,6 +814,7 @@ fn render_image(
surface.pop();
}
#[allow(clippy::too_many_arguments)]
fn render_barcode(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
@@ -809,7 +835,14 @@ fn render_barcode(
let h_px = ((h * 4.0) as u32).max(1);
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 {
Ok(png_bytes) => {
@@ -862,6 +895,7 @@ fn embed_png(
surface.pop();
}
#[allow(clippy::too_many_arguments)]
fn render_chart(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
@@ -873,8 +907,8 @@ fn render_chart(
measurer: &mut TextMeasurer,
) {
use crate::chart_layout::{
color_at, compute_bar_layout, compute_chart_layout, compute_legend,
compute_line_layout, compute_pie_layout, format_value,
color_at, compute_bar_layout, compute_chart_layout, compute_legend, compute_line_layout,
compute_pie_layout, format_value,
};
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;
// Background
chart_rect(surface, base_x_mm, base_y_mm, w_mm, h_mm,
parse_color(data.background_color.as_deref().unwrap_or("#FFFFFF")));
chart_rect(
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);
@@ -905,7 +945,11 @@ fn render_chart(
let ty = pt(title.y);
surface.draw_text(
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 bar.value > 0.0 {
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 {
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);
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 => {
let ll = compute_line_layout(data, &cl);
@@ -940,7 +1010,8 @@ fn render_chart(
for series_layout in &ll.series {
let color = parse_color(color_at(&cl.palette, series_layout.color_idx));
// 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_stroke(Some(Stroke {
paint: color.into(),
@@ -951,12 +1022,17 @@ fn render_chart(
let path = {
let mut pb = PathBuilder::new();
for (i, (lx, ly)) in points.iter().enumerate() {
if i == 0 { pb.move_to(pt(*lx), pt(*ly)); }
else { pb.line_to(pt(*lx), pt(*ly)); }
if i == 0 {
pb.move_to(pt(*lx), pt(*ly));
} else {
pb.line_to(pt(*lx), pt(*ly));
}
}
pb.finish()
};
if let Some(p) = path { surface.draw_path(&p); }
if let Some(p) = path {
surface.draw_path(&p);
}
// Points
if ll.show_points {
@@ -977,7 +1053,9 @@ fn render_chart(
pb.close();
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 {
for lp in &series_layout.points {
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);
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 => {
let pl = compute_pie_layout(data, &cl);
@@ -1004,21 +1099,65 @@ fn render_chart(
opacity: NormalizedF32::ONE,
..Default::default()
}));
let path = build_arc_path(pl.cx, pl.cy, pl.radius, pl.inner_radius, slice.start_angle, slice.end_angle);
if let Some(p) = path { surface.draw_path(&p); }
let path = build_arc_path(
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 {
let pct = (slice.fraction * 100.0).round();
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() {
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 {
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 {
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);
for item in &legend.items {
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_text_start(surface, item.text_x, item.text_y, &item.name, legend.font_size, "#666666", fonts, measurer);
chart_rect(
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
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));
surface.set_fill(Some(fill_from_color(color)));
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));
surface.set_fill(None);
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
/// font_size_mm: SVG viewBox'taki mm cinsinden boyut, pt'ye cevrilir
#[allow(clippy::too_many_arguments)]
fn chart_text_centered(
surface: &mut krilla::surface::Surface<'_>,
cx_mm: f64, cy_mm: f64,
text: &str, font_size_mm: f64, color_hex: &str,
fonts: &FontCollection, measurer: &mut TextMeasurer,
cx_mm: f64,
cy_mm: f64,
text: &str,
font_size_mm: f64,
color_hex: &str,
fonts: &FontCollection,
measurer: &mut TextMeasurer,
) {
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 fs_pt = pt(font_size_mm);
let (tw, _) = measurer.measure(text, None, fs_pt, None, None);
@@ -1109,19 +1286,30 @@ fn chart_text_centered(
surface.set_stroke(None);
surface.draw_text(
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)
#[allow(clippy::too_many_arguments)]
fn chart_text_end(
surface: &mut krilla::surface::Surface<'_>,
right_x_mm: f64, cy_mm: f64,
text: &str, font_size_mm: f64, color_hex: &str,
fonts: &FontCollection, measurer: &mut TextMeasurer,
right_x_mm: f64,
cy_mm: f64,
text: &str,
font_size_mm: f64,
color_hex: &str,
fonts: &FontCollection,
measurer: &mut TextMeasurer,
) {
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 fs_pt = pt(font_size_mm);
let (tw, _) = measurer.measure(text, None, fs_pt, None, None);
@@ -1129,26 +1317,41 @@ fn chart_text_end(
surface.set_stroke(None);
surface.draw_text(
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)
#[allow(clippy::too_many_arguments)]
fn chart_text_start(
surface: &mut krilla::surface::Surface<'_>,
x_mm: f64, cy_mm: f64,
text: &str, font_size_mm: f64, color_hex: &str,
fonts: &FontCollection, _measurer: &mut TextMeasurer,
x_mm: f64,
cy_mm: f64,
text: &str,
font_size_mm: f64,
color_hex: &str,
fonts: &FontCollection,
_measurer: &mut TextMeasurer,
) {
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 fs_pt = pt(font_size_mm);
surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None);
surface.draw_text(
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(
surface: &mut krilla::surface::Surface<'_>,
y_axis: &crate::chart_layout::YAxisLayout,
fonts: &FontCollection, measurer: &mut TextMeasurer,
fonts: &FontCollection,
measurer: &mut TextMeasurer,
) {
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 {
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
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
fn render_chart_x_labels(
surface: &mut krilla::surface::Surface<'_>,
x_labels: &crate::chart_layout::XLabelLayout,
fonts: &FontCollection, measurer: &mut TextMeasurer,
fonts: &FontCollection,
measurer: &mut TextMeasurer,
) {
for label in &x_labels.labels {
if x_labels.needs_rotate {
@@ -1182,20 +1412,41 @@ fn render_chart_x_labels(
let c = std::f32::consts::FRAC_PI_4.cos();
let s = std::f32::consts::FRAC_PI_4.sin();
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();
} 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)
fn build_arc_path(
cx: f64, cy: f64,
radius: f64, inner_r: f64,
start: f64, end: f64,
cx: f64,
cy: f64,
radius: f64,
inner_r: f64,
start: f64,
end: f64,
) -> Option<krilla::geom::Path> {
let mut pb = PathBuilder::new();
@@ -1221,12 +1472,7 @@ fn build_arc_path(
}
/// Arc'i cubic bezier segmentleriyle yaklasik ciz (her segment ≤ 90°)
fn approximate_arc(
pb: &mut PathBuilder,
cx: f64, cy: f64,
r: f64,
start: f64, end: f64,
) {
fn approximate_arc(pb: &mut PathBuilder, cx: f64, cy: f64, r: f64, start: f64, end: f64) {
let sweep = end - start;
let n_segs = ((sweep.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).max(1);
let seg_sweep = sweep / n_segs as f64;
@@ -1345,7 +1591,10 @@ mod tests {
let template = Template {
id: "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()],
header: None,
footer: None,
@@ -1356,11 +1605,19 @@ mod tests {
size: SizeConstraint {
width: 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(),
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(),
justify: "start".to_string(),
style: ContainerStyle::default(),
@@ -1372,7 +1629,10 @@ mod tests {
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
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 {
font_size: Some(18.0),
@@ -1387,7 +1647,10 @@ mod tests {
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
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 {
stroke_color: Some("#000000".to_string()),
@@ -1400,7 +1663,10 @@ mod tests {
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
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 {
font_size: Some(11.0),
@@ -1463,7 +1729,10 @@ mod tests {
#[test]
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);
}
}

View File

@@ -65,13 +65,13 @@ pub fn apply_size_to_style(
// Fr → flex_grow (main axis'e göre)
let main_fr = match parent_direction {
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
let cross_fr = match parent_direction {
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
@@ -210,14 +210,22 @@ mod tests {
fn test_mm_to_pt_one_inch() {
// 1 inch = 25.4mm = 72pt
let pt = mm_to_pt(25.4);
assert!((pt - 72.0).abs() < 0.01, "25.4mm should be ~72pt, got {}", pt);
assert!(
(pt - 72.0).abs() < 0.01,
"25.4mm should be ~72pt, got {}",
pt
);
}
#[test]
fn test_pt_to_mm_conversion() {
// 72pt = 25.4mm (1 inch)
let mm = pt_to_mm(72.0);
assert!((mm - 25.4).abs() < 0.01, "72pt should be ~25.4mm, got {}", mm);
assert!(
(mm - 25.4).abs() < 0.01,
"72pt should be ~25.4mm, got {}",
mm
);
}
#[test]
@@ -248,7 +256,10 @@ mod tests {
#[test]
fn test_fixed_size() {
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]
@@ -321,7 +332,12 @@ mod tests {
size: SizeConstraint::default(),
direction: "row".to_string(),
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(),
justify: "space-between".to_string(),
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);
// 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
if !is_auto.iter().any(|&a| a) {
@@ -60,7 +64,10 @@ fn compute_auto_column_widths(
}
// 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)
let mut max_widths_mm = vec![0.0_f64; num_cols];
@@ -87,13 +94,7 @@ fn compute_auto_column_widths(
if text.is_empty() {
continue;
}
let (w_pt, _) = measurer.measure(
text,
None,
font_size as f32,
None,
None,
);
let (w_pt, _) = measurer.measure(text, None, font_size as f32, None, None);
let w_mm = w_pt as f64 / (72.0 / 25.4);
if w_mm > max_widths_mm[col_idx] {
max_widths_mm[col_idx] = w_mm;
@@ -107,10 +108,10 @@ fn compute_auto_column_widths(
// Fixed sütunların kapladığı alanı hesapla
let mut fixed_total_mm = 0.0_f64;
for (col_idx, col) in table.columns.iter().enumerate() {
if !is_auto[col_idx] {
if let SizeValue::Fixed { value } = &col.width {
fixed_total_mm += value;
}
if !is_auto[col_idx]
&& let SizeValue::Fixed { value } = &col.width
{
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).
// 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 { .. }))
.count();
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());
} else if auto_natural_total <= auto_budget {
// 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 {
// Sığmıyor — budget'a oransal küçült
let ratio = max_widths_mm[col_idx] / auto_natural_total;
let width_mm = auto_budget * ratio;
result.push(SizeValue::Fixed { value: width_mm });
} else {
result.push(SizeValue::Fixed { value: max_widths_mm[col_idx] });
result.push(SizeValue::Fixed {
value: max_widths_mm[col_idx],
});
}
}
} else {
@@ -177,7 +184,9 @@ pub fn expand_table_cached(
available_width_mm: f64,
cache: &mut TableExpandCache,
) -> ContainerElement {
let rows = resolved.tables.get(&table.id)
let rows = resolved
.tables
.get(&table.id)
.map(|t| t.rows.as_slice())
.unwrap_or(&[]);
let key = table_cache_key(table, rows, available_width_mm);
@@ -203,9 +212,7 @@ pub fn expand_table(
available_width_mm: f64,
) -> ContainerElement {
let resolved_table = resolved.tables.get(&table.id);
let rows = resolved_table
.map(|t| t.rows.as_slice())
.unwrap_or(&[]);
let rows = resolved_table.map(|t| t.rows.as_slice()).unwrap_or(&[]);
// Auto sütunlar için içerik bazlı genişlik hesapla
let effective_widths = compute_auto_column_widths(table, rows, measurer, available_width_mm);
@@ -329,10 +336,7 @@ pub fn expand_table(
.iter()
.enumerate()
.map(|(col_idx, col)| {
let text_content = row_data
.get(col_idx)
.cloned()
.unwrap_or_default();
let text_content = row_data.get(col_idx).cloned().unwrap_or_default();
let text = TemplateElement::StaticText(StaticTextElement {
id: format!("{}_r{}c{}", table.id, row_idx, col_idx),
@@ -448,9 +452,9 @@ pub fn expand_table(
#[cfg(test)]
mod tests {
use super::*;
use crate::FontData;
use crate::data_resolve::{ResolvedData, ResolvedTable};
use crate::text_measure::TextMeasurer;
use crate::FontData;
use std::collections::HashMap;
fn make_table(num_columns: usize) -> RepeatingTableElement {
@@ -473,7 +477,9 @@ mod tests {
height: SizeValue::Auto,
..Default::default()
},
data_source: ArrayBinding { path: "items".to_string() },
data_source: ArrayBinding {
path: "items".to_string(),
},
columns,
style: TableStyle::default(),
repeat_header: Some(true),
@@ -509,7 +515,11 @@ mod tests {
fn unwrap_cell_text(cell: &TemplateElement) -> &StaticTextElement {
match cell {
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] {
TemplateElement::StaticText(t) => t,
_ => panic!("Expected StaticText inside cell wrapper"),
@@ -522,10 +532,13 @@ mod tests {
#[test]
fn test_expand_table_structure() {
let table = make_table(2);
let resolved = make_resolved("tbl", vec![
vec!["A".to_string(), "1".to_string()],
vec!["B".to_string(), "2".to_string()],
]);
let resolved = make_resolved(
"tbl",
vec![
vec!["A".to_string(), "1".to_string()],
vec!["B".to_string(), "2".to_string()],
],
);
let mut measurer = make_measurer();
let container = expand_table(&table, &resolved, &mut measurer, 180.0);
@@ -586,9 +599,10 @@ mod tests {
#[test]
fn test_expand_table_column_count() {
let table = make_table(4);
let resolved = make_resolved("tbl", vec![
vec!["a".into(), "b".into(), "c".into(), "d".into()],
]);
let resolved = make_resolved(
"tbl",
vec![vec!["a".into(), "b".into(), "c".into(), "d".into()]],
);
let mut measurer = make_measurer();
let container = expand_table(&table, &resolved, &mut measurer, 180.0);
@@ -610,9 +624,7 @@ mod tests {
#[test]
fn test_expand_table_data_cell_content() {
let table = make_table(2);
let resolved = make_resolved("tbl", vec![
vec!["Hello".to_string(), "42".to_string()],
]);
let resolved = make_resolved("tbl", vec![vec!["Hello".to_string(), "42".to_string()]]);
let mut measurer = make_measurer();
let container = expand_table(&table, &resolved, &mut measurer, 180.0);
@@ -633,9 +645,7 @@ mod tests {
fn test_expand_table_with_border_adds_separator() {
let mut table = make_table(2);
table.style.border_color = Some("#000000".to_string());
let resolved = make_resolved("tbl", vec![
vec!["A".to_string(), "1".to_string()],
]);
let resolved = make_resolved("tbl", vec![vec!["A".to_string(), "1".to_string()]]);
let mut measurer = make_measurer();
let container = expand_table(&table, &resolved, &mut measurer, 180.0);
@@ -657,11 +667,14 @@ mod tests {
let mut table = make_table(1);
table.style.zebra_odd = Some("#f0f0f0".to_string());
table.style.zebra_even = Some("#ffffff".to_string());
let resolved = make_resolved("tbl", vec![
vec!["row0".into()],
vec!["row1".into()],
vec!["row2".into()],
]);
let resolved = make_resolved(
"tbl",
vec![
vec!["row0".into()],
vec!["row1".into()],
vec!["row2".into()],
],
);
let mut measurer = make_measurer();
let container = expand_table(&table, &resolved, &mut measurer, 180.0);
@@ -722,16 +735,21 @@ mod tests {
height: SizeValue::Auto,
..Default::default()
},
data_source: ArrayBinding { path: "items".to_string() },
data_source: ArrayBinding {
path: "items".to_string(),
},
columns,
style: TableStyle::default(),
repeat_header: Some(true),
};
let resolved = make_resolved("tbl", vec![
vec!["1".into(), "Web Uygulama Gelistirme".into()],
vec!["2".into(), "SSL Sertifikasi".into()],
]);
let resolved = make_resolved(
"tbl",
vec![
vec!["1".into(), "Web Uygulama Gelistirme".into()],
vec!["2".into(), "SSL Sertifikasi".into()],
],
);
let mut measurer = make_measurer();
let container = expand_table(&table, &resolved, &mut measurer, 180.0);
@@ -753,10 +771,16 @@ mod tests {
},
_ => 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ı
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"),
}
@@ -765,9 +789,7 @@ mod tests {
#[test]
fn test_table_cache_hit() {
let table = make_table(2);
let resolved = make_resolved("tbl", vec![
vec!["A".to_string(), "1".to_string()],
]);
let resolved = make_resolved("tbl", vec![vec!["A".to_string(), "1".to_string()]]);
let mut measurer = make_measurer();
let mut cache = TableExpandCache::new();
@@ -785,12 +807,8 @@ mod tests {
#[test]
fn test_table_cache_miss_on_data_change() {
let table = make_table(2);
let resolved1 = make_resolved("tbl", vec![
vec!["A".to_string(), "1".to_string()],
]);
let resolved2 = make_resolved("tbl", vec![
vec!["B".to_string(), "2".to_string()],
]);
let resolved1 = make_resolved("tbl", vec![vec!["A".to_string(), "1".to_string()]]);
let resolved2 = make_resolved("tbl", vec![vec!["B".to_string(), "2".to_string()]]);
let mut measurer = make_measurer();
let mut cache = TableExpandCache::new();
@@ -805,9 +823,7 @@ mod tests {
#[test]
fn test_table_cache_miss_on_width_change() {
let table = make_table(2);
let resolved = make_resolved("tbl", vec![
vec!["A".to_string(), "1".to_string()],
]);
let resolved = make_resolved("tbl", vec![vec!["A".to_string(), "1".to_string()]]);
let mut measurer = make_measurer();
let mut cache = TableExpandCache::new();

View File

@@ -102,7 +102,9 @@ impl TextMeasurer {
/// Cache'i dışarı taşı (persist etmek için).
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).
@@ -120,13 +122,25 @@ impl TextMeasurer {
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) {
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);
result
}
@@ -253,10 +267,7 @@ impl TextMeasurer {
}
// En büyük font boyutunu bul — line height buna göre belirlenir
let max_font_size_pt = spans
.iter()
.map(|s| s.font_size_pt)
.fold(0.0f32, f32::max);
let max_font_size_pt = spans.iter().map(|s| s.font_size_pt).fold(0.0f32, f32::max);
if max_font_size_pt <= 0.0 {
return (0.0, 0.0);
@@ -393,11 +404,21 @@ mod tests {
fn test_wrapping_reduces_width() {
let mut m = make_measurer();
// Sınırsız genişlikte ölç
let (w_unlimited, h_unlimited) =
m.measure("This is a longer text that should wrap", None, 12.0, None, None);
let (w_unlimited, h_unlimited) = m.measure(
"This is a longer text that should wrap",
None,
12.0,
None,
None,
);
// Dar genişlikte ölç
let (w_narrow, h_narrow) =
m.measure("This is a longer text that should wrap", None, 12.0, None, Some(50.0));
let (w_narrow, h_narrow) = m.measure(
"This is a longer text that should wrap",
None,
12.0,
None,
Some(50.0),
);
// Dar genişlikte yükseklik artmalı (wrapping oldu)
assert!(

View File

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

View File

@@ -1,5 +1,5 @@
use std::sync::Mutex;
use std::collections::HashMap;
use std::sync::Mutex;
use wasm_bindgen::prelude::*;
@@ -92,10 +92,12 @@ pub fn get_loaded_fonts() -> String {
let result: Vec<serde_json::Value> = families
.into_iter()
.map(|(family, variants)| serde_json::json!({
"family": family,
"variants": variants,
}))
.map(|(family, variants)| {
serde_json::json!({
"family": family,
"variants": variants,
})
})
.collect();
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();
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)
@@ -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).
/// Sonuç cache'lenir — aynı parametrelerle tekrar çağrılırsa cache'ten döner.
#[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 {
format: format.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_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)
.map_err(|e| JsValue::from_str(&e))?;
let result = crate::barcode_gen::generate_barcode_pixels(
format,
value,
width,
height,
include_text,
fonts_slice,
)
.map_err(|e| JsValue::from_str(&e))?;
// Grayscale → RGBA (canvas ImageData formatı)
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); // G
rgba.push(gray); // B
rgba.push(255); // A
rgba.push(255); // A
}
// Header (8 byte: width LE + height LE) + RGBA pixel verisi

View File

@@ -8,7 +8,7 @@
#![cfg(not(target_arch = "wasm32"))]
use dreport_core::models::*;
use dreport_layout::{compute_layout, LayoutResult, ResolvedContent};
use dreport_layout::{LayoutResult, ResolvedContent, compute_layout};
mod common;
use common::load_test_fonts;
@@ -17,7 +17,10 @@ fn base_template() -> Template {
Template {
id: "imp_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()],
header: None,
footer: None,
@@ -28,7 +31,12 @@ fn base_template() -> Template {
size: SizeConstraint::default(),
direction: "column".to_string(),
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(),
justify: "start".to_string(),
style: ContainerStyle::default(),
@@ -63,7 +71,11 @@ fn test_1_2_text_wrapping_layout_height() {
let fonts = load_test_fonts();
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).
// 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 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ı
assert_eq!(
@@ -143,27 +159,33 @@ fn test_1_3_image_object_fit_in_layout() {
fn test_1_4_italic_font_in_pdf() {
// fontStyle: italic ile PDF render — crash olmamalı
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
id: "italic_text".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(12.0),
font_style: Some("italic".to_string()),
..Default::default()
},
content: "Bu metin italic olmalı".to_string(),
}));
tpl.root
.children
.push(TemplateElement::StaticText(StaticTextElement {
id: "italic_text".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(12.0),
font_style: Some("italic".to_string()),
..Default::default()
},
content: "Bu metin italic olmalı".to_string(),
}));
let fonts = load_test_fonts();
let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap();
// 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"));
// PDF render crash olmamalı
@@ -174,22 +196,24 @@ fn test_1_4_italic_font_in_pdf() {
#[test]
fn test_1_4_bold_italic_font_in_pdf() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
id: "bold_italic".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(14.0),
font_weight: Some("bold".to_string()),
font_style: Some("italic".to_string()),
..Default::default()
},
content: "Bold Italic Test".to_string(),
}));
tpl.root
.children
.push(TemplateElement::StaticText(StaticTextElement {
id: "bold_italic".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(14.0),
font_weight: Some("bold".to_string()),
font_style: Some("italic".to_string()),
..Default::default()
},
content: "Bold Italic Test".to_string(),
}));
let fonts = load_test_fonts();
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() {
// repeat_header: false olan tablo, 2. sayfada header tekrarlamamalı
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl_no_repeat".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
data_source: ArrayBinding { path: "items".to_string() },
columns: vec![
TableColumn {
tpl.root
.children
.push(TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl_no_repeat".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
data_source: ArrayBinding {
path: "items".to_string(),
},
columns: vec![TableColumn {
id: "col_name".to_string(),
field: "name".to_string(),
title: "Name".to_string(),
width: SizeValue::Fr { value: 1.0 },
align: "left".to_string(),
format: None,
},
],
style: TableStyle::default(),
repeat_header: Some(false), // Header tekrarlanmasın
}));
}],
style: TableStyle::default(),
repeat_header: Some(false), // Header tekrarlanmasın
}));
// Çok sayıda satır — sayfa taşması için
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();
// Header row'u "tbl_no_repeat_header" pattern'inde olmalı, 2. sayfada bulunmamalı
let has_header_clone = page2_ids.iter().any(|id| {
id.contains("header") && id.contains("tbl_no_repeat") && id.contains("_p")
});
let has_header_clone = page2_ids
.iter()
.any(|id| id.contains("header") && id.contains("tbl_no_repeat") && id.contains("_p"));
assert!(
!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() {
// repeat_header: true (varsayılan) olan tablo, 2. sayfada header tekrarlamalı
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl_repeat".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
data_source: ArrayBinding { path: "items".to_string() },
columns: vec![
TableColumn {
tpl.root
.children
.push(TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl_repeat".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
data_source: ArrayBinding {
path: "items".to_string(),
},
columns: vec![TableColumn {
id: "col_name".to_string(),
field: "name".to_string(),
title: "Name".to_string(),
width: SizeValue::Fr { value: 1.0 },
align: "left".to_string(),
format: None,
},
],
style: TableStyle::default(),
repeat_header: Some(true),
}));
}],
style: TableStyle::default(),
repeat_header: Some(true),
}));
let items: Vec<serde_json::Value> = (0..80)
.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())
.collect();
let has_header_clone = page2_ids.iter().any(|id| {
id.contains("tbl_repeat_header") || id.contains("tbl_repeat_hdr")
});
let has_header_clone = page2_ids
.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
// 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 {
// 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ış)
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 {
// 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
assert!(
page2_first_y > 0.0 || has_header_clone,
@@ -342,36 +377,40 @@ fn test_2_1_repeat_header_true_repeats_on_second_page() {
#[test]
fn test_2_2_table_column_format_currency() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl_fmt".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
data_source: ArrayBinding { path: "items".to_string() },
columns: vec![
TableColumn {
id: "col_name".to_string(),
field: "name".to_string(),
title: "Ürün".to_string(),
tpl.root
.children
.push(TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl_fmt".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
align: "left".to_string(),
format: None,
height: SizeValue::Auto,
..Default::default()
},
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()),
data_source: ArrayBinding {
path: "items".to_string(),
},
],
style: TableStyle::default(),
repeat_header: Some(true),
}));
columns: vec![
TableColumn {
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!({
"items": [
@@ -431,7 +470,11 @@ fn test_2_3_rounded_rectangle_renders() {
let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap();
// 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.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_width = Some(0.5);
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
id: "text_in_rounded".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle { font_size: Some(12.0), ..Default::default() },
content: "Rounded container".to_string(),
}));
tpl.root
.children
.push(TemplateElement::StaticText(StaticTextElement {
id: "text_in_rounded".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(12.0),
..Default::default()
},
content: "Rounded container".to_string(),
}));
let fonts = load_test_fonts();
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_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");
}
@@ -511,7 +560,8 @@ fn test_2_7_format_config_number() {
currency_symbol: "".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");
}

View File

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

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();
// PDF should not be empty
assert!(
!pdf_bytes.is_empty(),
"PDF output should not be empty"
);
assert!(!pdf_bytes.is_empty(), "PDF output should not be empty");
// PDF should start with %PDF magic bytes
assert!(
@@ -239,7 +236,10 @@ fn test_page_break_produces_multiple_pages() {
let template = Template {
id: "pb_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()],
header: None,
footer: None,
@@ -250,7 +250,12 @@ fn test_page_break_produces_multiple_pages() {
size: SizeConstraint::default(),
direction: "column".to_string(),
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(),
justify: "start".to_string(),
style: ContainerStyle::default(),
@@ -259,16 +264,32 @@ fn test_page_break_produces_multiple_pages() {
TemplateElement::StaticText(StaticTextElement {
id: "t1".to_string(),
position: PositionMode::Flow,
size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, height: SizeValue::Auto, ..Default::default() },
style: TextStyle { font_size: Some(18.0), ..Default::default() },
size: SizeConstraint {
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(),
}),
TemplateElement::PageBreak(PageBreakElement { id: "pb1".to_string() }),
TemplateElement::PageBreak(PageBreakElement {
id: "pb1".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "t2".to_string(),
position: PositionMode::Flow,
size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, height: SizeValue::Auto, ..Default::default() },
style: TextStyle { font_size: Some(18.0), ..Default::default() },
size: SizeConstraint {
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(),
}),
],
@@ -284,15 +305,26 @@ fn test_page_break_produces_multiple_pages() {
for (i, page) in layout.pages.iter().enumerate() {
println!("Page {}: {} elements", i, page.elements.len());
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");
// 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 p2_ids: Vec<&str> = layout.pages[1].elements.iter().map(|e| e.id.as_str()).collect();
let p1_ids: Vec<&str> = layout.pages[0]
.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 2 IDs: {:?}", p2_ids);

View File

@@ -15,8 +15,8 @@ mod visual {
use std::process::Command;
use dreport_core::models::Template;
use dreport_layout::{compute_layout, ResolvedContent};
use dreport_layout::pdf_render::render_pdf;
use dreport_layout::{ResolvedContent, compute_layout};
use crate::common::load_test_fonts;
@@ -101,11 +101,10 @@ mod visual {
for (a, r) in actual_rgba.pixels().zip(reference_rgba.pixels()) {
// Allow per-channel tolerance of 2 for font rendering differences
let channel_diff = a
.0
.iter()
.zip(r.0.iter())
.any(|(ac, rc)| (*ac as i32 - *rc as i32).unsigned_abs() > 2);
let channel_diff =
a.0.iter()
.zip(r.0.iter())
.any(|(ac, rc)| (*ac as i32 - *rc as i32).unsigned_abs() > 2);
if channel_diff {
diff_pixels += 1;
}
@@ -181,7 +180,9 @@ mod visual {
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 el in &page.elements {
@@ -212,9 +213,21 @@ mod visual {
#[ignore]
fn generate_cross_renderer_refs() {
let fixtures = [
("visual_test_template.json", "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"),
(
"visual_test_template.json",
"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();
@@ -222,7 +235,11 @@ mod visual {
for (template_file, data_file, name) in &fixtures {
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));
if !pdf_to_png(&pdf_bytes, &png_path) {
@@ -234,7 +251,11 @@ mod visual {
#[test]
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]
@@ -252,10 +273,18 @@ mod visual {
// SVG HTML ciktisini kaydet (karsilastirma icin)
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);
// 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",
);
}
}