mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
fix bugs
This commit is contained in:
29
layout-engine/Cargo.toml
Normal file
29
layout-engine/Cargo.toml
Normal 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 = []
|
||||
286
layout-engine/src/barcode_gen.rs
Normal file
286
layout-engine/src/barcode_gen.rs
Normal 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']));
|
||||
}
|
||||
}
|
||||
161
layout-engine/src/data_resolve.rs
Normal file
161
layout-engine/src/data_resolve.rs
Normal 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
142
layout-engine/src/lib.rs
Normal 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>,
|
||||
}
|
||||
680
layout-engine/src/pdf_render.rs
Normal file
680
layout-engine/src/pdf_render.rs
Normal 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
222
layout-engine/src/sizing.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
191
layout-engine/src/table_layout.rs
Normal file
191
layout-engine/src/table_layout.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
277
layout-engine/src/text_measure.rs
Normal file
277
layout-engine/src/text_measure.rs
Normal 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
1027
layout-engine/src/tree.rs
Normal file
File diff suppressed because it is too large
Load Diff
138
layout-engine/src/wasm_api.rs
Normal file
138
layout-engine/src/wasm_api.rs
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user