mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-02 02:49:16 +00:00
0.2.0
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user