This commit is contained in:
2026-03-29 19:17:09 +03:00
parent 9b17d2aef4
commit 1cbe42ed75
34 changed files with 4690 additions and 3105 deletions

29
layout-engine/Cargo.toml Normal file
View File

@@ -0,0 +1,29 @@
[package]
name = "dreport-layout"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
dreport-core = { path = "../core" }
taffy = "0.7"
cosmic-text = { version = "0.12", default-features = false, features = ["std", "swash"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rxing = { version = "0.8", default-features = false, features = ["encoding_rs"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
krilla = { version = "0.6", features = ["raster-images", "simple-text"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
base64 = "0.22"
[features]
default = []
wasm = []

View File

@@ -0,0 +1,286 @@
//! Barcode/QR code üretimi — rxing ile.
//! Hem native hem WASM'da derlenir.
//! Text rendering: cosmic-text (font varsa) veya bitmap fallback.
use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, SwashCache};
use rxing::{BarcodeFormat, EncodeHints, Writer};
use crate::FontData;
/// dreport format string → rxing BarcodeFormat
fn to_rxing_format(format: &str) -> Result<BarcodeFormat, String> {
match format {
"qr" => Ok(BarcodeFormat::QR_CODE),
"ean13" => Ok(BarcodeFormat::EAN_13),
"ean8" => Ok(BarcodeFormat::EAN_8),
"code128" => Ok(BarcodeFormat::CODE_128),
"code39" => Ok(BarcodeFormat::CODE_39),
_ => Err(format!("Desteklenmeyen barcode formatı: {format}")),
}
}
/// Barcode üretim sonucu — ham grayscale pixel verisi
pub struct BarcodePixels {
/// Grayscale pixel verileri (0=siyah, 255=beyaz), row-major
pub pixels: Vec<u8>,
pub width: u32,
pub height: u32,
}
/// Herhangi bir barcode formatında ham pixel verisi üret.
/// `include_text`: true ise lineer barkodların altına değer yazılır (QR için etkisiz).
/// `font_data`: Verilirse cosmic-text ile güzel font rendering, yoksa bitmap fallback.
pub fn generate_barcode_pixels(
format: &str,
value: &str,
width_px: u32,
height_px: u32,
include_text: bool,
font_data: Option<&[FontData]>,
) -> Result<BarcodePixels, String> {
if value.is_empty() {
return Err("Boş barcode değeri".to_string());
}
let bc_format = to_rxing_format(format)?;
let is_qr = bc_format == BarcodeFormat::QR_CODE;
// QR kod her zaman kare olmalı
let (req_w, req_h) = if is_qr {
let side = width_px.min(height_px);
(side, side)
} else {
(width_px, height_px)
};
// 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)
} else {
0
};
let bar_h = req_h - text_area_h;
let mut hints = EncodeHints::default();
hints.Margin = Some("1".to_string());
if is_qr {
hints.ErrorCorrection = Some("M".to_string());
}
let writer = rxing::MultiFormatWriter::default();
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}"))?;
let mat_w = matrix.width() as u32;
let mat_h = matrix.height() as u32;
// Çıktı boyutu: bar matrisi + metin alanı
let out_w = mat_w;
let out_h = mat_h + text_area_h;
let mut pixels = vec![255u8; (out_w * out_h) as usize];
// Bar matrisini çiz
for y in 0..mat_h {
for x in 0..mat_w {
if matrix.get(x, y) {
pixels[(y * out_w + x) as usize] = 0;
}
}
}
// 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);
}
Ok(BarcodePixels { pixels, width: out_w, height: out_h })
}
/// cosmic-text ile metin render et — gerçek font rendering
fn render_text_cosmic(
pixels: &mut [u8],
img_w: u32,
img_h: u32,
text_y: u32,
text_h: u32,
text: &str,
font_data: Option<&[FontData]>,
) {
let mut font_system = FontSystem::new_with_locale_and_db(
"tr-TR".to_string(),
cosmic_text::fontdb::Database::new(),
);
match font_data {
Some(fonts) if !fonts.is_empty() => {
for f in fonts {
font_system.db_mut().load_font_data(f.data.clone());
}
}
_ => return, // Font yoksa metin render edemeyiz
}
// Font boyutunu text alanına göre ayarla (px cinsinden)
let font_size_px = (text_h as f32 * 0.7).max(10.0);
let line_height_px = font_size_px * 1.2;
let metrics = Metrics::new(font_size_px, line_height_px);
let mut buffer = Buffer::new(&mut font_system, metrics);
buffer.set_size(&mut font_system, Some(img_w as f32), Some(text_h as f32));
let attrs = Attrs::new().family(Family::SansSerif);
buffer.set_text(&mut font_system, text, attrs, Shaping::Advanced);
buffer.shape_until_scroll(&mut font_system, false);
let mut swash_cache = SwashCache::new();
// Text genişliğini hesapla (ortalama için)
let mut text_width: f32 = 0.0;
for run in buffer.layout_runs() {
for glyph in run.glyphs.iter() {
let end = glyph.x + glyph.w;
if end > text_width {
text_width = end;
}
}
}
// Ortalama offset
let offset_x = if (text_width as u32) < img_w {
((img_w as f32 - text_width) / 2.0) as i32
} else {
0
};
let offset_y = text_y as i32 + ((text_h as f32 - line_height_px) / 2.0).max(0.0) as i32;
// Glyph'leri pixel buffer'a çiz
for run in buffer.layout_runs() {
let line_y = offset_y + run.line_y as i32;
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 {
continue;
};
let gx = physical.x + image.placement.left;
let gy = physical.y - image.placement.top;
let gw = image.placement.width as i32;
let gh = image.placement.height as i32;
for row in 0..gh {
for col in 0..gw {
let px = gx + col;
let py = gy + row;
if px < 0 || py < 0 || px >= img_w as i32 || py >= img_h as i32 {
continue;
}
let src_idx = (row * gw + col) as usize;
if src_idx >= image.data.len() { continue; }
let alpha = image.data[src_idx];
if alpha == 0 { continue; }
let dst_idx = (py as u32 * img_w + px as u32) as usize;
if dst_idx >= pixels.len() { continue; }
// Alpha blending: beyaz arka plan üzerine siyah metin
let bg = pixels[dst_idx] as f32;
let a = alpha as f32 / 255.0;
pixels[dst_idx] = (bg * (1.0 - a)) as u8;
}
}
}
}
}
/// PNG bytes olarak barcode üret (sadece native).
#[cfg(not(target_arch = "wasm32"))]
pub fn generate_barcode_png(
format: &str,
value: &str,
width_px: u32,
height_px: u32,
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 img = image::GrayImage::from_raw(result.width, result.height, result.pixels)
.ok_or_else(|| "Pixel buffer boyutu uyumsuz".to_string())?;
let mut buf = Vec::new();
let encoder = image::codecs::png::PngEncoder::new(&mut buf);
image::ImageEncoder::write_image(
encoder,
img.as_raw(),
img.width(),
img.height(),
image::ExtendedColorType::L8,
)
.map_err(|e| format!("PNG encode hatası: {e}"))?;
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_qr_is_square() {
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();
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();
assert!(result.width > 0);
assert!(result.height > 0);
}
#[cfg(not(target_arch = "wasm32"))]
#[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();
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");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_qr_png() {
let png = generate_barcode_png("qr", "https://example.com", 200, 200, false, None).unwrap();
assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
}
#[cfg(not(target_arch = "wasm32"))]
#[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();
assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_code128_png() {
let png = generate_barcode_png("code128", "ABC-123", 300, 80, true, None).unwrap();
assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
}
}

View File

@@ -0,0 +1,161 @@
use dreport_core::models::*;
use serde_json::Value;
use std::collections::HashMap;
/// Her element ID'si için çözümlenmiş text içeriğini tutar.
/// Table ve barcode gibi özel tipler de burada çözülür.
#[derive(Debug, Clone)]
pub struct ResolvedData {
/// element_id → çözümlenmiş text içeriği
pub texts: HashMap<String, String>,
/// element_id → çözümlenmiş tablo verileri (headers, rows)
pub tables: HashMap<String, ResolvedTable>,
/// element_id → çözümlenmiş barcode değeri
pub barcodes: HashMap<String, String>,
/// element_id → çözümlenmiş image src
pub images: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct ResolvedTable {
pub rows: Vec<Vec<String>>,
}
/// JSON path ile veri çek: "firma.unvan" → data["firma"]["unvan"]
fn resolve_path<'a>(data: &'a Value, path: &str) -> &'a Value {
let mut current = data;
for key in path.split('.') {
current = match current {
Value::Object(map) => map.get(key).unwrap_or(&Value::Null),
_ => &Value::Null,
};
}
current
}
/// JSON Value → display string
fn value_to_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => String::new(),
_ => v.to_string(),
}
}
/// Template'deki tüm binding'leri çözümle.
pub fn resolve_template(template: &Template, data: &Value) -> ResolvedData {
let mut resolved = ResolvedData {
texts: HashMap::new(),
tables: HashMap::new(),
barcodes: HashMap::new(),
images: HashMap::new(),
};
resolve_element(&TemplateElement::Container(template.root.clone()), data, &mut resolved);
resolved
}
fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedData) {
match el {
TemplateElement::StaticText(e) => {
resolved.texts.insert(e.id.clone(), e.content.clone());
}
TemplateElement::Text(e) => {
let bound_value = value_to_string(resolve_path(data, &e.binding.path));
let text = match &e.content {
Some(prefix) if !prefix.is_empty() => format!("{}{}", prefix, bound_value),
_ => bound_value,
};
resolved.texts.insert(e.id.clone(), text);
}
TemplateElement::PageNumber(e) => {
// Sayfa numarası layout sonrasında çözülecek, placeholder koy
let fmt = e.format.as_deref().unwrap_or("{current} / {total}");
resolved.texts.insert(e.id.clone(), fmt.replace("{current}", "1").replace("{total}", "1"));
}
TemplateElement::Barcode(e) => {
let value = if let Some(binding) = &e.binding {
value_to_string(resolve_path(data, &binding.path))
} else {
e.value.clone().unwrap_or_default()
};
resolved.barcodes.insert(e.id.clone(), value);
}
TemplateElement::Image(e) => {
let src = if let Some(binding) = &e.binding {
value_to_string(resolve_path(data, &binding.path))
} else {
e.src.clone().unwrap_or_default()
};
resolved.images.insert(e.id.clone(), src);
}
TemplateElement::RepeatingTable(e) => {
let array = resolve_path(data, &e.data_source.path);
let rows = match array {
Value::Array(items) => {
items
.iter()
.map(|item| {
e.columns
.iter()
.map(|col| {
let v = resolve_path(item, &col.field);
value_to_string(v)
})
.collect()
})
.collect()
}
_ => vec![],
};
resolved.tables.insert(e.id.clone(), ResolvedTable { rows });
}
TemplateElement::Container(e) => {
for child in &e.children {
resolve_element(child, data, resolved);
}
}
TemplateElement::Line(_) => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_path() {
let data: Value = serde_json::json!({
"firma": {
"unvan": "Acme A.Ş.",
"vergiNo": "123"
}
});
assert_eq!(
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, "nonexistent.path")),
""
);
}
#[test]
fn test_resolve_array() {
let data: Value = serde_json::json!({
"kalemler": [
{ "adi": "Widget", "tutar": 100 },
{ "adi": "Gadget", "tutar": 200 }
]
});
let arr = resolve_path(&data, "kalemler");
assert!(arr.is_array());
assert_eq!(arr.as_array().unwrap().len(), 2);
}
}

142
layout-engine/src/lib.rs Normal file
View File

@@ -0,0 +1,142 @@
pub mod sizing;
pub mod text_measure;
pub mod data_resolve;
pub mod table_layout;
pub mod tree;
#[cfg(target_arch = "wasm32")]
pub mod wasm_api;
pub mod barcode_gen;
#[cfg(not(target_arch = "wasm32"))]
pub mod pdf_render;
use dreport_core::models::Template;
use serde::{Deserialize, Serialize};
// --- Layout sonuç tipleri ---
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayoutResult {
pub pages: Vec<PageLayout>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageLayout {
pub page_index: usize,
pub width_mm: f64,
pub height_mm: f64,
pub elements: Vec<ElementLayout>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ElementLayout {
pub id: String,
pub x_mm: f64,
pub y_mm: f64,
pub width_mm: f64,
pub height_mm: f64,
pub element_type: String,
pub content: Option<ResolvedContent>,
pub style: ResolvedStyle,
pub children: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ResolvedContent {
#[serde(rename = "text")]
Text { value: String },
#[serde(rename = "image")]
Image { src: String },
#[serde(rename = "line")]
Line,
#[serde(rename = "barcode")]
Barcode { format: String, value: String },
#[serde(rename = "page_number")]
PageNumber { current: usize, total: usize },
#[serde(rename = "table")]
Table {
headers: Vec<TableHeaderCell>,
rows: Vec<Vec<TableCell>>,
column_widths_mm: Vec<f64>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableHeaderCell {
pub text: String,
pub align: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableCell {
pub text: String,
pub align: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedStyle {
// Text
pub font_size: Option<f64>,
pub font_weight: Option<String>,
pub font_family: Option<String>,
pub color: Option<String>,
pub text_align: Option<String>,
// Line
pub stroke_color: Option<String>,
pub stroke_width: Option<f64>,
// Container
pub background_color: Option<String>,
pub border_color: Option<String>,
pub border_width: Option<f64>,
pub border_radius: Option<f64>,
pub border_style: Option<String>,
// Table
pub header_bg: Option<String>,
pub header_color: Option<String>,
pub zebra_odd: Option<String>,
pub zebra_even: Option<String>,
pub header_font_size: Option<f64>,
// Image
pub object_fit: Option<String>,
// Barcode
pub barcode_color: Option<String>,
pub barcode_include_text: Option<bool>,
}
/// Ana layout hesaplama fonksiyonu.
/// Template + data + font verileri alır, her element için pozisyon döner.
pub fn compute_layout(
template: &Template,
data: &serde_json::Value,
font_data: &[FontData],
) -> LayoutResult {
let mut measurer = text_measure::TextMeasurer::new(font_data);
let resolved = data_resolve::resolve_template(template, data);
tree::compute(template, &resolved, &mut measurer)
}
/// Cache-aware layout hesaplama.
/// Önceki çağrıdan kalan text measurement cache'ini alır, hesaplama sonrası
/// güncellenen cache'i geri döner. WASM tarafında cross-call persist için kullanılır.
pub fn compute_layout_cached(
template: &Template,
data: &serde_json::Value,
font_data: &[FontData],
text_cache: text_measure::TextMeasureCache,
) -> (LayoutResult, text_measure::TextMeasureCache) {
let mut measurer = text_measure::TextMeasurer::new_with_cache(font_data, text_cache);
let resolved = data_resolve::resolve_template(template, data);
let result = tree::compute(template, &resolved, &mut measurer);
(result, measurer.take_cache())
}
/// Font verisi (ham TTF/OTF bytes)
#[derive(Debug, Clone)]
pub struct FontData {
pub family: String,
pub data: Vec<u8>,
}

View File

@@ -0,0 +1,680 @@
//! LayoutResult → PDF bytes (krilla ile).
//! Sadece native (non-WASM) hedeflerde derlenir.
use std::collections::HashMap;
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};
/// mm → pt dönüşümü (1mm = 2.83465pt)
const MM_TO_PT: f32 = 72.0 / 25.4;
fn mm(v: f64) -> f32 {
v as f32 * MM_TO_PT
}
/// Hex renk (#RRGGBB veya #RGB) → rgb::Color
fn parse_color(hex: &str) -> rgb::Color {
let hex = hex.trim_start_matches('#');
let (r, g, b) = match hex.len() {
6 => (
u8::from_str_radix(&hex[0..2], 16).unwrap_or(0),
u8::from_str_radix(&hex[2..4], 16).unwrap_or(0),
u8::from_str_radix(&hex[4..6], 16).unwrap_or(0),
),
3 => {
let r = u8::from_str_radix(&hex[0..1], 16).unwrap_or(0);
let g = u8::from_str_radix(&hex[1..2], 16).unwrap_or(0);
let b = u8::from_str_radix(&hex[2..3], 16).unwrap_or(0);
(r * 17, g * 17, b * 17)
}
_ => (0, 0, 0),
};
rgb::Color::new(r, g, b)
}
fn fill_from_color(color: rgb::Color) -> Fill {
Fill {
paint: color.into(),
opacity: NormalizedF32::ONE,
rule: Default::default(),
}
}
/// Font koleksiyonu — family + weight + italic → KrillaFont mapping
struct FontCollection {
/// (family_lower, is_bold, is_italic) → KrillaFont
fonts: HashMap<(String, bool, bool), KrillaFont>,
/// Fallback font (ilk yüklenen regular)
default: Option<KrillaFont>,
}
impl FontCollection {
fn new(font_data: &[FontData]) -> Self {
let mut fonts = HashMap::new();
let mut default = None;
for fd in font_data {
let Some(font) = KrillaFont::new(
krilla::Data::from(fd.data.clone()),
0,
) else {
continue;
};
let family_lower = fd.family.to_lowercase();
let is_bold = is_font_bold(&fd.data);
let is_italic = is_font_italic(&fd.data);
// Default font: ilk regular (non-bold, non-italic)
if default.is_none() && !is_bold && !is_italic {
default = Some(font.clone());
}
fonts.insert((family_lower.clone(), is_bold, is_italic), font);
}
// 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);
}
}
Self { fonts, default }
}
fn get(&self, family: Option<&str>, weight: Option<&str>) -> Option<&KrillaFont> {
let is_bold = matches!(weight, Some("bold"));
let family_lower = family.unwrap_or("noto sans").to_lowercase();
// Her zaman non-italic font ara (italic desteği henüz yok)
self.fonts
.get(&(family_lower.clone(), is_bold, false))
.or_else(|| self.fonts.get(&(family_lower, false, false)))
.or(self.default.as_ref())
}
}
/// TTF OS/2 tablosunun offset'ini bul
fn find_os2_table(data: &[u8]) -> Option<usize> {
if data.len() < 12 {
return None;
}
let num_tables = u16::from_be_bytes([data[4], data[5]]) as usize;
let mut offset = 12;
for _ in 0..num_tables {
if offset + 16 > data.len() {
break;
}
let tag = &data[offset..offset + 4];
if tag == b"OS/2" {
let table_offset =
u32::from_be_bytes([data[offset + 8], data[offset + 9], data[offset + 10], data[offset + 11]])
as usize;
return Some(table_offset);
}
offset += 16;
}
None
}
/// TTF dosyasının bold olup olmadığını OS/2 tablosundan kontrol et
fn is_font_bold(data: &[u8]) -> bool {
let Some(table_offset) = find_os2_table(data) else {
return false;
};
// usWeightClass is at offset 4 in OS/2 table
if table_offset + 6 <= data.len() {
let weight_class = u16::from_be_bytes([data[table_offset + 4], data[table_offset + 5]]);
return weight_class >= 700;
}
false
}
/// TTF dosyasının italic olup olmadığını OS/2 tablosundan kontrol et
fn is_font_italic(data: &[u8]) -> bool {
let Some(table_offset) = find_os2_table(data) else {
return false;
};
// fsSelection is at offset 62 in OS/2 table, bit 0 = ITALIC
if table_offset + 64 <= data.len() {
let fs_selection = u16::from_be_bytes([data[table_offset + 62], data[table_offset + 63]]);
return fs_selection & 0x0001 != 0;
}
false
}
/// LayoutResult → PDF bytes
pub fn render_pdf(layout: &LayoutResult, font_data: &[FontData]) -> Result<Vec<u8>, String> {
let fonts = FontCollection::new(font_data);
let mut measurer = TextMeasurer::new(font_data);
let mut doc = Document::new();
for page in &layout.pages {
render_page(&mut doc, page, &fonts, font_data, &mut measurer)?;
}
doc.finish().map_err(|e| format!("PDF oluşturma hatası: {e:?}"))
}
fn render_page(
doc: &mut Document,
page: &PageLayout,
fonts: &FontCollection,
font_data: &[FontData],
measurer: &mut TextMeasurer,
) -> Result<(), String> {
let w = mm(page.width_mm);
let h = mm(page.height_mm);
let page_settings =
PageSettings::from_wh(w, h).ok_or_else(|| "Geçersiz sayfa boyutu".to_string())?;
let mut pdf_page = doc.start_page_with(page_settings);
let mut surface = pdf_page.surface();
for el in &page.elements {
render_element(&mut surface, el, fonts, font_data, measurer);
}
surface.finish();
pdf_page.finish();
Ok(())
}
fn render_element(
surface: &mut krilla::surface::Surface<'_>,
el: &ElementLayout,
fonts: &FontCollection,
font_data: &[FontData],
measurer: &mut TextMeasurer,
) {
let x = mm(el.x_mm);
let y = mm(el.y_mm);
let w = mm(el.width_mm);
let h = mm(el.height_mm);
// Container background/border
if el.element_type == "container" {
render_container_bg(surface, x, y, w, h, &el.style);
}
let Some(ref content) = el.content else {
return;
};
match content {
ResolvedContent::Text { value } => {
render_text(surface, x, y, w, h, value, &el.style, fonts, measurer);
}
ResolvedContent::Line => {
render_line(surface, x, y, w, &el.style);
}
ResolvedContent::Image { src } => {
render_image(surface, x, y, w, h, src);
}
ResolvedContent::PageNumber { current, total } => {
let text = format!("{current} / {total}");
render_text(surface, x, y, w, h, &text, &el.style, fonts, measurer);
}
ResolvedContent::Table { .. } => {
// Tablolar expand edilerek container + text olarak render edilir.
// Bu branch'e normalde düşmemeli.
}
ResolvedContent::Barcode { format, value } => {
render_barcode(surface, x, y, w, h, format, value, &el.style, font_data);
}
}
}
fn render_container_bg(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
y: f32,
w: f32,
h: f32,
style: &ResolvedStyle,
) {
let has_bg = style.background_color.is_some();
let has_border = style.border_color.is_some() && style.border_width.unwrap_or(0.0) > 0.0;
if !has_bg && !has_border {
return;
}
// Fill
if let Some(ref bg) = style.background_color {
surface.set_fill(Some(fill_from_color(parse_color(bg))));
} else {
surface.set_fill(None);
}
// Stroke
if has_border {
let border_color = parse_color(style.border_color.as_deref().unwrap_or("#000000"));
let border_width = mm(style.border_width.unwrap_or(0.5));
surface.set_stroke(Some(Stroke {
paint: border_color.into(),
width: border_width,
opacity: NormalizedF32::ONE,
..Default::default()
}));
} else {
surface.set_stroke(None);
}
let rect_path = {
let mut pb = PathBuilder::new();
if let Some(rect) = krilla::geom::Rect::from_xywh(x, y, w, h) {
pb.push_rect(rect);
}
pb.finish()
};
if let Some(path) = rect_path {
surface.draw_path(&path);
}
// Reset
surface.set_fill(None);
surface.set_stroke(None);
}
fn render_text(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
y: f32,
w: f32,
_h: f32,
text: &str,
style: &ResolvedStyle,
fonts: &FontCollection,
measurer: &mut TextMeasurer,
) {
if text.is_empty() {
return;
}
let font_size = style.font_size.unwrap_or(11.0) as f32;
let color = style
.color
.as_deref()
.map(parse_color)
.unwrap_or(rgb::Color::new(0, 0, 0));
let Some(font) = fonts.get(
style.font_family.as_deref(),
style.font_weight.as_deref(),
) else {
return;
};
surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None);
// Text baseline: y + ascent (yaklaşık font_size * 0.8)
let baseline_y = y + font_size * 0.8;
// Hizalama — cosmic-text ile text genişliğini ölçerek gerçek pozisyon hesapla
let text_x = match style.text_align.as_deref() {
Some("center") | Some("right") => {
let (text_width_pt, _) = measurer.measure(
text,
style.font_family.as_deref(),
font_size,
style.font_weight.as_deref(),
None,
);
if style.text_align.as_deref() == Some("center") {
x + (w - text_width_pt) / 2.0
} else {
x + w - text_width_pt
}
}
_ => x,
};
surface.draw_text(
Point::from_xy(text_x, baseline_y),
font.clone(),
font_size,
text,
false,
TextDirection::Auto,
);
}
fn render_line(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
y: f32,
w: f32,
style: &ResolvedStyle,
) {
let stroke_color = style
.stroke_color
.as_deref()
.map(parse_color)
.unwrap_or(rgb::Color::new(0, 0, 0));
let stroke_width = mm(style.stroke_width.unwrap_or(0.5));
surface.set_fill(None);
surface.set_stroke(Some(Stroke {
paint: stroke_color.into(),
width: stroke_width,
opacity: NormalizedF32::ONE,
..Default::default()
}));
let line_y = y + stroke_width / 2.0;
let path = {
let mut pb = PathBuilder::new();
pb.move_to(x, line_y);
pb.line_to(x + w, line_y);
pb.finish()
};
if let Some(p) = path {
surface.draw_path(&p);
}
surface.set_stroke(None);
}
fn render_image(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
y: f32,
w: f32,
h: f32,
src: &str,
) {
if src.is_empty() {
return;
}
// 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)]);
return;
};
use base64::Engine;
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(base64_part) else {
eprintln!("[dreport] Image base64 decode hatası");
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
}
};
embed_png(surface, x, y, w, h, &png_data);
}
fn render_barcode(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
y: f32,
w: f32,
h: f32,
format: &str,
value: &str,
style: &ResolvedStyle,
font_data: &[FontData],
) {
if value.is_empty() {
return;
}
// Hedef piksel boyutları (yüksek çözünürlük için 4x, minimum 1px)
let w_px = ((w * 4.0) as u32).max(1);
let h_px = ((h * 4.0) as u32).max(1);
let include_text = style.barcode_include_text.unwrap_or(false);
let 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) => {
embed_png(surface, x, y, w, h, &png_bytes);
}
Err(e) => {
eprintln!("[dreport] Barcode üretim hatası ({format}): {e}");
}
}
}
/// image crate ile herhangi bir formattan decode edip PNG bytes'a çevir
fn decode_to_png(raw: &[u8]) -> Option<Vec<u8>> {
let img = image::load_from_memory(raw).ok()?;
let rgba = img.to_rgba8();
let mut buf = Vec::new();
let encoder = image::codecs::png::PngEncoder::new(&mut buf);
image::ImageEncoder::write_image(
encoder,
rgba.as_raw(),
rgba.width(),
rgba.height(),
image::ExtendedColorType::Rgba8,
)
.ok()?;
Some(buf)
}
/// PNG bytes'ı PDF'e göm
fn embed_png(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
y: f32,
w: f32,
h: f32,
data: &[u8],
) {
let data_vec: Vec<u8> = data.to_vec();
let Ok(img) = krilla::image::Image::from_png(data_vec.into(), true) else {
eprintln!("[dreport] PNG 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();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ElementLayout, PageLayout, ResolvedContent, ResolvedStyle};
fn test_fonts() -> Vec<FontData> {
crate::text_measure::load_test_fonts()
}
#[test]
fn test_simple_pdf() {
let layout = LayoutResult {
pages: vec![PageLayout {
page_index: 0,
width_mm: 210.0,
height_mm: 297.0,
elements: vec![
ElementLayout {
id: "title".to_string(),
x_mm: 15.0,
y_mm: 15.0,
width_mm: 180.0,
height_mm: 20.0,
element_type: "static_text".to_string(),
content: Some(ResolvedContent::Text {
value: "FATURA".to_string(),
}),
style: ResolvedStyle {
font_size: Some(18.0),
font_weight: Some("bold".to_string()),
..Default::default()
},
children: vec![],
},
ElementLayout {
id: "line1".to_string(),
x_mm: 15.0,
y_mm: 38.0,
width_mm: 180.0,
height_mm: 0.5,
element_type: "line".to_string(),
content: Some(ResolvedContent::Line),
style: ResolvedStyle {
stroke_color: Some("#000000".to_string()),
stroke_width: Some(0.5),
..Default::default()
},
children: vec![],
},
ElementLayout {
id: "body".to_string(),
x_mm: 15.0,
y_mm: 42.0,
width_mm: 180.0,
height_mm: 14.0,
element_type: "static_text".to_string(),
content: Some(ResolvedContent::Text {
value: "Bu bir test belgesidir.".to_string(),
}),
style: ResolvedStyle {
font_size: Some(11.0),
..Default::default()
},
children: vec![],
},
],
}],
};
let fonts = test_fonts();
let pdf = render_pdf(&layout, &fonts).expect("PDF oluşturulabilmeli");
// PDF magic bytes kontrolü
assert!(pdf.starts_with(b"%PDF"), "Geçerli PDF çıktısı olmalı");
assert!(pdf.len() > 100, "PDF boyutu çok küçük: {} bytes", pdf.len());
// Debug: dosyaya yaz
let out_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("test_output.pdf");
std::fs::write(&out_path, &pdf).unwrap();
println!("Test PDF yazıldı: {}", out_path.display());
}
#[test]
fn test_full_pipeline() {
use dreport_core::models::*;
use serde_json::json;
let template = Template {
id: "test".to_string(),
name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec!["Noto Sans".to_string()],
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Auto,
height: SizeValue::Auto,
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 },
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None, min_height: None, max_width: None, max_height: None,
},
style: TextStyle {
font_size: Some(18.0),
font_weight: Some("bold".to_string()),
..Default::default()
},
content: "FATURA".to_string(),
}),
TemplateElement::Line(LineElement {
id: "line1".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None, min_height: None, max_width: None, max_height: None,
},
style: LineStyle {
stroke_color: Some("#000000".to_string()),
stroke_width: Some(0.5),
},
}),
TemplateElement::Text(TextElement {
id: "firma".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None, min_height: None, max_width: None, max_height: None,
},
style: TextStyle {
font_size: Some(11.0),
..Default::default()
},
content: None,
binding: dreport_core::models::ScalarBinding {
path: "firma.unvan".to_string(),
},
}),
],
},
};
let data = json!({
"firma": { "unvan": "Acme Teknoloji A.Ş." }
});
let fonts = test_fonts();
let layout = crate::compute_layout(&template, &data, &fonts);
let pdf = render_pdf(&layout, &fonts).expect("Full pipeline PDF");
assert!(pdf.starts_with(b"%PDF"));
assert!(pdf.len() > 200);
let out_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("test_output_full.pdf");
std::fs::write(&out_path, &pdf).unwrap();
println!("Full pipeline PDF: {}", out_path.display());
}
}

222
layout-engine/src/sizing.rs Normal file
View File

@@ -0,0 +1,222 @@
use dreport_core::models::{ContainerElement, PositionMode, SizeConstraint, SizeValue};
use taffy::prelude::*;
/// 1mm = 72/25.4 pt (kesin değer)
const MM_TO_PT_F64: f64 = 72.0 / 25.4;
pub fn mm_to_pt(mm: f64) -> f32 {
(mm * MM_TO_PT_F64) as f32
}
pub fn pt_to_mm(pt: f32) -> f64 {
(pt as f64) / MM_TO_PT_F64
}
/// SizeValue → taffy Dimension (width veya height için)
fn size_value_to_dimension(sv: &SizeValue) -> Dimension {
match sv {
SizeValue::Fixed { value } => Dimension::Length(mm_to_pt(*value)),
SizeValue::Auto => Dimension::Auto,
// Fr için dimension Auto, flex_grow ayrıca set edilir
SizeValue::Fr { .. } => Dimension::Auto,
}
}
/// SizeValue → taffy LengthPercentage (min/max constraint'ler için)
fn mm_to_length(mm: f64) -> Dimension {
Dimension::Length(mm_to_pt(mm))
}
/// Fr değerini döndür (yoksa 0)
fn fr_value(sv: &SizeValue) -> f32 {
match sv {
SizeValue::Fr { value } => *value as f32,
_ => 0.0,
}
}
/// SizeConstraint'ten taffy Style'a flex_grow ayarını da dahil ederek dönüştür.
/// `main_axis` parametresi parent container'ın direction'ına göre
/// hangi eksenin flex_grow kullanacağını belirler.
pub fn apply_size_to_style(
style: &mut Style,
size: &SizeConstraint,
parent_direction: Option<&str>,
) {
style.size = Size {
width: size_value_to_dimension(&size.width),
height: size_value_to_dimension(&size.height),
};
// Min/max constraint'ler
if let Some(min_w) = size.min_width {
style.min_size.width = mm_to_length(min_w);
}
if let Some(min_h) = size.min_height {
style.min_size.height = mm_to_length(min_h);
}
if let Some(max_w) = size.max_width {
style.max_size.width = mm_to_length(max_w);
}
if let Some(max_h) = size.max_height {
style.max_size.height = mm_to_length(max_h);
}
// 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),
};
// 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),
};
// Eğer main axis fr ise, flex_grow ayarla ve flex_basis 0 yap
if main_fr > 0.0 {
style.flex_grow = main_fr;
style.flex_shrink = 1.0;
style.flex_basis = Dimension::Length(0.0);
// min-width: 0 (row) veya min-height: 0 (column) ayarla —
// taffy'de min_size default Auto = içerik boyutunun altına küçülemez.
// Fr elemanların içerik taşırması engellemek için min_size 0 olmalı.
match parent_direction {
Some("row") => {
if size.min_width.is_none() {
style.min_size.width = Dimension::Length(0.0);
}
}
_ => {
if size.min_height.is_none() {
style.min_size.height = Dimension::Length(0.0);
}
}
}
}
// Cross axis fr ise, align_self stretch yeterli
// (taffy'de cross axis flex_grow doğrudan yok, stretch ile çözülür)
if cross_fr > 0.0 {
style.align_self = Some(AlignSelf::Stretch);
}
}
/// ContainerElement → taffy Style
pub fn container_to_style(el: &ContainerElement, parent_direction: Option<&str>) -> Style {
let mut style = Style {
display: Display::Flex,
flex_direction: match el.direction.as_str() {
"row" => FlexDirection::Row,
_ => FlexDirection::Column,
},
gap: Size {
width: LengthPercentage::Length(mm_to_pt(el.gap)),
height: LengthPercentage::Length(mm_to_pt(el.gap)),
},
padding: Rect {
top: LengthPercentage::Length(mm_to_pt(el.padding.top)),
right: LengthPercentage::Length(mm_to_pt(el.padding.right)),
bottom: LengthPercentage::Length(mm_to_pt(el.padding.bottom)),
left: LengthPercentage::Length(mm_to_pt(el.padding.left)),
},
align_items: Some(match el.align.as_str() {
"center" => AlignItems::Center,
"end" => AlignItems::FlexEnd,
"stretch" => AlignItems::Stretch,
_ => AlignItems::FlexStart,
}),
justify_content: Some(match el.justify.as_str() {
"center" => JustifyContent::Center,
"end" => JustifyContent::FlexEnd,
"space-between" => JustifyContent::SpaceBetween,
_ => JustifyContent::FlexStart,
}),
..Default::default()
};
// Pozisyon moduna göre
match &el.position {
PositionMode::Absolute { x, y } => {
style.position = Position::Absolute;
style.inset = Rect {
top: LengthPercentageAuto::Length(mm_to_pt(*y)),
left: LengthPercentageAuto::Length(mm_to_pt(*x)),
right: auto(),
bottom: auto(),
};
}
PositionMode::Flow => {}
}
// Boyut
apply_size_to_style(&mut style, &el.size, parent_direction);
// Container border
if let Some(bw) = el.style.border_width {
let bpt = mm_to_pt(bw);
style.border = Rect {
top: LengthPercentage::Length(bpt),
right: LengthPercentage::Length(bpt),
bottom: LengthPercentage::Length(bpt),
left: LengthPercentage::Length(bpt),
};
}
style
}
/// Leaf element (text, line, image vs.) için taffy Style
pub fn leaf_style(
size: &SizeConstraint,
position: &PositionMode,
parent_direction: Option<&str>,
) -> Style {
let mut style = Style::default();
match position {
PositionMode::Absolute { x, y } => {
style.position = Position::Absolute;
style.inset = Rect {
top: LengthPercentageAuto::Length(mm_to_pt(*y)),
left: LengthPercentageAuto::Length(mm_to_pt(*x)),
right: auto(),
bottom: auto(),
};
}
PositionMode::Flow => {}
}
apply_size_to_style(&mut style, size, parent_direction);
style
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mm_to_pt_conversion() {
let pt = mm_to_pt(210.0);
// A4 width = 210mm ≈ 595.28pt
assert!((pt - 595.28).abs() < 0.1);
}
#[test]
fn test_fixed_size() {
let sv = SizeValue::Fixed { value: 50.0 };
match size_value_to_dimension(&sv) {
Dimension::Length(pt) => assert!((pt - mm_to_pt(50.0)).abs() < 0.01),
_ => panic!("Expected Length"),
}
}
#[test]
fn test_fr_maps_to_auto_dimension() {
let sv = SizeValue::Fr { value: 2.0 };
assert!(matches!(size_value_to_dimension(&sv), Dimension::Auto));
}
}

View File

@@ -0,0 +1,191 @@
use dreport_core::models::*;
use crate::data_resolve::ResolvedData;
/// 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)
///
/// Bu sayede tablo, normal container layout'u ile hesaplanır.
pub fn expand_table(
table: &RepeatingTableElement,
resolved: &ResolvedData,
) -> ContainerElement {
let resolved_table = resolved.tables.get(&table.id);
let rows = resolved_table
.map(|t| t.rows.as_slice())
.unwrap_or(&[]);
let mut children: Vec<TemplateElement> = Vec::new();
// Header row
let header_cells: Vec<TemplateElement> = table
.columns
.iter()
.enumerate()
.map(|(i, col)| {
TemplateElement::StaticText(StaticTextElement {
id: format!("{}_hdr_{}", table.id, i),
position: PositionMode::Flow,
size: SizeConstraint {
width: col.width.clone(),
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
style: TextStyle {
font_size: table.style.header_font_size.or(table.style.font_size),
font_weight: Some("bold".to_string()),
font_family: None,
color: table.style.header_color.clone(),
align: Some(col.align.clone()),
},
content: col.title.clone(),
})
})
.collect();
children.push(TemplateElement::Container(ContainerElement {
id: format!("{}_header", table.id),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
direction: "row".to_string(),
gap: 0.0,
padding: Padding {
top: 1.0,
right: 0.0,
bottom: 1.0,
left: 0.0,
},
align: "center".to_string(),
justify: "start".to_string(),
style: ContainerStyle {
background_color: table.style.header_bg.clone(),
..Default::default()
},
children: header_cells,
}));
// Header altına ayırıcı çizgi
if table.style.border_color.is_some() {
children.push(TemplateElement::Line(LineElement {
id: format!("{}_header_line", table.id),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
style: LineStyle {
stroke_color: table.style.border_color.clone(),
stroke_width: table.style.border_width,
},
}));
}
// Data rows
for (row_idx, row_data) in rows.iter().enumerate() {
let cells: Vec<TemplateElement> = table
.columns
.iter()
.enumerate()
.map(|(col_idx, col)| {
let text = row_data
.get(col_idx)
.cloned()
.unwrap_or_default();
TemplateElement::StaticText(StaticTextElement {
id: format!("{}_r{}c{}", table.id, row_idx, col_idx),
position: PositionMode::Flow,
size: SizeConstraint {
width: col.width.clone(),
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
style: TextStyle {
font_size: table.style.font_size,
font_weight: None,
font_family: None,
color: None,
align: Some(col.align.clone()),
},
content: text,
})
})
.collect();
// row_idx 0-based: 0. satır görsel olarak 1. (tek/odd), 1. satır 2. (çift/even)
let bg = if row_idx % 2 == 0 {
table.style.zebra_odd.clone()
} else {
table.style.zebra_even.clone()
};
children.push(TemplateElement::Container(ContainerElement {
id: format!("{}_row_{}", table.id, row_idx),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
direction: "row".to_string(),
gap: 0.0,
padding: Padding {
top: 0.5,
right: 0.0,
bottom: 0.5,
left: 0.0,
},
align: "center".to_string(),
justify: "start".to_string(),
style: ContainerStyle {
background_color: bg,
..Default::default()
},
children: cells,
}));
}
// Wrapper container (column direction, tüm tablo)
ContainerElement {
id: table.id.clone(),
position: table.position.clone(),
size: table.size.clone(),
direction: "column".to_string(),
gap: 0.0,
padding: Padding {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: 0.0,
},
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle {
border_color: table.style.border_color.clone(),
border_width: table.style.border_width,
..Default::default()
},
children,
}
}

View File

@@ -0,0 +1,277 @@
use std::collections::HashMap;
use std::hash::Hash;
use crate::FontData;
use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, Weight};
/// Opak text ölçüm cache'i. `TextMeasurer` call'ları arasında taşınarak
/// aynı parametrelerle yapılan ölçümlerin yeniden hesaplanmasını önler.
#[derive(Default)]
pub struct TextMeasureCache {
entries: HashMap<MeasureCacheKey, (f32, f32)>,
}
impl TextMeasureCache {
/// Cache içeriğini al ve yerine boş cache bırak.
pub fn take(&mut self) -> Self {
Self {
entries: std::mem::take(&mut self.entries),
}
}
}
/// Cache key — text ölçüm parametrelerinin hash'lenebilir temsili.
/// f32 değerler bit-exact karşılaştırma için u32'ye çevrilir.
#[derive(Clone, Eq, PartialEq, Hash)]
struct MeasureCacheKey {
text: String,
font_family: Option<String>,
font_size_bits: u32,
font_weight: Option<String>,
available_width_bits: Option<u32>,
}
impl MeasureCacheKey {
fn new(
text: &str,
font_family: Option<&str>,
font_size_pt: f32,
font_weight: Option<&str>,
available_width_pt: Option<f32>,
) -> Self {
Self {
text: text.to_string(),
font_family: font_family.map(|s| s.to_string()),
font_size_bits: font_size_pt.to_bits(),
font_weight: font_weight.map(|s| s.to_string()),
available_width_bits: available_width_pt.map(|w| w.to_bits()),
}
}
}
/// Text ölçüm motoru. cosmic-text kullanarak verilen font, boyut ve
/// mevcut genişlik kısıtı ile text'in kaplayacağı alanı hesaplar.
/// Ölçüm sonuçları cache'lenir — aynı parametrelerle tekrar çağrılırsa
/// cosmic-text'e gitmeden cache'ten döner.
pub struct TextMeasurer {
font_system: FontSystem,
cache: HashMap<MeasureCacheKey, (f32, f32)>,
}
/// pt → px dönüşümü (cosmic-text px cinsinden çalışır, 1pt = 1.333px @96dpi)
const PT_TO_PX: f32 = 96.0 / 72.0;
impl TextMeasurer {
pub fn new(fonts: &[FontData]) -> Self {
let mut font_system = FontSystem::new_with_locale_and_db(
"tr-TR".to_string(),
cosmic_text::fontdb::Database::new(),
);
for font in fonts {
font_system.db_mut().load_font_data(font.data.clone());
}
Self {
font_system,
cache: HashMap::new(),
}
}
/// Mevcut cache'i koruyarak yeni bir TextMeasurer oluştur.
/// Font seti değişmediyse eski cache geçerliliğini korur.
pub fn new_with_cache(fonts: &[FontData], cache: TextMeasureCache) -> Self {
let mut m = Self::new(fonts);
m.cache = cache.entries;
m
}
/// Cache'i dışarı taşı (persist etmek için).
pub fn take_cache(self) -> TextMeasureCache {
TextMeasureCache { entries: self.cache }
}
/// Text'i ölç. Dönen değerler pt cinsinden (width, height).
/// `available_width_pt`: Mevcut genişlik kısıtı (pt). None ise sınırsız.
/// Sonuç cache'lenir — aynı parametrelerle tekrar çağrılırsa cache'ten döner.
pub fn measure(
&mut self,
text: &str,
font_family: Option<&str>,
font_size_pt: f32,
font_weight: Option<&str>,
available_width_pt: Option<f32>,
) -> (f32, f32) {
if text.is_empty() {
return (0.0, font_size_pt * 1.2);
}
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);
self.cache.insert(key, result);
result
}
/// Cache'siz ölçüm — cosmic-text ile gerçek hesaplama.
fn measure_uncached(
&mut self,
text: &str,
font_family: Option<&str>,
font_size_pt: f32,
font_weight: Option<&str>,
available_width_pt: Option<f32>,
) -> (f32, f32) {
let font_size_px = font_size_pt * PT_TO_PX;
let line_height_px = font_size_px * 1.2;
let metrics = Metrics::new(font_size_px, line_height_px);
let mut buffer = Buffer::new(&mut self.font_system, metrics);
let width_px = available_width_pt.map(|w| w * PT_TO_PX);
buffer.set_size(&mut self.font_system, width_px, None);
let weight = match font_weight {
Some("bold") => Weight::BOLD,
_ => Weight::NORMAL,
};
let family_name = font_family.unwrap_or("Noto Sans");
let attrs = Attrs::new()
.family(Family::Name(family_name))
.weight(weight);
buffer.set_text(&mut self.font_system, text, attrs, Shaping::Advanced);
buffer.shape_until_scroll(&mut self.font_system, false);
let mut max_width: f32 = 0.0;
let mut total_height: f32 = 0.0;
for run in buffer.layout_runs() {
let run_width = run.line_w;
if run_width > max_width {
max_width = run_width;
}
total_height = run.line_top + line_height_px;
}
if total_height == 0.0 {
total_height = line_height_px;
}
let width_pt = max_width / PT_TO_PX;
let height_pt = total_height / PT_TO_PX;
// Text genişliğine küçük bir tolerans ekle (0.5pt ≈ 0.18mm).
// cosmic-text ile browser font engine'i farklı subpixel sonuçlar üretir;
// bu fark zoom değişimlerinde text wrap sınırında flickering'e yol açar.
// 0.5pt baskıda görünmez ama wrapping dengesizliğini önler.
let width_pt = width_pt + 0.5;
(width_pt, height_pt)
}
}
#[cfg(test)]
pub(crate) fn load_test_fonts() -> Vec<crate::FontData> {
let font_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("backend/fonts");
let mut fonts = Vec::new();
for entry in std::fs::read_dir(&font_dir).expect("backend/fonts dizini bulunamadı") {
let entry = entry.unwrap();
let path = entry.path();
if path.extension().is_some_and(|e| e == "ttf") {
let data = std::fs::read(&path).unwrap();
let family = if path
.file_name()
.unwrap()
.to_str()
.unwrap()
.contains("Mono")
{
"Noto Sans Mono".to_string()
} else {
"Noto Sans".to_string()
};
fonts.push(crate::FontData { family, data });
}
}
fonts
}
#[cfg(test)]
mod tests {
use super::*;
fn make_measurer() -> TextMeasurer {
TextMeasurer::new(&load_test_fonts())
}
#[test]
fn test_empty_text() {
let mut m = make_measurer();
let (w, h) = m.measure("", None, 12.0, None, None);
assert_eq!(w, 0.0);
assert!(h > 0.0);
}
#[test]
fn test_basic_measurement() {
let mut m = make_measurer();
let (w, h) = m.measure("Hello", None, 12.0, None, None);
assert!(w > 0.0, "Width should be positive, got {w}");
assert!(h > 0.0, "Height should be positive, got {h}");
}
#[test]
fn test_cache_returns_same_result() {
let mut m = make_measurer();
let (w1, h1) = m.measure("Cache test", None, 14.0, Some("bold"), Some(100.0));
let (w2, h2) = m.measure("Cache test", None, 14.0, Some("bold"), Some(100.0));
assert_eq!(w1, w2);
assert_eq!(h1, h2);
// Cache'te 1 entry olmalı (aynı key iki kere çağrıldı)
assert_eq!(m.cache.len(), 1);
}
#[test]
fn test_cache_persists_across_measurers() {
let fonts = load_test_fonts();
let mut m1 = TextMeasurer::new(&fonts);
let (w1, h1) = m1.measure("Persist test", None, 12.0, None, None);
let cache = m1.take_cache();
let mut m2 = TextMeasurer::new_with_cache(&fonts, cache);
assert_eq!(m2.cache.len(), 1);
let (w2, h2) = m2.measure("Persist test", None, 12.0, None, None);
assert_eq!(w1, w2);
assert_eq!(h1, h2);
}
#[test]
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);
// 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));
// Dar genişlikte yükseklik artmalı (wrapping oldu)
assert!(
h_narrow >= h_unlimited,
"Wrapped height ({h_narrow}) should be >= unlimited height ({h_unlimited})"
);
// Dar genişlikte genişlik kısıtlanmış olmalı
assert!(
w_narrow <= w_unlimited + 1.0,
"Wrapped width ({w_narrow}) should be <= unlimited width ({w_unlimited})"
);
}
}

1027
layout-engine/src/tree.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
use std::sync::{Mutex, OnceLock};
use std::collections::HashMap;
use wasm_bindgen::prelude::*;
use crate::FontData;
use crate::text_measure::TextMeasureCache;
/// Font verileri worker'da cache'lenir.
static FONTS: OnceLock<Vec<FontData>> = OnceLock::new();
/// Text ölçüm cache'i — layout call'ları arasında persist eder.
/// Aynı text + font + size + weight + available_width → aynı sonuç.
static TEXT_CACHE: OnceLock<Mutex<TextMeasureCache>> = OnceLock::new();
/// Barcode pixel cache — (format, value, width, height, include_text) → RGBA bytes (header dahil).
static BARCODE_CACHE: OnceLock<Mutex<HashMap<BarcodeCacheKey, Vec<u8>>>> = OnceLock::new();
#[derive(Clone, Eq, PartialEq, Hash)]
struct BarcodeCacheKey {
format: String,
value: String,
width: u32,
height: u32,
include_text: bool,
}
/// Font verilerini yükle (worker init sırasında bir kere çağrılır).
/// `families`: JSON array of font family names — ["Noto Sans", "Noto Sans", ...]
/// `buffers`: Her font dosyasının raw bytes'ı (sırayla)
#[wasm_bindgen(js_name = "loadFonts")]
pub fn load_fonts(families: &str, buffers: Vec<js_sys::Uint8Array>) -> Result<(), JsValue> {
let families: Vec<String> =
serde_json::from_str(families).map_err(|e| JsValue::from_str(&e.to_string()))?;
if families.len() != buffers.len() {
return Err(JsValue::from_str("families and buffers length mismatch"));
}
let fonts: Vec<FontData> = families
.into_iter()
.zip(buffers.into_iter())
.map(|(family, buf)| FontData {
family,
data: buf.to_vec(),
})
.collect();
FONTS
.set(fonts)
.map_err(|_| JsValue::from_str("Fonts already loaded"))?;
Ok(())
}
/// Layout hesapla.
/// `template_json`: Template JSON string
/// `data_json`: Data JSON string
/// Dönen değer: LayoutResult JSON string
///
/// Text ölçüm sonuçları cross-call cache'lenir — değişmeyen text elemanları
/// cosmic-text'e gitmeden cache'ten döner.
#[wasm_bindgen(js_name = "computeLayout")]
pub fn compute_layout_wasm(template_json: &str, data_json: &str) -> Result<String, JsValue> {
let template: dreport_core::models::Template =
serde_json::from_str(template_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
let data: serde_json::Value =
serde_json::from_str(data_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
let fonts = FONTS
.get()
.ok_or_else(|| JsValue::from_str("Fonts not loaded. Call loadFonts() first."))?;
// Text cache'i al (veya ilk kullanımda oluştur)
let cache_mutex = TEXT_CACHE.get_or_init(|| Mutex::new(TextMeasureCache::default()));
let text_cache = cache_mutex.lock().unwrap().take();
let (result, new_cache) = crate::compute_layout_cached(&template, &data, fonts, text_cache);
// Güncel cache'i geri koy
*cache_mutex.lock().unwrap() = new_cache;
serde_json::to_string(&result).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// 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> {
let cache_key = BarcodeCacheKey {
format: format.to_string(),
value: value.to_string(),
width,
height,
include_text,
};
let cache_mutex = BARCODE_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
// Cache hit?
{
let cache = cache_mutex.lock().unwrap();
if let Some(cached_data) = cache.get(&cache_key) {
let arr = js_sys::Uint8ClampedArray::new_with_length(cached_data.len() as u32);
arr.copy_from(cached_data);
return Ok(arr);
}
}
// Cache miss — üret
let fonts = FONTS.get().map(|f| f.as_slice());
let result = crate::barcode_gen::generate_barcode_pixels(format, value, width, height, include_text, fonts)
.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);
for &gray in &result.pixels {
rgba.push(gray); // R
rgba.push(gray); // G
rgba.push(gray); // B
rgba.push(255); // A
}
// Header (8 byte: width LE + height LE) + RGBA pixel verisi
let mut data = Vec::with_capacity(8 + rgba.len());
data.extend_from_slice(&result.width.to_le_bytes());
data.extend_from_slice(&result.height.to_le_bytes());
data.extend_from_slice(&rgba);
let arr = js_sys::Uint8ClampedArray::new_with_length(data.len() as u32);
arr.copy_from(&data);
// Cache'e kaydet
cache_mutex.lock().unwrap().insert(cache_key, data);
Ok(arr)
}