This commit is contained in:
2026-04-07 01:07:12 +03:00
parent b6287906a9
commit 7cf49ee1e4
24 changed files with 769 additions and 270 deletions

View File

@@ -81,20 +81,27 @@ pub fn apply_format_with_config(value: &str, format: Option<&str>, config: &drep
}
fn format_currency(value: &str, config: &dreport_core::models::FormatConfig) -> String {
if let Ok(n) = value.parse::<f64>() {
let abs = n.abs();
let integer = abs.floor() as i64;
let frac = ((abs - abs.floor()) * 100.0).round() as i64;
use dexpr::Decimal;
let int_str = format_with_thousands(integer, &config.thousands_separator);
let sign = if n < 0.0 { "-" } else { "" };
if config.currency_position == "prefix" {
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)
}
let Ok(d) = value.parse::<Decimal>() else {
return value.to_string();
};
// 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);
// 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 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)
} else {
value.to_string()
format!("{}{}{}{:02} {}", sign, int_str, config.decimal_separator, frac, config.currency_symbol)
}
}
@@ -265,4 +272,49 @@ mod tests {
"2880"
);
}
#[test]
fn test_currency_format_basic() {
let config = dreport_core::models::FormatConfig::default();
assert_eq!(format_currency("1500", &config), "1.500,00 ₺");
assert_eq!(format_currency("0", &config), "0,00 ₺");
}
#[test]
fn test_currency_format_fractional() {
let config = dreport_core::models::FormatConfig::default();
assert_eq!(format_currency("19.99", &config), "19,99 ₺");
assert_eq!(format_currency("100.50", &config), "100,50 ₺");
}
#[test]
fn test_currency_format_rounding_edge_case() {
// 1.005 should round to 1.01, not 1.00
let config = dreport_core::models::FormatConfig::default();
let result = format_currency("1.005", &config);
assert_eq!(result, "1,01 ₺");
}
#[test]
fn test_currency_format_negative() {
let config = dreport_core::models::FormatConfig::default();
assert_eq!(format_currency("-250.75", &config), "-250,75 ₺");
}
#[test]
fn test_currency_format_large_number() {
let config = dreport_core::models::FormatConfig::default();
assert_eq!(format_currency("1234567.89", &config), "1.234.567,89 ₺");
}
#[test]
fn test_currency_format_prefix_position() {
let config = dreport_core::models::FormatConfig {
currency_symbol: "$".to_string(),
currency_position: "prefix".to_string(),
thousands_separator: ",".to_string(),
decimal_separator: ".".to_string(),
};
assert_eq!(format_currency("1500.25", &config), "$1,500.25");
}
}

View File

@@ -711,6 +711,29 @@ fn render_line(
surface.set_fill(None);
}
#[derive(Debug, PartialEq)]
enum ImageFormat {
Png,
Jpeg,
Gif,
WebP,
Unknown,
}
fn detect_image_format(data: &[u8]) -> ImageFormat {
if data.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
ImageFormat::Png
} else if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
ImageFormat::Jpeg
} else if data.starts_with(b"GIF8") {
ImageFormat::Gif
} else if data.len() >= 12 && &data[8..12] == b"WEBP" {
ImageFormat::WebP
} else {
ImageFormat::Unknown
}
}
fn render_image(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
@@ -735,16 +758,35 @@ fn render_image(
return;
};
// Tüm formatları image crate ile decode edip PNG'ye çevir (krilla JPEG desteği sınırlı)
let png_data = match decode_to_png(&decoded) {
Some(data) => data,
None => {
eprintln!("[dreport] Image decode/re-encode hatası, ham veri deneniyor");
decoded
// Magic bytes ile format tespit et, krilla'nın native desteğini kullan
let img_result = match detect_image_format(&decoded) {
ImageFormat::Png => krilla::image::Image::from_png(decoded.into(), true),
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;
}
}
}
};
embed_png(surface, x, y, w, h, &png_data);
let Ok(img) = img_result else {
eprintln!("[dreport] Image krilla embed hatası");
return;
};
let Some(size) = Size::from_wh(w, h) else {
return;
};
surface.push_transform(&Transform::from_translate(x, y));
surface.draw_image(img, size);
surface.pop();
}
fn render_barcode(
@@ -1391,4 +1433,37 @@ mod tests {
std::fs::write(&out_path, &pdf).unwrap();
println!("Full pipeline PDF: {}", out_path.display());
}
#[test]
fn test_detect_png() {
let data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
assert_eq!(detect_image_format(&data), ImageFormat::Png);
}
#[test]
fn test_detect_jpeg() {
let data = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
assert_eq!(detect_image_format(&data), ImageFormat::Jpeg);
}
#[test]
fn test_detect_gif() {
assert_eq!(detect_image_format(b"GIF89a..."), ImageFormat::Gif);
assert_eq!(detect_image_format(b"GIF87a..."), ImageFormat::Gif);
}
#[test]
fn test_detect_webp() {
// RIFF____WEBP
let mut data = vec![0u8; 12];
data[0..4].copy_from_slice(b"RIFF");
data[8..12].copy_from_slice(b"WEBP");
assert_eq!(detect_image_format(&data), ImageFormat::WebP);
}
#[test]
fn test_detect_unknown() {
assert_eq!(detect_image_format(&[0x00, 0x01, 0x02]), ImageFormat::Unknown);
assert_eq!(detect_image_format(&[]), ImageFormat::Unknown);
}
}

View File

@@ -1,8 +1,38 @@
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use dreport_core::models::*;
use crate::data_resolve::ResolvedData;
use crate::text_measure::TextMeasurer;
/// Cache for expanded table containers.
/// Key: hash of (table JSON + resolved rows + available width).
pub type TableExpandCache = HashMap<u64, ContainerElement>;
fn table_cache_key(
table: &RepeatingTableElement,
rows: &[Vec<String>],
available_width_mm: f64,
) -> u64 {
let mut hasher = std::hash::DefaultHasher::new();
// Serialize table definition (id, columns, style, etc.)
if let Ok(json) = serde_json::to_string(table) {
json.hash(&mut hasher);
}
// Hash resolved row data
for row in rows {
for cell in row {
cell.hash(&mut hasher);
}
row.len().hash(&mut hasher);
}
rows.len().hash(&mut hasher);
// Hash available width (as bits to avoid float hashing issues)
available_width_mm.to_bits().hash(&mut hasher);
hasher.finish()
}
/// Her auto sütun için header + tüm data satırlarındaki en geniş text'i ölç,
/// doğal genişliklerini Fixed olarak ata.
/// Fr sütunları olduğu gibi bırak (kalan alanı taffy dağıtır).
@@ -138,6 +168,29 @@ fn compute_auto_column_widths(
result
}
/// Cache-aware table expansion.
/// Verilen cache'e bakar, hit varsa clone döner. Miss'te expand edip cache'e yazar.
pub fn expand_table_cached(
table: &RepeatingTableElement,
resolved: &ResolvedData,
measurer: &mut TextMeasurer,
available_width_mm: f64,
cache: &mut TableExpandCache,
) -> ContainerElement {
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);
if let Some(cached) = cache.get(&key) {
return cached.clone();
}
let result = expand_table(table, resolved, measurer, available_width_mm);
cache.insert(key, result.clone());
result
}
/// RepeatingTable element'ini bir container ağacına expand eder.
/// Tablo → column container (header row + data rows)
/// Her row → row container (cell'ler → static_text)
@@ -708,4 +761,61 @@ mod tests {
_ => panic!("Expected Container"),
}
}
#[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 mut measurer = make_measurer();
let mut cache = TableExpandCache::new();
// First call — cache miss
let result1 = expand_table_cached(&table, &resolved, &mut measurer, 180.0, &mut cache);
assert_eq!(cache.len(), 1);
// Second call — same inputs — cache hit
let result2 = expand_table_cached(&table, &resolved, &mut measurer, 180.0, &mut cache);
assert_eq!(cache.len(), 1); // no new entry
assert_eq!(result1.id, result2.id);
assert_eq!(result1.children.len(), result2.children.len());
}
#[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 mut measurer = make_measurer();
let mut cache = TableExpandCache::new();
expand_table_cached(&table, &resolved1, &mut measurer, 180.0, &mut cache);
assert_eq!(cache.len(), 1);
// Different data — cache miss
expand_table_cached(&table, &resolved2, &mut measurer, 180.0, &mut cache);
assert_eq!(cache.len(), 2);
}
#[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 mut measurer = make_measurer();
let mut cache = TableExpandCache::new();
expand_table_cached(&table, &resolved, &mut measurer, 180.0, &mut cache);
assert_eq!(cache.len(), 1);
// Different available width — cache miss
expand_table_cached(&table, &resolved, &mut measurer, 160.0, &mut cache);
assert_eq!(cache.len(), 2);
}
}

View File

@@ -5,7 +5,7 @@ use taffy::prelude::*;
use crate::data_resolve::ResolvedData;
use crate::sizing::{self, mm_to_pt, pt_to_mm};
use crate::table_layout;
use crate::table_layout::{self, TableExpandCache};
use crate::text_measure::TextMeasurer;
use crate::{ElementLayout, LayoutError, LayoutResult, ResolvedContent, ResolvedStyle};
@@ -55,6 +55,7 @@ pub fn compute(
let mut taffy = TaffyTree::<MeasureContext>::new();
taffy.disable_rounding();
let mut node_map: HashMap<NodeId, NodeInfo> = HashMap::new();
let mut table_cache = TableExpandCache::new();
let page_width_mm = template.page.width;
let root_node = build_container(
@@ -65,6 +66,7 @@ pub fn compute(
None,
measurer,
page_width_mm,
&mut table_cache,
)?;
// Sayfa wrapper: sayfa genişliğinde ama yükseklik sınırsız (auto)
@@ -131,8 +133,9 @@ fn compute_section(
let mut taffy = TaffyTree::<MeasureContext>::new();
taffy.disable_rounding();
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)?;
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,
@@ -214,6 +217,7 @@ fn build_container(
parent_direction: Option<&str>,
measurer: &mut TextMeasurer,
page_width_mm: f64,
table_cache: &mut TableExpandCache,
) -> Result<NodeId, LayoutError> {
let style = sizing::container_to_style(el, parent_direction);
let direction = el.direction.as_str();
@@ -232,7 +236,7 @@ fn build_container(
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)?;
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());
}
@@ -269,10 +273,11 @@ fn build_element(
parent_direction: Option<&str>,
measurer: &mut TextMeasurer,
page_width_mm: f64,
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)
build_container(e, taffy, node_map, resolved, parent_direction, measurer, page_width_mm, table_cache)
}
TemplateElement::StaticText(e) => build_text_leaf(
taffy,
@@ -440,8 +445,8 @@ fn build_element(
Ok(node)
}
TemplateElement::RepeatingTable(e) => {
// Tabloyu container ağacına expand et (measurer ile auto sütun genişlikleri hesaplanır)
let expanded = table_layout::expand_table(e, resolved, measurer, page_width_mm);
// Tabloyu container ağacına expand et (cache ile)
let expanded = table_layout::expand_table_cached(e, resolved, measurer, page_width_mm, table_cache);
// Expand edilmiş tablo cell'lerinin text'lerini resolved'a ekle
// (expand_table StaticText'ler üretir, bunların text'leri zaten content'te)
@@ -460,6 +465,7 @@ fn build_element(
parent_direction,
measurer,
page_width_mm,
table_cache,
)
}
TemplateElement::Shape(e) => {