mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
fmt
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use axum::{Router, routing::get, Json};
|
||||
use axum::{Json, Router, routing::get};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
})],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
],
|
||||
@@ -277,32 +298,43 @@ fn test_page_break_produces_multiple_pages() {
|
||||
|
||||
let data = serde_json::json!({});
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
|
||||
let layout = compute_layout(&template, &data, &fonts).unwrap();
|
||||
|
||||
|
||||
println!("Layout pages: {}", layout.pages.len());
|
||||
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);
|
||||
|
||||
|
||||
assert!(p1_ids.contains(&"t1"), "Page 1 should contain t1");
|
||||
assert!(p2_ids.contains(&"t2"), "Page 2 should contain t2");
|
||||
|
||||
|
||||
// Render PDF and verify it's valid
|
||||
let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
|
||||
assert!(pdf_bytes.starts_with(b"%PDF"));
|
||||
|
||||
|
||||
// Write PDF to temp dir for manual inspection
|
||||
let out_path = std::env::temp_dir().join("dreport_test_page_break.pdf");
|
||||
std::fs::write(&out_path, &pdf_bytes).unwrap();
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user