bug fixes & improvements & missing features & font loader

This commit is contained in:
2026-04-07 00:36:21 +03:00
parent e95606d18b
commit 9f658f5615
54 changed files with 4087 additions and 1843 deletions

View File

@@ -1,271 +0,0 @@
# Eleman Tipleri — dreport
Bu belge, dreport toolbar'inda bulunan ve planlanmis tum eleman tiplerini aciklar.
---
## Mevcut Elemanlar
### `container` — Duzen Kutusu
CSS Flexbox mantiginda calisan layout container'i. Cocuk elemanlari `direction` (row/column) dogrultusunda dizer. Ic ice gecebilir. Tum diger elemanlar bir container icinde yer alir.
- **Binding:** Yok
- **Ozellikler:** `direction`, `gap`, `padding`, `align`, `justify`, `style`
---
### `static_text` — Sabit Metin
Veri baglantisi olmayan, kullanicinin dogrudan yazdigi metin. Fatura basliklari, etiketler, aciklama satirlari icin kullanilir.
- **Binding:** Yok
- **Ozellikler:** `content`, `style` (fontSize, fontWeight, color, align)
---
### `text` — Dinamik Metin
JSON schema'dan veri ceken metin elemani. Kullanici schema agacindan bir alani surukleyip bu elemana baglar.
- **Binding:** Scalar (`"binding": { "type": "scalar", "path": "firma.unvan" }`)
- **Ozellikler:** `binding`, `style`, `format` (currency, date, percentage)
---
### `repeating_table` — Tekrarlayan Tablo
Array verisinden tekrarlayan satirlar ureten tablo bileseni. Fatura kalemleri, stok listeleri gibi tekrarlayan veri icin kullanilir.
- **Binding:** Array (`"dataSource": "kalemler"`)
- **Ozellikler:** `columns` (alan, genislik, hizalama), `headerStyle`, `rowStyle`, `zebraStyle`
---
### `line` — Cizgi
Yatay veya dikey ayirici cizgi. Bolum ayirma, dekoratif amaclarla kullanilir.
- **Binding:** Yok
- **Ozellikler:** `style` (strokeColor, strokeWidth)
---
### `image` — Gorsel
Statik (base64/URL) veya dinamik (schema'dan) gorsel. Logo, imza, urun gorseli gibi kullanim alanlari.
- **Binding:** Opsiyonel scalar (dinamik gorsel icin)
- **Ozellikler:** `src` (statik), `binding`, `style` (objectFit)
---
### `page_number` — Sayfa Numarasi
Cok sayfali belgelerde otomatik sayfa numarasi. Format sablonu destekler (or: "Sayfa {current} / {total}").
- **Binding:** Otomatik
- **Ozellikler:** `format`, `style`
---
### `barcode` — Barkod / QR Kod
1D ve 2D barkod ureteci. e-Fatura, e-Arsiv, urun etiketleri icin kullanilir.
- **Binding:** Scalar (barkod verisi icin)
- **Desteklenen formatlar:** QR, EAN-13, EAN-8, CODE128, CODE39
- **Ozellikler:** `barcodeType`, `binding`, `style`
---
## Planlanmis Elemanlar
### `rich_text` — Zengin Metin [Yapildi]
Tek bir metin blogu icinde karisik formatlama destekleyen eleman. Kalin, italik, farkli font boyutu, renk gibi stilleri ayni paragraf icinde kullanmayi saglar.
- **Kullanim alanlari:** Fatura aciklama alanlari, sozlesme maddeleri, rapor notlari, uzun formlu metin icerikleri
- **Binding:** Opsiyonel scalar (dinamik icerik icin)
- **Yaklasim:** Inline span'lar ile zengin metin. cosmic-text attributed text destekledigi icin layout engine tarafinda uyumlu.
```jsonc
{
"type": "rich_text",
"content": [
{ "text": "Odeme vadesi: ", "style": {} },
{ "text": "30 gun", "style": { "fontWeight": "bold", "color": "#e00" } }
]
}
```
**Referans:** Telerik (HtmlTextBox), DevExpress (Rich Text), Stimulsoft, FastReport, CraftMyPDF — hepsinde mevcut. Belge tasarim araclarinda standart bir beklenti.
---
### `shape` — Sekil (Dikdortgen / Elips) [Yapildi]
Cocuk eleman barindirmayan sade gorsel element. Vurgu kutulari, dekoratif cerceveler, arka plan alanlari icin kullanilir. Container'dan farki: layout'a katilmaz, sadece gorsel amaclidir.
- **Kullanim alanlari:** Toplam kutusunun arka plani, raporlarda highlight alanlari, dekoratif cerceveler
- **Binding:** Yok
- **Sekil tipleri:** `rectangle`, `ellipse`, `rounded_rectangle`
```jsonc
{
"type": "shape",
"shapeType": "rectangle",
"style": {
"backgroundColor": "#f0f0f0",
"borderColor": "#333",
"borderWidth": 0.5,
"borderRadius": 2
}
}
```
**Referans:** JasperReports, Telerik, DevExpress, Stimulsoft, FastReport, CraftMyPDF — neredeyse tum araclarda var.
---
### `checkbox` — Onay Kutusu [Yapildi]
Boolean deger gosteren isaret kutusu. Isaretsiz kare veya isaretli (checkmark) kare olarak render edilir. Veri baglantisi ile dinamik calisan veya statik olarak kullanilabilen basit bir element.
- **Kullanim alanlari:** Irsaliyelerde "teslim edildi / edilmedi", faturalarda odeme durumu, raporlarda checklist, form benzeri belgeler
- **Binding:** Scalar (boolean alan)
```jsonc
{
"type": "checkbox",
"binding": { "type": "scalar", "path": "fatura.odpiendi" },
"style": { "size": 4, "checkColor": "#000", "borderColor": "#333" }
}
```
**Referans:** DevExpress, Telerik, Stimulsoft, FastReport, CraftMyPDF.
---
### `calculated_text` — Hesaplanmis Alan [Yapildi]
Basit ifadeler (expression) ile hesaplanmis deger gosteren metin elemani. Aritmetik islemler, string birlestirme ve kosullu metin destekler.
- **Kullanim alanlari:** Ara toplam hesaplari (`araToplam * 0.20`), string birlestirme (`"Fatura No: " + fatura.no`), kosullu metin, rapor ozetleri
- **Binding:** Expression-based (birden fazla alana referans verebilir)
- **Format:** currency, date, percentage, number destegi
```jsonc
{
"type": "calculated_text",
"expression": "toplamlar.araToplam * 0.20",
"format": "currency",
"style": { "fontSize": 10 }
}
```
**Referans:** Crystal Reports (Formula Field), JasperReports (Variable), Stimulsoft (Expression).
---
### `current_date` — Tarih / Zaman [Yapildi]
Belgenin basilma/render anindaki tarihi otomatik gosteren element. `page_number` gibi otomatik deger uretir, veri baglantisi gerektirmez.
- **Kullanim alanlari:** Fatura basim tarihi, rapor olusturma zamani, belge altbilgisi
- **Binding:** Otomatik
- **Format:** Konfigurasyon ile (or: `DD.MM.YYYY`, `DD MMMM YYYY`, `DD.MM.YYYY HH:mm`)
```jsonc
{
"type": "current_date",
"format": "DD.MM.YYYY",
"style": { "fontSize": 8, "color": "#666" }
}
```
**Referans:** Crystal Reports (Print Date), JasperReports (Current Date), BIRT (AutoText).
---
### `page_break` — Sayfa Sonu [Yapildi]
Kullanicinin belirli bir noktada yeni sayfaya gecmesini saglayan kontrol elemani. Otomatik sayfa sonu (page_break.rs) zaten mevcut, bu element manuel kontrol saglar.
- **Kullanim alanlari:** Rapor ozet sayfasi + detay sayfasi ayrimi, faturada ek bilgi sayfasi, belirli bolumlerin ayri sayfada baslamasi
- **Binding:** Yok
- **Gorsel:** Editorde kesikli cizgi olarak gosterilir, PDF'te sayfa gecisi uretir.
```jsonc
{
"type": "page_break"
}
```
**Referans:** DevExpress (Page Break kontrol), Stimulsoft.
---
### `chart` — Grafik [Henuz implemente edilmedi]
Veri gorselIestirme icin basit grafik elemani. Rapor ciktilari icin degerli, fatura/irsaliye icin genellikle gereksiz.
- **Kullanim alanlari:** Satis raporlari, performans ozetleri, karsilastirmali veriler
- **Binding:** Array veya multiple scalar
- **Grafik tipleri:** `bar`, `pie`, `line` (baslangic seti)
- **Yaklasim:** Backend'de SVG olarak render edilip PDF'e image olarak gomulur.
```jsonc
{
"type": "chart",
"chartType": "bar",
"dataSource": "aylik_satislar",
"labelField": "ay",
"valueField": "tutar",
"style": { "width": 120, "height": 80 }
}
```
**Referans:** JasperReports, Crystal Reports, Telerik, DevExpress, Stimulsoft, CraftMyPDF — enterprise araclarin tamami destekler.
---
## Toolbar Organizasyonu
```
Toolbar
├── Duzen
│ ├── Container (mevcut)
│ └── Page Break (mevcut)
├── Metin
│ ├── Statik Metin (mevcut)
│ ├── Rich Text (mevcut)
│ └── Hesaplanmis Alan (mevcut)
├── Veri
│ ├── Tekrarlayan Tablo (mevcut)
│ └── Checkbox (mevcut)
├── Gorsel
│ ├── Gorsel (mevcut)
│ ├── Cizgi (mevcut)
│ ├── Sekil (mevcut)
│ └── Barkod / QR (mevcut)
├── Otomatik
│ ├── Sayfa No (mevcut)
│ └── Tarih (mevcut)
└── Rapor
└── Grafik (planlanmis)
```
---
## Oncelik Sirasi
| Oncelik | Element | Gerekce | Durum |
|---------|---------|---------|-------|
| 1 | `rich_text` | Karisik formatlama en cok talep edilen ozellik, cosmic-text uyumlu | Yapildi |
| 2 | `shape` | Basit implementasyon, gorsel zenginlik katiyor | Yapildi |
| 3 | `checkbox` | Boolean gosterim, form/irsaliye icin onemli | Yapildi |
| 4 | `calculated_text` | Hesaplama ihtiyaci fatura/rapor icin kritik | Yapildi |
| 5 | `current_date` | Kucuk ama kullanisli, hizli implemente edilir | Yapildi |
| 6 | `page_break` | Manuel sayfa kontrolu, rapor senaryolari icin | Yapildi |
| 7 | `chart` | En karmasik, rapor fazinda ele alinabilir | |

View File

@@ -7,7 +7,7 @@
## 1. Kritik Buglar
### 1.1 Undo/Redo `Object.assign` Hatasi `[IMPLEMENTE EDILMEDI]`
### 1.1 Undo/Redo `Object.assign` Hatasi `[IMPLEMENTE EDILDI]`
**Dosya:** `frontend/src/composables/useUndoRedo.ts` (satir 52)
@@ -35,7 +35,7 @@ Undo/redo watcher'da 300ms debounce var. Kullanici hizli bir edit yapip 300ms ic
---
### 1.2 PDF'te Text Wrapping Yok `[IMPLEMENTE EDILMEDI]`
### 1.2 PDF'te Text Wrapping Yok `[IMPLEMENTE EDILDI]`
**Dosya:** `layout-engine/src/pdf_render.rs` (satir ~487)
@@ -52,7 +52,7 @@ Bu, projenin temel vaadi olan "editorde gordugum = PDF'te aldigim" WYSIWYG garan
---
### 1.3 Image objectFit Hardcoded `[IMPLEMENTE EDILMEDI]`
### 1.3 Image objectFit Hardcoded `[IMPLEMENTE EDILDI]`
**Dosya:** `frontend/src/components/editor/LayoutRenderer.vue` (satir ~229)
@@ -74,7 +74,7 @@ objectFit: el.style.objectFit || 'fill',
---
### 1.4 PDF'te Italic Font Secilmiyor `[IMPLEMENTE EDILMEDI]`
### 1.4 PDF'te Italic Font Secilmiyor `[IMPLEMENTE EDILDI]`
**Dosya:** `layout-engine/src/pdf_render.rs` (satir ~104)
@@ -286,7 +286,7 @@ Ayri bir message type namespace kullanmak — `msg.type` alani ile ayristirma za
## 3. Eksik Ozellikler (CLAUDE.md'de Tanimli)
### 3.1 Coklu Secim (Multi-Selection) `[IMPLEMENTE EDILMEDI]`
### 3.1 Coklu Secim (Multi-Selection) `[IMPLEMENTE EDILDI]`
**Referans:** CLAUDE.md — "Shift+tiklama ile coklu secim"
@@ -302,7 +302,7 @@ Ayri bir message type namespace kullanmak — `msg.type` alani ile ayristirma za
---
### 3.2 Z-Order Kontrolleri `[IMPLEMENTE EDILMEDI]`
### 3.2 Z-Order Kontrolleri `[IMPLEMENTE EDILDI]`
**Referans:** CLAUDE.md — "One Getir / Arkaya Gonder"
@@ -316,7 +316,7 @@ Ayri bir message type namespace kullanmak — `msg.type` alani ile ayristirma za
---
### 3.3 Dinamik Image Binding UI `[IMPLEMENTE EDILMEDI]`
### 3.3 Dinamik Image Binding UI `[IMPLEMENTE EDILDI]`
**Referans:** CLAUDE.md — "image: Statik veya dinamik gorsel, Opsiyonel scalar binding"
@@ -330,7 +330,7 @@ Ayri bir message type namespace kullanmak — `msg.type` alani ile ayristirma za
---
### 3.4 RulerBar (Cetvel) `[IMPLEMENTE EDILMEDI]`
### 3.4 RulerBar (Cetvel) `[IMPLEMENTE EDILDI]`
**Referans:** CLAUDE.md proje yapisi — `components/editor/RulerBar.vue`
@@ -346,7 +346,7 @@ Component dosyasi olusturulmamis, hicbir yerde import edilmiyor.
---
### 3.5 Format Fonksiyonlari (Tablo Sutunlari) `[IMPLEMENTE EDILMEDI]`
### 3.5 Format Fonksiyonlari (Tablo Sutunlari) `[IMPLEMENTE EDILDI]`
**Referans:** CLAUDE.md roadmap — "Format fonksiyonlari (currency, date)"
@@ -421,7 +421,7 @@ Buyuk tablolarda layout hesaplama suresi ve bellek kullanimi artar. Editorde her
---
### 4.4 Font Loader Iyilestirmesi (Backend) `[IMPLEMENTE EDILMEDI]`
### 4.4 Font Loader Iyilestirmesi (Backend) `[font loader implementasyonu yapıldı. check edilmesi gerek bu sorunun tekrar]`
**Dosya:** `backend/src/main.rs` (satirlar 44-53)
@@ -444,7 +444,7 @@ TTF/OTF `name` tablosunu okuyarak font ailesini (family name) metadata'dan almak
`1.005` gibi degerler icin floating-point representation kaybi nedeniyle kusurat 0 veya 1 olarak yanlis yuvarlanabilir.
**Cozum:**
`Decimal` arithmetic kullanmak veya en azindan `format!("{:.2}", value)` ile string uzerinden islem yapmak.
`Decimal` arithmetic kullanmak.
---

View File

@@ -0,0 +1,164 @@
use std::collections::HashMap;
use dreport_layout::FontData;
use dreport_layout::font_meta::{self, FontFamilyInfo, FontVariantKey};
use dreport_layout::font_provider::FontProvider;
/// Font registry — manages all available fonts from embedded defaults + external directory.
pub struct FontRegistry {
/// family_lower -> variant_key -> FontData
families: HashMap<String, HashMap<FontVariantKey, FontData>>,
/// Original-case family names
family_names: HashMap<String, String>,
}
impl FontRegistry {
pub fn new() -> Self {
let mut registry = Self {
families: HashMap::new(),
family_names: HashMap::new(),
};
// Load embedded default fonts
registry.load_embedded_defaults();
// Load fonts from DREPORT_FONTS_DIR if set
if let Ok(dir) = std::env::var("DREPORT_FONTS_DIR") {
registry.load_from_directory(&dir);
}
registry
}
fn load_embedded_defaults(&mut self) {
let embedded: &[(&str, &[u8])] = &[
("NotoSans-Regular", include_bytes!("../fonts/NotoSans-Regular.ttf")),
("NotoSans-Bold", include_bytes!("../fonts/NotoSans-Bold.ttf")),
("NotoSans-Italic", include_bytes!("../fonts/NotoSans-Italic.ttf")),
("NotoSans-BoldItalic", include_bytes!("../fonts/NotoSans-BoldItalic.ttf")),
("NotoSansMono-Regular", include_bytes!("../fonts/NotoSansMono-Regular.ttf")),
];
for (_name, data) in embedded {
self.register_font(data.to_vec());
}
}
fn load_from_directory(&mut self, dir: &str) {
let path = std::path::Path::new(dir);
if !path.is_dir() {
eprintln!("DREPORT_FONTS_DIR dizini bulunamadı: {}", dir);
return;
}
let entries = match std::fs::read_dir(path) {
Ok(e) => e,
Err(e) => {
eprintln!("DREPORT_FONTS_DIR okunamadı: {}", e);
return;
}
};
for entry in entries.flatten() {
let p = entry.path();
if p.extension().is_some_and(|e| e == "ttf" || e == "otf") {
if let Ok(data) = std::fs::read(&p) {
if self.register_font(data) {
println!(" Font yüklendi: {}", p.display());
} else {
eprintln!(" Font parse edilemedi: {}", p.display());
}
}
}
}
}
/// Register a font from raw bytes. Returns true if successful.
fn register_font(&mut self, data: Vec<u8>) -> bool {
let Some(meta) = font_meta::parse_font_meta(&data) else {
return false;
};
let family_lower = meta.family.to_lowercase();
let variant_key = meta.variant_key();
self.family_names
.entry(family_lower.clone())
.or_insert_with(|| meta.family.clone());
let font_data = FontData::new(meta.family, meta.weight, meta.italic, data);
self.families
.entry(family_lower)
.or_default()
.insert(variant_key, font_data);
true
}
/// Get a specific font's raw bytes
pub fn get_font_bytes(&self, family: &str, weight: u16, italic: bool) -> Option<&[u8]> {
let family_lower = family.to_lowercase();
let key = FontVariantKey { weight, italic };
self.families
.get(&family_lower)
.and_then(|variants| variants.get(&key))
.map(|fd| fd.data.as_slice())
}
/// Get all FontData for given family names (for passing to layout engine)
pub fn fonts_for_families(&self, families: &[String]) -> Vec<FontData> {
let mut result = Vec::new();
let mut loaded = std::collections::HashSet::new();
// Always include default family
let default_lower = "noto sans".to_string();
let mut to_load: Vec<String> = vec![default_lower.clone()];
for f in families {
let fl = f.to_lowercase();
if !to_load.contains(&fl) {
to_load.push(fl);
}
}
for family_lower in &to_load {
if loaded.contains(family_lower) {
continue;
}
if let Some(variants) = self.families.get(family_lower) {
for fd in variants.values() {
result.push(fd.clone());
}
loaded.insert(family_lower.clone());
}
}
result
}
}
impl FontProvider for FontRegistry {
fn list_families(&self) -> Vec<FontFamilyInfo> {
self.families
.iter()
.map(|(family_lower, variants)| {
let family = self.family_names
.get(family_lower)
.cloned()
.unwrap_or_else(|| family_lower.clone());
FontFamilyInfo {
family,
variants: variants.keys().cloned().collect(),
}
})
.collect()
}
fn load_font(&self, family: &str, weight: u16, italic: bool) -> Option<FontData> {
let family_lower = family.to_lowercase();
let key = FontVariantKey { weight, italic };
self.families
.get(&family_lower)
.and_then(|variants| variants.get(&key))
.cloned()
}
}

View File

@@ -1,17 +1,21 @@
use axum::{Router, serve};
use dreport_layout::FontData;
use std::sync::Arc;
use tokio::net::TcpListener;
use tower_http::cors::{Any, CorsLayer};
mod font_registry;
mod models;
mod routes;
use font_registry::FontRegistry;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
println!("Fontlar yukleniyor...");
let fonts = Arc::new(load_fonts());
println!("Fontlar yuklendi ({} font dosyasi)", fonts.len());
println!("Font registry başlatılıyor...");
let registry = Arc::new(FontRegistry::new());
let family_count = dreport_layout::font_provider::FontProvider::list_families(registry.as_ref()).len();
println!("Font registry hazır ({} font ailesi)", family_count);
let cors = CorsLayer::new()
.allow_origin(Any)
@@ -21,7 +25,7 @@ async fn main() -> anyhow::Result<()> {
let app = Router::new()
.merge(routes::router())
.layer(cors)
.with_state(fonts);
.with_state(registry);
let listener = TcpListener::bind("0.0.0.0:3001").await?;
println!("dreport backend listening on http://localhost:3001");
@@ -29,31 +33,3 @@ async fn main() -> anyhow::Result<()> {
Ok(())
}
/// Proje fontlarını yükler (backend/fonts/ dizininden).
fn load_fonts() -> Vec<FontData> {
let font_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fonts");
let mut fonts = Vec::new();
let entries = std::fs::read_dir(&font_dir).expect("backend/fonts dizini bulunamadi");
for entry in entries {
let entry = entry.unwrap();
let path = entry.path();
if path.extension().is_some_and(|e| e == "ttf" || e == "otf") {
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(FontData { family, data });
}
}
fonts
}

View File

@@ -0,0 +1,77 @@
use axum::{
Router,
extract::{Path, State},
http::{StatusCode, header},
response::IntoResponse,
routing::get,
Json,
};
use dreport_layout::font_provider::FontProvider;
use serde::Serialize;
use std::sync::Arc;
use crate::font_registry::FontRegistry;
#[derive(Serialize)]
struct FontFamilyResponse {
family: String,
variants: Vec<FontVariantResponse>,
}
#[derive(Serialize)]
struct FontVariantResponse {
weight: u16,
italic: bool,
}
/// GET /api/fonts — list all available font families
async fn list_fonts(
State(registry): State<Arc<FontRegistry>>,
) -> Json<Vec<FontFamilyResponse>> {
let families = registry.list_families();
let response: Vec<FontFamilyResponse> = families
.into_iter()
.map(|f| FontFamilyResponse {
family: f.family,
variants: f.variants
.into_iter()
.map(|v| FontVariantResponse {
weight: v.weight,
italic: v.italic,
})
.collect(),
})
.collect();
Json(response)
}
/// GET /api/fonts/:family/:weight/:italic — serve font binary
async fn get_font(
State(registry): State<Arc<FontRegistry>>,
Path((family, weight, italic)): Path<(String, u16, String)>,
) -> impl IntoResponse {
let is_italic = italic == "true" || italic == "1";
match registry.get_font_bytes(&family, weight, is_italic) {
Some(data) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "font/ttf")],
data.to_vec(),
)
.into_response(),
None => (
StatusCode::NOT_FOUND,
format!(
"Font bulunamadı: {} weight={} italic={}",
family, weight, is_italic
),
)
.into_response(),
}
}
pub fn router() -> Router<Arc<FontRegistry>> {
Router::new()
.route("/api/fonts", get(list_fonts))
.route("/api/fonts/{family}/{weight}/{italic}", get(get_font))
}

View File

@@ -1,8 +1,9 @@
use axum::{Router, routing::get, Json};
use dreport_layout::FontData;
use serde::Serialize;
use std::sync::Arc;
use crate::font_registry::FontRegistry;
#[derive(Serialize)]
struct HealthResponse {
status: &'static str,
@@ -16,6 +17,6 @@ async fn health() -> Json<HealthResponse> {
})
}
pub fn router() -> Router<Arc<Vec<FontData>>> {
pub fn router() -> Router<Arc<FontRegistry>> {
Router::new().route("/api/health", get(health))
}

View File

@@ -1,12 +1,15 @@
mod fonts;
mod health;
mod render;
use axum::Router;
use dreport_layout::FontData;
use std::sync::Arc;
pub fn router() -> Router<Arc<Vec<FontData>>> {
use crate::font_registry::FontRegistry;
pub fn router() -> Router<Arc<FontRegistry>> {
Router::new()
.merge(health::router())
.merge(render::router())
.merge(fonts::router())
}

View File

@@ -6,10 +6,10 @@ use axum::{
routing::post,
Json,
};
use dreport_layout::FontData;
use serde::Deserialize;
use std::sync::Arc;
use crate::font_registry::FontRegistry;
use crate::models::Template;
#[derive(Deserialize)]
@@ -20,28 +20,39 @@ pub struct RenderRequest {
/// POST /api/render — Template + Data → PDF
pub async fn render(
State(fonts): State<Arc<Vec<FontData>>>,
State(registry): State<Arc<FontRegistry>>,
Json(payload): Json<RenderRequest>,
) -> impl IntoResponse {
// 1. Layout hesapla
let layout = dreport_layout::compute_layout(&payload.template, &payload.data, &fonts);
// CPU-intensive layout + PDF render'ı blocking thread'de çalıştır
let result = tokio::task::spawn_blocking(move || {
// Template'in fonts alanına göre sadece gerekli fontları yükle
let fonts = registry.fonts_for_families(&payload.template.fonts);
let layout = dreport_layout::compute_layout(&payload.template, &payload.data, &fonts)
.map_err(|e| format!("Layout error: {}", e))?;
dreport_layout::pdf_render::render_pdf(&layout, &fonts)
})
.await;
// 2. PDF render
match dreport_layout::pdf_render::render_pdf(&layout, &fonts) {
Ok(pdf_bytes) => (
match result {
Ok(Ok(pdf_bytes)) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "application/pdf")],
pdf_bytes,
)
.into_response(),
Ok(Err(err)) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("PDF render hatası: {}", err),
)
.into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("PDF render hatasi: {}", err),
format!("Task hatası: {}", err),
)
.into_response(),
}
}
pub fn router() -> Router<Arc<Vec<FontData>>> {
pub fn router() -> Router<Arc<FontRegistry>> {
Router::new().route("/api/render", post(render))
}

View File

@@ -74,6 +74,7 @@ pub enum PositionMode {
pub struct TextStyle {
pub font_size: Option<f64>,
pub font_weight: Option<String>,
pub font_style: Option<String>,
pub font_family: Option<String>,
pub color: Option<String>,
pub align: Option<String>,
@@ -559,4 +560,42 @@ pub struct Template {
#[serde(default)]
pub footer: Option<ContainerElement>,
pub root: ContainerElement,
#[serde(default)]
pub format_config: Option<FormatConfig>,
}
/// Sayı/para birimi formatlama ayarları.
/// Belirtilmezse Türk Lirası varsayılan (. binlik, , ondalık, ₺ sembol).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FormatConfig {
/// Binlik ayırıcı (varsayılan ".")
#[serde(default = "FormatConfig::default_thousands_sep")]
pub thousands_separator: String,
/// Ondalık ayırıcı (varsayılan ",")
#[serde(default = "FormatConfig::default_decimal_sep")]
pub decimal_separator: String,
/// Para birimi sembolü (varsayılan "₺")
#[serde(default = "FormatConfig::default_currency_symbol")]
pub currency_symbol: String,
/// Para birimi sembolü pozisyonu: "suffix" (varsayılan) veya "prefix"
#[serde(default = "FormatConfig::default_currency_position")]
pub currency_position: String,
}
impl FormatConfig {
fn default_thousands_sep() -> String { ".".to_string() }
fn default_decimal_sep() -> String { ",".to_string() }
fn default_currency_symbol() -> String { "".to_string() }
fn default_currency_position() -> String { "suffix".to_string() }
}
impl Default for FormatConfig {
fn default() -> Self {
Self {
thousands_separator: Self::default_thousands_sep(),
decimal_separator: Self::default_decimal_sep(),
currency_symbol: Self::default_currency_symbol(),
currency_position: Self::default_currency_position(),
}
}
}

View File

@@ -9,6 +9,7 @@
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.41.0",
"@duhanbalci/codemirror-lang-dexpr": "0.1.0",
"@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.8",
"codemirror": "^6.0.2",
@@ -56,6 +57,8 @@
"@codemirror/view": ["@codemirror/view@6.41.0", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA=="],
"@duhanbalci/codemirror-lang-dexpr": ["@duhanbalci/codemirror-lang-dexpr@0.1.0", "https://gitea.duhanbalci.com/api/packages/duhanbalci/npm/%40duhanbalci%2Fcodemirror-lang-dexpr/-/0.1.0/codemirror-lang-dexpr-0.1.0.tgz", { "peerDependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-cR8SGbtW3Dq1/w7RyWG0yJeBfl3sqQGtJtPZE/d7hziIy75tvt1zEtYxODyuzlDkteX9XzUnaLrS3/TK9dZj8Q=="],
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],

View File

@@ -17,10 +17,10 @@
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.41.0",
"@duhanbalci/codemirror-lang-dexpr": "0.1.0",
"@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.8",
"codemirror": "^6.0.2",
"codemirror-lang-dexpr": "file:../../rust-expr/editor",
"pinia": "^3.0.4",
"vue": "^3.5.31"
},

View File

@@ -6,6 +6,7 @@ import { useEditorStore } from '../../stores/editor'
import { useLayoutEngine } from '../../composables/useLayoutEngine'
import LayoutRenderer from './LayoutRenderer.vue'
import InteractionOverlay from './InteractionOverlay.vue'
import RulerBar from './RulerBar.vue'
const props = withDefaults(defineProps<{
handleErrors?: boolean
@@ -204,6 +205,15 @@ function onPointerUp(e: PointerEvent) {
<template>
<div class="editor-canvas-wrapper">
<!-- Cetvel -->
<RulerBar
:page-width="templateStore.template.page.width"
:page-height="templateStore.template.page.height"
:scale="scale"
:pan-x="editorStore.panX"
:pan-y="editorStore.panY"
/>
<!-- Scroll alanı -->
<div
class="editor-canvas"
@@ -252,6 +262,8 @@ function onPointerUp(e: PointerEvent) {
align-items: flex-start;
justify-content: center;
padding: 40px;
padding-top: 60px; /* cetvel için üstten ek boşluk */
padding-left: 60px; /* cetvel için soldan ek boşluk */
}
.editor-canvas__pages {

View File

@@ -13,7 +13,7 @@ const props = defineProps<{
const editorStore = useEditorStore()
const templateStore = useTemplateStore()
const isSelected = computed(() => editorStore.selectedElementId === props.element.id)
const isSelected = computed(() => editorStore.isSelected(props.element.id))
const isContainerEl = computed(() => isContainer(props.element))
const isAbsolute = computed(() => props.element.position.type === 'absolute')

View File

@@ -96,6 +96,12 @@ function updateChartStyle(key: string, value: unknown) {
if (!selected.value) return
update({ style: { ...selected.value.style, [key]: value } })
}
// Z-order
function bringForward() { if (selected.value) templateStore.bringForward(selected.value.id) }
function sendBackward() { if (selected.value) templateStore.sendBackward(selected.value.id) }
function bringToFront() { if (selected.value) templateStore.bringToFront(selected.value.id) }
function sendToBack() { if (selected.value) templateStore.sendToBack(selected.value.id) }
</script>
<template>
@@ -409,6 +415,37 @@ function updateChartStyle(key: string, value: unknown) {
</label>
</div>
</template>
<!-- ===== Z-Order (tüm elemanlar) ===== -->
<template v-if="selected">
<div class="et__sep" />
<div class="et__group">
<button class="et__btn" data-tip="Arkaya Gonder" @click="sendToBack">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="5" y="5" width="7" height="7" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="2" y="2" width="7" height="7" rx="1" fill="currentColor"/>
</svg>
</button>
<button class="et__btn" data-tip="Bir Geri" @click="sendBackward">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="5" y="5" width="7" height="7" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="2" y="2" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2" fill="none"/>
</svg>
</button>
<button class="et__btn" data-tip="Bir Ileri" @click="bringForward">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="2" y="2" width="7" height="7" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="5" y="5" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2" fill="none"/>
</svg>
</button>
<button class="et__btn" data-tip="One Getir" @click="bringToFront">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="2" y="2" width="7" height="7" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="5" y="5" width="7" height="7" rx="1" fill="currentColor"/>
</svg>
</button>
</div>
</template>
</div>
</template>

View File

@@ -98,7 +98,11 @@ function getElementStyle(el: TemplateElement) {
function onElementClick(e: PointerEvent, id: string) {
e.stopPropagation()
if (didDrag.value) return
editorStore.selectElement(id)
if (e.shiftKey) {
editorStore.toggleSelection(id)
} else {
editorStore.selectElement(id)
}
}
function onCanvasClick() {
@@ -637,7 +641,7 @@ const isAnyDragActive = computed(() =>
:key="el.id"
class="element-handle"
:class="{
'element-handle--selected': editorStore.selectedElementId === el.id,
'element-handle--selected': editorStore.isSelected(el.id),
'element-handle--container': isContainer(el),
'element-handle--dragging': isDragging && dragElementId === el.id,
'element-handle--drop-target': isContainer(el) && dropTargetContainerId === el.id && isAnyDragActive,
@@ -646,10 +650,10 @@ const isAnyDragActive = computed(() =>
@pointerdown="(e: PointerEvent) => { onElementClick(e, el.id); onDragStart(e, el) }"
>
<!-- Selection border -->
<div v-if="editorStore.selectedElementId === el.id" class="selection-border" />
<div v-if="editorStore.isSelected(el.id)" class="selection-border" />
<!-- Resize handles -->
<template v-if="editorStore.selectedElementId === el.id && !isResizing && el.type !== 'page_break'">
<!-- Resize handles (sadece tek seçimde) -->
<template v-if="editorStore.isSelected(el.id) && editorStore.selectedElementIds.size === 1 && !isResizing && el.type !== 'page_break'">
<template v-if="el.type === 'barcode' || el.type === 'image'">
<!-- Barkod/Görsel: sadece yatay resize (aspect ratio korunur) -->
<div class="resize-handle resize-handle--e" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'e')" />

View File

@@ -234,7 +234,7 @@ watch(
:style="{
width: '100%',
height: '100%',
objectFit: 'fill',
objectFit: el.style.objectFit || 'fill',
}"
/>
<div v-else class="layout-el__placeholder">Görsel</div>

View File

@@ -0,0 +1,231 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
const props = defineProps<{
/** Sayfa genişliği mm */
pageWidth: number
/** Sayfa yüksekliği mm */
pageHeight: number
/** mm → px dönüşüm katsayısı (scale * zoom) */
scale: number
/** Pan offset X (px) */
panX: number
/** Pan offset Y (px) */
panY: number
/** Cetvel kalınlığı px */
rulerSize?: number
}>()
const RULER_SIZE = computed(() => props.rulerSize ?? 20)
const hCanvas = ref<HTMLCanvasElement | null>(null)
const vCanvas = ref<HTMLCanvasElement | null>(null)
function drawRuler(
canvas: HTMLCanvasElement | null,
direction: 'horizontal' | 'vertical',
) {
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
const size = RULER_SIZE.value
if (direction === 'horizontal') {
const w = canvas.clientWidth
canvas.width = w * dpr
canvas.height = size * dpr
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, w, size)
ctx.fillStyle = '#f1f5f9'
ctx.fillRect(0, 0, w, size)
ctx.strokeStyle = '#e2e8f0'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(0, size - 0.5)
ctx.lineTo(w, size - 0.5)
ctx.stroke()
drawTicks(ctx, direction, w, size)
} else {
const h = canvas.clientHeight
canvas.width = size * dpr
canvas.height = h * dpr
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, size, h)
ctx.fillStyle = '#f1f5f9'
ctx.fillRect(0, 0, size, h)
ctx.strokeStyle = '#e2e8f0'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(size - 0.5, 0)
ctx.lineTo(size - 0.5, h)
ctx.stroke()
drawTicks(ctx, direction, h, size)
}
}
function drawTicks(
ctx: CanvasRenderingContext2D,
direction: 'horizontal' | 'vertical',
length: number,
size: number,
) {
const s = props.scale
const pageMm = direction === 'horizontal' ? props.pageWidth : props.pageHeight
const pan = direction === 'horizontal' ? props.panX : props.panY
// Sayfa başlangıcı: ortaya hizalı + pan
// EditorCanvas sayfayı ortalar, ruler da buna uymalı
// Yatay: canvas ortası - sayfa genişliği/2
// Sayfanın canvas üzerindeki orijin px'i
const canvasCenter = direction === 'horizontal'
? (length / 2) // flex centering approximation
: 40 // EditorCanvas padding-top: 40px
const pageStartPx = canvasCenter - (pageMm * s) / 2 + pan
// Tick aralığı belirleme (zoom'a göre)
const mmPerPx = 1 / s
let tickMm = 1
if (mmPerPx > 2) tickMm = 50
else if (mmPerPx > 1) tickMm = 20
else if (mmPerPx > 0.5) tickMm = 10
else if (mmPerPx > 0.2) tickMm = 5
else tickMm = 1
ctx.fillStyle = '#94a3b8'
ctx.strokeStyle = '#94a3b8'
ctx.lineWidth = 0.5
ctx.font = '9px system-ui, sans-serif'
ctx.textBaseline = 'top'
// Sayfanın mm aralığını çiz
const startMm = 0
const endMm = pageMm
for (let mm = startMm; mm <= endMm; mm += tickMm) {
const px = pageStartPx + mm * s
if (px < -10 || px > length + 10) continue
const isMajor = mm % 10 === 0
const isMedium = mm % 5 === 0
let tickLen = 4
if (isMajor) tickLen = size * 0.6
else if (isMedium) tickLen = size * 0.35
ctx.beginPath()
if (direction === 'horizontal') {
ctx.moveTo(px, size)
ctx.lineTo(px, size - tickLen)
} else {
ctx.moveTo(size, px)
ctx.lineTo(size - tickLen, px)
}
ctx.stroke()
// Sayı etiketi (her 10mm'de bir)
if (isMajor && mm > 0) {
const label = String(mm)
if (direction === 'horizontal') {
ctx.textAlign = 'center'
ctx.fillText(label, px, 2)
} else {
ctx.save()
ctx.translate(2, px)
ctx.rotate(-Math.PI / 2)
ctx.textAlign = 'center'
ctx.fillText(label, 0, 0)
ctx.restore()
}
}
}
// Sayfa kenar çizgileri (margin göstergesi)
ctx.strokeStyle = 'rgba(59, 130, 246, 0.3)'
ctx.lineWidth = 1
const startPx = pageStartPx
const endPx = pageStartPx + pageMm * s
ctx.beginPath()
if (direction === 'horizontal') {
ctx.moveTo(startPx, 0)
ctx.lineTo(startPx, size)
ctx.moveTo(endPx, 0)
ctx.lineTo(endPx, size)
} else {
ctx.moveTo(0, startPx)
ctx.lineTo(size, startPx)
ctx.moveTo(0, endPx)
ctx.lineTo(size, endPx)
}
ctx.stroke()
}
function redraw() {
drawRuler(hCanvas.value, 'horizontal')
drawRuler(vCanvas.value, 'vertical')
}
watch(() => [props.scale, props.panX, props.panY, props.pageWidth, props.pageHeight], redraw)
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
redraw()
const parent = hCanvas.value?.parentElement?.parentElement
if (parent) {
resizeObserver = new ResizeObserver(() => redraw())
resizeObserver.observe(parent)
}
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
</script>
<template>
<div class="ruler-corner" :style="{ width: `${RULER_SIZE}px`, height: `${RULER_SIZE}px` }" />
<canvas
ref="hCanvas"
class="ruler-h"
:style="{ height: `${RULER_SIZE}px` }"
/>
<canvas
ref="vCanvas"
class="ruler-v"
:style="{ width: `${RULER_SIZE}px` }"
/>
</template>
<style scoped>
.ruler-corner {
position: absolute;
top: 0;
left: 0;
background: #f1f5f9;
border-right: 1px solid #e2e8f0;
border-bottom: 1px solid #e2e8f0;
z-index: 50;
}
.ruler-h {
position: absolute;
top: 0;
left: 20px;
right: 0;
z-index: 50;
pointer-events: none;
}
.ruler-v {
position: absolute;
top: 20px;
left: 0;
bottom: 0;
z-index: 50;
pointer-events: none;
}
</style>

View File

@@ -38,11 +38,15 @@ const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const selectedElement = computed(() => {
const id = editorStore.selectedElementId
const ids = editorStore.selectedElementIds
if (ids.size !== 1) return null
const id = ids.values().next().value
if (!id) return null
return templateStore.getElementById(id) ?? null
})
const multipleSelected = computed(() => editorStore.selectedElementIds.size > 1)
const elementTypeLabel = computed(() => {
const el = selectedElement.value
if (!el) return ''
@@ -87,11 +91,24 @@ function deleteElement() {
editorStore.clearSelection()
templateStore.removeElement(id)
}
function deleteSelected() {
const ids = [...editorStore.selectedElementIds]
editorStore.clearSelection()
for (const id of ids) {
if (id !== 'root') templateStore.removeElement(id)
}
}
</script>
<template>
<div class="properties-panel">
<div v-if="!selectedElement" class="properties-panel__empty">
<div v-if="multipleSelected" class="properties-panel__empty">
{{ editorStore.selectedElementIds.size }} eleman secili
<button class="prop-delete-btn" style="margin-top: 12px" @click="deleteSelected">Secilenleri Sil</button>
</div>
<div v-else-if="!selectedElement" class="properties-panel__empty">
Bir eleman secin
</div>

View File

@@ -1,12 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import { useSchemaStore } from '../../stores/schema'
import type { ImageElement, TemplateElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: ImageElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const schemaStore = useSchemaStore()
/** Statik mi dinamik mi? */
const isDynamic = computed(() => !!props.element.binding)
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
@@ -24,30 +30,86 @@ function onImageFileSelect(e: Event) {
if (!file) return
const reader = new FileReader()
reader.onload = () => {
update({ src: reader.result as string } as Partial<TemplateElement>)
update({ src: reader.result as string, binding: undefined } as Partial<TemplateElement>)
}
reader.readAsDataURL(file)
}
function setMode(mode: 'static' | 'dynamic') {
if (mode === 'static') {
update({ binding: undefined } as Partial<TemplateElement>)
} else {
// Dinamik moda geç — ilk uygun alanı seç veya boş bırak
const imageFields = schemaStore.scalarFields.filter(f => f.format === 'image' || f.type === 'string')
const path = imageFields.length > 0 ? imageFields[0].path : ''
update({ src: undefined, binding: { type: 'scalar', path } } as Partial<TemplateElement>)
}
}
function setBindingPath(path: string) {
update({ binding: { type: 'scalar', path } } as Partial<TemplateElement>)
}
/** Schema'dan görsel olabilecek alanlar (format: image veya string) */
const imageScalarFields = computed(() => {
return schemaStore.scalarFields.filter(f => f.format === 'image' || f.type === 'string')
})
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Gorsel</div>
<div class="prop-row" data-tip="Gorsel dosyasi secin (PNG, JPG, SVG)">
<label class="prop-label">Kaynak</label>
<label class="prop-file-btn">
Dosya Sec
<input type="file" accept="image/*" style="display: none" @change="onImageFileSelect" />
</label>
</div>
<div v-if="element.src" class="prop-row" data-tip="Yuklenen gorsel onizlemesi">
<label class="prop-label">Onizleme</label>
<img :src="element.src" class="prop-image-preview" />
</div>
<div v-if="element.src" class="prop-row" data-tip="Gorseli kaldirmak icin tiklayin">
<label class="prop-label"></label>
<button class="prop-clear" @click="update({ src: undefined } as any)">Gorseli kaldir</button>
<!-- Statik / Dinamik toggle -->
<div class="prop-row" data-tip="Gorsel kaynagi: dosya veya veri alanından">
<label class="prop-label">Mod</label>
<div class="prop-toggle-group">
<button class="prop-toggle-btn" :class="{ 'prop-toggle-btn--active': !isDynamic }" @click="setMode('static')">Statik</button>
<button class="prop-toggle-btn" :class="{ 'prop-toggle-btn--active': isDynamic }" @click="setMode('dynamic')">Dinamik</button>
</div>
</div>
<!-- Statik: dosya seçimi -->
<template v-if="!isDynamic">
<div class="prop-row" data-tip="Gorsel dosyasi secin (PNG, JPG, SVG)">
<label class="prop-label">Kaynak</label>
<label class="prop-file-btn">
Dosya Sec
<input type="file" accept="image/*" style="display: none" @change="onImageFileSelect" />
</label>
</div>
<div v-if="element.src" class="prop-row" data-tip="Yuklenen gorsel onizlemesi">
<label class="prop-label">Onizleme</label>
<img :src="element.src" class="prop-image-preview" />
</div>
<div v-if="element.src" class="prop-row" data-tip="Gorseli kaldirmak icin tiklayin">
<label class="prop-label"></label>
<button class="prop-clear" @click="update({ src: undefined } as any)">Gorseli kaldir</button>
</div>
</template>
<!-- Dinamik: schema alan seçimi -->
<template v-else>
<div class="prop-row" data-tip="Gorsel URL'sinin gelecegi veri alani">
<label class="prop-label">Veri Alani</label>
<select class="prop-input prop-select"
:value="element.binding?.path ?? ''"
@change="(e) => setBindingPath((e.target as HTMLSelectElement).value)">
<option value="" disabled>Secin...</option>
<option
v-for="field in imageScalarFields"
:key="field.path"
:value="field.path"
>{{ field.title }} ({{ field.path }})</option>
</select>
</div>
<div v-if="element.binding?.path" class="prop-row">
<label class="prop-label">Path</label>
<span class="prop-info">{{ element.binding.path }}</span>
</div>
</template>
<!-- Sığdırma modu (ortak) -->
<div class="prop-row" data-tip="Gorselin alana sigdirma modu">
<label class="prop-label">Sigdirma</label>
<select class="prop-input prop-select"
@@ -60,3 +122,42 @@ function onImageFileSelect(e: Event) {
</div>
</div>
</template>
<style scoped>
.prop-toggle-group {
display: flex;
gap: 0;
}
.prop-toggle-btn {
flex: 1;
padding: 3px 8px;
border: 1px solid #e2e8f0;
background: white;
color: #64748b;
font-size: 11px;
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.prop-toggle-btn:first-child {
border-radius: 4px 0 0 4px;
}
.prop-toggle-btn:last-child {
border-radius: 0 4px 4px 0;
border-left: none;
}
.prop-toggle-btn--active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.prop-info {
font-size: 11px;
color: #94a3b8;
word-break: break-all;
}
</style>

View File

@@ -4,10 +4,16 @@ import type { LayoutResult, LayoutMapEntry } from '../core/layout-types'
export type { LayoutMapEntry }
export interface LayoutEngineOptions {
/** Font API base URL. Default: '/api/fonts' */
fontApiBase?: string
}
export function useLayoutEngine(
template: Ref<Template>,
data: Ref<Record<string, unknown>>,
layoutVersion?: Ref<number>,
options?: LayoutEngineOptions,
) {
const layout = ref<LayoutResult | null>(null)
const error = ref<string | null>(null)
@@ -24,6 +30,11 @@ export function useLayoutEngine(
type: 'module',
})
// Configure font API base if provided
if (options?.fontApiBase) {
worker.postMessage({ type: 'configure', fontApiBase: options.fontApiBase })
}
worker.onmessage = (e: MessageEvent<any>) => {
const msg = e.data
@@ -105,8 +116,15 @@ export function useLayoutEngine(
if (!worker) initWorker()
return new Promise(resolve => {
barcodeReqId++
const id = barcodeReqId + 100000 // compile id'leriyle çakışmasın
barcodeCallbacks.set(id, resolve)
const id = barcodeReqId
const timeout = setTimeout(() => {
barcodeCallbacks.delete(id)
resolve(null)
}, 5000)
barcodeCallbacks.set(id, (result) => {
clearTimeout(timeout)
resolve(result)
})
worker!.postMessage({ type: 'barcode', format, value, width, height, includeText, id })
})
}
@@ -124,6 +142,10 @@ export function useLayoutEngine(
function dispose() {
worker?.terminate()
worker = null
// Bekleyen barcode promise'lerini null ile resolve et
for (const cb of barcodeCallbacks.values()) {
cb(null)
}
barcodeCallbacks.clear()
}

View File

@@ -49,7 +49,7 @@ export function useUndoRedo<T>(source: Ref<T>, maxHistory = 50) {
function applySnapshot(snap: string) {
skipWatch = true
Object.assign(source.value as object, JSON.parse(snap))
source.value = JSON.parse(snap)
skipWatch = false
}

View File

@@ -312,7 +312,7 @@ export interface Template {
// --- Editor state ---
export interface EditorState {
selectedElementId: string | null
selectedElementIds: Set<string>
zoom: number // 0.25 - 4.0
panX: number
panY: number

View File

@@ -87,14 +87,14 @@ function onKeyDown(e: KeyboardEvent) {
const tag = target?.tagName
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable
// Delete / Backspace
if ((e.key === 'Delete' || e.key === 'Backspace') && editorStore.selectedElementId) {
// Delete / Backspace — çoklu seçim desteği
if ((e.key === 'Delete' || e.key === 'Backspace') && editorStore.selectedElementIds.size > 0) {
if (isInput) return
e.preventDefault()
const id = editorStore.selectedElementId
if (id && id !== 'root') {
editorStore.clearSelection()
templateStore.removeElement(id)
const ids = [...editorStore.selectedElementIds]
editorStore.clearSelection()
for (const id of ids) {
if (id !== 'root') templateStore.removeElement(id)
}
}
@@ -114,6 +114,23 @@ function onKeyDown(e: KeyboardEvent) {
e.preventDefault()
templateStore.redo()
}
// Z-Order kısayolları
if ((e.ctrlKey || e.metaKey) && editorStore.selectedElementId && editorStore.selectedElementId !== 'root') {
if (e.key === ']' && e.shiftKey) {
e.preventDefault()
templateStore.bringToFront(editorStore.selectedElementId)
} else if (e.key === ']') {
e.preventDefault()
templateStore.bringForward(editorStore.selectedElementId)
} else if (e.key === '[' && e.shiftKey) {
e.preventDefault()
templateStore.sendToBack(editorStore.selectedElementId)
} else if (e.key === '[') {
e.preventDefault()
templateStore.sendBackward(editorStore.selectedElementId)
}
}
}
// Browser'ın native pinch-zoom'unu editör alanında engelle

View File

@@ -0,0 +1,523 @@
/**
* IMPROVEMENTS.md bölüm 1, 2, 3 implementasyonlarının testleri.
*
* Bölüm 1: Kritik Buglar (1.11.4)
* Bölüm 2: Önemli Teknik Sorunlar (2.9, 2.11)
* Bölüm 3: Eksik Özellikler (3.1, 3.2)
*
* Not: Rust tarafı testleri layout-engine/tests/improvements_test.rs dosyasındadır.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useTemplateStore } from '../template'
import { useEditorStore } from '../editor'
import type { Template, StaticTextElement, ContainerElement, ImageElement, TemplateElement } from '../../core/types'
import { sz } from '../../core/types'
function createTestTemplate(): Template {
return {
id: 'test',
name: 'Test',
page: { width: 210, height: 297 },
fonts: ['Noto Sans'],
root: {
id: 'root',
type: 'container' as const,
position: { type: 'flow' as const },
size: { width: sz.auto(), height: sz.auto() },
direction: 'column' as const,
gap: 5,
padding: { top: 10, right: 10, bottom: 10, left: 10 },
align: 'stretch' as const,
justify: 'start' as const,
style: {},
children: [],
},
}
}
function createTextElement(id: string, content: string): StaticTextElement {
return {
id,
type: 'static_text',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 12 },
content,
}
}
// =============================================================================
// 1.1 Undo/Redo — Object.assign yerine reference replacement
// =============================================================================
describe('1.1 Undo/Redo reference replacement', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('undo properly removes keys that were added after snapshot', async () => {
vi.useFakeTimers()
const store = useTemplateStore()
store.template = createTestTemplate()
// Snapshot al (debounce beklenmeli)
await vi.advanceTimersByTimeAsync(400)
// Header ekle
store.enableHeader()
expect(store.template.header).toBeDefined()
// Snapshot al
await vi.advanceTimersByTimeAsync(400)
// Undo: header eklenmeden önceki state'e dön
store.undo()
expect(store.template.header).toBeUndefined()
vi.useRealTimers()
})
it('undo properly removes footer key', async () => {
vi.useFakeTimers()
const store = useTemplateStore()
store.template = createTestTemplate()
await vi.advanceTimersByTimeAsync(400)
store.enableFooter()
expect(store.template.footer).toBeDefined()
await vi.advanceTimersByTimeAsync(400)
store.undo()
expect(store.template.footer).toBeUndefined()
vi.useRealTimers()
})
it('redo restores the removed key after undo', async () => {
vi.useFakeTimers()
const store = useTemplateStore()
store.template = createTestTemplate()
await vi.advanceTimersByTimeAsync(400)
store.enableHeader()
await vi.advanceTimersByTimeAsync(400)
store.undo()
expect(store.template.header).toBeUndefined()
store.redo()
expect(store.template.header).toBeDefined()
expect(store.template.header!.id).toBe('header')
vi.useRealTimers()
})
})
// =============================================================================
// 1.3 Image objectFit — LayoutRenderer'da style.objectFit okunmalı
// (Birim test olarak ImageElement tipi üzerinden doğrulanır)
// =============================================================================
describe('1.3 Image objectFit', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('ImageElement stores objectFit in style', () => {
const store = useTemplateStore()
store.template = createTestTemplate()
const img: ImageElement = {
id: 'img_1',
type: 'image',
position: { type: 'flow' },
size: { width: sz.fixed(50), height: sz.fixed(30) },
src: 'data:image/png;base64,abc',
style: { objectFit: 'contain' },
}
store.addChild('root', img as unknown as TemplateElement)
const el = store.getElementById('img_1') as ImageElement
expect(el.style.objectFit).toBe('contain')
})
it('updateElement changes objectFit', () => {
const store = useTemplateStore()
store.template = createTestTemplate()
const img: ImageElement = {
id: 'img_2',
type: 'image',
position: { type: 'flow' },
size: { width: sz.fixed(50), height: sz.fixed(30) },
src: 'data:image/png;base64,abc',
style: { objectFit: 'contain' },
}
store.addChild('root', img as unknown as TemplateElement)
store.updateElement('img_2', { style: { objectFit: 'cover' } } as Partial<TemplateElement>)
const el = store.getElementById('img_2') as ImageElement
expect(el.style.objectFit).toBe('cover')
})
})
// =============================================================================
// 2.9 importTemplate validasyon
// =============================================================================
describe('2.9 importTemplate validation', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('throws on invalid JSON', () => {
const store = useTemplateStore()
expect(() => store.importTemplate('not json')).toThrow('Geçersiz JSON')
})
it('throws on missing root', () => {
const store = useTemplateStore()
const bad = JSON.stringify({ page: { width: 210, height: 297 } })
expect(() => store.importTemplate(bad)).toThrow('root')
})
it('throws on root that is not container', () => {
const store = useTemplateStore()
const bad = JSON.stringify({
root: { type: 'text', id: 'r' },
page: { width: 210, height: 297 },
})
expect(() => store.importTemplate(bad)).toThrow('container')
})
it('throws on missing page', () => {
const store = useTemplateStore()
const bad = JSON.stringify({
root: { type: 'container', id: 'root', children: [] },
})
expect(() => store.importTemplate(bad)).toThrow('page')
})
it('throws on invalid page dimensions', () => {
const store = useTemplateStore()
const bad = JSON.stringify({
root: { type: 'container', id: 'root', children: [] },
page: { width: 'abc', height: 297 },
})
expect(() => store.importTemplate(bad)).toThrow('page')
})
it('preserves previous state on failed import', () => {
const store = useTemplateStore()
store.template = createTestTemplate()
store.addChild('root', createTextElement('keep_me', 'Keep'))
try {
store.importTemplate('invalid json')
} catch {
// beklenen
}
// Önceki state korunmuş olmalı
expect(store.getElementById('keep_me')).toBeDefined()
})
it('accepts valid template JSON', () => {
const store = useTemplateStore()
const tpl = createTestTemplate()
tpl.name = 'Valid Import'
const json = JSON.stringify(tpl)
store.importTemplate(json)
expect(store.template.name).toBe('Valid Import')
})
})
// =============================================================================
// 2.11 moveElement — tek layoutVersion bump
// =============================================================================
describe('2.11 moveElement single layoutVersion bump', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('moveElement increments layoutVersion exactly once', () => {
const store = useTemplateStore()
store.template = createTestTemplate()
// İç içe container yapısı oluştur
const child: ContainerElement = {
id: 'child_container',
type: 'container',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
direction: 'column',
gap: 0,
padding: { top: 0, right: 0, bottom: 0, left: 0 },
align: 'stretch',
justify: 'start',
style: {},
children: [],
}
store.addChild('root', child as unknown as TemplateElement)
store.addChild('root', createTextElement('el_move', 'Move me'))
const versionBefore = store.layoutVersion
store.moveElement('el_move', 'child_container')
// Tek bump: tam olarak 1 artmalı
expect(store.layoutVersion).toBe(versionBefore + 1)
// Eleman taşınmış olmalı
const moved = store.getElementById('el_move')
expect(moved).toBeDefined()
const parent = store.getParent('el_move')
expect(parent?.id).toBe('child_container')
})
})
// =============================================================================
// 3.1 Çoklu Seçim (Multi-Selection)
// =============================================================================
describe('3.1 Multi-Selection', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('selectedElementIds starts empty', () => {
const store = useEditorStore()
expect(store.selectedElementIds.size).toBe(0)
expect(store.selectedElementId).toBeNull()
})
it('selectElement sets single selection', () => {
const store = useEditorStore()
store.selectElement('el_1')
expect(store.selectedElementIds.size).toBe(1)
expect(store.selectedElementId).toBe('el_1')
})
it('selectElement clears previous selection', () => {
const store = useEditorStore()
store.selectElement('el_1')
store.selectElement('el_2')
expect(store.selectedElementIds.size).toBe(1)
expect(store.selectedElementId).toBe('el_2')
expect(store.isSelected('el_1')).toBe(false)
})
it('toggleSelection adds to selection', () => {
const store = useEditorStore()
store.selectElement('el_1')
store.toggleSelection('el_2')
expect(store.selectedElementIds.size).toBe(2)
expect(store.isSelected('el_1')).toBe(true)
expect(store.isSelected('el_2')).toBe(true)
})
it('toggleSelection removes from selection', () => {
const store = useEditorStore()
store.selectElement('el_1')
store.toggleSelection('el_2')
store.toggleSelection('el_1')
expect(store.selectedElementIds.size).toBe(1)
expect(store.isSelected('el_1')).toBe(false)
expect(store.isSelected('el_2')).toBe(true)
})
it('clearSelection clears all', () => {
const store = useEditorStore()
store.selectElement('el_1')
store.toggleSelection('el_2')
store.toggleSelection('el_3')
expect(store.selectedElementIds.size).toBe(3)
store.clearSelection()
expect(store.selectedElementIds.size).toBe(0)
expect(store.selectedElementId).toBeNull()
})
it('isSelected returns correct state', () => {
const store = useEditorStore()
expect(store.isSelected('el_1')).toBe(false)
store.selectElement('el_1')
expect(store.isSelected('el_1')).toBe(true)
expect(store.isSelected('el_2')).toBe(false)
})
it('selectedElementId returns first selected (backward compat)', () => {
const store = useEditorStore()
store.selectElement('el_1')
store.toggleSelection('el_2')
// İlk eklenen eleman
expect(store.selectedElementId).toBe('el_1')
})
it('selectElement(null) clears selection', () => {
const store = useEditorStore()
store.selectElement('el_1')
store.selectElement(null)
expect(store.selectedElementIds.size).toBe(0)
})
})
// =============================================================================
// 3.2 Z-Order Kontrolleri
// =============================================================================
describe('3.2 Z-Order controls', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
function setupThreeElements() {
const store = useTemplateStore()
store.template = createTestTemplate()
store.addChild('root', createTextElement('a', 'A'))
store.addChild('root', createTextElement('b', 'B'))
store.addChild('root', createTextElement('c', 'C'))
return store
}
it('bringForward moves element one step up', () => {
const store = setupThreeElements()
// Sıra: [a, b, c] → bringForward(a) → [b, a, c]
store.bringForward('a')
expect(store.template.root.children.map(c => c.id)).toEqual(['b', 'a', 'c'])
})
it('sendBackward moves element one step down', () => {
const store = setupThreeElements()
// Sıra: [a, b, c] → sendBackward(c) → [a, c, b]
store.sendBackward('c')
expect(store.template.root.children.map(c => c.id)).toEqual(['a', 'c', 'b'])
})
it('bringToFront moves element to end', () => {
const store = setupThreeElements()
// Sıra: [a, b, c] → bringToFront(a) → [b, c, a]
store.bringToFront('a')
expect(store.template.root.children.map(c => c.id)).toEqual(['b', 'c', 'a'])
})
it('sendToBack moves element to beginning', () => {
const store = setupThreeElements()
// Sıra: [a, b, c] → sendToBack(c) → [c, a, b]
store.sendToBack('c')
expect(store.template.root.children.map(c => c.id)).toEqual(['c', 'a', 'b'])
})
it('bringForward on last element is no-op', () => {
const store = setupThreeElements()
store.bringForward('c')
expect(store.template.root.children.map(c => c.id)).toEqual(['a', 'b', 'c'])
})
it('sendBackward on first element is no-op', () => {
const store = setupThreeElements()
store.sendBackward('a')
expect(store.template.root.children.map(c => c.id)).toEqual(['a', 'b', 'c'])
})
it('bringToFront on last element is no-op', () => {
const store = setupThreeElements()
store.bringToFront('c')
expect(store.template.root.children.map(c => c.id)).toEqual(['a', 'b', 'c'])
})
it('sendToBack on first element is no-op', () => {
const store = setupThreeElements()
store.sendToBack('a')
expect(store.template.root.children.map(c => c.id)).toEqual(['a', 'b', 'c'])
})
})
// =============================================================================
// 3.3 Dinamik Image Binding
// =============================================================================
describe('3.3 Dynamic Image Binding', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('ImageElement supports binding field', () => {
const store = useTemplateStore()
store.template = createTestTemplate()
const img: ImageElement = {
id: 'img_dyn',
type: 'image',
position: { type: 'flow' },
size: { width: sz.fixed(40), height: sz.fixed(40) },
binding: { type: 'scalar', path: 'firma.logo' },
style: { objectFit: 'contain' },
}
store.addChild('root', img as unknown as TemplateElement)
const el = store.getElementById('img_dyn') as ImageElement
expect(el.binding).toBeDefined()
expect(el.binding!.path).toBe('firma.logo')
expect(el.src).toBeUndefined()
})
it('can switch from static to dynamic mode', () => {
const store = useTemplateStore()
store.template = createTestTemplate()
const img: ImageElement = {
id: 'img_switch',
type: 'image',
position: { type: 'flow' },
size: { width: sz.fixed(40), height: sz.fixed(40) },
src: 'data:image/png;base64,abc',
style: {},
}
store.addChild('root', img as unknown as TemplateElement)
// Dinamik moda geç
store.updateElement('img_switch', {
src: undefined,
binding: { type: 'scalar', path: 'firma.logo' },
} as Partial<TemplateElement>)
const el = store.getElementById('img_switch') as ImageElement
expect(el.binding).toBeDefined()
expect(el.binding!.path).toBe('firma.logo')
})
it('can switch from dynamic to static mode', () => {
const store = useTemplateStore()
store.template = createTestTemplate()
const img: ImageElement = {
id: 'img_back',
type: 'image',
position: { type: 'flow' },
size: { width: sz.fixed(40), height: sz.fixed(40) },
binding: { type: 'scalar', path: 'firma.logo' },
style: {},
}
store.addChild('root', img as unknown as TemplateElement)
store.updateElement('img_back', {
binding: undefined,
src: 'data:image/png;base64,xyz',
} as Partial<TemplateElement>)
const el = store.getElementById('img_back') as ImageElement
expect(el.src).toBe('data:image/png;base64,xyz')
})
})

View File

@@ -3,7 +3,8 @@ import { ref, computed } from 'vue'
import type { TemplateElement } from '../core/types'
export const useEditorStore = defineStore('editor', () => {
const selectedElementId = ref<string | null>(null)
/** Seçili eleman ID'leri — çoklu seçim desteği */
const selectedElementIds = ref<Set<string>>(new Set())
const zoom = ref(1)
const panX = ref(0)
const panY = ref(0)
@@ -15,12 +16,36 @@ export const useEditorStore = defineStore('editor', () => {
const zoomPercent = computed(() => Math.round(zoom.value * 100))
/** Geriye uyumluluk: tek seçili eleman ID'si (ilk seçili veya null) */
const selectedElementId = computed<string | null>(() => {
const ids = selectedElementIds.value
if (ids.size === 0) return null
return ids.values().next().value ?? null
})
/** Tek eleman seç (önceki seçimi temizler) */
function selectElement(id: string | null) {
selectedElementId.value = id
selectedElementIds.value = id ? new Set([id]) : new Set()
}
/** Shift+click: seçime ekle/çıkar (toggle) */
function toggleSelection(id: string) {
const next = new Set(selectedElementIds.value)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
selectedElementIds.value = next
}
/** Eleman seçili mi? */
function isSelected(id: string): boolean {
return selectedElementIds.value.has(id)
}
function clearSelection() {
selectedElementId.value = null
selectedElementIds.value = new Set()
}
function setZoom(value: number) {
@@ -56,6 +81,7 @@ export const useEditorStore = defineStore('editor', () => {
}
return {
selectedElementIds,
selectedElementId,
zoom,
panX,
@@ -65,6 +91,8 @@ export const useEditorStore = defineStore('editor', () => {
dropTargetContainerId,
zoomPercent,
selectElement,
toggleSelection,
isSelected,
clearSelection,
setZoom,
setPan,

View File

@@ -139,14 +139,27 @@ export const useTemplateStore = defineStore('template', () => {
}
}
/** Element'i başka bir container'a taşı */
/** Element'i başka bir container'a taşı (tek layoutVersion bump) */
function moveElement(elementId: string, targetParentId: string, index?: number) {
const el = getElementById(elementId)
if (!el) return
// removeElement bump'lar, addChild de bump'lar — ama tek mantıksal operasyon.
// Fazladan 1 bump sorun değil (debounce var), ama istersek optimize edebiliriz.
removeElement(elementId)
addChild(targetParentId, el, index)
// Ağaçtan kaldır (bump'sız)
const parent = getParent(elementId)
if (parent) {
const idx = parent.children.findIndex(c => c.id === elementId)
if (idx !== -1) parent.children.splice(idx, 1)
}
// Hedef container'a ekle (bump'sız)
const target = getElementById(targetParentId)
if (target && isContainer(target)) {
if (index !== undefined) {
target.children.splice(index, 0, el)
} else {
target.children.push(el)
}
}
// Tek bump
bumpLayoutVersion()
}
/** Absolute pozisyon güncelle */
@@ -185,14 +198,62 @@ export const useTemplateStore = defineStore('template', () => {
bumpLayoutVersion()
}
/** Bir adım öne getir */
function bringForward(elementId: string) {
const parent = getParent(elementId)
if (!parent) return
const idx = parent.children.findIndex(c => c.id === elementId)
if (idx < 0 || idx >= parent.children.length - 1) return
reorderChild(parent.id, idx, idx + 1)
}
/** Bir adım arkaya gönder */
function sendBackward(elementId: string) {
const parent = getParent(elementId)
if (!parent) return
const idx = parent.children.findIndex(c => c.id === elementId)
if (idx <= 0) return
reorderChild(parent.id, idx, idx - 1)
}
/** En öne getir */
function bringToFront(elementId: string) {
const parent = getParent(elementId)
if (!parent) return
const idx = parent.children.findIndex(c => c.id === elementId)
if (idx < 0 || idx >= parent.children.length - 1) return
reorderChild(parent.id, idx, parent.children.length - 1)
}
/** En arkaya gönder */
function sendToBack(elementId: string) {
const parent = getParent(elementId)
if (!parent) return
const idx = parent.children.findIndex(c => c.id === elementId)
if (idx <= 0) return
reorderChild(parent.id, idx, 0)
}
/** Şablonu JSON olarak dışa aktar */
function exportTemplate(): string {
return JSON.stringify(template.value, null, 2)
}
/** JSON'dan şablon yükle */
/** JSON'dan şablon yükle (validasyonlu) */
function importTemplate(json: string) {
const parsed = JSON.parse(json) as Template
let parsed: Template
try {
parsed = JSON.parse(json) as Template
} catch (e) {
throw new Error(`Geçersiz JSON: ${e instanceof Error ? e.message : String(e)}`)
}
// Minimum schema doğrulaması
if (!parsed.root || parsed.root.type !== 'container') {
throw new Error('Geçersiz şablon: root alanı eksik veya container değil')
}
if (!parsed.page || typeof parsed.page.width !== 'number' || typeof parsed.page.height !== 'number') {
throw new Error('Geçersiz şablon: page alanı eksik veya geçersiz')
}
template.value = parsed
bumpLayoutVersion()
}
@@ -269,6 +330,10 @@ export const useTemplateStore = defineStore('template', () => {
updateElementSize,
updateElement,
reorderChild,
bringForward,
sendBackward,
bringToFront,
sendToBack,
exportTemplate,
importTemplate,
resetTemplate,

View File

@@ -1,38 +1,126 @@
/// Layout Engine Web Worker
/// Template JSON + Data JSON → Layout WASM → LayoutResult
/// Font loading is dynamic — fetches from backend API based on template needs.
import init, { loadFonts, computeLayout, generateBarcode } from '../core/wasm-layout/dreport_layout.js'
import init, { loadFonts, addFonts, computeLayout, generateBarcode } from '../core/wasm-layout/dreport_layout.js'
import type { LayoutResult } from '../core/layout-types'
let initPromise: Promise<void> | null = null
const FONT_FILES = [
{ path: '/fonts/NotoSans-Regular.ttf', family: 'Noto Sans' },
{ path: '/fonts/NotoSans-Bold.ttf', family: 'Noto Sans' },
{ path: '/fonts/NotoSans-Italic.ttf', family: 'Noto Sans' },
{ path: '/fonts/NotoSans-BoldItalic.ttf', family: 'Noto Sans' },
{ path: '/fonts/NotoSansMono-Regular.ttf', family: 'Noto Sans Mono' },
]
/** Configurable font API base URL. Default: same origin /api/fonts */
let fontApiBase = '/api/fonts'
/** Font catalog from backend API */
interface FontVariantInfo {
weight: number
italic: boolean
}
interface FontFamilyInfo {
family: string
variants: FontVariantInfo[]
}
let fontCatalog: FontFamilyInfo[] = []
/** Track which font families are already loaded into WASM */
const loadedFamilies = new Set<string>()
async function doInit() {
console.log('[layout-worker] WASM başlatılıyor...')
await init({ module_or_path: '/wasm/dreport_layout_bg.wasm' })
console.log('[layout-worker] Fontlar yükleniyor...')
const families: string[] = []
const buffers: Uint8Array[] = []
// Fetch font catalog from backend
try {
const res = await fetch(fontApiBase)
if (res.ok) {
fontCatalog = await res.json()
console.log(`[layout-worker] Font kataloğu yüklendi (${fontCatalog.length} aile)`)
} else {
console.warn(`[layout-worker] Font kataloğu alınamadı (HTTP ${res.status}), static fallback deneniyor`)
await loadStaticFallback()
return
}
} catch {
console.warn('[layout-worker] Font API erişilemedi, static fallback deneniyor')
await loadStaticFallback()
return
}
// Load default fonts (Noto Sans + Noto Sans Mono)
await ensureFamiliesLoaded(['Noto Sans', 'Noto Sans Mono'])
console.log('[layout-worker] Hazır')
}
/** Fallback: load fonts from static /fonts/ directory (backwards compat) */
async function loadStaticFallback() {
const STATIC_FONTS = [
'/fonts/NotoSans-Regular.ttf',
'/fonts/NotoSans-Bold.ttf',
'/fonts/NotoSans-Italic.ttf',
'/fonts/NotoSans-BoldItalic.ttf',
'/fonts/NotoSansMono-Regular.ttf',
]
const buffers: Uint8Array[] = []
await Promise.all(
FONT_FILES.map(async (f) => {
const res = await fetch(new URL(f.path, self.location.origin).href)
const buf = await res.arrayBuffer()
families.push(f.family)
buffers.push(new Uint8Array(buf))
STATIC_FONTS.map(async (path) => {
const url = new URL(path, self.location.origin).href
const res = await fetch(url)
if (res.ok) {
buffers.push(new Uint8Array(await res.arrayBuffer()))
}
})
)
loadFonts(JSON.stringify(families), buffers)
console.log('[layout-worker] Hazır')
if (buffers.length > 0) {
loadFonts(buffers)
loadedFamilies.add('noto sans')
loadedFamilies.add('noto sans mono')
console.log(`[layout-worker] Static fallback: ${buffers.length} font yüklendi`)
}
}
/** Load all variants of given families from the API into WASM */
async function ensureFamiliesLoaded(families: string[]): Promise<void> {
const toLoad = families.filter(f => !loadedFamilies.has(f.toLowerCase()))
if (toLoad.length === 0) return
const buffers: Uint8Array[] = []
for (const family of toLoad) {
const info = fontCatalog.find(f => f.family.toLowerCase() === family.toLowerCase())
if (!info) {
console.warn(`[layout-worker] Font ailesi bulunamadı: ${family}`)
continue
}
const fetches = info.variants.map(async (v) => {
const url = `${fontApiBase}/${encodeURIComponent(info.family)}/${v.weight}/${v.italic}`
const res = await fetch(url)
if (res.ok) {
return new Uint8Array(await res.arrayBuffer())
}
return null
})
const results = await Promise.all(fetches)
for (const buf of results) {
if (buf && buf.byteLength > 0) {
buffers.push(buf)
}
}
loadedFamilies.add(family.toLowerCase())
}
if (buffers.length > 0) {
if (loadedFamilies.size <= toLoad.length) {
// First load — use loadFonts
loadFonts(buffers)
} else {
// Subsequent loads — use addFonts
addFonts(buffers)
}
console.log(`[layout-worker] ${toLoad.join(', ')} yüklendi (${buffers.length} variant)`)
}
}
function ensureInit(): Promise<void> {
@@ -45,14 +133,32 @@ function ensureInit(): Promise<void> {
type WorkerMessage =
| { type: 'compile'; templateJson: string; dataJson: string; id: number }
| { type: 'barcode'; format: string; value: string; width: number; height: number; includeText: boolean; id: number }
| { type: 'configure'; fontApiBase?: string }
self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
const msg = e.data
if (msg.type === 'configure') {
if (msg.fontApiBase) {
fontApiBase = msg.fontApiBase
}
return
}
if (msg.type === 'compile') {
try {
await ensureInit()
// Extract font families from template and ensure they're loaded
try {
const tpl = JSON.parse(msg.templateJson)
if (Array.isArray(tpl.fonts) && tpl.fonts.length > 0) {
await ensureFamiliesLoaded(tpl.fonts)
}
} catch {
// Template parse failure will be caught by computeLayout below
}
const t0 = performance.now()
const resultJson = computeLayout(msg.templateJson, msg.dataJson)
const layout: LayoutResult = JSON.parse(resultJson)

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -5,5 +5,10 @@ export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
exclude: [
'**/node_modules/**',
'**/dist/**',
'tests/visual/**',
],
},
})

View File

@@ -25,18 +25,51 @@ wasm:
wasm-watch:
watchexec -w layout-engine/src -w core/src -e rs -- just wasm
# --- Test Komutlari ---
# Rust testleri (core + layout-engine + backend)
test-rust:
cargo test
# Frontend unit testleri (Vitest)
test-front:
cd frontend && bun run test:run
# Generate PDF reference PNGs for cross-renderer visual tests
visual-refs:
cargo test -p dreport-layout --test visual_test -- generate_cross_renderer --ignored
# Run cross-renderer visual tests (Playwright vs PDF)
visual-test: visual-refs
# Rust visual snapshot testleri
test-visual-rust:
cargo test -p dreport-layout --test visual_test
# Cross-renderer visual testleri (Playwright: HTML vs PDF)
test-visual-cross: visual-refs
cd frontend && bun run test:visual -- --project=cross-renderer
# Run all visual tests (editor + cross-renderer)
visual-test-all: visual-refs
# Editor visual testleri (Playwright)
test-visual-editor:
cd frontend && bun run test:visual -- --project=editor
# Tum visual testler (Playwright: editor + cross-renderer)
test-visual: visual-refs
cd frontend && bun run test:visual
# Tum testler (Rust + frontend unit + visual)
test-all: test-rust test-front test-visual
# Visual diff sonuclarini ac (cross-renderer)
diff-open:
#!/usr/bin/env bash
DIFF_DIR="frontend/tests/visual/cross-renderer-diffs"
if [ -z "$(ls -A "$DIFF_DIR" 2>/dev/null)" ]; then
echo "Diff klasoru bos — once 'just test-visual-cross' calistirin."
exit 1
fi
open "$DIFF_DIR"/*_diff.png "$DIFF_DIR"/*_html.png 2>/dev/null || xdg-open "$DIFF_DIR" 2>/dev/null || echo "Dosyalar: $DIFF_DIR"
# --- Publish ---
# Publish dreport-core to Gitea
publish-core:
cargo publish -p dreport-core --registry gitea --allow-dirty

View File

@@ -0,0 +1,799 @@
//! Shared chart layout computation — used by both SVG (chart_render) and PDF (pdf_render).
//!
//! This module extracts the **what to draw and where** logic into shared structs.
//! Each renderer then handles the **how** (actual drawing calls) using these structs.
use dreport_core::models::ChartType;
pub const DEFAULT_COLORS: &[&str] = &[
"#4F46E5", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16",
];
// ---------------------------------------------------------------------------
// Shared structs
// ---------------------------------------------------------------------------
pub struct ChartLayout {
/// Absolute plot area origin X (mm). For SVG this equals margin_left;
/// for PDF this is base_x_mm + margin_left.
pub plot_x: f64,
/// Absolute plot area origin Y (mm).
pub plot_y: f64,
pub plot_w: f64,
pub plot_h: f64,
pub margin_top: f64,
pub margin_bottom: f64,
pub margin_left: f64,
pub margin_right: f64,
pub palette: Vec<String>,
pub title: Option<TitleLayout>,
pub legend_show: bool,
pub legend_pos: String,
pub legend_font: f64,
}
pub struct TitleLayout {
pub text: String,
pub font_size: f64,
pub color: String,
/// x position in mm (absolute)
pub x: f64,
/// y position in mm (absolute)
pub y: f64,
pub align: String, // "left", "center", "right"
}
pub struct YAxisLayout {
pub ticks: Vec<YTick>,
pub show_grid: bool,
pub grid_color: String,
/// Y axis vertical line positions (mm, absolute)
pub axis_x: f64,
pub axis_y_start: f64,
pub axis_y_end: f64,
/// Right edge of the grid lines (axis_x + plot_w)
pub grid_end_x: f64,
}
pub struct YTick {
pub value: f64,
pub label: String,
/// Absolute Y position (mm)
pub y: f64,
}
pub struct XLabelLayout {
pub labels: Vec<XLabel>,
pub needs_rotate: bool,
}
pub struct XLabel {
pub text: String,
/// Absolute X position (mm)
pub x: f64,
/// Absolute Y position (mm)
pub y: f64,
}
/// Pre-computed bar geometry for a single bar rect
pub struct BarRect {
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
pub color_idx: usize,
pub value: f64,
/// Label position (center x, label y)
pub label_x: f64,
pub label_y: f64,
}
pub struct BarChartLayout {
pub min_val: f64,
pub max_val: f64,
pub y_axis: YAxisLayout,
pub x_labels: XLabelLayout,
pub bars: Vec<BarRect>,
pub show_labels: bool,
pub label_font: f64,
pub label_color: String,
pub stacked: bool,
/// X axis line endpoints
pub x_axis_y: f64,
pub x_axis_x1: f64,
pub x_axis_x2: f64,
}
/// Pre-computed point position for line chart
pub struct LinePoint {
pub x: f64,
pub y: f64,
pub value: f64,
}
pub struct LineSeriesLayout {
pub color_idx: usize,
pub points: Vec<LinePoint>,
}
pub struct LineChartLayout {
pub min_val: f64,
pub max_val: f64,
pub y_axis: YAxisLayout,
pub x_labels: XLabelLayout,
pub series: Vec<LineSeriesLayout>,
pub line_width: f64,
pub show_points: bool,
pub show_labels: bool,
pub label_font: f64,
pub label_color: String,
/// X axis line endpoints
pub x_axis_y: f64,
pub x_axis_x1: f64,
pub x_axis_x2: f64,
}
pub struct PieSlice {
pub start_angle: f64,
pub end_angle: f64,
pub sweep: f64,
pub color_idx: usize,
pub value: f64,
pub fraction: f64,
/// Label position inside slice
pub label_x: f64,
pub label_y: f64,
pub label_text: String,
/// Leader line + category label outside
pub leader_start_x: f64,
pub leader_start_y: f64,
pub leader_end_x: f64,
pub leader_end_y: f64,
pub cat_label_x: f64,
pub cat_label_y: f64,
pub cat_label_text: String,
pub cat_label_anchor_end: bool, // true = end/right, false = start/left
}
pub struct PieChartLayout {
pub cx: f64,
pub cy: f64,
pub radius: f64,
pub inner_radius: f64,
pub slices: Vec<PieSlice>,
pub show_labels: bool,
pub label_font: f64,
pub label_color: String,
}
/// Legend item with pre-computed position
pub struct LegendItemLayout {
pub name: String,
pub color_idx: usize,
/// Swatch rect position (mm)
pub swatch_x: f64,
pub swatch_y: f64,
/// Text position (mm)
pub text_x: f64,
pub text_y: f64,
}
pub struct LegendLayout {
pub items: Vec<LegendItemLayout>,
pub font_size: f64,
pub position: String,
pub swatch_size: f64,
}
// ---------------------------------------------------------------------------
// Common input abstraction — both ResolvedChartData and ChartRenderData
// can provide these values
// ---------------------------------------------------------------------------
/// Trait that abstracts over the two chart data representations used by
/// SVG renderer (ResolvedChartData) and PDF renderer (ChartRenderData).
pub trait ChartDataSource {
fn chart_type(&self) -> ChartType;
fn categories(&self) -> &[String];
fn series_count(&self) -> usize;
fn series_name(&self, idx: usize) -> &str;
fn series_values(&self, idx: usize) -> &[f64];
fn title_text(&self) -> Option<&str>;
fn title_font_size(&self) -> Option<f64>;
fn title_color(&self) -> Option<&str>;
fn title_align(&self) -> Option<&str>;
fn legend_show(&self) -> bool;
fn legend_position(&self) -> Option<&str>;
fn legend_font_size(&self) -> Option<f64>;
fn x_label(&self) -> Option<&str>;
fn y_label(&self) -> Option<&str>;
fn show_grid(&self) -> bool;
fn grid_color(&self) -> Option<&str>;
fn bar_gap(&self) -> Option<f64>;
fn stacked(&self) -> bool;
fn colors(&self) -> Option<&[String]>;
fn background_color(&self) -> Option<&str>;
fn show_labels(&self) -> bool;
fn label_font_size(&self) -> Option<f64>;
fn label_color(&self) -> Option<&str>;
fn inner_radius(&self) -> Option<f64>;
fn show_points(&self) -> Option<bool>;
fn line_width(&self) -> Option<f64>;
}
// ---------------------------------------------------------------------------
// Impl for SVG renderer's ResolvedChartData
// ---------------------------------------------------------------------------
impl ChartDataSource for crate::data_resolve::ResolvedChartData {
fn chart_type(&self) -> ChartType { self.chart_type.clone() }
fn categories(&self) -> &[String] { &self.categories }
fn series_count(&self) -> usize { self.series.len() }
fn series_name(&self, idx: usize) -> &str { &self.series[idx].name }
fn series_values(&self, idx: usize) -> &[f64] { &self.series[idx].values }
fn title_text(&self) -> Option<&str> {
self.title.as_ref().map(|t| t.text.as_str()).filter(|t| !t.is_empty())
}
fn title_font_size(&self) -> Option<f64> { self.title.as_ref().and_then(|t| t.font_size) }
fn title_color(&self) -> Option<&str> { self.title.as_ref().and_then(|t| t.color.as_deref()) }
fn title_align(&self) -> Option<&str> { self.title.as_ref().and_then(|t| t.align.as_deref()) }
fn legend_show(&self) -> bool { self.legend.as_ref().is_some_and(|l| l.show) }
fn legend_position(&self) -> Option<&str> { self.legend.as_ref().and_then(|l| l.position.as_deref()) }
fn legend_font_size(&self) -> Option<f64> { self.legend.as_ref().and_then(|l| l.font_size) }
fn x_label(&self) -> Option<&str> { self.axis.as_ref().and_then(|a| a.x_label.as_deref()) }
fn y_label(&self) -> Option<&str> { self.axis.as_ref().and_then(|a| a.y_label.as_deref()) }
fn show_grid(&self) -> bool { self.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true) }
fn grid_color(&self) -> Option<&str> { self.axis.as_ref().and_then(|a| a.grid_color.as_deref()) }
fn bar_gap(&self) -> Option<f64> { self.style.bar_gap }
fn stacked(&self) -> bool { matches!(self.group_mode, Some(dreport_core::models::GroupMode::Stacked)) }
fn colors(&self) -> Option<&[String]> { self.style.colors.as_deref() }
fn background_color(&self) -> Option<&str> { self.style.background_color.as_deref() }
fn show_labels(&self) -> bool { self.labels.as_ref().is_some_and(|l| l.show) }
fn label_font_size(&self) -> Option<f64> { self.labels.as_ref().and_then(|l| l.font_size) }
fn label_color(&self) -> Option<&str> { self.labels.as_ref().and_then(|l| l.color.as_deref()) }
fn inner_radius(&self) -> Option<f64> { self.style.inner_radius }
fn show_points(&self) -> Option<bool> { self.style.show_points }
fn line_width(&self) -> Option<f64> { self.style.line_width }
}
// ---------------------------------------------------------------------------
// Impl for PDF renderer's ChartRenderData
// ---------------------------------------------------------------------------
impl ChartDataSource for crate::ChartRenderData {
fn chart_type(&self) -> ChartType { self.chart_type.clone() }
fn categories(&self) -> &[String] { &self.categories }
fn series_count(&self) -> usize { self.series.len() }
fn series_name(&self, idx: usize) -> &str { &self.series[idx].name }
fn series_values(&self, idx: usize) -> &[f64] { &self.series[idx].values }
fn title_text(&self) -> Option<&str> {
self.title_text.as_deref().filter(|t| !t.is_empty())
}
fn title_font_size(&self) -> Option<f64> { self.title_font_size }
fn title_color(&self) -> Option<&str> { self.title_color.as_deref() }
fn title_align(&self) -> Option<&str> { self.title_align.as_deref() }
fn legend_show(&self) -> bool { self.legend_show }
fn legend_position(&self) -> Option<&str> { self.legend_position.as_deref() }
fn legend_font_size(&self) -> Option<f64> { self.legend_font_size }
fn x_label(&self) -> Option<&str> { self.x_label.as_deref() }
fn y_label(&self) -> Option<&str> { self.y_label.as_deref() }
fn show_grid(&self) -> bool { self.show_grid }
fn grid_color(&self) -> Option<&str> { self.grid_color.as_deref() }
fn bar_gap(&self) -> Option<f64> { self.bar_gap }
fn stacked(&self) -> bool { self.stacked }
fn colors(&self) -> Option<&[String]> {
if self.colors.is_empty() { None } else { Some(&self.colors) }
}
fn background_color(&self) -> Option<&str> { self.background_color.as_deref() }
fn show_labels(&self) -> bool { self.show_labels }
fn label_font_size(&self) -> Option<f64> { self.label_font_size }
fn label_color(&self) -> Option<&str> { self.label_color.as_deref() }
fn inner_radius(&self) -> Option<f64> { self.inner_radius }
fn show_points(&self) -> Option<bool> { self.show_points }
fn line_width(&self) -> Option<f64> { self.line_width }
}
// ---------------------------------------------------------------------------
// Shared computation functions
// ---------------------------------------------------------------------------
pub fn color_at(palette: &[String], i: usize) -> &str {
&palette[i % palette.len()]
}
pub fn build_palette(data: &dyn ChartDataSource) -> Vec<String> {
let n_colors = data.categories().len().max(data.series_count()).max(1);
let user_colors = data.colors();
(0..n_colors)
.map(|i| {
if let Some(uc) = user_colors {
if i < uc.len() {
return uc[i].clone();
}
}
DEFAULT_COLORS[i % DEFAULT_COLORS.len()].to_string()
})
.collect()
}
pub fn format_value(v: f64) -> String {
if v.abs() >= 1_000_000.0 {
format!("{:.1}M", v / 1_000_000.0)
} else if v.abs() >= 1_000.0 {
format!("{:.1}K", v / 1_000.0)
} else if v.fract().abs() < 1e-10 {
format!("{}", v as i64)
} else {
format!("{:.1}", v)
}
}
/// Compute the value range (min, max) across all series.
pub fn compute_value_range(data: &dyn ChartDataSource, stacked: bool) -> (f64, f64) {
if data.series_count() == 0 {
return (0.0, 1.0);
}
if stacked {
let n = data.categories().len();
let mut max_stack = 0.0_f64;
for ci in 0..n {
let sum: f64 = (0..data.series_count())
.map(|si| data.series_values(si).get(ci).copied().unwrap_or(0.0))
.sum();
max_stack = max_stack.max(sum);
}
(0.0, max_stack * 1.05)
} else {
let mut min_v = f64::MAX;
let mut max_v = f64::MIN;
for si in 0..data.series_count() {
for val in data.series_values(si) {
min_v = min_v.min(*val);
max_v = max_v.max(*val);
}
}
if min_v > 0.0 {
min_v = 0.0;
}
max_v *= 1.05;
(min_v, max_v)
}
}
fn safe_range(min_val: f64, max_val: f64) -> f64 {
let r = max_val - min_val;
if r.abs() < 1e-10 { 1.0 } else { r }
}
/// Compute margins and plot area. `origin_x/y` is 0 for SVG or base_x_mm/base_y_mm for PDF.
pub fn compute_chart_layout(
data: &dyn ChartDataSource,
width_mm: f64,
height_mm: f64,
origin_x: f64,
origin_y: f64,
) -> ChartLayout {
let palette = build_palette(data);
let mut margin_top = 2.0_f64;
let mut margin_bottom = 4.0_f64;
let mut margin_left = 8.0_f64;
let margin_right = 4.0_f64;
// Title
let title = if let Some(text) = data.title_text() {
let fs = data.title_font_size().unwrap_or(4.0);
margin_top += fs * 0.4 + 2.0;
let color = data.title_color().unwrap_or("#333333").to_string();
let align = data.title_align().unwrap_or("center").to_string();
let x = match align.as_str() {
"left" => origin_x + margin_left,
"right" => origin_x + width_mm - margin_right,
_ => origin_x + width_mm / 2.0,
};
let y = origin_y + margin_top - 1.0;
Some(TitleLayout { text: text.to_string(), font_size: fs, color, x, y, align })
} else {
None
};
// Legend space
let legend_show = data.legend_show();
let legend_pos = data.legend_position().unwrap_or("bottom").to_string();
let legend_font = data.legend_font_size().unwrap_or(2.8);
if legend_show && data.series_count() > 1 {
match legend_pos.as_str() {
"top" => margin_top += legend_font + 3.0,
"bottom" => margin_bottom += legend_font + 3.0,
_ => {}
}
}
// Axis labels space (bar and line only)
let has_axis = !matches!(data.chart_type(), ChartType::Pie);
if has_axis {
if data.x_label().is_some() {
margin_bottom += 4.0;
}
if data.y_label().is_some() {
margin_left += 4.0;
}
// Category labels bottom space
let max_label_len = data.categories().iter().map(|c| c.len()).max().unwrap_or(0);
let n_cats = data.categories().len();
let available_w = width_mm - margin_left - margin_right;
let cat_width = if n_cats > 0 { available_w / n_cats as f64 } else { available_w };
let max_chars_fit = (cat_width / 1.25).max(1.0) as usize;
let will_rotate = max_label_len > max_chars_fit;
if will_rotate {
let char_w_mm = 1.1;
let max_text_w = max_label_len as f64 * char_w_mm;
let label_v = max_text_w * 0.707;
margin_bottom += label_v.min(25.0).max(6.0);
let label_h = max_text_w * 0.707;
let extra_left = (label_h - cat_width / 2.0).max(0.0);
margin_left += extra_left.min(10.0);
} else {
margin_bottom += 4.0;
}
// Y-axis value labels left space
margin_left += 6.0;
}
let plot_x = origin_x + margin_left;
let plot_y = origin_y + margin_top;
let plot_w = (width_mm - margin_left - margin_right).max(1.0);
let plot_h = (height_mm - margin_top - margin_bottom).max(1.0);
ChartLayout {
plot_x, plot_y, plot_w, plot_h,
margin_top, margin_bottom, margin_left, margin_right,
palette, title, legend_show, legend_pos, legend_font,
}
}
/// Compute Y axis ticks and grid lines.
pub fn compute_y_axis(
min_val: f64, max_val: f64,
px: f64, py: f64, pw: f64, ph: f64,
show_grid: bool, grid_color: &str,
) -> YAxisLayout {
let range = safe_range(min_val, max_val);
let tick_count = 5;
let ticks = (0..=tick_count)
.map(|i| {
let frac = i as f64 / tick_count as f64;
let val = min_val + frac * range;
let y = py + ph - frac * ph;
YTick { value: val, label: format_value(val), y }
})
.collect();
YAxisLayout {
ticks,
show_grid,
grid_color: grid_color.to_string(),
axis_x: px,
axis_y_start: py,
axis_y_end: py + ph,
grid_end_x: px + pw,
}
}
/// Compute X label positions for bar chart (slot-based spacing).
pub fn compute_x_labels_bar(categories: &[String], px: f64, baseline_y: f64, pw: f64) -> XLabelLayout {
let n_cats = categories.len();
if n_cats == 0 {
return XLabelLayout { labels: vec![], needs_rotate: false };
}
let cat_width = pw / n_cats as f64;
let max_chars = (cat_width / 1.25).max(1.0) as usize;
let needs_rotate = categories.iter().any(|c| c.len() > max_chars);
let labels = categories.iter().enumerate().map(|(ci, cat)| {
XLabel {
text: cat.clone(),
x: px + ci as f64 * cat_width + cat_width / 2.0,
y: baseline_y + 2.5,
}
}).collect();
XLabelLayout { labels, needs_rotate }
}
/// Compute X label positions for line chart (point-based spacing).
pub fn compute_x_labels_line(categories: &[String], px: f64, baseline_y: f64, pw: f64) -> XLabelLayout {
let n_cats = categories.len();
if n_cats == 0 {
return XLabelLayout { labels: vec![], needs_rotate: false };
}
let spacing = if n_cats == 1 { pw } else { pw / (n_cats - 1) as f64 };
let max_chars = (spacing / 1.25).max(1.0) as usize;
let needs_rotate = categories.iter().any(|c| c.len() > max_chars);
let labels = categories.iter().enumerate().map(|(ci, cat)| {
let x = if n_cats == 1 { px + pw / 2.0 } else { px + ci as f64 * pw / (n_cats - 1) as f64 };
XLabel { text: cat.clone(), x, y: baseline_y + 2.5 }
}).collect();
XLabelLayout { labels, needs_rotate }
}
/// Compute bar chart layout (all bar geometries + axes).
pub fn compute_bar_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> BarChartLayout {
let px = cl.plot_x;
let py = cl.plot_y;
let pw = cl.plot_w;
let ph = cl.plot_h;
let stacked = data.stacked();
let (min_val, max_val) = compute_value_range(data, stacked);
let range = safe_range(min_val, max_val);
let show_grid = data.show_grid();
let grid_color = data.grid_color().unwrap_or("#E5E7EB");
let y_axis = compute_y_axis(min_val, max_val, px, py, pw, ph, show_grid, grid_color);
let n_cats = data.categories().len();
let n_series = data.series_count();
let cat_width = if n_cats > 0 { pw / n_cats as f64 } else { pw };
let bar_gap = data.bar_gap().unwrap_or(0.2).clamp(0.0, 0.8);
let group_width = cat_width * (1.0 - bar_gap);
let show_labels = data.show_labels();
let label_font = data.label_font_size().unwrap_or(2.2);
let label_color = data.label_color().unwrap_or("#333").to_string();
let mut bars = Vec::new();
for ci in 0..n_cats {
let cat_x = px + ci as f64 * cat_width;
if stacked {
let mut y_offset = 0.0_f64;
for si in 0..n_series {
let val = data.series_values(si).get(ci).copied().unwrap_or(0.0);
let bar_h = (val / range) * ph;
let bar_y = py + ph - y_offset - bar_h;
let bx = cat_x + cat_width * bar_gap / 2.0;
bars.push(BarRect {
x: bx,
y: bar_y,
w: group_width,
h: bar_h.max(0.0),
color_idx: si,
value: val,
label_x: cat_x + cat_width / 2.0,
label_y: bar_y + bar_h / 2.0 + label_font * 0.15,
});
y_offset += bar_h;
}
} else {
let bar_w = if n_series > 0 { group_width / n_series as f64 } else { group_width };
for si in 0..n_series {
let val = data.series_values(si).get(ci).copied().unwrap_or(0.0);
let bar_h = ((val - min_val) / range) * ph;
let bx = cat_x + cat_width * bar_gap / 2.0 + si as f64 * bar_w;
let by = py + ph - bar_h;
bars.push(BarRect {
x: bx,
y: by,
w: bar_w.max(0.1),
h: bar_h.max(0.0),
color_idx: si,
value: val,
label_x: bx + bar_w / 2.0,
label_y: by - 0.8,
});
}
}
}
let x_labels = compute_x_labels_bar(data.categories(), px, py + ph, pw);
BarChartLayout {
min_val, max_val,
y_axis, x_labels, bars,
show_labels, label_font, label_color, stacked,
x_axis_y: py + ph,
x_axis_x1: px,
x_axis_x2: px + pw,
}
}
/// Compute line chart layout (all point positions + axes).
pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> LineChartLayout {
let px = cl.plot_x;
let py = cl.plot_y;
let pw = cl.plot_w;
let ph = cl.plot_h;
let (min_val, max_val) = compute_value_range(data, false);
let range = safe_range(min_val, max_val);
let n_cats = data.categories().len();
let show_grid = data.show_grid();
let grid_color = data.grid_color().unwrap_or("#E5E7EB");
let y_axis = compute_y_axis(min_val, max_val, px, py, pw, ph, show_grid, grid_color);
let line_width = data.line_width().unwrap_or(0.5);
let show_points = data.show_points().unwrap_or(true);
let show_labels = data.show_labels();
let label_font = data.label_font_size().unwrap_or(2.2);
let label_color = data.label_color().unwrap_or("#333").to_string();
let series = (0..data.series_count()).map(|si| {
let values = data.series_values(si);
let points = values.iter().enumerate().map(|(ci, val)| {
let x = if n_cats == 1 { px + pw / 2.0 } else { px + ci as f64 * pw / (n_cats - 1) as f64 };
let y = py + ph - ((val - min_val) / range) * ph;
LinePoint { x, y, value: *val }
}).collect();
LineSeriesLayout { color_idx: si, points }
}).collect();
let x_labels = compute_x_labels_line(data.categories(), px, py + ph, pw);
LineChartLayout {
min_val, max_val,
y_axis, x_labels, series,
line_width: line_width,
show_points, show_labels, label_font, label_color,
x_axis_y: py + ph,
x_axis_x1: px,
x_axis_x2: px + pw,
}
}
/// Compute pie chart layout (slice angles and label positions).
pub fn compute_pie_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> PieChartLayout {
let px = cl.plot_x;
let py = cl.plot_y;
let pw = cl.plot_w;
let ph = cl.plot_h;
let values: Vec<f64> = if data.series_count() == 1 {
data.series_values(0).to_vec()
} else {
data.categories().iter().enumerate().map(|(ci, _)| {
(0..data.series_count())
.map(|si| data.series_values(si).get(ci).copied().unwrap_or(0.0))
.sum()
}).collect()
};
let total: f64 = values.iter().sum();
let show_labels = data.show_labels();
let label_font = data.label_font_size().unwrap_or(3.0);
let label_color = data.label_color().unwrap_or("#333").to_string();
let cx = px + pw / 2.0;
let cy = py + ph / 2.0;
let radius = pw.min(ph) / 2.0 * 0.65;
let inner_frac = data.inner_radius().unwrap_or(0.0).clamp(0.0, 0.9);
let inner_r = radius * inner_frac;
let mut slices = Vec::new();
if total > 0.0 {
let mut start_angle = -std::f64::consts::FRAC_PI_2;
let categories = data.categories();
for (i, val) in values.iter().enumerate() {
if *val <= 0.0 {
start_angle += 0.0; // skip
continue;
}
let sweep = (val / total) * std::f64::consts::TAU;
let end_angle = start_angle + sweep;
let mid_angle = start_angle + sweep / 2.0;
// Label inside slice
let label_r = if inner_r > 0.0 { (radius + inner_r) / 2.0 } else { radius * 0.65 };
let lx = cx + label_r * mid_angle.cos();
let ly = cy + label_r * mid_angle.sin();
let pct = (val / total * 100.0).round();
// Leader line + category label
let line_start_r = radius;
let line_end_r = radius + 3.0;
let text_r = radius + 4.0;
let leader_sx = cx + line_start_r * mid_angle.cos();
let leader_sy = cy + line_start_r * mid_angle.sin();
let leader_ex = cx + line_end_r * mid_angle.cos();
let leader_ey = cy + line_end_r * mid_angle.sin();
let cat_lx = cx + text_r * mid_angle.cos();
let cat_ly = cy + text_r * mid_angle.sin();
let cat_text = if i < categories.len() { categories[i].clone() } else { String::new() };
let anchor_end = mid_angle.cos() < 0.0;
slices.push(PieSlice {
start_angle, end_angle, sweep,
color_idx: i,
value: *val,
fraction: val / total,
label_x: lx, label_y: ly,
label_text: format!("{}%", pct),
leader_start_x: leader_sx, leader_start_y: leader_sy,
leader_end_x: leader_ex, leader_end_y: leader_ey,
cat_label_x: cat_lx, cat_label_y: cat_ly,
cat_label_text: cat_text,
cat_label_anchor_end: anchor_end,
});
start_angle = end_angle;
}
}
PieChartLayout {
cx, cy, radius, inner_radius: inner_r,
slices, show_labels, label_font, label_color,
}
}
/// Compute legend item positions.
pub fn compute_legend(
data: &dyn ChartDataSource,
cl: &ChartLayout,
origin_x: f64,
origin_y: f64,
total_w: f64,
total_h: f64,
) -> LegendLayout {
let font_size = cl.legend_font;
let position = cl.legend_pos.clone();
let swatch_size = 2.5;
let item_gap = 3.0 + font_size * 0.4;
let spacing = 4.0;
let is_pie = matches!(data.chart_type(), ChartType::Pie);
let names: Vec<String> = if is_pie {
data.categories().to_vec()
} else {
(0..data.series_count()).map(|i| data.series_name(i).to_string()).collect()
};
let mut items = Vec::new();
match position.as_str() {
"top" => {
let y = origin_y + cl.margin_top - font_size - 1.5;
let mut x = origin_x + cl.margin_left;
for (i, name) in names.iter().enumerate() {
items.push(LegendItemLayout {
name: name.clone(), color_idx: i,
swatch_x: x, swatch_y: y - font_size * 0.3,
text_x: x + item_gap, text_y: y + font_size * 0.3,
});
x += item_gap + name.len() as f64 * font_size * 0.5 + spacing;
}
}
"right" => {
let x = origin_x + cl.margin_left + cl.plot_w + 4.0;
let mut y = origin_y + cl.margin_top + 2.0;
for (i, name) in names.iter().enumerate() {
items.push(LegendItemLayout {
name: name.clone(), color_idx: i,
swatch_x: x, swatch_y: y,
text_x: x + item_gap, text_y: y + font_size * 0.7,
});
y += font_size + 2.0;
}
}
_ => {
// bottom (default)
let y = origin_y + total_h - 3.0;
let total_legend_w: f64 = names.iter()
.map(|n| item_gap + n.len() as f64 * font_size * 0.5 + spacing)
.sum::<f64>() - spacing;
let mut x = origin_x + (total_w - total_legend_w) / 2.0;
for (i, name) in names.iter().enumerate() {
items.push(LegendItemLayout {
name: name.clone(), color_idx: i,
swatch_x: x, swatch_y: y - font_size * 0.3,
text_x: x + item_gap, text_y: y + font_size * 0.3,
});
x += item_gap + name.len() as f64 * font_size * 0.5 + spacing;
}
}
}
LegendLayout { items, font_size, position, swatch_size }
}

View File

@@ -1,15 +1,10 @@
use crate::chart_layout::{
self, color_at, compute_bar_layout, compute_chart_layout, compute_legend,
compute_line_layout, compute_pie_layout, format_value, ChartLayout,
};
use crate::data_resolve::ResolvedChartData;
use dreport_core::models::{ChartType, GroupMode};
use std::fmt::Write;
pub const DEFAULT_COLORS: &[&str] = &[
"#4F46E5", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16",
];
fn color_at(palette: &[String], i: usize) -> &str {
&palette[i % palette.len()]
}
/// mm cinsinden chart SVG uret
pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> String {
let mut svg = String::with_capacity(4096);
@@ -33,134 +28,40 @@ pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> St
)
.unwrap();
// Max sayida renk: kategoriler + seriler
let n_colors = data.categories.len().max(data.series.len()).max(1);
let palette: Vec<String> = (0..n_colors)
.map(|i| {
if let Some(ref user_colors) = data.style.colors {
if i < user_colors.len() {
return user_colors[i].clone();
}
}
DEFAULT_COLORS[i % DEFAULT_COLORS.len()].to_string()
})
.collect();
// Margin hesaplari
let mut margin_top = 2.0_f64;
let mut margin_bottom = 4.0_f64;
let mut margin_left = 8.0_f64;
let margin_right = 4.0_f64;
let cl = compute_chart_layout(data, width_mm, height_mm, 0.0, 0.0);
// Title
if let Some(ref title) = data.title {
if !title.text.is_empty() {
let font_size = title.font_size.unwrap_or(4.0);
margin_top += font_size * 0.4 + 2.0;
let color = title.color.as_deref().unwrap_or("#333333");
let align = title.align.as_deref().unwrap_or("center");
let x = match align {
"left" => margin_left,
"right" => width_mm - margin_right,
_ => width_mm / 2.0,
};
let anchor = match align {
"left" => "start",
"right" => "end",
_ => "middle",
};
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="{}" font-weight="bold">{}</text>"##,
x,
margin_top - 1.0,
font_size,
color,
anchor,
escape_xml(&title.text)
)
.unwrap();
}
}
// Legend space
let legend_show = data.legend.as_ref().is_some_and(|l| l.show);
let legend_pos = data
.legend
.as_ref()
.and_then(|l| l.position.as_deref())
.unwrap_or("bottom");
let legend_font = data
.legend
.as_ref()
.and_then(|l| l.font_size)
.unwrap_or(2.8);
if legend_show && data.series.len() > 1 {
match legend_pos {
"top" => margin_top += legend_font + 3.0,
"bottom" => margin_bottom += legend_font + 3.0,
_ => {} // right — icerde handle edilecek
}
}
// Axis labels icin yer ac (bar ve line)
let has_axis = !matches!(data.chart_type, ChartType::Pie);
if has_axis {
if data.axis.as_ref().and_then(|a| a.x_label.as_ref()).is_some() {
margin_bottom += 4.0;
}
if data.axis.as_ref().and_then(|a| a.y_label.as_ref()).is_some() {
margin_left += 4.0;
}
// Category labels icin alt bosluk
let max_label_len = data.categories.iter().map(|c| c.len()).max().unwrap_or(0);
let n_cats = data.categories.len();
let available_w = width_mm - margin_left - margin_right;
let cat_width = if n_cats > 0 {
available_w / n_cats as f64
} else {
available_w
if let Some(ref title) = cl.title {
let anchor = match title.align.as_str() {
"left" => "start",
"right" => "end",
_ => "middle",
};
let max_chars_fit = (cat_width / 1.25).max(1.0) as usize;
let will_rotate = max_label_len > max_chars_fit;
if will_rotate {
// Rotated labels (-45°): dikey ≈ text_width * sin(45°), yatay ≈ text_width * cos(45°)
let char_w_mm = 1.1;
let max_text_w = max_label_len as f64 * char_w_mm;
let label_v = max_text_w * 0.707; // sin(45°)
margin_bottom += label_v.min(25.0).max(6.0);
// Sol taraftaki label yana tasabilir
let label_h = max_text_w * 0.707; // cos(45°)
let extra_left = (label_h - cat_width / 2.0).max(0.0);
margin_left += extra_left.min(10.0);
} else {
margin_bottom += 4.0;
}
// Y-axis value labels icin sol bosluk
margin_left += 6.0;
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="{}" font-weight="bold">{}</text>"##,
title.x, title.y, title.font_size, title.color, anchor, escape_xml(&title.text)
)
.unwrap();
}
let plot_x = margin_left;
let plot_y = margin_top;
let plot_w = (width_mm - margin_left - margin_right).max(1.0);
let plot_h = (height_mm - margin_top - margin_bottom).max(1.0);
match data.chart_type {
ChartType::Bar => render_bar(&mut svg, data, &palette, plot_x, plot_y, plot_w, plot_h),
ChartType::Line => render_line(&mut svg, data, &palette, plot_x, plot_y, plot_w, plot_h),
ChartType::Pie => render_pie(&mut svg, data, &palette, width_mm, height_mm, plot_x, plot_y, plot_w, plot_h),
dreport_core::models::ChartType::Bar => render_bar(&mut svg, data, &cl),
dreport_core::models::ChartType::Line => render_line(&mut svg, data, &cl),
dreport_core::models::ChartType::Pie => render_pie(&mut svg, data, &cl),
}
// Legend render
if legend_show && data.series.len() > 1 {
render_legend(&mut svg, data, &palette, legend_pos, legend_font, width_mm, height_mm, margin_left, margin_top, plot_w, plot_h);
if cl.legend_show && data.series.len() > 1 {
render_legend(&mut svg, data, &cl, width_mm, height_mm);
}
// Axis labels
let has_axis = !matches!(data.chart_type, dreport_core::models::ChartType::Pie);
if has_axis {
if let Some(ref axis) = data.axis {
if let Some(ref x_label) = axis.x_label {
let x = plot_x + plot_w / 2.0;
let x = cl.plot_x + cl.plot_w / 2.0;
let y = height_mm - 2.0;
write!(
svg,
@@ -171,7 +72,7 @@ pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> St
}
if let Some(ref y_label) = axis.y_label {
let x = 3.0;
let y = plot_y + plot_h / 2.0;
let y = cl.plot_y + cl.plot_h / 2.0;
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.8" fill="#666" text-anchor="middle" transform="rotate(-90,{:.2},{:.2})">{}</text>"##,
@@ -186,199 +87,91 @@ pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> St
svg
}
fn render_bar(
svg: &mut String,
data: &ResolvedChartData,
palette: &[String],
px: f64,
py: f64,
pw: f64,
ph: f64,
) {
fn render_bar(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
if data.categories.is_empty() || data.series.is_empty() {
return;
}
let stacked = matches!(data.group_mode, Some(GroupMode::Stacked));
let (min_val, max_val) = value_range(data, stacked);
let bl = compute_bar_layout(data, cl);
let show_grid = data.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true);
let grid_color = data
.axis
.as_ref()
.and_then(|a| a.grid_color.as_deref())
.unwrap_or("#E5E7EB");
// Y axis
render_y_axis_svg(svg, &bl.y_axis);
// Grid + Y axis labels
render_y_axis(svg, min_val, max_val, px, py, pw, ph, show_grid, grid_color);
// Bars
for bar in &bl.bars {
let color = color_at(&cl.palette, bar.color_idx);
write!(
svg,
r##"<rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="{}" rx="0.5"/>"##,
bar.x, bar.y, bar.w, bar.h, color
)
.unwrap();
let n_cats = data.categories.len();
let n_series = data.series.len();
let cat_width = pw / n_cats as f64;
let bar_gap = data.style.bar_gap.unwrap_or(0.2).clamp(0.0, 0.8);
let group_width = cat_width * (1.0 - bar_gap);
let show_labels = data.labels.as_ref().is_some_and(|l| l.show);
let label_font = data.labels.as_ref().and_then(|l| l.font_size).unwrap_or(2.2);
let label_color = data
.labels
.as_ref()
.and_then(|l| l.color.as_deref())
.unwrap_or("#333");
let range = if (max_val - min_val).abs() < 1e-10 {
1.0
} else {
max_val - min_val
};
for ci in 0..data.categories.len() {
let cat_x = px + ci as f64 * cat_width;
if stacked {
let mut y_offset = 0.0_f64;
for (si, series) in data.series.iter().enumerate() {
let val = series.values.get(ci).copied().unwrap_or(0.0);
let bar_h = (val / range) * ph;
let bar_y = py + ph - y_offset - bar_h;
write!(
svg,
r##"<rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="{}" rx="0.5"/>"##,
cat_x + cat_width * bar_gap / 2.0,
bar_y,
group_width,
bar_h.max(0.0),
color_at(palette,si)
)
.unwrap();
if show_labels && val > 0.0 {
if bl.show_labels {
if bl.stacked {
if bar.value > 0.0 {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle">{}</text>"##,
cat_x + cat_width / 2.0,
bar_y + bar_h / 2.0 + label_font * 0.15,
label_font,
label_color,
format_value(val)
bar.label_x, bar.label_y, bl.label_font, bl.label_color, format_value(bar.value)
)
.unwrap();
}
y_offset += bar_h;
}
} else {
// Grouped
let bar_w = group_width / n_series as f64;
for (si, series) in data.series.iter().enumerate() {
let val = series.values.get(ci).copied().unwrap_or(0.0);
let bar_h = ((val - min_val) / range) * ph;
let bar_x = cat_x + cat_width * bar_gap / 2.0 + si as f64 * bar_w;
let bar_y = py + ph - bar_h;
} else {
write!(
svg,
r##"<rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="{}" rx="0.5"/>"##,
bar_x,
bar_y,
bar_w.max(0.1),
bar_h.max(0.0),
color_at(palette,si)
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle">{}</text>"##,
bar.label_x, bar.label_y, bl.label_font, bl.label_color, format_value(bar.value)
)
.unwrap();
if show_labels {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle">{}</text>"##,
bar_x + bar_w / 2.0,
bar_y - 0.8,
label_font,
label_color,
format_value(val)
)
.unwrap();
}
}
}
}
// X axis labels — rotate if too many categories
render_x_labels(svg, &data.categories, px, py + ph, pw, n_cats);
// X axis labels
render_x_labels_svg(svg, &bl.x_labels);
// X axis line
write!(
svg,
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="#9CA3AF" stroke-width="0.3"/>"##,
px, py + ph, px + pw, py + ph
bl.x_axis_x1, bl.x_axis_y, bl.x_axis_x2, bl.x_axis_y
)
.unwrap();
}
fn render_line(
svg: &mut String,
data: &ResolvedChartData,
palette: &[String],
px: f64,
py: f64,
pw: f64,
ph: f64,
) {
fn render_line(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
if data.categories.is_empty() || data.series.is_empty() {
return;
}
let (min_val, max_val) = value_range(data, false);
let range = if (max_val - min_val).abs() < 1e-10 {
1.0
} else {
max_val - min_val
};
let ll = compute_line_layout(data, cl);
let show_grid = data.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true);
let grid_color = data
.axis
.as_ref()
.and_then(|a| a.grid_color.as_deref())
.unwrap_or("#E5E7EB");
render_y_axis(svg, min_val, max_val, px, py, pw, ph, show_grid, grid_color);
// Y axis
render_y_axis_svg(svg, &ll.y_axis);
let n_cats = data.categories.len();
let line_w = data.style.line_width.unwrap_or(0.5);
let show_points = data.style.show_points.unwrap_or(true);
let show_labels = data.labels.as_ref().is_some_and(|l| l.show);
let label_font = data.labels.as_ref().and_then(|l| l.font_size).unwrap_or(2.2);
let label_color = data
.labels
.as_ref()
.and_then(|l| l.color.as_deref())
.unwrap_or("#333");
for (si, series) in data.series.iter().enumerate() {
let color = color_at(palette,si);
for series_layout in &ll.series {
let color = color_at(&cl.palette, series_layout.color_idx);
let mut points = String::new();
let mut point_circles = String::new();
for (ci, val) in series.values.iter().enumerate() {
let x = if n_cats == 1 {
px + pw / 2.0
} else {
px + ci as f64 * pw / (n_cats - 1) as f64
};
let y = py + ph - ((val - min_val) / range) * ph;
write!(points, "{:.2},{:.2} ", x, y).unwrap();
for pt in &series_layout.points {
write!(points, "{:.2},{:.2} ", pt.x, pt.y).unwrap();
if show_points {
if ll.show_points {
write!(
point_circles,
r##"<circle cx="{:.2}" cy="{:.2}" r="0.8" fill="{}" stroke="white" stroke-width="0.3"/>"##,
x, y, color
pt.x, pt.y, color
)
.unwrap();
}
if show_labels {
if ll.show_labels {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle">{}</text>"##,
x, y - 1.5, label_font, label_color, format_value(*val)
pt.x, pt.y - 1.5, ll.label_font, ll.label_color, format_value(pt.value)
)
.unwrap();
}
@@ -387,100 +180,50 @@ fn render_line(
write!(
svg,
r##"<polyline points="{}" fill="none" stroke="{}" stroke-width="{:.2}" stroke-linejoin="round" stroke-linecap="round"/>"##,
points.trim(),
color,
line_w
points.trim(), color, ll.line_width
)
.unwrap();
svg.push_str(&point_circles);
}
// X axis labels — for line chart, spacing is different
render_x_labels_line(svg, &data.categories, px, py + ph, pw, n_cats);
// X axis labels
render_x_labels_svg(svg, &ll.x_labels);
// Axis lines
// Axis line
write!(
svg,
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="#9CA3AF" stroke-width="0.3"/>"##,
px, py + ph, px + pw, py + ph
ll.x_axis_x1, ll.x_axis_y, ll.x_axis_x2, ll.x_axis_y
)
.unwrap();
}
fn render_pie(
svg: &mut String,
data: &ResolvedChartData,
palette: &[String],
_total_w: f64,
_total_h: f64,
px: f64,
py: f64,
pw: f64,
ph: f64,
) {
// Pie icin ilk serinin degerlerini kullan (veya tum serilerin toplamlarini)
let values: Vec<f64> = if data.series.len() == 1 {
data.series[0].values.clone()
} else {
// Birden fazla seri varsa, her kategori icin toplam al
data.categories
.iter()
.enumerate()
.map(|(ci, _)| {
data.series
.iter()
.map(|s| s.values.get(ci).copied().unwrap_or(0.0))
.sum()
})
.collect()
};
fn render_pie(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
let pl = compute_pie_layout(data, cl);
let total: f64 = values.iter().sum();
if total <= 0.0 || data.categories.is_empty() {
if pl.slices.is_empty() {
return;
}
let cx = px + pw / 2.0;
let cy = py + ph / 2.0;
let radius = pw.min(ph) / 2.0 * 0.65;
let inner_frac = data.style.inner_radius.unwrap_or(0.0).clamp(0.0, 0.9);
let inner_r = radius * inner_frac;
let cx = pl.cx;
let cy = pl.cy;
let radius = pl.radius;
let inner_r = pl.inner_radius;
let show_labels = data.labels.as_ref().is_some_and(|l| l.show);
let label_font = data.labels.as_ref().and_then(|l| l.font_size).unwrap_or(3.0);
let label_color = data
.labels
.as_ref()
.and_then(|l| l.color.as_deref())
.unwrap_or("#333");
for slice in &pl.slices {
let color = color_at(&cl.palette, slice.color_idx);
let large_arc = if slice.sweep > std::f64::consts::PI { 1 } else { 0 };
let mut start_angle = -std::f64::consts::FRAC_PI_2; // 12 o'clock
for (i, val) in values.iter().enumerate() {
if *val <= 0.0 {
continue;
}
let sweep = (val / total) * std::f64::consts::TAU;
let end_angle = start_angle + sweep;
let large_arc = if sweep > std::f64::consts::PI {
1
} else {
0
};
let x1 = cx + radius * start_angle.cos();
let y1 = cy + radius * start_angle.sin();
let x2 = cx + radius * end_angle.cos();
let y2 = cy + radius * end_angle.sin();
let color = color_at(palette,i);
let x1 = cx + radius * slice.start_angle.cos();
let y1 = cy + radius * slice.start_angle.sin();
let x2 = cx + radius * slice.end_angle.cos();
let y2 = cy + radius * slice.end_angle.sin();
if inner_r > 0.0 {
// Donut
let ix1 = cx + inner_r * start_angle.cos();
let iy1 = cy + inner_r * start_angle.sin();
let ix2 = cx + inner_r * end_angle.cos();
let iy2 = cy + inner_r * end_angle.sin();
let ix1 = cx + inner_r * slice.start_angle.cos();
let iy1 = cy + inner_r * slice.start_angle.sin();
let ix2 = cx + inner_r * slice.end_angle.cos();
let iy2 = cy + inner_r * slice.end_angle.sin();
write!(
svg,
r##"<path d="M {:.2} {:.2} A {:.2} {:.2} 0 {} 1 {:.2} {:.2} L {:.2} {:.2} A {:.2} {:.2} 0 {} 0 {:.2} {:.2} Z" fill="{}" stroke="white" stroke-width="0.3"/>"##,
@@ -490,7 +233,6 @@ fn render_pie(
)
.unwrap();
} else {
// Full pie
write!(
svg,
r##"<path d="M {:.2} {:.2} L {:.2} {:.2} A {:.2} {:.2} 0 {} 1 {:.2} {:.2} Z" fill="{}" stroke="white" stroke-width="0.3"/>"##,
@@ -500,258 +242,75 @@ fn render_pie(
}
// Percentage label inside slice
if show_labels {
let mid_angle = start_angle + sweep / 2.0;
let label_r = if inner_r > 0.0 {
(radius + inner_r) / 2.0
} else {
radius * 0.65
};
let lx = cx + label_r * mid_angle.cos();
let ly = cy + label_r * mid_angle.sin();
let pct = (val / total * 100.0).round();
if pl.show_labels {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle" dominant-baseline="central">{}%</text>"##,
lx, ly, label_font, label_color, pct
slice.label_x, slice.label_y, pl.label_font, pl.label_color,
(slice.fraction * 100.0).round()
)
.unwrap();
}
// Category name label outside slice with leader line
if i < data.categories.len() {
let mid_angle = start_angle + sweep / 2.0;
let line_start_r = radius; // starts at pie edge
let line_end_r = radius + 3.0;
let text_r = radius + 4.0;
// Leader line from pie edge to label
let lx1 = cx + line_start_r * mid_angle.cos();
let ly1 = cy + line_start_r * mid_angle.sin();
let lx2 = cx + line_end_r * mid_angle.cos();
let ly2 = cy + line_end_r * mid_angle.sin();
if !slice.cat_label_text.is_empty() {
write!(
svg,
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="#999" stroke-width="0.2"/>"##,
lx1, ly1, lx2, ly2
slice.leader_start_x, slice.leader_start_y,
slice.leader_end_x, slice.leader_end_y
)
.unwrap();
// Category text
let tx = cx + text_r * mid_angle.cos();
let ty = cy + text_r * mid_angle.sin();
let anchor = if mid_angle.cos() >= 0.0 { "start" } else { "end" };
let anchor = if slice.cat_label_anchor_end { "end" } else { "start" };
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.5" fill="#555" text-anchor="{}" dominant-baseline="central">{}</text>"##,
tx, ty, anchor, escape_xml(&data.categories[i])
slice.cat_label_x, slice.cat_label_y, anchor, escape_xml(&slice.cat_label_text)
)
.unwrap();
}
start_angle = end_angle;
}
}
fn render_legend(
svg: &mut String,
data: &ResolvedChartData,
palette: &[String],
position: &str,
font_size: f64,
total_w: f64,
total_h: f64,
margin_left: f64,
margin_top: f64,
plot_w: f64,
_plot_h: f64,
) {
let names: Vec<&str> = if matches!(data.chart_type, ChartType::Pie) {
data.categories.iter().map(|s| s.as_str()).collect()
} else {
data.series.iter().map(|s| s.name.as_str()).collect()
};
fn render_legend(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout, total_w: f64, total_h: f64) {
let legend = compute_legend(data, cl, 0.0, 0.0, total_w, total_h);
let item_w = 3.0 + font_size * 0.4; // color rect + gap
let spacing = 4.0;
match position {
"top" => {
let y = margin_top - font_size - 1.5;
let mut x = margin_left;
for (i, name) in names.iter().enumerate() {
write!(
svg,
r##"<rect x="{:.2}" y="{:.2}" width="2.5" height="2.5" fill="{}" rx="0.3"/>"##,
x, y - font_size * 0.3, color_at(palette,i)
)
.unwrap();
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="#666">{}</text>"##,
x + item_w, y + font_size * 0.3, font_size, escape_xml(name)
)
.unwrap();
x += item_w + name.len() as f64 * font_size * 0.5 + spacing;
}
}
"right" => {
let x = margin_left + plot_w + 4.0;
let mut y = margin_top + 2.0;
for (i, name) in names.iter().enumerate() {
write!(
svg,
r##"<rect x="{:.2}" y="{:.2}" width="2.5" height="2.5" fill="{}" rx="0.3"/>"##,
x, y, color_at(palette,i)
)
.unwrap();
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="#666">{}</text>"##,
x + item_w, y + font_size * 0.7, font_size, escape_xml(name)
)
.unwrap();
y += font_size + 2.0;
}
}
_ => {
// bottom (default)
let y = total_h - 3.0;
let total_legend_w: f64 = names
.iter()
.map(|n| item_w + n.len() as f64 * font_size * 0.5 + spacing)
.sum::<f64>()
- spacing;
let mut x = (total_w - total_legend_w) / 2.0;
for (i, name) in names.iter().enumerate() {
write!(
svg,
r##"<rect x="{:.2}" y="{:.2}" width="2.5" height="2.5" fill="{}" rx="0.3"/>"##,
x, y - font_size * 0.3, color_at(palette,i)
)
.unwrap();
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="#666">{}</text>"##,
x + item_w, y + font_size * 0.3, font_size, escape_xml(name)
)
.unwrap();
x += item_w + name.len() as f64 * font_size * 0.5 + spacing;
}
}
}
}
/// X-axis labels ortak render — bar chart icin (slot-based spacing)
fn render_x_labels(
svg: &mut String,
categories: &[String],
px: f64,
baseline_y: f64,
pw: f64,
n_cats: usize,
) {
if n_cats == 0 {
return;
}
let cat_width = pw / n_cats as f64;
let max_chars = (cat_width / 1.25).max(1.0) as usize;
let needs_rotate = categories.iter().any(|c| c.len() > max_chars);
for (ci, cat) in categories.iter().enumerate() {
let x = px + ci as f64 * cat_width + cat_width / 2.0;
let y = baseline_y + 2.5;
render_single_x_label(svg, cat, x, y, needs_rotate);
}
}
/// X-axis labels — line chart icin (point-based spacing)
fn render_x_labels_line(
svg: &mut String,
categories: &[String],
px: f64,
baseline_y: f64,
pw: f64,
n_cats: usize,
) {
if n_cats == 0 {
return;
}
let spacing = if n_cats == 1 { pw } else { pw / (n_cats - 1) as f64 };
let max_chars = (spacing / 1.25).max(1.0) as usize;
let needs_rotate = categories.iter().any(|c| c.len() > max_chars);
for (ci, cat) in categories.iter().enumerate() {
let x = if n_cats == 1 {
px + pw / 2.0
} else {
px + ci as f64 * pw / (n_cats - 1) as f64
};
let y = baseline_y + 2.5;
render_single_x_label(svg, cat, x, y, needs_rotate);
}
}
/// Tek bir X-axis label render — rotate gerekiyorsa -45° ile, anchor "end"
/// Anchor noktasi bar/point'in tam altinda, text sola yukari dogru uzanir
fn render_single_x_label(svg: &mut String, text: &str, x: f64, y: f64, rotate: bool) {
if rotate {
// -45° rotate, text-anchor="end": text, anchor noktasindan sola-yukari dogru uzanir
// Bu sayede text asagi-sola tasmaz, sadece yukari-sola gider (plot area icinde kalir)
for item in &legend.items {
let color = color_at(&cl.palette, item.color_idx);
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.2" fill="#666" text-anchor="end" transform="rotate(-45,{:.2},{:.2})">{}</text>"##,
x, y, x, y, escape_xml(text)
r##"<rect x="{:.2}" y="{:.2}" width="2.5" height="2.5" fill="{}" rx="0.3"/>"##,
item.swatch_x, item.swatch_y, color
)
.unwrap();
} else {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.5" fill="#666" text-anchor="middle">{}</text>"##,
x, y, escape_xml(text)
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="#666">{}</text>"##,
item.text_x, item.text_y, legend.font_size, escape_xml(&item.name)
)
.unwrap();
}
}
fn render_y_axis(
svg: &mut String,
min_val: f64,
max_val: f64,
px: f64,
py: f64,
pw: f64,
ph: f64,
show_grid: bool,
grid_color: &str,
) {
let range = if (max_val - min_val).abs() < 1e-10 {
1.0
} else {
max_val - min_val
};
let tick_count = 5;
for i in 0..=tick_count {
let frac = i as f64 / tick_count as f64;
let val = min_val + frac * range;
let y = py + ph - frac * ph;
// ---------------------------------------------------------------------------
// SVG-specific helper renderers that consume shared layout structs
// ---------------------------------------------------------------------------
// Label
fn render_y_axis_svg(svg: &mut String, y_axis: &chart_layout::YAxisLayout) {
for tick in &y_axis.ticks {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.3" fill="#666" text-anchor="end">{}</text>"##,
px - 1.5,
y + 0.8,
format_value(val)
y_axis.axis_x - 1.5, tick.y + 0.8, tick.label
)
.unwrap();
// Grid line
if show_grid {
if y_axis.show_grid {
write!(
svg,
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="{}" stroke-width="0.15"/>"##,
px, y, px + pw, y, grid_color
y_axis.axis_x, tick.y, y_axis.grid_end_x, tick.y, y_axis.grid_color
)
.unwrap();
}
@@ -761,56 +320,28 @@ fn render_y_axis(
write!(
svg,
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="#9CA3AF" stroke-width="0.3"/>"##,
px, py, px, py + ph
y_axis.axis_x, y_axis.axis_y_start, y_axis.axis_x, y_axis.axis_y_end
)
.unwrap();
}
/// Tum serilerdeki min/max deger araligini bul
fn value_range(data: &ResolvedChartData, stacked: bool) -> (f64, f64) {
if data.series.is_empty() {
return (0.0, 1.0);
}
if stacked {
let n = data.categories.len();
let mut max_stack = 0.0_f64;
for ci in 0..n {
let sum: f64 = data
.series
.iter()
.map(|s| s.values.get(ci).copied().unwrap_or(0.0))
.sum();
max_stack = max_stack.max(sum);
fn render_x_labels_svg(svg: &mut String, x_labels: &chart_layout::XLabelLayout) {
for label in &x_labels.labels {
if x_labels.needs_rotate {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.2" fill="#666" text-anchor="end" transform="rotate(-45,{:.2},{:.2})">{}</text>"##,
label.x, label.y, label.x, label.y, escape_xml(&label.text)
)
.unwrap();
} else {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.5" fill="#666" text-anchor="middle">{}</text>"##,
label.x, label.y, escape_xml(&label.text)
)
.unwrap();
}
(0.0, max_stack * 1.05)
} else {
let mut min_v = f64::MAX;
let mut max_v = f64::MIN;
for series in &data.series {
for val in &series.values {
min_v = min_v.min(*val);
max_v = max_v.max(*val);
}
}
// min sifirdan buyukse sifirdan basla
if min_v > 0.0 {
min_v = 0.0;
}
max_v *= 1.05;
(min_v, max_v)
}
}
fn format_value(v: f64) -> String {
if v.abs() >= 1_000_000.0 {
format!("{:.1}M", v / 1_000_000.0)
} else if v.abs() >= 1_000.0 {
format!("{:.1}K", v / 1_000.0)
} else if v.fract().abs() < 1e-10 {
format!("{}", v as i64)
} else {
format!("{:.1}", v)
}
}

View File

@@ -204,7 +204,13 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
.iter()
.map(|col| {
let v = resolve_path(item, &col.field);
value_to_string(v)
let raw = value_to_string(v);
// Sütun formatı varsa uygula (currency, percentage, number, date)
if let Some(ref fmt) = col.format {
crate::expr_eval::apply_format(&raw, Some(fmt.as_str()))
} else {
raw
}
})
.collect()
})
@@ -449,6 +455,7 @@ mod tests {
fonts: vec![],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -493,6 +500,7 @@ mod tests {
fonts: vec![],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -537,6 +545,7 @@ mod tests {
fonts: vec![],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -573,6 +582,7 @@ mod tests {
fonts: vec![],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -638,6 +648,7 @@ mod tests {
fonts: vec![],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -687,6 +698,7 @@ mod tests {
fonts: vec![],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,

View File

@@ -65,25 +65,34 @@ fn dexpr_value_to_string(val: &DexprValue) -> String {
}
}
/// Format result with given format type
/// Format result with given format type (varsayılan Türk formatı)
pub fn apply_format(value: &str, format: Option<&str>) -> String {
apply_format_with_config(value, format, &dreport_core::models::FormatConfig::default())
}
/// Format result with given format type and config
pub fn apply_format_with_config(value: &str, format: Option<&str>, config: &dreport_core::models::FormatConfig) -> String {
match format {
Some("currency") => format_currency(value),
Some("currency") => format_currency(value, config),
Some("percentage") => format_percentage(value),
Some("number") => format_number_str(value),
Some("number") => format_number_str(value, config),
_ => value.to_string(),
}
}
fn format_currency(value: &str) -> String {
fn format_currency(value: &str, config: &dreport_core::models::FormatConfig) -> String {
if let Ok(n) = value.parse::<f64>() {
let abs = n.abs();
let integer = abs.floor() as i64;
let frac = ((abs - abs.floor()) * 100.0).round() as i64;
let int_str = format_with_thousands(integer);
let int_str = format_with_thousands(integer, &config.thousands_separator);
let sign = if n < 0.0 { "-" } else { "" };
format!("{}{},{:02}", sign, int_str, frac)
if config.currency_position == "prefix" {
format!("{}{}{}{}{:02}", config.currency_symbol, sign, int_str, config.decimal_separator, frac)
} else {
format!("{}{}{}{:02} {}", sign, int_str, config.decimal_separator, frac, config.currency_symbol)
}
} else {
value.to_string()
}
@@ -97,19 +106,21 @@ fn format_percentage(value: &str) -> String {
}
}
fn format_number_str(value: &str) -> String {
fn format_number_str(value: &str, config: &dreport_core::models::FormatConfig) -> String {
if let Ok(n) = value.parse::<f64>() {
if n == n.floor() && n.abs() < 1e15 {
format_with_thousands(n.abs() as i64)
format_with_thousands(n.abs() as i64, &config.thousands_separator)
} else {
format!("{:.2}", n)
// Ondalık ayırıcıyı config'den al
let formatted = format!("{:.2}", n);
formatted.replace('.', &config.decimal_separator)
}
} else {
value.to_string()
}
}
fn format_with_thousands(n: i64) -> String {
fn format_with_thousands(n: i64, separator: &str) -> String {
let s = n.to_string();
let len = s.len();
if len <= 3 {
@@ -118,7 +129,7 @@ fn format_with_thousands(n: i64) -> String {
let mut result = String::new();
for (i, ch) in s.chars().enumerate() {
if i > 0 && (len - i) % 3 == 0 {
result.push('.');
result.push_str(separator);
}
result.push(ch);
}

View File

@@ -0,0 +1,330 @@
use serde::{Deserialize, Serialize};
/// Parsed metadata from a single font file
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FontMeta {
/// Font family name from name table (nameID 16 preferred, fallback nameID 1)
pub family: String,
/// usWeightClass from OS/2 table (100-900)
pub weight: u16,
/// fsSelection bit 0 from OS/2 table
pub italic: bool,
pub units_per_em: u16,
/// sTypoAscender from OS/2 table
pub ascender: i16,
/// sTypoDescender from OS/2 table
pub descender: i16,
}
/// Variant key for looking up a specific font within a family
#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)]
pub struct FontVariantKey {
pub weight: u16,
pub italic: bool,
}
/// Summary of a font family with all its available variants
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FontFamilyInfo {
pub family: String,
pub variants: Vec<FontVariantKey>,
}
impl FontMeta {
pub fn variant_key(&self) -> FontVariantKey {
FontVariantKey {
weight: self.weight,
italic: self.italic,
}
}
pub fn is_bold(&self) -> bool {
self.weight >= 700
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Read a big-endian u16 from `data` at `offset`. Returns `None` if out of bounds.
fn read_u16(data: &[u8], offset: usize) -> Option<u16> {
if offset + 2 > data.len() {
return None;
}
Some(u16::from_be_bytes([data[offset], data[offset + 1]]))
}
/// Read a big-endian i16 from `data` at `offset`. Returns `None` if out of bounds.
fn read_i16(data: &[u8], offset: usize) -> Option<i16> {
if offset + 2 > data.len() {
return None;
}
Some(i16::from_be_bytes([data[offset], data[offset + 1]]))
}
/// Read a big-endian u32 from `data` at `offset`. Returns `None` if out of bounds.
fn read_u32(data: &[u8], offset: usize) -> Option<u32> {
if offset + 4 > data.len() {
return None;
}
Some(u32::from_be_bytes([
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
]))
}
/// Find a table in the font's table directory by its 4-byte ASCII tag.
/// Returns `(offset, length)` into `data`.
fn find_table(data: &[u8], tag: &[u8; 4]) -> Option<(usize, usize)> {
// Offset table (first 12 bytes):
// 0: sfVersion (u32) — 0x00010000 for TrueType, 'OTTO' for CFF
// 4: numTables (u16)
// 6: searchRange (u16)
// 8: entrySelector (u16)
// 10: rangeShift (u16)
let num_tables = read_u16(data, 4)? as usize;
// Table directory starts at offset 12, each entry is 16 bytes:
// 0: tag (4 bytes)
// 4: checksum (u32)
// 8: offset (u32)
// 12: length (u32)
for i in 0..num_tables {
let entry_offset = 12 + i * 16;
if entry_offset + 16 > data.len() {
return None;
}
if &data[entry_offset..entry_offset + 4] == tag {
let table_offset = read_u32(data, entry_offset + 8)? as usize;
let table_length = read_u32(data, entry_offset + 12)? as usize;
// Basic sanity check
if table_offset.checked_add(table_length)? > data.len() {
return None;
}
return Some((table_offset, table_length));
}
}
None
}
/// Decode a UTF-16BE byte slice into a `String`.
fn decode_utf16be(raw: &[u8]) -> Option<String> {
if raw.len() % 2 != 0 {
return None;
}
let code_units: Vec<u16> = raw
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
String::from_utf16(&code_units).ok()
}
/// Decode a MacRoman (platform 1, encoding 0) byte slice into a `String`.
/// MacRoman overlaps with ASCII for 0x000x7F; we accept those and replace
/// high bytes with the Unicode replacement character for simplicity, since
/// font family names are almost always pure ASCII.
fn decode_mac_roman(raw: &[u8]) -> String {
raw.iter()
.map(|&b| {
if b < 0x80 {
b as char
} else {
// Simplified: map non-ASCII MacRoman bytes to replacement char.
// Full MacRoman table not needed for typical font family names.
'\u{FFFD}'
}
})
.collect()
}
/// Extract the font family name from the `name` table.
///
/// Prefers nameID 16 (Typographic Family Name) over nameID 1 (Font Family).
/// Among platforms, prefers Windows (3) and Unicode (0) for UTF-16BE, falls
/// back to Macintosh (1) for MacRoman.
fn read_family_name(data: &[u8], table_offset: usize, table_length: usize) -> Option<String> {
let tbl = table_offset;
// name table header:
// 0: format (u16)
// 2: count (u16)
// 4: stringOffset (u16) — offset from start of table to string storage
let count = read_u16(data, tbl + 2)? as usize;
let string_offset = read_u16(data, tbl + 4)? as usize;
let storage_base = tbl + string_offset;
// Each name record (12 bytes, starting at tbl + 6):
// 0: platformID (u16)
// 2: encodingID (u16)
// 4: languageID (u16)
// 6: nameID (u16)
// 8: length (u16)
// 10: offset (u16) — from storage_base
// We collect candidates, preferring nameID 16 over 1, and Windows/Unicode
// over Mac.
let mut best: Option<String> = None;
let mut best_priority: u8 = 0; // higher = better
for i in 0..count {
let rec = tbl + 6 + i * 12;
if rec + 12 > tbl + table_length {
break;
}
let platform_id = read_u16(data, rec)?;
let encoding_id = read_u16(data, rec + 2)?;
let name_id = read_u16(data, rec + 6)?;
let str_length = read_u16(data, rec + 8)? as usize;
let str_offset = read_u16(data, rec + 10)? as usize;
// Only interested in nameID 1 (Font Family) or 16 (Typographic Family)
if name_id != 1 && name_id != 16 {
continue;
}
let name_priority = if name_id == 16 { 4 } else { 0 };
let abs_start = storage_base + str_offset;
let abs_end = abs_start + str_length;
if abs_end > data.len() {
continue;
}
let raw = &data[abs_start..abs_end];
let (decoded, platform_priority) = match platform_id {
// Platform 0 — Unicode: UTF-16BE
0 => {
if let Some(s) = decode_utf16be(raw) {
(s, 2u8)
} else {
continue;
}
}
// Platform 1 — Macintosh, encoding 0 = MacRoman
1 if encoding_id == 0 => (decode_mac_roman(raw), 1u8),
// Platform 3 — Windows, encoding 1 = Unicode BMP (UTF-16BE)
3 if encoding_id == 1 => {
if let Some(s) = decode_utf16be(raw) {
(s, 3u8)
} else {
continue;
}
}
_ => continue,
};
let priority = name_priority + platform_priority;
if priority > best_priority {
best_priority = priority;
best = Some(decoded);
}
}
best
}
/// Parse font metadata from raw TTF/OTF bytes.
///
/// Returns `None` if the data is too short, tables are missing, or offsets
/// point outside the buffer.
pub fn parse_font_meta(data: &[u8]) -> Option<FontMeta> {
// Minimum: 12-byte offset table header
if data.len() < 12 {
return None;
}
// ---- OS/2 table ----
let (os2_off, os2_len) = find_table(data, b"OS/2")?;
// Need at least 72 bytes for sTypoDescender (offset 70, 2 bytes)
if os2_len < 72 {
return None;
}
let weight = read_u16(data, os2_off + 4)?;
let fs_selection = read_u16(data, os2_off + 62)?;
let italic = (fs_selection & 1) != 0;
let ascender = read_i16(data, os2_off + 68)?;
let descender = read_i16(data, os2_off + 70)?;
// ---- head table ----
let (head_off, head_len) = find_table(data, b"head")?;
// unitsPerEm is at offset 18 (2 bytes), so need at least 20 bytes
if head_len < 20 {
return None;
}
let units_per_em = read_u16(data, head_off + 18)?;
// ---- name table ----
let (name_off, name_len) = find_table(data, b"name")?;
let family = read_family_name(data, name_off, name_len)?;
Some(FontMeta {
family,
weight,
italic,
units_per_em,
ascender,
descender,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_table_returns_none_on_empty() {
assert!(find_table(&[], b"head").is_none());
}
#[test]
fn parse_font_meta_returns_none_on_garbage() {
assert!(parse_font_meta(&[0u8; 11]).is_none());
assert!(parse_font_meta(&[0u8; 64]).is_none());
}
#[test]
fn variant_key_and_is_bold() {
let meta = FontMeta {
family: "Test".into(),
weight: 700,
italic: true,
units_per_em: 1000,
ascender: 800,
descender: -200,
};
assert!(meta.is_bold());
assert!(meta.italic);
let key = meta.variant_key();
assert_eq!(key.weight, 700);
assert!(key.italic);
let regular = FontMeta {
weight: 400,
italic: false,
..meta.clone()
};
assert!(!regular.is_bold());
}
#[test]
fn decode_utf16be_basic() {
// "AB" in UTF-16BE
let raw = [0x00, 0x41, 0x00, 0x42];
assert_eq!(decode_utf16be(&raw).unwrap(), "AB");
}
#[test]
fn decode_utf16be_odd_length_returns_none() {
assert!(decode_utf16be(&[0x00, 0x41, 0x00]).is_none());
}
#[test]
fn decode_mac_roman_ascii() {
let raw = b"Noto Sans";
assert_eq!(decode_mac_roman(raw), "Noto Sans");
}
}

View File

@@ -0,0 +1,51 @@
use crate::font_meta::FontFamilyInfo;
use crate::FontData;
/// Font resolution trait — host apps implement this to provide fonts.
/// Backend implements it with a file-based registry, WASM side with API fetching.
pub trait FontProvider: Send + Sync {
/// List all available font families with their variants.
fn list_families(&self) -> Vec<FontFamilyInfo>;
/// Load a specific font variant. Returns None if not found.
fn load_font(&self, family: &str, weight: u16, italic: bool) -> Option<FontData>;
/// The default/fallback font family name.
fn default_family(&self) -> &str {
"Noto Sans"
}
/// Load all variants of the given families. Falls back to default family if a family is not found.
/// Always includes the default family.
fn load_families(&self, families: &[String]) -> Vec<FontData> {
let mut result = Vec::new();
let mut loaded_families = std::collections::HashSet::new();
// Always include default family
let mut all_families: Vec<String> = vec![self.default_family().to_string()];
for f in families {
if !all_families.iter().any(|af| af.eq_ignore_ascii_case(f)) {
all_families.push(f.clone());
}
}
for family in &all_families {
let family_lower = family.to_lowercase();
if loaded_families.contains(&family_lower) {
continue;
}
let infos = self.list_families();
if let Some(info) = infos.iter().find(|i| i.family.to_lowercase() == family_lower) {
for variant in &info.variants {
if let Some(fd) = self.load_font(&info.family, variant.weight, variant.italic) {
result.push(fd);
}
}
loaded_families.insert(family_lower);
}
}
result
}
}

View File

@@ -10,7 +10,10 @@ pub mod expr_eval;
pub mod wasm_api;
pub mod barcode_gen;
pub mod chart_layout;
pub mod chart_render;
pub mod font_meta;
pub mod font_provider;
#[cfg(not(target_arch = "wasm32"))]
pub mod pdf_render;
@@ -18,6 +21,28 @@ pub mod pdf_render;
use dreport_core::models::{ChartType, Template};
use serde::{Deserialize, Serialize};
/// Layout hesaplama hata tipi
#[derive(Debug)]
pub enum LayoutError {
Taffy(taffy::TaffyError),
}
impl std::fmt::Display for LayoutError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LayoutError::Taffy(e) => write!(f, "Taffy layout hatası: {:?}", e),
}
}
}
impl std::error::Error for LayoutError {}
impl From<taffy::TaffyError> for LayoutError {
fn from(e: taffy::TaffyError) -> Self {
LayoutError::Taffy(e)
}
}
// --- Layout sonuç tipleri ---
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -172,6 +197,7 @@ pub struct ResolvedStyle {
// Text
pub font_size: Option<f64>,
pub font_weight: Option<String>,
pub font_style: Option<String>,
pub font_family: Option<String>,
pub color: Option<String>,
pub text_align: Option<String>,
@@ -203,7 +229,7 @@ pub fn compute_layout(
template: &Template,
data: &serde_json::Value,
font_data: &[FontData],
) -> LayoutResult {
) -> Result<LayoutResult, LayoutError> {
let mut measurer = text_measure::TextMeasurer::new(font_data);
let resolved = data_resolve::resolve_template(template, data);
tree::compute(template, &resolved, &mut measurer)
@@ -217,16 +243,41 @@ pub fn compute_layout_cached(
data: &serde_json::Value,
font_data: &[FontData],
text_cache: text_measure::TextMeasureCache,
) -> (LayoutResult, text_measure::TextMeasureCache) {
) -> Result<(LayoutResult, text_measure::TextMeasureCache), LayoutError> {
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())
let result = tree::compute(template, &resolved, &mut measurer)?;
Ok((result, measurer.take_cache()))
}
/// Font verisi (ham TTF/OTF bytes)
/// Font verisi (ham TTF/OTF bytes + metadata)
#[derive(Debug, Clone)]
pub struct FontData {
pub family: String,
pub weight: u16,
pub italic: bool,
pub data: Vec<u8>,
}
impl FontData {
/// Create FontData from raw bytes, parsing metadata from the font file.
/// Returns None if font metadata cannot be parsed.
pub fn from_bytes(data: Vec<u8>) -> Option<Self> {
let meta = font_meta::parse_font_meta(&data)?;
Some(Self {
family: meta.family,
weight: meta.weight,
italic: meta.italic,
data,
})
}
/// Create FontData with explicit metadata (when metadata is already known).
pub fn new(family: String, weight: u16, italic: bool, data: Vec<u8>) -> Self {
Self { family, weight, italic, data }
}
pub fn is_bold(&self) -> bool {
self.weight >= 700
}
}

View File

@@ -24,6 +24,8 @@ pub struct PageSplitInput {
pub page_number_formats: HashMap<String, String>,
/// Root container'ın üst padding'i (mm) — sayfa 2+ için body offset
pub root_padding_top_mm: f64,
/// Header tekrarı kapatılmış tablo ID'leri
pub no_repeat_header_tables: HashSet<String>,
}
/// Body elemanlarını sayfalara böl, header/footer ekle, page number'ları çöz.
@@ -53,7 +55,11 @@ pub fn split_into_pages(input: PageSplitInput) -> Vec<PageLayout> {
let avoid_groups = build_avoid_groups(&input.body_elements, &input.break_modes, &parent_map);
// Tablo yapısı tespiti: table_id → header element id'leri
let table_info = detect_table_structure(&input.body_elements);
// repeat_header == false olan tablolar hariç tutulur
let mut table_info = detect_table_structure(&input.body_elements);
for table_id in &input.no_repeat_header_tables {
table_info.remove(table_id);
}
// Elemanları sayfalara böl
let page_slices = split_elements(
@@ -561,6 +567,7 @@ mod tests {
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
@@ -584,6 +591,7 @@ mod tests {
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
@@ -611,6 +619,7 @@ mod tests {
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
@@ -638,6 +647,7 @@ mod tests {
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
@@ -678,6 +688,7 @@ mod tests {
break_modes: HashMap::new(),
page_number_formats: formats,
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
@@ -768,6 +779,7 @@ mod tests {
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
@@ -860,6 +872,7 @@ mod tests {
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
@@ -943,6 +956,7 @@ mod tests {
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 5.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);

File diff suppressed because it is too large Load Diff

View File

@@ -185,6 +185,7 @@ pub fn expand_table(
style: TextStyle {
font_size: table.style.header_font_size.or(table.style.font_size),
font_weight: Some("bold".to_string()),
font_style: None,
font_family: None,
color: table.style.header_color.clone(),
align: Some(col.align.clone()),
@@ -294,6 +295,7 @@ pub fn expand_table(
style: TextStyle {
font_size: table.style.font_size,
font_weight: None,
font_style: None,
font_family: None,
color: None,
align: Some(col.align.clone()),
@@ -446,10 +448,7 @@ mod tests {
.unwrap()
.join("backend/fonts/NotoSans-Regular.ttf");
let font_bytes = std::fs::read(&font_path).expect("Font file not found");
let font_data = vec![FontData {
family: "Noto Sans".to_string(),
data: font_bytes,
}];
let font_data = vec![FontData::from_bytes(font_bytes).expect("Font parse failed")];
TextMeasurer::new(&font_data)
}

View File

@@ -4,6 +4,13 @@ use std::hash::Hash;
use crate::FontData;
use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, Weight};
/// Tek bir satırın layout bilgisi (PDF render için)
pub struct TextLine {
pub text: String,
pub y_offset_pt: f32,
pub width_pt: f32,
}
/// Rich text span — ölçüm için gerekli bilgiler
#[derive(Clone)]
pub struct RichSpanMeasure {
@@ -182,6 +189,58 @@ impl TextMeasurer {
(width_pt, height_pt)
}
/// Text'i verilen genişlik kısıtı ile satırlara böl.
/// Her satır için text içeriği ve y-offset (pt) döner.
/// PDF render sırasında text wrapping için kullanılır.
pub fn layout_lines(
&mut self,
text: &str,
font_family: Option<&str>,
font_size_pt: f32,
font_weight: Option<&str>,
available_width_pt: f32,
) -> Vec<TextLine> {
if text.is_empty() {
return vec![];
}
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 * PT_TO_PX;
buffer.set_size(&mut self.font_system, Some(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, None);
buffer.shape_until_scroll(&mut self.font_system, false);
let mut lines = Vec::new();
for run in buffer.layout_runs() {
let line_text = run.text.to_string();
let line_top_pt = run.line_top / PT_TO_PX;
let line_width_pt = run.line_w / PT_TO_PX;
lines.push(TextLine {
text: line_text,
y_offset_pt: line_top_pt,
width_pt: line_width_pt,
});
}
lines
}
/// Rich text ölç — birden fazla span, her biri farklı font/boyut/kalınlık.
/// cosmic-text set_rich_text() ile attributed text ölçümü yapar.
pub fn measure_rich_text(
@@ -273,18 +332,9 @@ pub(crate) fn load_test_fonts() -> Vec<crate::FontData> {
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 });
if let Some(fd) = crate::FontData::from_bytes(data) {
fonts.push(fd);
}
}
}
fonts

View File

@@ -7,7 +7,7 @@ use crate::data_resolve::ResolvedData;
use crate::sizing::{self, mm_to_pt, pt_to_mm};
use crate::table_layout;
use crate::text_measure::TextMeasurer;
use crate::{ElementLayout, LayoutResult, ResolvedContent, ResolvedStyle};
use crate::{ElementLayout, LayoutError, LayoutResult, ResolvedContent, ResolvedStyle};
/// Taffy node ile dreport element arasındaki mapping
struct NodeInfo {
@@ -33,20 +33,20 @@ pub fn compute(
template: &Template,
resolved: &ResolvedData,
measurer: &mut TextMeasurer,
) -> LayoutResult {
) -> Result<LayoutResult, LayoutError> {
let page_w_pt = mm_to_pt(template.page.width);
let page_width_mm = template.page.width;
// --- 1. Header layout (varsa) ---
let (header_elements, header_height_mm) = if let Some(ref header) = template.header {
compute_section(header, page_w_pt, page_width_mm, resolved, measurer)
compute_section(header, page_w_pt, page_width_mm, resolved, measurer)?
} else {
(vec![], 0.0)
};
// --- 2. Footer layout (varsa) ---
let (footer_elements, footer_height_mm) = if let Some(ref footer) = template.footer {
compute_section(footer, page_w_pt, page_width_mm, resolved, measurer)
compute_section(footer, page_w_pt, page_width_mm, resolved, measurer)?
} else {
(vec![], 0.0)
};
@@ -65,7 +65,7 @@ pub fn compute(
None,
measurer,
page_width_mm,
);
)?;
// Sayfa wrapper: sayfa genişliğinde ama yükseklik sınırsız (auto)
let page_style = Style {
@@ -77,7 +77,7 @@ pub fn compute(
},
..Default::default()
};
let page_node = taffy.new_with_children(page_style, &[root_node]).unwrap();
let page_node = taffy.new_with_children(page_style, &[root_node])?;
taffy
.compute_layout_with_measure(
@@ -89,14 +89,16 @@ pub fn compute(
|known_dimensions, available_space, _node_id, context, _style| {
measure_leaf(known_dimensions, available_space, context, measurer)
},
)
.unwrap();
)?;
let body_elements = collect_layout(&taffy, root_node, &node_map, resolved, 0.0, 0.0);
let body_elements = collect_layout(&taffy, root_node, &node_map, resolved, 0.0, 0.0)?;
// --- 4. Container break modlarını topla ---
let break_modes = collect_break_modes(&template.root);
// --- 4b. repeat_header == false olan tablo ID'lerini topla ---
let no_repeat_header_tables = collect_no_repeat_header_tables(&template.root);
// --- 5. Sayfalara böl ---
let input = crate::page_break::PageSplitInput {
body_elements,
@@ -109,11 +111,12 @@ pub fn compute(
break_modes,
page_number_formats: resolved.page_number_formats.clone(),
root_padding_top_mm: template.root.padding.top,
no_repeat_header_tables,
};
let pages = crate::page_break::split_into_pages(input);
LayoutResult { pages }
Ok(LayoutResult { pages })
}
/// Header veya footer gibi bağımsız bir container section'ı hesapla.
@@ -124,12 +127,12 @@ fn compute_section(
page_width_mm: f64,
resolved: &ResolvedData,
measurer: &mut TextMeasurer,
) -> (Vec<ElementLayout>, f64) {
) -> Result<(Vec<ElementLayout>, f64), LayoutError> {
let mut taffy = TaffyTree::<MeasureContext>::new();
taffy.disable_rounding();
let mut node_map: HashMap<NodeId, NodeInfo> = HashMap::new();
let section_node = build_container(container, &mut taffy, &mut node_map, resolved, None, measurer, page_width_mm);
let section_node = build_container(container, &mut taffy, &mut node_map, resolved, None, measurer, page_width_mm)?;
let wrapper_style = Style {
display: Display::Flex,
@@ -140,7 +143,7 @@ fn compute_section(
},
..Default::default()
};
let wrapper_node = taffy.new_with_children(wrapper_style, &[section_node]).unwrap();
let wrapper_node = taffy.new_with_children(wrapper_style, &[section_node])?;
taffy
.compute_layout_with_measure(
@@ -152,16 +155,15 @@ fn compute_section(
|known_dimensions, available_space, _node_id, context, _style| {
measure_leaf(known_dimensions, available_space, context, measurer)
},
)
.unwrap();
)?;
let elements = collect_layout(&taffy, section_node, &node_map, resolved, 0.0, 0.0);
let elements = collect_layout(&taffy, section_node, &node_map, resolved, 0.0, 0.0)?;
// Section yüksekliği
let section_layout = taffy.layout(section_node).unwrap();
let section_layout = taffy.layout(section_node)?;
let height_mm = pt_to_mm(section_layout.size.height);
(elements, height_mm)
Ok((elements, height_mm))
}
/// Template ağacındaki tüm container'ların break_inside modlarını topla.
@@ -180,6 +182,29 @@ fn collect_break_modes_recursive(el: &TemplateElement, modes: &mut HashMap<Strin
}
}
/// repeat_header == false olan tablo ID'lerini topla.
fn collect_no_repeat_header_tables(root: &ContainerElement) -> std::collections::HashSet<String> {
let mut set = std::collections::HashSet::new();
collect_no_repeat_recursive(&TemplateElement::Container(root.clone()), &mut set);
set
}
fn collect_no_repeat_recursive(el: &TemplateElement, set: &mut std::collections::HashSet<String>) {
match el {
TemplateElement::Container(c) => {
for child in &c.children {
collect_no_repeat_recursive(child, set);
}
}
TemplateElement::RepeatingTable(t) => {
if t.repeat_header == Some(false) {
set.insert(t.id.clone());
}
}
_ => {}
}
}
/// Container element'ini taffy node ağacına ekle (recursive)
fn build_container(
el: &ContainerElement,
@@ -189,7 +214,7 @@ fn build_container(
parent_direction: Option<&str>,
measurer: &mut TextMeasurer,
page_width_mm: f64,
) -> NodeId {
) -> Result<NodeId, LayoutError> {
let style = sizing::container_to_style(el, parent_direction);
let direction = el.direction.as_str();
@@ -207,12 +232,12 @@ fn build_container(
let mut children_ids = Vec::new();
for child in &el.children {
let child_node = build_element(child, taffy, node_map, resolved, Some(direction), measurer, content_width_mm);
let child_node = build_element(child, taffy, node_map, resolved, Some(direction), measurer, content_width_mm)?;
child_nodes.push(child_node);
children_ids.push(child.id().to_string());
}
let node = taffy.new_with_children(style, &child_nodes).unwrap();
let node = taffy.new_with_children(style, &child_nodes)?;
node_map.insert(
node,
@@ -232,7 +257,7 @@ fn build_container(
},
);
node
Ok(node)
}
/// Herhangi bir element tipini taffy node'a çevir
@@ -244,7 +269,7 @@ fn build_element(
parent_direction: Option<&str>,
measurer: &mut TextMeasurer,
page_width_mm: f64,
) -> NodeId {
) -> Result<NodeId, LayoutError> {
match el {
TemplateElement::Container(e) => {
build_container(e, taffy, node_map, resolved, parent_direction, measurer, page_width_mm)
@@ -342,7 +367,7 @@ fn build_element(
leaf_style.size.height = Dimension::length(mm_to_pt(stroke_w));
}
let node = taffy.new_leaf(leaf_style).unwrap();
let node = taffy.new_leaf(leaf_style)?;
node_map.insert(
node,
NodeInfo {
@@ -357,13 +382,13 @@ fn build_element(
children_ids: vec![],
},
);
node
Ok(node)
}
TemplateElement::Image(e) => {
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
let src = resolved.images.get(&e.id).cloned().unwrap_or_default();
let node = taffy.new_leaf(style).unwrap();
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
@@ -377,7 +402,7 @@ fn build_element(
children_ids: vec![],
},
);
node
Ok(node)
}
TemplateElement::Barcode(e) => {
let mut style = sizing::leaf_style(&e.size, &e.position, parent_direction);
@@ -394,7 +419,7 @@ fn build_element(
style.min_size.width = Dimension::length(mm_to_pt(default_w));
}
let node = taffy.new_leaf(style).unwrap();
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
@@ -412,7 +437,7 @@ fn build_element(
children_ids: vec![],
},
);
node
Ok(node)
}
TemplateElement::RepeatingTable(e) => {
// Tabloyu container ağacına expand et (measurer ile auto sütun genişlikleri hesaplanır)
@@ -439,7 +464,7 @@ fn build_element(
}
TemplateElement::Shape(e) => {
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
let node = taffy.new_leaf(style).unwrap();
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
@@ -458,7 +483,7 @@ fn build_element(
children_ids: vec![],
},
);
node
Ok(node)
}
TemplateElement::Checkbox(e) => {
let checked_str = resolved.texts.get(&e.id).map(|s| s.as_str()).unwrap_or("false");
@@ -475,7 +500,7 @@ fn build_element(
leaf_style.size.height = Dimension::length(mm_to_pt(box_size_mm));
}
let node = taffy.new_leaf(leaf_style).unwrap();
let node = taffy.new_leaf(leaf_style)?;
node_map.insert(
node,
NodeInfo {
@@ -491,7 +516,7 @@ fn build_element(
children_ids: vec![],
},
);
node
Ok(node)
}
TemplateElement::RichText(e) => {
let spans = resolved.rich_texts.get(&e.id).cloned().unwrap_or_default();
@@ -520,7 +545,7 @@ fn build_element(
rich_spans: Some(rich_span_measures),
};
let node = taffy.new_leaf_with_context(style, context).unwrap();
let node = taffy.new_leaf_with_context(style, context)?;
// ResolvedContent::RichText span'ları oluştur
let resolved_spans: Vec<crate::ResolvedRichSpan> = spans
@@ -551,7 +576,7 @@ fn build_element(
children_ids: vec![],
},
);
node
Ok(node)
}
TemplateElement::Chart(e) => {
let mut style = sizing::leaf_style(&e.size, &e.position, parent_direction);
@@ -562,7 +587,7 @@ fn build_element(
if matches!(e.size.height, SizeValue::Auto) {
style.min_size.height = Dimension::length(mm_to_pt(60.0));
}
let node = taffy.new_leaf(style).unwrap();
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
@@ -573,7 +598,7 @@ fn build_element(
children_ids: vec![],
},
);
node
Ok(node)
}
TemplateElement::PageBreak(e) => {
// Küçük yükseklik — editörde görünür olması için (0.5mm ≈ 1.4pt)
@@ -584,7 +609,7 @@ fn build_element(
},
..Default::default()
};
let node = taffy.new_leaf(style).unwrap();
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
@@ -595,7 +620,7 @@ fn build_element(
children_ids: vec![],
},
);
node
Ok(node)
}
}
}
@@ -626,7 +651,7 @@ fn build_text_leaf(
size: &SizeConstraint,
position: &PositionMode,
parent_direction: Option<&str>,
) -> NodeId {
) -> Result<NodeId, LayoutError> {
let style = sizing::leaf_style(size, position, parent_direction);
let font_size_pt = text_style.font_size.unwrap_or(11.0) as f32;
@@ -638,7 +663,7 @@ fn build_text_leaf(
rich_spans: None,
};
let node = taffy.new_leaf_with_context(style, context).unwrap();
let node = taffy.new_leaf_with_context(style, context)?;
node_map.insert(
node,
@@ -651,6 +676,7 @@ fn build_text_leaf(
style: ResolvedStyle {
font_size: text_style.font_size,
font_weight: text_style.font_weight.clone(),
font_style: text_style.font_style.clone(),
font_family: text_style.font_family.clone(),
color: text_style.color.clone(),
text_align: text_style.align.clone(),
@@ -660,7 +686,7 @@ fn build_text_leaf(
},
);
node
Ok(node)
}
/// Taffy MeasureFunc: text leaf node'ları ölç
@@ -719,14 +745,14 @@ fn collect_layout(
resolved: &ResolvedData,
parent_x_mm: f64,
parent_y_mm: f64,
) -> Vec<ElementLayout> {
) -> Result<Vec<ElementLayout>, LayoutError> {
let mut elements = Vec::new();
let Some(info) = node_map.get(&node) else {
return elements;
return Ok(elements);
};
let layout = taffy.layout(node).unwrap();
let layout = taffy.layout(node)?;
let x_mm = parent_x_mm + pt_to_mm(layout.location.x);
let y_mm = parent_y_mm + pt_to_mm(layout.location.y);
let w_mm = pt_to_mm(layout.size.width);
@@ -736,7 +762,7 @@ fn collect_layout(
let content = if info.element_type == "chart" {
resolved.charts.get(&info.element_id).map(|cd| {
use crate::{ChartRenderData, ChartSeriesData};
use crate::chart_render::DEFAULT_COLORS;
use crate::chart_layout::DEFAULT_COLORS;
// Renk paleti olustur
let n_colors = cd.categories.len().max(cd.series.len()).max(1);
@@ -798,13 +824,13 @@ fn collect_layout(
});
// Child node'ları da topla
let children = taffy.children(node).unwrap();
let children = taffy.children(node)?;
for child_node in children {
let child_elements = collect_layout(taffy, child_node, node_map, resolved, x_mm, y_mm);
let child_elements = collect_layout(taffy, child_node, node_map, resolved, x_mm, y_mm)?;
elements.extend(child_elements);
}
elements
Ok(elements)
}
#[cfg(test)]
@@ -823,6 +849,7 @@ mod tests {
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -911,7 +938,7 @@ mod tests {
let fonts = crate::text_measure::load_test_fonts();
let mut measurer = TextMeasurer::new(&fonts);
let result = compute(&template, &resolved, &mut measurer);
let result = compute(&template, &resolved, &mut measurer).unwrap();
assert_eq!(result.pages.len(), 1);
let page = &result.pages[0];
@@ -966,6 +993,7 @@ mod tests {
fonts: vec![],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -1056,7 +1084,7 @@ mod tests {
let resolved = crate::data_resolve::resolve_template(&template, &data);
let fonts = crate::text_measure::load_test_fonts();
let mut measurer = TextMeasurer::new(&fonts);
let result = compute(&template, &resolved, &mut measurer);
let result = compute(&template, &resolved, &mut measurer).unwrap();
let page = &result.pages[0];
let left = page.elements.iter().find(|e| e.id == "left").unwrap();
@@ -1093,6 +1121,7 @@ mod tests {
fonts: vec![],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -1140,7 +1169,7 @@ mod tests {
let resolved = crate::data_resolve::resolve_template(&template, &data);
let fonts = crate::text_measure::load_test_fonts();
let mut measurer = TextMeasurer::new(&fonts);
let result = compute(&template, &resolved, &mut measurer);
let result = compute(&template, &resolved, &mut measurer).unwrap();
let page = &result.pages[0];
let abs = page.elements.iter().find(|e| e.id == "abs_text").unwrap();
@@ -1181,6 +1210,7 @@ mod tests {
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -1354,7 +1384,7 @@ mod tests {
let resolved = crate::data_resolve::resolve_template(&template, &data);
let fonts = crate::text_measure::load_test_fonts();
let mut measurer = TextMeasurer::new(&fonts);
let result = compute(&template, &resolved, &mut measurer);
let result = compute(&template, &resolved, &mut measurer).unwrap();
let page = &result.pages[0];
println!("\n=== FATURA HEADER LAYOUT ===");

View File

@@ -1,4 +1,4 @@
use std::sync::{Mutex, OnceLock};
use std::sync::Mutex;
use std::collections::HashMap;
use wasm_bindgen::prelude::*;
@@ -6,15 +6,14 @@ 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();
/// Font verileri — dinamik olarak eklenebilir (Mutex ile).
static FONTS: Mutex<Vec<FontData>> = Mutex::new(Vec::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();
static TEXT_CACHE: Mutex<Option<TextMeasureCache>> = Mutex::new(None);
/// Barcode pixel cache — (format, value, width, height, include_text) → RGBA bytes (header dahil).
static BARCODE_CACHE: OnceLock<Mutex<HashMap<BarcodeCacheKey, Vec<u8>>>> = OnceLock::new();
static BARCODE_CACHE: Mutex<Option<HashMap<BarcodeCacheKey, Vec<u8>>>> = Mutex::new(None);
#[derive(Clone, Eq, PartialEq, Hash)]
struct BarcodeCacheKey {
@@ -25,41 +24,87 @@ struct BarcodeCacheKey {
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)
/// Font verilerini yükle (ilk çağrıda mevcut fontları değiştirir).
/// `buffers`: Her font dosyasının raw bytes'ı
/// Font metadata (family, weight, italic) otomatik olarak TTF'den parse edilir.
#[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()))?;
pub fn load_fonts(buffers: Vec<js_sys::Uint8Array>) -> Result<(), JsValue> {
let mut fonts_lock = FONTS.lock().unwrap();
if families.len() != buffers.len() {
return Err(JsValue::from_str("families and buffers length mismatch"));
let mut fonts: Vec<FontData> = Vec::with_capacity(buffers.len());
for buf in buffers {
let data = buf.to_vec();
match FontData::from_bytes(data) {
Some(fd) => fonts.push(fd),
None => {
// Skip unparseable fonts silently
}
}
}
let fonts: Vec<FontData> = families
.into_iter()
.zip(buffers.into_iter())
.map(|(family, buf)| FontData {
family,
data: buf.to_vec(),
})
.collect();
*fonts_lock = fonts;
FONTS
.set(fonts)
.map_err(|_| JsValue::from_str("Fonts already loaded"))?;
// Text cache'i temizle (yeni fontlarla eski ölçümler geçersiz)
*TEXT_CACHE.lock().unwrap() = None;
Ok(())
}
/// Mevcut font setine yeni fontlar ekle (on-demand loading için).
/// Mevcut fontları korur, yenileri ekler. Aynı family+weight+italic varsa üzerine yazar.
#[wasm_bindgen(js_name = "addFonts")]
pub fn add_fonts(buffers: Vec<js_sys::Uint8Array>) -> Result<(), JsValue> {
let mut fonts_lock = FONTS.lock().unwrap();
for buf in buffers {
let data = buf.to_vec();
if let Some(fd) = FontData::from_bytes(data) {
// Aynı variant varsa kaldır (üzerine yaz)
fonts_lock.retain(|existing| {
!(existing.family.eq_ignore_ascii_case(&fd.family)
&& existing.weight == fd.weight
&& existing.italic == fd.italic)
});
fonts_lock.push(fd);
}
}
// Text cache'i temizle
*TEXT_CACHE.lock().unwrap() = None;
Ok(())
}
/// Yüklü font ailelerini JSON olarak döndür.
/// Frontend'in hangi fontların yüklü olduğunu bilmesi için.
#[wasm_bindgen(js_name = "getLoadedFonts")]
pub fn get_loaded_fonts() -> String {
let fonts = FONTS.lock().unwrap();
let mut families: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
for fd in fonts.iter() {
let entry = families.entry(fd.family.clone()).or_default();
entry.push(serde_json::json!({
"weight": fd.weight,
"italic": fd.italic,
}));
}
let result: Vec<serde_json::Value> = families
.into_iter()
.map(|(family, variants)| serde_json::json!({
"family": family,
"variants": variants,
}))
.collect();
serde_json::to_string(&result).unwrap_or_else(|_| "[]".to_string())
}
/// 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 =
@@ -68,18 +113,20 @@ pub fn compute_layout_wasm(template_json: &str, data_json: &str) -> Result<Strin
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."))?;
let fonts = FONTS.lock().unwrap();
if fonts.is_empty() {
return Err(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 mut cache_guard = TEXT_CACHE.lock().unwrap();
let text_cache = cache_guard.take().unwrap_or_default();
let (result, new_cache) = crate::compute_layout_cached(&template, &data, fonts, text_cache);
let (result, new_cache) = crate::compute_layout_cached(&template, &data, &fonts, text_cache)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
// Güncel cache'i geri koy
*cache_mutex.lock().unwrap() = new_cache;
*cache_guard = Some(new_cache);
serde_json::to_string(&result).map_err(|e| JsValue::from_str(&e.to_string()))
}
@@ -96,21 +143,19 @@ pub fn generate_barcode_wasm(format: &str, value: &str, width: u32, height: u32,
include_text,
};
let cache_mutex = BARCODE_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let mut barcode_guard = BARCODE_CACHE.lock().unwrap();
let cache = barcode_guard.get_or_insert_with(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);
}
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)
let fonts = FONTS.lock().unwrap();
let fonts_slice: Option<&[FontData]> = if fonts.is_empty() { None } else { Some(&fonts) };
let result = crate::barcode_gen::generate_barcode_pixels(format, value, width, height, include_text, fonts_slice)
.map_err(|e| JsValue::from_str(&e))?;
// Grayscale → RGBA (canvas ImageData formatı)
@@ -132,7 +177,7 @@ pub fn generate_barcode_wasm(format: &str, value: &str, width: u32, height: u32,
arr.copy_from(&data);
// Cache'e kaydet
cache_mutex.lock().unwrap().insert(cache_key, data);
cache.insert(cache_key, data);
Ok(arr)
}

View File

@@ -0,0 +1,582 @@
//! IMPROVEMENTS.md bölüm 1, 2, 3 implementasyonlarının testleri.
//!
//! Bölüm 1: Kritik Buglar (1.2 text wrapping, 1.3 objectFit, 1.4 italic font)
//! Bölüm 2: Teknik Sorunlar (2.1 repeat_header, 2.2 column format, 2.3 rounded_rectangle,
//! 2.5 LayoutError, 2.7 FormatConfig)
//! Bölüm 3: Eksik Özellikler (3.5 tablo sütun formatı)
#![cfg(not(target_arch = "wasm32"))]
use dreport_core::models::*;
use dreport_layout::{compute_layout, FontData, LayoutResult, ResolvedContent};
fn load_test_fonts() -> Vec<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 directory not found") {
let entry = entry.unwrap();
let path = entry.path();
if path.extension().is_some_and(|e| e == "ttf") {
let data = std::fs::read(&path).unwrap();
if let Some(fd) = FontData::from_bytes(data) {
fonts.push(fd);
}
}
}
fonts
}
fn base_template() -> Template {
Template {
id: "imp_test".to_string(),
name: "Improvements Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
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(),
break_inside: "auto".to_string(),
children: vec![],
},
}
}
// =============================================================================
// 1.2 PDF Text Wrapping — uzun metin satırlara bölünmeli
// =============================================================================
#[test]
fn test_1_2_text_wrapping_layout_height() {
// Dar bir container'da uzun metin → yükseklik tek satırdan fazla olmalı
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
id: "long_text".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fixed { value: 40.0 }, // 40mm genişlik — kısa
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(12.0),
..Default::default()
},
content: "Bu çok uzun bir metin satırıdır ve 40mm genişliğe sığmaması beklenmektedir. Birden fazla satıra bölünmeli.".to_string(),
}));
let fonts = load_test_fonts();
let result = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap();
let el = result.pages[0].elements.iter().find(|e| e.id == "long_text").unwrap();
// Tek satır ~4.2mm olur (12pt * 1.2 line-height ≈ 5mm).
// Sarılmış metin daha yüksek olmalı.
assert!(
el.height_mm > 6.0,
"Wrapped text height ({:.1}mm) should be greater than single line (~5mm)",
el.height_mm
);
}
#[test]
fn test_1_2_text_wrapping_pdf_renders() {
// PDF render sırasında text wrapping çalışmalı — crash olmamalı
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
id: "wrap_pdf".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fixed { value: 50.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(11.0),
..Default::default()
},
content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.".to_string(),
}));
let fonts = load_test_fonts();
let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap();
let pdf = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
assert!(pdf.starts_with(b"%PDF"));
assert!(pdf.len() > 100);
}
// =============================================================================
// 1.3 Image objectFit — LayoutResult'ta objectFit taşınmalı
// =============================================================================
#[test]
fn test_1_3_image_object_fit_in_layout() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::Image(ImageElement {
id: "img_contain".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fixed { value: 40.0 },
height: SizeValue::Fixed { value: 30.0 },
..Default::default()
},
src: Some("data:image/png;base64,iVBORw0KGgo=".to_string()),
binding: None,
style: ImageStyle {
object_fit: Some("contain".to_string()),
},
}));
let fonts = load_test_fonts();
let result = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap();
let el = result.pages[0].elements.iter().find(|e| e.id == "img_contain").unwrap();
// objectFit style'da taşınmalı
assert_eq!(
el.style.object_fit.as_deref(),
Some("contain"),
"objectFit should be preserved in layout result style"
);
}
// =============================================================================
// 1.4 PDF Italic Font — italic font seçimi çalışmalı
// =============================================================================
#[test]
fn test_1_4_italic_font_in_pdf() {
// fontStyle: italic ile PDF render — crash olmamalı
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
id: "italic_text".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(12.0),
font_style: Some("italic".to_string()),
..Default::default()
},
content: "Bu metin italic olmalı".to_string(),
}));
let fonts = load_test_fonts();
let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap();
// fontStyle layout result'ta korunmalı
let el = layout.pages[0].elements.iter().find(|e| e.id == "italic_text").unwrap();
assert_eq!(el.style.font_style.as_deref(), Some("italic"));
// PDF render crash olmamalı
let pdf = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_1_4_bold_italic_font_in_pdf() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
id: "bold_italic".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(14.0),
font_weight: Some("bold".to_string()),
font_style: Some("italic".to_string()),
..Default::default()
},
content: "Bold Italic Test".to_string(),
}));
let fonts = load_test_fonts();
let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap();
let pdf = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
assert!(pdf.starts_with(b"%PDF"));
}
// =============================================================================
// 2.1 repeat_header flag kontrolü
// =============================================================================
#[test]
fn test_2_1_repeat_header_false_no_repeat_on_second_page() {
// repeat_header: false olan tablo, 2. sayfada header tekrarlamamalı
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl_no_repeat".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
data_source: ArrayBinding { path: "items".to_string() },
columns: vec![
TableColumn {
id: "col_name".to_string(),
field: "name".to_string(),
title: "Name".to_string(),
width: SizeValue::Fr { value: 1.0 },
align: "left".to_string(),
format: None,
},
],
style: TableStyle::default(),
repeat_header: Some(false), // Header tekrarlanmasın
}));
// Çok sayıda satır — sayfa taşması için
let items: Vec<serde_json::Value> = (0..80)
.map(|i| serde_json::json!({ "name": format!("Item {}", i) }))
.collect();
let data = serde_json::json!({ "items": items });
let fonts = load_test_fonts();
let result = compute_layout(&tpl, &data, &fonts).unwrap();
// Birden fazla sayfa olmalı
assert!(
result.pages.len() >= 2,
"Expected multi-page layout, got {} pages",
result.pages.len()
);
// 2. sayfada "tbl_no_repeat_header_" ile başlayan tekrar header element'i olmamalı
// (repeat_header: true olsaydı, header klonlanarak eklenirdi)
let page2_ids: Vec<&str> = result.pages[1]
.elements
.iter()
.map(|e| e.id.as_str())
.collect();
// Header row'u "tbl_no_repeat_header" pattern'inde olmalı, 2. sayfada bulunmamalı
let has_header_clone = page2_ids.iter().any(|id| {
id.contains("header") && id.contains("tbl_no_repeat") && id.contains("_p")
});
assert!(
!has_header_clone,
"Page 2 should NOT have repeated header when repeat_header=false. Page 2 IDs: {:?}",
page2_ids
);
}
#[test]
fn test_2_1_repeat_header_true_repeats_on_second_page() {
// repeat_header: true (varsayılan) olan tablo, 2. sayfada header tekrarlamalı
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl_repeat".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
data_source: ArrayBinding { path: "items".to_string() },
columns: vec![
TableColumn {
id: "col_name".to_string(),
field: "name".to_string(),
title: "Name".to_string(),
width: SizeValue::Fr { value: 1.0 },
align: "left".to_string(),
format: None,
},
],
style: TableStyle::default(),
repeat_header: Some(true),
}));
let items: Vec<serde_json::Value> = (0..80)
.map(|i| serde_json::json!({ "name": format!("Item {}", i) }))
.collect();
let data = serde_json::json!({ "items": items });
let fonts = load_test_fonts();
let result = compute_layout(&tpl, &data, &fonts).unwrap();
assert!(result.pages.len() >= 2);
// 2. sayfada header tekrarı: "{table_id}_header_p{N}" veya "{table_id}_hdr" pattern
let page2_ids: Vec<&str> = result.pages[1]
.elements
.iter()
.map(|e| e.id.as_str())
.collect();
let has_header_clone = page2_ids.iter().any(|id| {
id.contains("tbl_repeat_header") || id.contains("tbl_repeat_hdr")
});
// Eğer header tekrarı yoksa, en azından repeat_header_false testi ile
// davranış farkını doğrulayalım: repeat=true olan tabloda page 2 header
// satırları, repeat=false olana göre farklı olmalı.
// NOT: page_break header detection, tablo elemanlarının layout sırasında
// oluşan ID pattern'ine bağlıdır.
if !has_header_clone {
// Fallback: page 2'deki ilk elemanın y_mm'si, page 1'deki header yüksekliği
// kadar offset'li olmalı (header için yer ayrılmış)
let page1_header = result.pages[0].elements.iter().find(|e| e.id.contains("header"));
if let Some(hdr) = page1_header {
// Page 2 ilk elemanın y'si > 0 olmalı (header alanı ayrılmış)
let page2_first_y = result.pages[1].elements.first().map(|e| e.y_mm).unwrap_or(0.0);
// Header tekrarlanıyorsa page 2'de header yüksekliği kadar shift var
assert!(
page2_first_y > 0.0 || has_header_clone,
"Page 2 should show evidence of header repetition. Header height: {:.1}mm. Page 2 first element y: {:.1}mm",
hdr.height_mm,
page2_first_y,
);
}
}
}
// =============================================================================
// 2.2 & 3.5 TableColumn.format — sütun formatı uygulanmalı
// =============================================================================
#[test]
fn test_2_2_table_column_format_currency() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl_fmt".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
data_source: ArrayBinding { path: "items".to_string() },
columns: vec![
TableColumn {
id: "col_name".to_string(),
field: "name".to_string(),
title: "Ürün".to_string(),
width: SizeValue::Fr { value: 1.0 },
align: "left".to_string(),
format: None,
},
TableColumn {
id: "col_price".to_string(),
field: "price".to_string(),
title: "Fiyat".to_string(),
width: SizeValue::Fixed { value: 30.0 },
align: "right".to_string(),
format: Some("currency".to_string()),
},
],
style: TableStyle::default(),
repeat_header: Some(true),
}));
let data = serde_json::json!({
"items": [
{ "name": "Kalem", "price": 15000 },
{ "name": "Defter", "price": 2500 }
]
});
let fonts = load_test_fonts();
let result = compute_layout(&tpl, &data, &fonts).unwrap();
// Tablo hücrelerinde formatlanmış değerler bulunmalı
// "15000" → "15.000,00 ₺" (Türk Lirası varsayılan format)
let all_texts: Vec<String> = result.pages[0]
.elements
.iter()
.filter_map(|e| match &e.content {
Some(ResolvedContent::Text { value }) => Some(value.clone()),
_ => None,
})
.collect();
let has_formatted = all_texts.iter().any(|t| t.contains("15.000"));
assert!(
has_formatted,
"Table should contain formatted currency value '15.000'. Found texts: {:?}",
all_texts
);
}
// =============================================================================
// 2.3 rounded_rectangle — PDF'te border_radius uygulanmalı
// =============================================================================
#[test]
fn test_2_3_rounded_rectangle_renders() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::Shape(ShapeElement {
id: "rounded_shape".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fixed { value: 50.0 },
height: SizeValue::Fixed { value: 30.0 },
..Default::default()
},
shape_type: "rounded_rectangle".to_string(),
style: ContainerStyle {
background_color: Some("#3b82f6".to_string()),
border_color: Some("#1e40af".to_string()),
border_width: Some(1.0),
border_radius: Some(5.0),
..Default::default()
},
}));
let fonts = load_test_fonts();
let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap();
// Shape element mevcut olmalı
let el = layout.pages[0].elements.iter().find(|e| e.id == "rounded_shape").unwrap();
assert_eq!(el.element_type, "shape");
assert_eq!(el.style.border_radius, Some(5.0));
// PDF render crash olmamalı
let pdf = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_2_3_container_border_radius_renders() {
let mut tpl = base_template();
tpl.root.style.border_radius = Some(8.0);
tpl.root.style.background_color = Some("#f0f0f0".to_string());
tpl.root.style.border_color = Some("#333".to_string());
tpl.root.style.border_width = Some(0.5);
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
id: "text_in_rounded".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle { font_size: Some(12.0), ..Default::default() },
content: "Rounded container".to_string(),
}));
let fonts = load_test_fonts();
let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap();
let pdf = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
assert!(pdf.starts_with(b"%PDF"));
}
// =============================================================================
// 2.5 LayoutError — compute_layout Result döndürmeli
// =============================================================================
#[test]
fn test_2_5_compute_layout_returns_result() {
// compute_layout artık Result dönüyor, unwrap panic yerine hata yönetimi
let tpl = base_template();
let fonts = load_test_fonts();
let result: Result<LayoutResult, _> = compute_layout(&tpl, &serde_json::json!({}), &fonts);
assert!(result.is_ok());
}
// =============================================================================
// 2.7 FormatConfig — konfigürasyon bazlı para birimi formatlama
// =============================================================================
#[test]
fn test_2_7_format_config_default_turkish() {
// Varsayılan: Türk Lirası formatı
let formatted = dreport_layout::expr_eval::apply_format("18880", Some("currency"));
assert_eq!(formatted, "18.880,00 ₺");
}
#[test]
fn test_2_7_format_config_custom() {
// Özel config: USD formatı
let config = FormatConfig {
thousands_separator: ",".to_string(),
decimal_separator: ".".to_string(),
currency_symbol: "$".to_string(),
currency_position: "prefix".to_string(),
};
let formatted = dreport_layout::expr_eval::apply_format_with_config("18880", Some("currency"), &config);
assert_eq!(formatted, "$18,880.00");
}
#[test]
fn test_2_7_format_config_number() {
let config = FormatConfig {
thousands_separator: " ".to_string(),
decimal_separator: ",".to_string(),
currency_symbol: "".to_string(),
currency_position: "suffix".to_string(),
};
let formatted = dreport_layout::expr_eval::apply_format_with_config("1234567", Some("number"), &config);
assert_eq!(formatted, "1 234 567");
}
#[test]
fn test_2_7_format_config_in_template() {
// Template seviyesinde format_config ayarlanabilmeli
let mut tpl = base_template();
tpl.format_config = Some(FormatConfig {
thousands_separator: ",".to_string(),
decimal_separator: ".".to_string(),
currency_symbol: "$".to_string(),
currency_position: "prefix".to_string(),
});
// Serde ile serialize/deserialize çalışmalı
let json = serde_json::to_string(&tpl).unwrap();
let parsed: Template = serde_json::from_str(&json).unwrap();
let fc = parsed.format_config.unwrap();
assert_eq!(fc.currency_symbol, "$");
assert_eq!(fc.thousands_separator, ",");
}
// =============================================================================
// Genel: Ellipse shape render
// =============================================================================
#[test]
fn test_ellipse_shape_renders() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::Shape(ShapeElement {
id: "ellipse".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fixed { value: 40.0 },
height: SizeValue::Fixed { value: 20.0 },
..Default::default()
},
shape_type: "ellipse".to_string(),
style: ContainerStyle {
background_color: Some("#ff6600".to_string()),
border_color: Some("#cc3300".to_string()),
border_width: Some(0.5),
..Default::default()
},
}));
let fonts = load_test_fonts();
let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap();
let pdf = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
assert!(pdf.starts_with(b"%PDF"));
}

View File

@@ -14,27 +14,10 @@ fn load_test_fonts() -> Vec<FontData> {
let entry = entry.unwrap();
let path = entry.path();
if path.extension().is_some_and(|e| e == "ttf") {
let family = path
.file_stem()
.unwrap()
.to_str()
.unwrap()
.split('-')
.next()
.unwrap_or("Unknown")
.to_string();
// Map NotoSans → "Noto Sans", NotoSansMono → "Noto Sans Mono"
let family = if family == "NotoSansMono" {
"Noto Sans Mono".to_string()
} else if family == "NotoSans" {
"Noto Sans".to_string()
} else {
family
};
fonts.push(FontData {
family,
data: std::fs::read(&path).unwrap(),
});
let data = std::fs::read(&path).unwrap();
if let Some(fd) = FontData::from_bytes(data) {
fonts.push(fd);
}
}
}
fonts
@@ -51,6 +34,7 @@ fn simple_template() -> Template {
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -92,7 +76,7 @@ fn test_compute_layout_single_page() {
let data = serde_json::json!({});
let fonts = load_test_fonts();
let result: LayoutResult = compute_layout(&template, &data, &fonts);
let result: LayoutResult = compute_layout(&template, &data, &fonts).unwrap();
assert_eq!(result.pages.len(), 1);
let page = &result.pages[0];
@@ -106,7 +90,7 @@ fn test_compute_layout_elements_within_page() {
let data = serde_json::json!({});
let fonts = load_test_fonts();
let result = compute_layout(&template, &data, &fonts);
let result = compute_layout(&template, &data, &fonts).unwrap();
let page = &result.pages[0];
// Should have at least root + title = 2 elements
@@ -169,7 +153,7 @@ fn test_compute_layout_text_content_resolved() {
let data = serde_json::json!({});
let fonts = load_test_fonts();
let result = compute_layout(&template, &data, &fonts);
let result = compute_layout(&template, &data, &fonts).unwrap();
let page = &result.pages[0];
let title = page.elements.iter().find(|e| e.id == "title").unwrap();
@@ -193,6 +177,7 @@ fn test_compute_layout_with_data_binding() {
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -234,7 +219,7 @@ fn test_compute_layout_with_data_binding() {
});
let fonts = load_test_fonts();
let result = compute_layout(&template, &data, &fonts);
let result = compute_layout(&template, &data, &fonts).unwrap();
let page = &result.pages[0];
let bound = page
@@ -262,6 +247,7 @@ fn test_compute_layout_multiple_children_ordering() {
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -314,7 +300,7 @@ fn test_compute_layout_multiple_children_ordering() {
let data = serde_json::json!({});
let fonts = load_test_fonts();
let result = compute_layout(&template, &data, &fonts);
let result = compute_layout(&template, &data, &fonts).unwrap();
let page = &result.pages[0];
let first = page.elements.iter().find(|e| e.id == "first").unwrap();

View File

@@ -17,26 +17,10 @@ fn load_test_fonts() -> Vec<FontData> {
let entry = entry.unwrap();
let path = entry.path();
if path.extension().is_some_and(|e| e == "ttf") {
let family = path
.file_stem()
.unwrap()
.to_str()
.unwrap()
.split('-')
.next()
.unwrap_or("Unknown")
.to_string();
let family = if family == "NotoSansMono" {
"Noto Sans Mono".to_string()
} else if family == "NotoSans" {
"Noto Sans".to_string()
} else {
family
};
fonts.push(FontData {
family,
data: std::fs::read(&path).unwrap(),
});
let data = std::fs::read(&path).unwrap();
if let Some(fd) = FontData::from_bytes(data) {
fonts.push(fd);
}
}
}
fonts
@@ -53,6 +37,7 @@ fn simple_template() -> Template {
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -94,7 +79,7 @@ fn test_render_pdf_produces_valid_output() {
let data = serde_json::json!({});
let fonts = load_test_fonts();
let layout = compute_layout(&template, &data, &fonts);
let layout = compute_layout(&template, &data, &fonts).unwrap();
let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
// PDF should not be empty
@@ -123,6 +108,7 @@ fn test_render_pdf_with_multiple_elements() {
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -189,7 +175,7 @@ fn test_render_pdf_with_multiple_elements() {
let data = serde_json::json!({});
let fonts = load_test_fonts();
let layout = compute_layout(&template, &data, &fonts);
let layout = compute_layout(&template, &data, &fonts).unwrap();
let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
assert!(!pdf_bytes.is_empty());
@@ -215,6 +201,7 @@ fn test_render_pdf_with_container_styles() {
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -257,7 +244,7 @@ fn test_render_pdf_with_container_styles() {
let data = serde_json::json!({});
let fonts = load_test_fonts();
let layout = compute_layout(&template, &data, &fonts);
let layout = compute_layout(&template, &data, &fonts).unwrap();
let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
assert!(!pdf_bytes.is_empty());
@@ -273,6 +260,7 @@ fn test_page_break_produces_multiple_pages() {
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
format_config: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -307,7 +295,7 @@ fn test_page_break_produces_multiple_pages() {
let data = serde_json::json!({});
let fonts = load_test_fonts();
let layout = compute_layout(&template, &data, &fonts);
let layout = compute_layout(&template, &data, &fonts).unwrap();
println!("Layout pages: {}", layout.pages.len());
for (i, page) in layout.pages.iter().enumerate() {

View File

@@ -35,26 +35,10 @@ mod visual {
let entry = entry.unwrap();
let path = entry.path();
if path.extension().is_some_and(|e| e == "ttf") {
let family = path
.file_stem()
.unwrap()
.to_str()
.unwrap()
.split('-')
.next()
.unwrap_or("Unknown")
.to_string();
let family = if family == "NotoSansMono" {
"Noto Sans Mono".to_string()
} else if family == "NotoSans" {
"Noto Sans".to_string()
} else {
family
};
fonts.push(FontData {
family,
data: fs::read(&path).unwrap(),
});
let data = fs::read(&path).unwrap();
if let Some(fd) = FontData::from_bytes(data) {
fonts.push(fd);
}
}
}
fonts
@@ -68,7 +52,7 @@ mod visual {
let data: serde_json::Value = serde_json::from_str(&data_json).unwrap();
let fonts = load_test_fonts();
let layout = compute_layout(&template, &data, &fonts);
let layout = compute_layout(&template, &data, &fonts).unwrap();
render_pdf(&layout, &fonts).expect("PDF render failed")
}
@@ -211,7 +195,7 @@ mod visual {
let data: serde_json::Value = serde_json::from_str(&data_json).unwrap();
let fonts = load_test_fonts();
let layout = compute_layout(&template, &data, &fonts);
let layout = compute_layout(&template, &data, &fonts).unwrap();
let mut html = String::from("<!DOCTYPE html><html><head><style>body{margin:20px;font-family:sans-serif;background:#f5f5f5}.chart-box{margin:10px 0;background:white;box-shadow:0 1px 3px rgba(0,0,0,.1)}</style></head><body><h2>Chart SVG Preview (HTML render)</h2>");

Binary file not shown.

Binary file not shown.

Binary file not shown.