diff --git a/.gitignore b/.gitignore index 2f7896d..2ca8a8b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,10 @@ -target/ +**/target/ +node_modules/ +dist/ +*.wasm +.DS_Store + +# Visual test artifacts (regenerated on demand) +frontend/tests/visual/cross-renderer-refs/ +frontend/tests/visual/cross-renderer-diffs/ +frontend/tests/visual/test-results/ diff --git a/Cargo.lock b/Cargo.lock index 685bdc1..6378079 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -399,7 +399,7 @@ dependencies = [ name = "dexpr" version = "0.1.0" source = "sparse+https://gitea.duhanbalci.com/api/packages/duhanbalci/cargo/" -checksum = "66f1b8752c5d700b0399128c3ba4d5cad1204be8b29de8489d2c4b3c53f975c8" +checksum = "37e0a98f2810bb770c76ef1e99d07066a15997086f9ead93917a82711274af25" dependencies = [ "bumpalo", "indexmap", diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md new file mode 100644 index 0000000..7f13e01 --- /dev/null +++ b/IMPROVEMENTS.md @@ -0,0 +1,825 @@ +# dreport - Improvement & Feature Tracker + +> Bu dosya projenin kapsamli analizinden elde edilen bulgu, iyilestirme ve yeni ozellik onerilerini icerir. +> Her basligin yanindaki durum etiketi, ilgili madde tamamlandiginda `[IMPLEMENTE EDILDI]` olarak guncellenecektir. + +--- + +## 1. Kritik Buglar + +### 1.1 Undo/Redo `Object.assign` Hatasi `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `frontend/src/composables/useUndoRedo.ts` (satir 52) + +**Sorun:** +`applySnapshot` fonksiyonu snapshot'i geri yuklerken `Object.assign(source.value, JSON.parse(snap))` kullaniyor. `Object.assign` shallow merge yapar — mevcut objede olan ama snapshot'ta olmayan key'leri **silmez**. Bu, ozellikle `header` ve `footer` toggle islemlerinde ciddi bir bug olusturur. + +**Senaryo:** +1. Kullanici template'e header ekler (`template.header` olusur) +2. Ctrl+Z ile geri alir +3. Snapshot header eklenmeden onceki state'i icerir ama `Object.assign` `header` key'ini silemez +4. Header hala template'te kalir — undo calismamis olur + +**Cozum:** +```typescript +// YANLIS (mevcut) +Object.assign(source.value as object, JSON.parse(snap)) + +// DOGRU +source.value = JSON.parse(snap) +``` +Vue'nun reactivity sistemi ref degeri tamamen degistirildiginde dogru calisiyor. Reference replacement ile tum key'ler (silinen dahil) dogru sekilde geri yuklenir. + +**Ek Sorun — Debounce Race Condition:** +Undo/redo watcher'da 300ms debounce var. Kullanici hizli bir edit yapip 300ms icinde Ctrl+Z basarsa, snapshot henuz push edilmemis olabilir ve undo onceki-onceki state'e doner. Debounce yerine `requestIdleCallback` veya edit sonrasi aninda flush mekanizmasi dusunulmeli. + +--- + +### 1.2 PDF'te Text Wrapping Yok `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `layout-engine/src/pdf_render.rs` (satir ~487) + +**Sorun:** +`render_text()` fonksiyonu metni tek bir `draw_text()` cagrisiyla ciziyor. Taffy layout engine'i text olcum sirasinda cosmic-text uzerinden line-break hesapliyor ve yuksekligi buna gore belirliyor. Ancak PDF render asamasinda bu line-break bilgisi kullanilmiyor — metin tek satirda, kutudan tasarak ciziliyor. + +**Etki:** +Bu, projenin temel vaadi olan "editorde gordugum = PDF'te aldigim" WYSIWYG garantisini kiran en buyuk bug. Editorde birden fazla satira sarilan bir text elemani, PDF'te tek satir olarak kutudan tasar. + +**Cozum Yaklasimi:** +1. `text_measure.rs`'deki cosmic-text `Buffer`'dan line-break pozisyonlarini `LayoutResult`'a tasimak +2. `pdf_render.rs`'de her satiri ayri `draw_text()` cagrisiyla, dogru y-offset ile cizmek +3. Alternatif: PDF render sirasinda cosmic-text'i tekrar calistirip line layout almak (daha basit ama daha yavas) + +--- + +### 1.3 Image objectFit Hardcoded `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `frontend/src/components/editor/LayoutRenderer.vue` (satir ~229) + +**Sorun:** +Image render sirasinda `objectFit` degeri sabit `'fill'` olarak ataniyor: +```typescript +objectFit: 'fill', // el.style.objectFit degerini yok sayiyor +``` + +`ImageStyle` tipi, `ImageElement` ve `ImageProperties.vue` hepsi `contain | cover | stretch` destekliyor. `ResolvedStyle` interface'inde `objectFit` alani var. Ancak `LayoutRenderer` bunu okumuyor. + +**Etki:** +Editor onizlemede tum gorseller her zaman `fill` modunda gosteriliyor. Kullanici `contain` veya `cover` secse bile editorde fark gormuyor. + +**Cozum:** +```typescript +objectFit: el.style.objectFit || 'fill', +``` + +--- + +### 1.4 PDF'te Italic Font Secilmiyor `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `layout-engine/src/pdf_render.rs` (satir ~104) + +**Sorun:** +`FontCollection::get()` metodu her zaman `is_italic: false` gonderiyor. Italic font variant'lari collection'a yukleniyor ama hicbir zaman secilemiyorlar. + +**Etki:** +Template'te `fontStyle: "italic"` olarak ayarlanmis metin, PDF ciktisinda normal (regular) olarak goruntulenir. Editor tarafinda HTML/CSS italic destekledigi icin sorun gorunmuyor, ama PDF farkli cikiyor. + +**Cozum:** +`FontCollection::get()` metoduna `is_italic` parametresi ekleyip, `ResolvedStyle.fontStyle` degerine gore italic font secimi yapmak. + +--- + +## 2. Onemli Teknik Sorunlar + +### 2.1 `repeat_header` Flag'i Kontrol Edilmiyor `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `layout-engine/src/table_layout.rs`, `layout-engine/src/page_break.rs` + +**Sorun:** +`RepeatingTableElement` uzerinde `repeat_header: Option` alani tanimli ve default degeri `true`. Ancak `table_layout.rs`'deki tablo genisletme kodu bu flag'i hic kontrol etmiyor — header her zaman tekrarlaniyor. + +**Etki:** +Kullanici tablo header tekrarini kapatamaz. Bazi belge tasarimlarinda (ornegin ozetlerde) header tekrari istenmeyebilir. + +**Cozum:** +`page_break.rs`'deki header klonlama mantigi `repeat_header` flag'ini kontrol etmeli. `false` ise yeni sayfada header eklememeli. + +--- + +### 2.2 TableColumn.format Uygulanmiyor `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `core/src/models.rs`, `layout-engine/src/table_layout.rs`, `layout-engine/src/data_resolve.rs` + +**Sorun:** +`TableColumn` struct'inda `format: Option` alani tanimli ama pipeline boyunca hic kullanilmiyor. Sutun bazinda currency, date veya percentage formatlama calismaz. + +**Etki:** +Kullanici bir tablo sutununu `currency` formatinda tanimlarsa, hucrelerdeki sayilar ham haliyle gosterilir (ornegin `15000` yerine `15.000,00 ₺` olmaz). + +**Cozum:** +`data_resolve.rs`'de tablo satir verisi cozumlenirken, ilgili sutunun `format` degerini `expr_eval::apply_format()` fonksiyonuna gecirerek formatlama uygulamak. + +--- + +### 2.3 rounded_rectangle Shape PDF'te Duz Dikdortgen `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `layout-engine/src/pdf_render.rs` — `render_shape()` + +**Sorun:** +Shape render fonksiyonu `ellipse` disindaki tum shape tiplerini duz dikdortgen olarak ciziyor. `rounded_rectangle` tipi ve `border_radius` stili yok sayiliyor. + +**Cozum:** +`border_radius > 0` kontrolu ile krilla'nin rounded rectangle API'sini kullanmak. + +--- + +### 2.4 Chart Render Kod Tekrari (~400 Satir) `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `layout-engine/src/chart_render.rs` (SVG), `layout-engine/src/pdf_render.rs` (PDF chart bolumu) + +**Sorun:** +Chart rendering iki ayri yerde uygulanmis: SVG icin `chart_render.rs`, PDF icin `pdf_render.rs`. Margin hesaplama, eksen cizimi, etiket yerlesimi, legend mantigi gibi ~400 satirlik logic her iki dosyada tekrarlaniyor. + +**Etki:** +Bir chart ozelligindeki degisiklik iki dosyada ayri ayri yapilmak zorunda. Senkronizasyon unutuldugunda SVG ve PDF chart'lar farkli gorunur. + +**Cozum:** +Ortak bir `ChartLayout` struct'i ile hesaplama mantigi tek yerde yapilip, SVG ve PDF renderer'lara sadece cizim primitive'leri gecirilmeli. Strategy/trait pattern ile: +```rust +trait ChartRenderer { + fn draw_line(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, style: &LineStyle); + fn draw_rect(&mut self, x: f64, y: f64, w: f64, h: f64, style: &FillStyle); + fn draw_text(&mut self, x: f64, y: f64, text: &str, style: &TextStyle); +} +``` + +--- + +### 2.5 Taffy unwrap() Kullanimi — Panic Riski `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `layout-engine/src/tree.rs` (satirlar: 80, 93, 143, 156, 215, 345, 366, 397) + +**Sorun:** +Taffy'nin `new_with_children()`, `compute_layout_with_measure()`, `layout()` gibi metodlari `Result` donduruyor ama tumu `.unwrap()` ile cagiriliyor. Taffy internal hatasi durumunda (bellek yetersizligi, invalid tree state) program panic yapar. + +**Etki:** +Backend'de bir template render istegi panic'e yol acarsa, o Tokio task sonlanir. WASM tarafinda panic tum worker'i oldurur. + +**Cozum:** +`unwrap()` yerine `map_err` ile `LayoutError` tipine donusturmek ve `compute_layout` fonksiyonundan `Result` dondurmek. + +--- + +### 2.6 Backend PDF Render Async Thread Blocking `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `backend/src/routes/render.rs` (satir ~25) + +**Sorun:** +`compute_layout()` ve `render_pdf()` senkron, CPU-intensive islemler. Axum async handler icinde dogrudan cagiriliyorlar — bu Tokio async thread'ini bloklar. + +**Etki:** +Yogun yuklenme altinda veya buyuk template'lerde diger HTTP isteklerinin islenmesi gecikir. Tokio'nun async avantaji kaybolur. + +**Cozum:** +```rust +let pdf_bytes = tokio::task::spawn_blocking(move || { + let layout = compute_layout(&template, &data, &fonts); + render_pdf(&layout, &fonts) +}).await??; +``` + +--- + +### 2.7 Currency Formatting Hardcoded Turkce `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `layout-engine/src/expr_eval.rs` (satir ~86) + +**Sorun:** +`format_currency()` fonksiyonu Turk Lirasi formati icin hardcoded: +- `.` binlik ayiraci +- `,` ondalik ayiraci +- `₺` para birimi sembolu + +Chart render'daki `format_value()` ise `.` ondalik ayirici ve `K/M` kisaltma kullaniyor — iki farkli lokalizasyon. + +**Cozum:** +Bir `Locale` veya `FormatConfig` struct'i olusturup, template seviyesinde veya global config ile para birimi, ondalik ayiraci ve binlik ayiraci belirlenebilir hale getirmek. + +--- + +### 2.8 Worker Font Fetch Hata Yakalama Yok `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `frontend/src/workers/layout.worker.ts` (satirlar 25-33) + +**Sorun:** +Font dosyalari `await fetch(...)` ile yukleniyor, hic `try/catch` veya response status kontrolu yok. Font dosyasi 404 donerse `Promise.all` bos/kirik buffer ile resolve olur ve WASM `loadFonts` yanlis metriklerle sessizce devam eder. + +**Etki:** +Font yuklenemezse layout engine kirik metriklerle calisir — text boyutlari yanlis hesaplanir, WYSIWYG bozulur, hata mesaji gorulmez. + +**Cozum:** +```typescript +const res = await fetch(url) +if (!res.ok) throw new Error(`Font yuklenemedi: ${url} (${res.status})`) +const buffer = await res.arrayBuffer() +``` + +--- + +### 2.9 importTemplate Validasyon Eksikligi `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `frontend/src/stores/template.ts` (satir ~195) + +**Sorun:** +`importTemplate` metodu `JSON.parse` sonucunu hic dogrulamadan store'a yaziyor. Bozuk veya eksik alanli JSON, store'u ara durumda birakir. + +**Cozum:** +1. `try/catch` ile parse hatalarini yakalamak +2. Minimum schema dogrulamasi: `root` alani var mi, `root.type === 'container'` mi, `page` alani gecerli mi +3. Basarisiz durumda onceki state'i korumak + +--- + +### 2.10 Barcode Promise Timeout Yok `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `frontend/src/composables/useLayoutEngine.ts` + +**Sorun:** +`generateBarcode()` bir Promise donduruyor ama timeout mekanizmasi yok. Worker crash olursa veya takilirsa, promise sonsuza kadar pending kalir. `dispose()` metodu da bekleyen promise'leri resolve/reject etmiyor. + +**Cozum:** +```typescript +const timeout = setTimeout(() => { + barcodeCallbacks.delete(id) + resolve(null) +}, 5000) +``` +`dispose()` icinde tum pending callback'leri `null` ile resolve etmek. + +--- + +### 2.11 moveElement Cift Layout Recompute `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `frontend/src/stores/template.ts` + +**Sorun:** +`moveElement` fonksiyonu `removeElement()` + `addChild()` cagiriyor, her biri `layoutVersion++` yapiyor. Tek bir mantiksal islem icin iki layout recompute tetikleniyor. + +**Cozum:** +`moveElement` icinde `layoutVersion` bump'ini tek seferde yapmak: +- `removeElement` ve `addChild`'in internal versiyonlarini olustur (version bump'siz) +- Islemin sonunda tek bir `layoutVersion++` yap + +--- + +### 2.12 Barcode ID Collision Riski `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `frontend/src/composables/useLayoutEngine.ts` + +**Sorun:** +Barcode request ID'leri `barcodeReqId + 100000` offset'i ile layout request ID'lerinden ayristiriliyor. Uzun sureli oturumlarda `requestId > 100000` olursa (dusuk ihtimal ama mumkun) ID'ler carpisabilir. + +**Cozum:** +Ayri bir message type namespace kullanmak — `msg.type` alani ile ayristirma zaten yapiliyor, ID offset'ine gerek yok. Veya barcode icin ayri bir counter kullanmak. + +--- + +## 3. Eksik Ozellikler (CLAUDE.md'de Tanimli) + +### 3.1 Coklu Secim (Multi-Selection) `[IMPLEMENTE EDILMEDI]` + +**Referans:** CLAUDE.md — "Shift+tiklama ile coklu secim" + +**Mevcut Durum:** +`selectedElementId` tek bir `string | null` olarak tanimli. Coklu secim icin hicbir state, UI veya islem mantigi yok. + +**Gerekli Degisiklikler:** +1. `stores/editor.ts`'de `selectedElementIds: Set` eklemek +2. `InteractionOverlay.vue`'da Shift+click ile set'e ekleme/cikarma +3. Coklu secimde toplu tasima (absolute elemanlar icin) +4. Coklu secimde toplu ozellik degistirme (ortak alanlar icin) +5. Coklu secimde toplu silme + +--- + +### 3.2 Z-Order Kontrolleri `[IMPLEMENTE EDILMEDI]` + +**Referans:** CLAUDE.md — "One Getir / Arkaya Gonder" + +**Mevcut Durum:** +`reorderChild` metodu var ve drag-to-reorder icin kullaniyor. Ancak "One Getir" / "Arkaya Gonder" / "En One Getir" / "En Arkaya Gonder" icin UI bulunmuyor. + +**Gerekli Degisiklikler:** +1. `ElementToolbar.vue`'ya z-order butonlari eklemek +2. Store'da `bringForward`, `sendBackward`, `bringToFront`, `sendToBack` action'lari +3. Klavye kisayollari (ornegin Ctrl+] / Ctrl+[) + +--- + +### 3.3 Dinamik Image Binding UI `[IMPLEMENTE EDILMEDI]` + +**Referans:** CLAUDE.md — "image: Statik veya dinamik gorsel, Opsiyonel scalar binding" + +**Mevcut Durum:** +`ImageElement` tipinde `binding: Option` tanimli. Backend veri cozumlemesi destekliyor. Ancak `ImageProperties.vue`'da sadece statik dosya yukleme UI'i var — binding secim arayuzu yok. + +**Gerekli Degisiklikler:** +1. `ImageProperties.vue`'ya "Statik / Dinamik" toggle eklemek +2. Dinamik modda schema agacindan alan secimi (format: image) +3. `mock-data-generator.ts`'de image binding'leri icin placeholder gorsel uretmek + +--- + +### 3.4 RulerBar (Cetvel) `[IMPLEMENTE EDILMEDI]` + +**Referans:** CLAUDE.md proje yapisi — `components/editor/RulerBar.vue` + +**Mevcut Durum:** +Component dosyasi olusturulmamis, hicbir yerde import edilmiyor. + +**Gerekli Ozellikler:** +1. Yatay (ust) ve dikey (sol) cetvel +2. mm olcek birimi ile isaretleme +3. Zoom seviyesiyle senkron olcekleme +4. Secili elemanin pozisyonunu cetvel uzerinde isaretleme +5. Sayfa kenarliklari (margin) gostergesi + +--- + +### 3.5 Format Fonksiyonlari (Tablo Sutunlari) `[IMPLEMENTE EDILMEDI]` + +**Referans:** CLAUDE.md roadmap — "Format fonksiyonlari (currency, date)" + +**Mevcut Durum:** +`expr_eval.rs`'de `apply_format()` fonksiyonu var ve `currency`, `percentage`, `number` formatlarini destekliyor. Ancak `TableColumn.format` alani pipeline'da hic kullanilmiyor (2.2 ile ayni sorun). + +**Gerekli Degisiklikler:** +1. `data_resolve.rs`'de tablo hucre verisi cozumlenirken sutun formatini uygulamak +2. `RepeatingTableProperties.vue`'da sutun bazinda format secimi UI'i +3. Schema'daki `format` alanina gore otomatik format onerisi + +--- + +## 4. Mimari Iyilestirmeler + +### 4.1 Worker Message Type Safety `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `frontend/src/composables/useLayoutEngine.ts` (satir 27) + +**Sorun:** +Worker mesajlari `MessageEvent` olarak aliniyor. `msg.type` string kontrolleri ile ayristiriliyor — yeni bir mesaj tipi eklendiyse TypeScript uyarmaz. + +**Cozum:** +```typescript +type WorkerMessage = + | { type: 'compiled'; id: number; result: string; error?: string } + | { type: 'barcode'; id: number; imageData?: ImageData; error?: string } + | { type: 'error'; error: string } + +worker.onmessage = (e: MessageEvent) => { + const msg = e.data + switch (msg.type) { + case 'compiled': ... + case 'barcode': ... + case 'error': ... + } +} +``` + +--- + +### 4.2 Image Re-Encoding Optimizasyonu `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `layout-engine/src/pdf_render.rs` (satir ~712) + +**Sorun:** +`render_image()` tum gorselleri format ne olursa olsun RGBA PNG'ye decode/re-encode ediyor. Neden: "krilla JPEG destegi sinirli" (satir ~666). Ancak PNG input'lari da gereksiz yere decode edilip tekrar encode ediliyor. + +**Etki:** +1MB JPEG → ~4MB RGBA decode → PNG re-encode. Bellek ve CPU israfi. + +**Cozum:** +- PNG input kontrolu (magic bytes `\x89PNG`): decode etmeden dogrudan embed +- JPEG icin: krilla'nin guncel JPEG destegini kontrol et, mumkunse dogrudan embed +- Fallback: sadece tanilmayan formatlar icin decode/re-encode + +--- + +### 4.3 Tablo Genisletme Cache `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `layout-engine/src/table_layout.rs` + +**Sorun:** +`expand_table()` her layout hesaplamasinda tum tablo satirlarini yeni container agacina klonluyor. 1000 satirlik bir tabloda binlerce `StaticTextElement` ve `ContainerElement` struct'i olusturuluyor. + +**Etki:** +Buyuk tablolarda layout hesaplama suresi ve bellek kullanimi artar. Editorde her degisiklikte tum tablo yeniden genisletilir. + +**Cozum:** +- Tablo verisinin hash'i uzerinden cache: veri degismemisse onceki genisletilmis agaci tekrar kullan +- Incremental update: sadece degisen satirlari guncelle (daha karmasik) + +--- + +### 4.4 Font Loader Iyilestirmesi (Backend) `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `backend/src/main.rs` (satirlar 44-53) + +**Sorun:** +Font ailesi tespiti dosya adinda `"Mono"` string'i aranarak yapiliyor. `"Mono"` icermeyen tum fontlar `"Noto Sans"` olarak etiketleniyor. Yeni font aileleri eklendikce bu mantik bozulur. + +**Cozum:** +TTF/OTF `name` tablosunu okuyarak font ailesini (family name) metadata'dan almak. `cosmic-text`'in `fontdb`'si bunu zaten yapiyor — ayni yaklasiM kullanilabilir. + +--- + +### 4.5 Floating-Point Currency Formatlama Hatasi `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `layout-engine/src/expr_eval.rs` (satir ~82) + +**Sorun:** +```rust +((abs - abs.floor()) * 100.0).round() as i64 +``` +`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. + +--- + +## 5. Altyapi ve Developer Experience + +### 5.1 CI/CD Pipeline `[IMPLEMENTE EDILMEDI]` + +**Mevcut Durum:** +Hicbir CI/CD konfigurasyonu yok (`.github/`, `.gitea/`, vb.). + +**Onerilen Pipeline (Gitea Actions):** +```yaml +# .gitea/workflows/ci.yml +jobs: + rust: + steps: + - cargo fmt --check + - cargo clippy -- -D warnings + - cargo test --workspace + frontend: + steps: + - bun install + - bun run type-check + - bun run test + wasm: + steps: + - wasm-pack build (verify WASM compile) +``` + +--- + +### 5.2 justfile Test/Lint/Fmt Recipe'leri `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `justfile` + +**Mevcut Durum:** +Sadece `front`, `back`, `dev`, `wasm`, `wasm-watch`, `publish-*` recipe'leri var. + +**Eklenecek Recipe'ler:** +```just +test: + cargo test --workspace + cd frontend && bun run test + +lint: + cargo clippy --workspace -- -D warnings + cd frontend && bun run lint + +fmt: + cargo fmt --workspace + cd frontend && bun run format + +build: + cd frontend && bun run build + cargo build --release -p dreport-backend + +check: + cargo check --workspace + cd frontend && bun run type-check +``` + +--- + +### 5.3 rust-toolchain.toml `[IMPLEMENTE EDILMEDI]` + +**Sorun:** +Proje Rust edition 2024 kullaniyor (Rust 1.85+) ama toolchain pinlenmemis. Farkli gelistirici ortamlarinda farkli Rust versiyonlari derleme hatalarina yol acabilir. + +**Cozum:** +```toml +# rust-toolchain.toml +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] +targets = ["wasm32-unknown-unknown"] +``` + +--- + +### 5.4 WASM Binary Git'te Tracked `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `frontend/public/wasm/dreport_layout_bg.wasm` + +**Sorun:** +`.gitignore`'da `*.wasm` var ama dosya onceden commit edilmis — ignore kurali gecersiz. ~2MB binary her commit'te diff'te gorunuyor. + +**Cozum:** +```bash +git rm --cached frontend/public/wasm/dreport_layout_bg.wasm +``` +WASM dosyasini build artifact olarak ele almak. CI/CD veya README'de build adimini belgelemek. + +--- + +### 5.5 codemirror-lang-dexpr Dis Bagimlilik `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `frontend/package.json` + +**Sorun:** +```json +"codemirror-lang-dexpr": "file:../../rust-expr/editor" +``` +Repo disinda, ust dizinde `rust-expr` projesinin checkout edilmis olmasini gerektiriyor. Bu bagimlilik belgelenmemis — baska bir gelistirici veya CI `bun install` yapinca sessizce kirilir. + +**Cozum Secenekleri:** +1. `rust-expr` paketini Gitea registry'ye publish edip npm/bun dependency olarak eklemek +2. Git submodule olarak eklemek +3. En azindan README'de belgelemek ve `bun install` basarisiz oldugunda anlasilir hata mesaji vermek + +--- + +### 5.6 ESLint / Prettier Kurulumu `[IMPLEMENTE EDILMEDI]` + +**Mevcut Durum:** +Frontend'de hicbir linter veya formatter konfigurasyonu yok. TypeScript strict mode tip hatalarini yakalasa da, AST-level linting (unused imports, Vue-specific patterns, tutarli stil kurallari) bulunmuyor. + +**Onerilen Yaklasim:** +- `eslint` + `@vue/eslint-config-typescript` + `eslint-plugin-vue` +- `prettier` + `.prettierrc` +- `package.json`'a `lint` ve `format` script'leri + +--- + +### 5.7 Test Helper Duplikasyonu `[IMPLEMENTE EDILMEDI]` + +**Dosyalar:** +- `layout-engine/tests/layout_integration.rs` +- `layout-engine/tests/pdf_render_test.rs` +- `layout-engine/tests/visual_test.rs` + +**Sorun:** +`load_test_fonts()` fonksiyonu uc test dosyasinda birebir ayni sekilde copy-paste edilmis. + +**Cozum:** +`layout-engine/tests/common/mod.rs` olusturup ortak test utility'lerini buraya tasimak: +```rust +// tests/common/mod.rs +pub fn load_test_fonts() -> Vec { ... } +``` + +--- + +### 5.8 Test Artifact Temizligi `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `layout-engine/tests/pdf_render_test.rs` + +**Sorun:** +`test_page_break_produces_multiple_pages` testi workspace root'a `test_page_break.pdf` yaziyor. Her test calistirmada kalir. + +**Cozum:** +`tempfile` crate'i ile gecici dosya olusturmak veya `tests/output/` dizinine yazip `.gitignore`'a eklemek. + +--- + +## 6. Test Coverage Bosluklari + +### 6.1 page_break.rs Test Eksikligi `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `layout-engine/src/page_break.rs` + +**Durum:** Projenin en karmasik mantik parcalarindan biri — sifir dedicated test. Entegrasyon testlerinde dolayli olarak test ediliyor ama edge case'ler (break_inside: avoid, tablo header tekrari, sayfa tasmasi sinirlari) test edilmemis. + +**Gerekli Testler:** +1. Basit sayfa tasmasi — icerik tek sayfaya sigmadigi durum +2. `break_inside: avoid` ile grup tasma ve yeni sayfaya gecis +3. Tablo header tekrari — cok sayfali tablo +4. `page_break` elemani ile zorunlu sayfa gecisi +5. Edge: tam sayfa sinirina denk gelen eleman +6. Edge: sayfaya sigmayan tek eleman (sayfadan buyuk) + +--- + +### 6.2 chart_render.rs Test Eksikligi `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `layout-engine/src/chart_render.rs` + +**Durum:** Sadece visual snapshot testi var. SVG ciktisi icin unit test yok. + +**Gerekli Testler:** +1. Bar chart SVG structure (dogru sayida rect, label) +2. Line chart data point koordinatlari +3. Pie chart dilim acilari (360 derece toplami) +4. Legend render kosullari (tek seri vs coklu seri) +5. Bos veri seti edge case + +--- + +### 6.3 pdf_render.rs Unit Test Eksikligi `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `layout-engine/src/pdf_render.rs` + +**Durum:** Sadece entegrasyon testleri var (PDF magic bytes kontrolu). `render_text`, `render_image`, `render_barcode`, `render_chart` gibi fonksiyonlar icin unit test yok. + +--- + +### 6.4 Frontend Component Testleri `[IMPLEMENTE EDILMEDI]` + +**Durum:** `vitest` ve `playwright` devDependency olarak yuklu ama test dosyasi yok (schema-parser testi haric). + +**Oncelikli Test Hedefleri:** +1. `useUndoRedo` — snapshot push, undo, redo, stack limitleri +2. `useSnapGuides` — snap hesaplama, threshold davranisi +3. Template store — CRUD islemleri, tree traversal +4. `InteractionOverlay` — drag/resize event handling (component test) + +--- + +## 7. Yeni Ozellik Onerileri + +### 7.1 Conditional Rendering `[IMPLEMENTE EDILMEDI]` + +**Aciklama:** +Template'te `v-if` benzeri kosullu gosterim. Data'daki bir alana gore eleman goster/gizle. + +**Ornek:** +```json +{ + "id": "el_iskonto", + "type": "text", + "condition": { + "path": "toplamlar.iskonto", + "operator": "gt", + "value": 0 + }, + "binding": { "type": "scalar", "path": "toplamlar.iskonto" } +} +``` + +**Etki:** Kullanici tek bir template ile farkli veri durumlarini karsilayabilir (ornegin iskonto varsa goster, yoksa gizle). + +--- + +### 7.2 Template Versiyonlama `[IMPLEMENTE EDILMEDI]` + +**Aciklama:** +Template JSON uzerinde degisiklik gecmisi. Her kayit/export'ta versiyon numarasi arttirilir, onceki versiyonlara donulebilir. + +**Yaklasim:** +- Template JSON'a `version: number` ve `history: ChangeEntry[]` alani +- JSON diff-bazli degisiklik kaydi (tam snapshot degil, sadece delta) +- UI'da versiyon gecmisi paneli + +--- + +### 7.3 Tekrarlayan Bolge (Repeating Region) `[IMPLEMENTE EDILMEDI]` + +**Referans:** CLAUDE.md kisitlamalar — "Serbest form repeating region ilerideki fazlarda degerlendirilir" + +**Aciklama:** +Tablo disinda array verisiyle tekrarlayan serbest-form container. Ornegin bir kart tasarimi array'deki her kayit icin tekrarlanir. + +**Karmasiklik:** Yuksek — layout engine'de container agacinin dinamik genisletilmesi, sayfa tasma mantigi, editor'da tekrar onizlemesi. + +--- + +### 7.4 PNG/SVG Export `[IMPLEMENTE EDILMEDI]` + +**Referans:** CLAUDE.md — "Sadece PDF cikti. Ileride PNG/SVG eklenebilir." + +**Yaklasim:** +- SVG: LayoutResult → SVG element'leri (chart_render.rs'deki pattern'e benzer) +- PNG: SVG render + rasterize (resvg crate) veya dogrudan image crate ile pixel render +- Backend'e `/api/render?format=png|svg|pdf` parametresi + +--- + +### 7.5 Coklu Dil / Lokalizasyon Destegi `[IMPLEMENTE EDILMEDI]` + +**Aciklama:** +Currency, date ve sayi formatlama icin lokalizasyon. Su an Turk lokali hardcoded. + +**Yaklasim:** +- Template JSON'a `locale: "tr-TR"` alani +- `expr_eval.rs`'de locale-aware formatlama +- UI etiketleri icin i18n framework (vue-i18n) + +--- + +### 7.6 Sayfa Basligi/Altligi Kosullari `[IMPLEMENTE EDILMEDI]` + +**Aciklama:** +Farkli sayfalar icin farkli header/footer: +- Ilk sayfa farkli (ornegin firma logosu sadece ilk sayfada) +- Son sayfa farkli (ornegin toplam ve imza sadece son sayfada) +- Cift/tek sayfa farkli (kitap/katalog baski icin) + +**Yaklasim:** +Template'te header/footer tanimi icin `condition` alani: +```json +"header": { + "condition": "first_page", + "children": [...] +} +``` + +--- + +### 7.7 QR Code Eleman Tipi `[IMPLEMENTE EDILMEDI]` + +**Mevcut Durum:** +`rxing` crate'i barcode uretimi icin zaten kullaniliyor ve QR Code destegi var. Ancak UI tarafinda ayri bir QR Code eleman tipi tanimlanmamis. + +**Gerekli Degisiklikler:** +1. `core/models.rs`'e `QrCodeElement` tipi +2. Barcode element'ten farkli olarak kare aspect ratio zorunlulugu +3. Editor'da QR Code onizlemesi +4. Properties panelinde QR icerik ve boyut ayarlari + +--- + +### 7.8 Template Marketplace / Galeri `[IMPLEMENTE EDILMEDI]` + +**Aciklama:** +Hazir sablon galerisi — kullanici sifirdan tasarlamak yerine bir sablon secip uzerine duzenleyebilir. + +**Yaklasim:** +- `shared/templates/` dizininde kategorize edilmis JSON sablonlar +- UI'da "Sablonlardan Baslat" modali +- Kategoriler: Fatura, Irsaliye, Rapor, Sertifika, Makbuz +- Her sablon icin thumbnail onizleme + +--- + +## 8. Kucuk Ama Degerli Iyilestirmeler + +### 8.1 Chart Legend Tek Seri Durumu `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `layout-engine/src/chart_render.rs` + +**Sorun:** Legend yalnizca `series.len() > 1` oldugunda render ediliyor. Tek serili bar chart'ta `legend: { show: true }` sessizce yok sayiliyor. + +--- + +### 8.2 Pie Chart Label Kontrolu `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `layout-engine/src/chart_render.rs` (satirlar 521-551) + +**Sorun:** Pie chart'ta kategori isimleri ve leader line'lar her zaman render ediliyor. `labels.show` flag'i sadece dilim icindeki yuzde etiketini kontrol ediyor. + +--- + +### 8.3 Data Path'te Nokta Kisitlamasi `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `layout-engine/src/data_resolve.rs` (satir ~117) + +**Sorun:** `resolve_path()` `.` karakteri ile split yapiyor. Alan isimleri nokta iceriyorsa (`firma.adres.il` vs `firma."adres.il"`) dogru cozumlenmiyor. Bu kisitlama belgelenmemis. + +**Cozum:** Bracket notation destegi (`firma["adres.il"]`) veya en azindan dokumantasyon. + +--- + +### 8.4 DreportEditor Prop-Store Sync Fragility `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `frontend/src/lib/DreportEditor.vue` + +**Sorun:** `let syncing = false` boolean'i ile prop↔store dongusu engelleniyor. `nextTick` arasinda gelen store mutation'lari (klavye kisayolu vb.) sessizce yutulabilir. + +**Cozum:** `syncing` flag'ini reactive yapmak ve watcher'da condition check yerine `watchEffect` kullanmak, veya store event bazli uni-directional data flow'a gecmek. + +--- + +### 8.5 CORS Konfigurasyonu `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `backend/src/main.rs` + +**Sorun:** `CorsLayer` tamamen acik (`allow_origin(Any)`, `allow_methods(Any)`, `allow_headers(Any)`). Yerel gelistirme icin sorun degil ama production icin kisitlanmali. + +**Cozum:** Environment variable ile origin kisitlamasi: `CORS_ORIGIN=http://localhost:5173` (dev), `CORS_ORIGIN=https://app.dreport.com` (prod). + +--- + +### 8.6 Request Size Limit `[IMPLEMENTE EDILMEDI]` + +**Dosya:** `backend/src/main.rs` + +**Sorun:** HTTP body boyut limiti yok. Buyuk JSON payload'lari tamamen belleqe alinir. + +**Cozum:** Axum'un `DefaultBodyLimit` middleware'i ile makul bir limit (ornegin 10MB) koymak. diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 92b164f..36ee9b2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -2,6 +2,7 @@ name = "dreport-backend" version = "0.1.0" edition = "2024" +publish = false [dependencies] dreport-core = { path = "../core" } diff --git a/core/Cargo.toml b/core/Cargo.toml index 0652e9d..14cb009 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -2,6 +2,9 @@ name = "dreport-core" version = "0.1.0" edition = "2024" +description = "Core models and types for dreport document design tool" +license = "MIT" +publish = ["gitea"] [lib] crate-type = ["rlib"] diff --git a/frontend/bun.lock b/frontend/bun.lock index b7a70a1..4c29d2c 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -19,10 +19,13 @@ "devDependencies": { "@playwright/test": "^1.58.2", "@types/node": "^25.5.0", + "@types/pngjs": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5", "@vue/test-utils": "^2.4.6", "@vue/tsconfig": "^0.9.0", "happy-dom": "^20.8.9", + "pixelmatch": "^7.1.0", + "pngjs": "^7.0.0", "typescript": "~6.0.2", "vite": "^8.0.1", "vitest": "^4.1.2", @@ -235,6 +238,8 @@ "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "@types/pngjs": ["@types/pngjs@6.0.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -471,12 +476,16 @@ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + "pixelmatch": ["pixelmatch@7.1.0", "", { "dependencies": { "pngjs": "^7.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng=="], + "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], diff --git a/frontend/package.json b/frontend/package.json index 006eff4..02e9fd0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "test": "vitest", "test:run": "vitest run", - "test:visual": "playwright test" + "test:visual": "playwright test", + "test:visual:cross": "playwright test --project=cross-renderer" }, "dependencies": { "@codemirror/autocomplete": "^6.20.1", @@ -26,10 +27,13 @@ "devDependencies": { "@playwright/test": "^1.58.2", "@types/node": "^25.5.0", + "@types/pngjs": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5", "@vue/test-utils": "^2.4.6", "@vue/tsconfig": "^0.9.0", "happy-dom": "^20.8.9", + "pixelmatch": "^7.1.0", + "pngjs": "^7.0.0", "typescript": "~6.0.2", "vite": "^8.0.1", "vitest": "^4.1.2", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 802856c..24de1ee 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -6,6 +6,8 @@ export default defineConfig({ use: { baseURL: 'http://localhost:5173', viewport: { width: 1400, height: 900 }, + // Disable HiDPI scaling for pixel-exact comparison + deviceScaleFactor: 1, }, webServer: { command: 'bun run dev', @@ -18,4 +20,19 @@ export default defineConfig({ maxDiffPixelRatio: 0.01, }, }, + projects: [ + { + name: 'editor', + testMatch: 'editor.spec.ts', + }, + { + name: 'cross-renderer', + testMatch: 'cross-renderer.spec.ts', + use: { + // Render test page needs larger viewport for A4 at 150 DPI + // A4 = 210mm x 297mm → 1240 x 1754 px at 150 DPI + viewport: { width: 1300, height: 1800 }, + }, + }, + ], }) diff --git a/frontend/render-test.html b/frontend/render-test.html new file mode 100644 index 0000000..3dba9c9 --- /dev/null +++ b/frontend/render-test.html @@ -0,0 +1,48 @@ + + + + + + dreport - Render Test + + + +
+ + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 588b722..d53fe92 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -604,11 +604,7 @@ async function downloadPdf() { const blob = await editorRef.value?.exportPdf() if (!blob) return const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `${template.value.name || 'belge'}.pdf` - a.click() - URL.revokeObjectURL(url) + window.open(url, '_blank') } catch (err) { alert(err instanceof Error ? err.message : 'PDF olusturulamadi') } finally { @@ -668,7 +664,7 @@ function resetTemplate() { + layoutMap: Record + pageHeightPx?: number }>() const templateStore = useTemplateStore() @@ -36,6 +39,15 @@ const isTable = computed(() => selected.value?.type === 'repeating_table') const tableEl = computed(() => isTable.value ? selected.value as RepeatingTableElement : null) const tableStyle = computed(() => tableEl.value?.style as TableStyle | undefined) +const isChart = computed(() => selected.value?.type === 'chart') +const chartEl = computed(() => isChart.value ? selected.value as ChartElement : null) + +function pageYOffset(pageIndex: number): number { + if (pageIndex <= 0) return 0 + const pageH = props.pageHeightPx ?? (templateStore.template.page.height * props.scale) + return pageIndex * (pageH + PAGE_GAP_PX) +} + const toolbarStyle = computed(() => { const el = selected.value if (!el) return { display: 'none' } @@ -43,10 +55,11 @@ const toolbarStyle = computed(() => { if (!l) return { display: 'none' } const s = props.scale + const pYOff = pageYOffset(l.pageIndex) return { position: 'absolute' as const, left: `${l.x_mm * s}px`, - top: `${l.y_mm * s - 30}px`, + top: `${l.y_mm * s - 30 + pYOff}px`, zIndex: 1100, } }) @@ -76,6 +89,13 @@ function updateTableStyle(key: string, value: unknown) { if (!selected.value) return update({ style: { ...selected.value.style, [key]: value } }) } + +// Chart +function setChartType(t: ChartType) { update({ chartType: t }) } +function updateChartStyle(key: string, value: unknown) { + if (!selected.value) return + update({ style: { ...selected.value.style, [key]: value } }) +} + + + diff --git a/frontend/src/render-test/RenderTestPage.vue b/frontend/src/render-test/RenderTestPage.vue new file mode 100644 index 0000000..39afff5 --- /dev/null +++ b/frontend/src/render-test/RenderTestPage.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/frontend/src/render-test/main.ts b/frontend/src/render-test/main.ts new file mode 100644 index 0000000..1a71198 --- /dev/null +++ b/frontend/src/render-test/main.ts @@ -0,0 +1,4 @@ +import { createApp } from 'vue' +import RenderTestPage from './RenderTestPage.vue' + +createApp(RenderTestPage).mount('#app') diff --git a/frontend/tests/visual/cross-renderer.spec.ts b/frontend/tests/visual/cross-renderer.spec.ts new file mode 100644 index 0000000..4ecd35b --- /dev/null +++ b/frontend/tests/visual/cross-renderer.spec.ts @@ -0,0 +1,166 @@ +/** + * Cross-renderer visual test: compares HTML render (browser) vs PDF render (Rust). + * + * Both renderers consume the same LayoutResult from the same layout engine. + * This test verifies that the visual output is consistent between: + * - LayoutRenderer.vue (HTML divs in browser) + * - pdf_render.rs → pdftoppm (PDF rasterized to PNG) + * + * Prerequisites: + * cargo test -p dreport-layout --test visual_test -- generate_cross_renderer --ignored + * (or: just visual-refs) + */ + +import { test, expect } from '@playwright/test' +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import { PNG } from 'pngjs' +import pixelmatch from 'pixelmatch' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURES_DIR = path.resolve(__dirname, '../../../layout-engine/tests/fixtures') +const REFS_DIR = path.resolve(__dirname, 'cross-renderer-refs') +const DIFFS_DIR = path.resolve(__dirname, 'cross-renderer-diffs') + +interface TestFixture { + name: string + templateFile: string + dataFile: string + /** Max allowed pixel diff ratio (0.0 – 1.0) */ + maxDiffRatio: number +} + +const FIXTURES: TestFixture[] = [ + { + name: 'visual_test', + templateFile: 'visual_test_template.json', + dataFile: 'visual_test_data.json', + // Text rendering differences between browser and PDF are expected. + // Font hinting, anti-aliasing, and line-height handling differ. + maxDiffRatio: 0.05, + }, + { + name: 'chart_test', + templateFile: 'chart_test_template.json', + dataFile: 'chart_test_data.json', + // Charts have more visual complexity (SVG vs PDF primitives) + maxDiffRatio: 0.08, + }, + { + name: 'comprehensive_test', + templateFile: 'comprehensive_test_template.json', + dataFile: 'comprehensive_test_data.json', + // All element types: text, rich_text, table, charts, barcodes, shapes, + // checkboxes, calculated_text, current_date, page_number, lines. + // Barcodes render differently (canvas vs PDF) so higher tolerance. + maxDiffRatio: 0.08, + }, +] + +test.describe('Cross-renderer: HTML vs PDF', () => { + test.beforeAll(() => { + fs.mkdirSync(DIFFS_DIR, { recursive: true }) + }) + + for (const fixture of FIXTURES) { + test(`${fixture.name}: HTML render matches PDF render`, async ({ page }) => { + const refPath = path.join(REFS_DIR, `${fixture.name}.png`) + if (!fs.existsSync(refPath)) { + test.skip(true, `PDF reference not found: ${refPath}. Run: just visual-refs`) + return + } + + // Read fixture files + const templateJson = fs.readFileSync(path.join(FIXTURES_DIR, fixture.templateFile), 'utf-8') + const dataJson = fs.readFileSync(path.join(FIXTURES_DIR, fixture.dataFile), 'utf-8') + + // Inject fixture data before page loads + await page.addInitScript((fixtureData: { template: string; data: string }) => { + ;(window as any).__DREPORT_FIXTURE__ = fixtureData + }, { template: templateJson, data: dataJson }) + + // Navigate to render test page + await page.goto('/render-test.html') + + // Wait for layout to compute and render + await page.waitForSelector('[data-render-ready]', { timeout: 20000 }) + + // Small delay for font rendering and any async canvas operations + await page.waitForTimeout(500) + + // Screenshot the rendered page (first page only) + const pageEl = page.locator('.layout-page').first() + const htmlScreenshot = await pageEl.screenshot({ type: 'png' }) + + // Load PDF reference PNG + const refBuffer = fs.readFileSync(refPath) + const refPng = PNG.sync.read(refBuffer) + const htmlPng = PNG.sync.read(htmlScreenshot) + + // Handle dimension mismatch by resizing to the smaller of the two + const width = Math.min(htmlPng.width, refPng.width) + const height = Math.min(htmlPng.height, refPng.height) + + // Crop both images to common size + const croppedHtml = cropPng(htmlPng, width, height) + const croppedRef = cropPng(refPng, width, height) + + // Pixel comparison + const diffPng = new PNG({ width, height }) + const numDiffPixels = pixelmatch( + croppedHtml.data, + croppedRef.data, + diffPng.data, + width, + height, + { + threshold: 0.15, // Per-pixel color distance threshold + alpha: 0.3, + includeAA: false, // Ignore anti-aliasing differences + }, + ) + + const totalPixels = width * height + const diffRatio = numDiffPixels / totalPixels + + // Save diff image for debugging + const diffPath = path.join(DIFFS_DIR, `${fixture.name}_diff.png`) + const htmlPath = path.join(DIFFS_DIR, `${fixture.name}_html.png`) + fs.writeFileSync(diffPath, PNG.sync.write(diffPng)) + fs.writeFileSync(htmlPath, htmlScreenshot) + + // Dimension info + const dimInfo = htmlPng.width !== refPng.width || htmlPng.height !== refPng.height + ? ` (HTML: ${htmlPng.width}x${htmlPng.height}, PDF: ${refPng.width}x${refPng.height}, compared: ${width}x${height})` + : ` (${width}x${height})` + + console.log( + `[${fixture.name}] diff: ${(diffRatio * 100).toFixed(2)}% pixels${dimInfo}`, + ) + + expect( + diffRatio, + `Visual diff too large for ${fixture.name}: ${(diffRatio * 100).toFixed(2)}% pixels differ (max: ${(fixture.maxDiffRatio * 100).toFixed(0)}%). Check diff at: ${diffPath}`, + ).toBeLessThanOrEqual(fixture.maxDiffRatio) + }) + } +}) + +/** Crop a PNG to the given width and height (top-left origin) */ +function cropPng(src: PNG, width: number, height: number): PNG { + if (src.width === width && src.height === height) return src + + const cropped = new PNG({ width, height }) + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const srcIdx = (y * src.width + x) * 4 + const dstIdx = (y * width + x) * 4 + cropped.data[dstIdx] = src.data[srcIdx] + cropped.data[dstIdx + 1] = src.data[srcIdx + 1] + cropped.data[dstIdx + 2] = src.data[srcIdx + 2] + cropped.data[dstIdx + 3] = src.data[srcIdx + 3] + } + } + return cropped +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 0368de6..b0632ce 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,8 +1,17 @@ import { defineConfig } from 'vite' +import { resolve } from 'path' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], + build: { + rollupOptions: { + input: { + main: resolve(__dirname, 'index.html'), + 'render-test': resolve(__dirname, 'render-test.html'), + }, + }, + }, resolve: { dedupe: [ '@codemirror/state', diff --git a/justfile b/justfile index beb086b..25eb2f0 100644 --- a/justfile +++ b/justfile @@ -24,3 +24,28 @@ wasm: # Layout engine WASM watch (rebuild on change) wasm-watch: watchexec -w layout-engine/src -w core/src -e rs -- just wasm + +# 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 + cd frontend && bun run test:visual -- --project=cross-renderer + +# Run all visual tests (editor + cross-renderer) +visual-test-all: visual-refs + cd frontend && bun run test:visual + +# Publish dreport-core to Gitea +publish-core: + cargo publish -p dreport-core --registry gitea --allow-dirty + +# Publish dreport-layout to Gitea (depends on core) +publish-layout: + cargo publish -p dreport-layout --registry gitea --allow-dirty + +# Publish all crates to Gitea (in order) +publish-all: + just publish-core + just publish-layout diff --git a/layout-engine/Cargo.toml b/layout-engine/Cargo.toml index 433a325..0ac5436 100644 --- a/layout-engine/Cargo.toml +++ b/layout-engine/Cargo.toml @@ -2,13 +2,16 @@ name = "dreport-layout" version = "0.1.0" edition = "2024" +description = "Layout engine for dreport (taffy + cosmic-text)" +license = "MIT" +publish = ["gitea"] [lib] crate-type = ["cdylib", "rlib"] [dependencies] -dreport-core = { path = "../core" } +dreport-core = { version = "0.1.0", path = "../core", registry = "gitea" } dexpr = { version = "0.1.0", registry = "gitea" } taffy = "0.9" cosmic-text = { version = "0.18", default-features = false, features = ["std", "swash"] } diff --git a/layout-engine/src/chart_render.rs b/layout-engine/src/chart_render.rs index 6d63061..afff301 100644 --- a/layout-engine/src/chart_render.rs +++ b/layout-engine/src/chart_render.rs @@ -442,12 +442,12 @@ fn render_pie( let cx = px + pw / 2.0; let cy = py + ph / 2.0; - let radius = pw.min(ph) / 2.0 * 0.9; + 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 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.5); + let label_font = data.labels.as_ref().and_then(|l| l.font_size).unwrap_or(3.0); let label_color = data .labels .as_ref() @@ -499,7 +499,7 @@ fn render_pie( .unwrap(); } - // Label + // Percentage label inside slice if show_labels { let mid_angle = start_angle + sweep / 2.0; let label_r = if inner_r > 0.0 { @@ -518,6 +518,37 @@ fn render_pie( .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(); + write!( + svg, + r##""##, + lx1, ly1, lx2, ly2 + ) + .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" }; + write!( + svg, + r##"{}"##, + tx, ty, anchor, escape_xml(&data.categories[i]) + ) + .unwrap(); + } + start_angle = end_angle; } } diff --git a/layout-engine/src/lib.rs b/layout-engine/src/lib.rs index 4303956..5f99da6 100644 --- a/layout-engine/src/lib.rs +++ b/layout-engine/src/lib.rs @@ -118,6 +118,24 @@ pub struct ChartRenderData { pub line_width: Option, #[serde(default)] pub background_color: Option, + // Label color + #[serde(default)] + pub label_color: Option, + // Legend + #[serde(default)] + pub legend_show: bool, + #[serde(default)] + pub legend_position: Option, + #[serde(default)] + pub legend_font_size: Option, + // Axis labels + #[serde(default)] + pub x_label: Option, + #[serde(default)] + pub y_label: Option, + // Title align + #[serde(default)] + pub title_align: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/layout-engine/src/pdf_render.rs b/layout-engine/src/pdf_render.rs index 399f987..cfe7faa 100644 --- a/layout-engine/src/pdf_render.rs +++ b/layout-engine/src/pdf_render.rs @@ -759,7 +759,6 @@ fn render_chart( measurer: &mut TextMeasurer, ) { // Tum hesaplar mm cinsinden yapilir, cizim pt'ye cevrilir - // base_x_mm, base_y_mm: element'in sayfa uzerindeki mm pozisyonu let base_x_mm: f64 = (x / MM_TO_PT) as f64; let base_y_mm: f64 = (y / MM_TO_PT) as f64; let w_mm: f64 = (w / MM_TO_PT) as f64; @@ -769,26 +768,31 @@ fn render_chart( chart_rect(surface, base_x_mm, base_y_mm, w_mm, h_mm, parse_color(data.background_color.as_deref().unwrap_or("#FFFFFF"))); - // Margin'ler (SVG renderer ile ayni mantik) - let mut mt = 2.0_f64; - let mut mb = 4.0_f64; - let ml = 14.0_f64; - let mr = 4.0_f64; + // Margin hesaplari — SVG renderer ile AYNI mantik + 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 if let Some(ref title) = data.title_text { if !title.is_empty() { let fs = data.title_font_size.unwrap_or(4.0); - mt += fs * 0.4 + 2.0; + margin_top += fs * 0.4 + 2.0; let color = parse_color(data.title_color.as_deref().unwrap_or("#333333")); let font = fonts.get(None, Some("bold")); if let Some(f) = font { surface.set_fill(Some(fill_from_color(color))); surface.set_stroke(None); - let fs_pt = fs as f32; + let fs_pt = pt(fs); let (tw, _) = measurer.measure(title, None, fs_pt, Some("bold"), None); - let tx = pt(base_x_mm + w_mm / 2.0) - tw / 2.0; - let ty = pt(base_y_mm + mt - 1.0); + let align = data.title_align.as_deref().unwrap_or("center"); + let tx = match align { + "left" => pt(base_x_mm + margin_left), + "right" => pt(base_x_mm + w_mm - margin_right) - tw, + _ => pt(base_x_mm + w_mm / 2.0) - tw / 2.0, + }; + let ty = pt(base_y_mm + margin_top - 1.0); surface.draw_text( Point::from_xy(tx, ty), f.clone(), fs_pt, title, false, TextDirection::Auto, @@ -797,24 +801,85 @@ fn render_chart( } } - let is_pie = matches!(data.chart_type, dreport_core::models::ChartType::Pie); + // Legend space + let legend_show = data.legend_show; + let legend_pos = data.legend_position.as_deref().unwrap_or("bottom"); + let legend_font = data.legend_font_size.unwrap_or(2.8); - if !is_pie { - let max_label_len = data.categories.iter().map(|c| c.len()).max().unwrap_or(0); - if max_label_len > 6 { mb += 10.0; } else { mb += 4.0; } - mb += 4.0; + 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 + } } - let plot_x = base_x_mm + ml; - let plot_y = base_y_mm + mt; - let plot_w = (w_mm - ml - mr).max(1.0); - let plot_h = (h_mm - mt - mb).max(1.0); + let is_pie = matches!(data.chart_type, dreport_core::models::ChartType::Pie); + + // Axis labels icin yer ac (bar ve line) — SVG ile ayni + if !is_pie { + if data.x_label.is_some() { + margin_bottom += 4.0; + } + if data.y_label.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 = w_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 icin sol bosluk + margin_left += 6.0; + } + + let plot_x = base_x_mm + margin_left; + let plot_y = base_y_mm + margin_top; + let plot_w = (w_mm - margin_left - margin_right).max(1.0); + let plot_h = (h_mm - margin_top - margin_bottom).max(1.0); use dreport_core::models::ChartType; match data.chart_type { - ChartType::Bar => render_chart_bar(surface, data, plot_x, plot_y, plot_w, plot_h), - ChartType::Line => render_chart_line(surface, data, plot_x, plot_y, plot_w, plot_h), - ChartType::Pie => render_chart_pie(surface, data, plot_x, plot_y, plot_w, plot_h), + ChartType::Bar => render_chart_bar(surface, data, plot_x, plot_y, plot_w, plot_h, fonts, measurer), + ChartType::Line => render_chart_line(surface, data, plot_x, plot_y, plot_w, plot_h, fonts, measurer), + ChartType::Pie => render_chart_pie(surface, data, plot_x, plot_y, plot_w, plot_h, fonts, measurer), + } + + // Legend render + if legend_show && data.series.len() > 1 { + render_chart_legend(surface, data, legend_pos, legend_font, base_x_mm, base_y_mm, w_mm, h_mm, margin_left, margin_top, plot_w, plot_h, fonts, measurer); + } + + // Axis labels + if !is_pie { + if let Some(ref x_label) = data.x_label { + let lx = plot_x + plot_w / 2.0; + let ly = base_y_mm + h_mm - 2.0; + chart_text_centered(surface, lx, ly, x_label, 2.8, "#666666", fonts, measurer); + } + if let Some(ref y_label) = data.y_label { + let lx = base_x_mm + 3.0; + let ly = plot_y + plot_h / 2.0; + // Rotated text — krilla'da transform ile + surface.push_transform(&Transform::from_translate(pt(lx), pt(ly))); + surface.push_transform(&Transform::from_row(0.0, -1.0, 1.0, 0.0, 0.0, 0.0)); + chart_text_centered(surface, 0.0, 0.0, y_label, 2.8, "#666666", fonts, measurer); + surface.pop(); + surface.pop(); + } } } @@ -855,37 +920,258 @@ fn chart_line_seg(surface: &mut krilla::surface::Surface<'_>, x1: f64, y1: f64, } } +/// Chart icin metin ciz — tek satirlik, centered +/// font_size_mm: SVG viewBox'taki mm cinsinden boyut, pt'ye cevrilir +fn chart_text_centered( + surface: &mut krilla::surface::Surface<'_>, + cx_mm: f64, cy_mm: f64, + text: &str, font_size_mm: f64, color_hex: &str, + fonts: &FontCollection, measurer: &mut TextMeasurer, +) { + let font = fonts.get(None, None); + let Some(f) = font else { return; }; + let color = parse_color(color_hex); + let fs_pt = pt(font_size_mm); + let (tw, _) = measurer.measure(text, None, fs_pt, None, None); + surface.set_fill(Some(fill_from_color(color))); + surface.set_stroke(None); + surface.draw_text( + Point::from_xy(pt(cx_mm) - tw / 2.0, pt(cy_mm)), + f.clone(), fs_pt, text, false, TextDirection::Auto, + ); +} + +/// Chart icin metin ciz — end-aligned (sag hizali) +fn chart_text_end( + surface: &mut krilla::surface::Surface<'_>, + right_x_mm: f64, cy_mm: f64, + text: &str, font_size_mm: f64, color_hex: &str, + fonts: &FontCollection, measurer: &mut TextMeasurer, +) { + let font = fonts.get(None, None); + let Some(f) = font else { return; }; + let color = parse_color(color_hex); + let fs_pt = pt(font_size_mm); + let (tw, _) = measurer.measure(text, None, fs_pt, None, None); + surface.set_fill(Some(fill_from_color(color))); + surface.set_stroke(None); + surface.draw_text( + Point::from_xy(pt(right_x_mm) - tw, pt(cy_mm)), + f.clone(), fs_pt, text, false, TextDirection::Auto, + ); +} + +/// Chart icin metin ciz — start-aligned (sol hizali) +fn chart_text_start( + surface: &mut krilla::surface::Surface<'_>, + x_mm: f64, cy_mm: f64, + text: &str, font_size_mm: f64, color_hex: &str, + fonts: &FontCollection, _measurer: &mut TextMeasurer, +) { + let font = fonts.get(None, None); + let Some(f) = font else { return; }; + let color = parse_color(color_hex); + let fs_pt = pt(font_size_mm); + surface.set_fill(Some(fill_from_color(color))); + surface.set_stroke(None); + surface.draw_text( + Point::from_xy(pt(x_mm), pt(cy_mm)), + f.clone(), fs_pt, text, false, TextDirection::Auto, + ); +} + +fn chart_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) + } +} + +/// Y-axis grid + value labels (SVG render_y_axis ile ayni) +fn render_chart_y_axis( + surface: &mut krilla::surface::Surface<'_>, + min_val: f64, max_val: f64, + px: f64, py: f64, pw: f64, ph: f64, + show_grid: bool, grid_color: &str, + fonts: &FontCollection, measurer: &mut TextMeasurer, +) { + 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; + + // Value label + let label = chart_format_value(val); + chart_text_end(surface, px - 1.5, y + 0.8, &label, 2.3, "#666666", fonts, measurer); + + // Grid line + if show_grid { + let gc = parse_color(grid_color); + chart_line_seg(surface, px, y, px + pw, y, gc, 0.4); + } + } + + // Y axis line + let ac = parse_color("#9CA3AF"); + chart_line_seg(surface, px, py, px, py + ph, ac, 0.8); +} + +/// X-axis category labels — bar chart (slot-based spacing) +fn render_chart_x_labels( + surface: &mut krilla::surface::Surface<'_>, + categories: &[String], + px: f64, baseline_y: f64, pw: f64, + fonts: &FontCollection, measurer: &mut TextMeasurer, +) { + let n_cats = categories.len(); + 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_chart_single_x_label(surface, cat, x, y, needs_rotate, fonts, measurer); + } +} + +/// X-axis category labels — line chart (point-based spacing) +fn render_chart_x_labels_line( + surface: &mut krilla::surface::Surface<'_>, + categories: &[String], + px: f64, baseline_y: f64, pw: f64, + fonts: &FontCollection, measurer: &mut TextMeasurer, +) { + let n_cats = categories.len(); + 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_chart_single_x_label(surface, cat, x, y, needs_rotate, fonts, measurer); + } +} + +/// Tek bir X-axis label — rotate gerekiyorsa -45° ile +fn render_chart_single_x_label( + surface: &mut krilla::surface::Surface<'_>, + text: &str, x_mm: f64, y_mm: f64, rotate: bool, + fonts: &FontCollection, measurer: &mut TextMeasurer, +) { + if rotate { + // -45° rotate, text-anchor="end" + surface.push_transform(&Transform::from_translate(pt(x_mm), pt(y_mm))); + // rotate(-45°) = cos(-45), sin(-45), -sin(-45), cos(-45) + let c = std::f32::consts::FRAC_PI_4.cos(); + let s = std::f32::consts::FRAC_PI_4.sin(); + surface.push_transform(&Transform::from_row(c, -s, s, c, 0.0, 0.0)); + // end-aligned: text saga hizali (negatif x'e dogru) + chart_text_end(surface, 0.0, 0.0, text, 2.2, "#666666", fonts, measurer); + surface.pop(); + surface.pop(); + } else { + chart_text_centered(surface, x_mm, y_mm, text, 2.5, "#666666", fonts, measurer); + } +} + +/// Legend render +fn render_chart_legend( + surface: &mut krilla::surface::Surface<'_>, + data: &crate::ChartRenderData, + position: &str, font_size: f64, + base_x: f64, base_y: f64, + total_w: f64, total_h: f64, + margin_left: f64, margin_top: f64, + plot_w: f64, _plot_h: f64, + fonts: &FontCollection, measurer: &mut TextMeasurer, +) { + use dreport_core::models::ChartType; + 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() + }; + + let swatch_size = 2.5; + let item_gap = 3.0 + font_size * 0.4; + let spacing = 4.0; + + match position { + "top" => { + let y = base_y + margin_top - font_size - 1.5; + let mut x = base_x + margin_left; + for (i, name) in names.iter().enumerate() { + let color = parse_color(data.colors.get(i).map(|s| s.as_str()).unwrap_or("#4F46E5")); + chart_rect(surface, x, y - font_size * 0.3, swatch_size, swatch_size, color); + chart_text_start(surface, x + item_gap, y + font_size * 0.3, name, font_size, "#666666", fonts, measurer); + x += item_gap + name.len() as f64 * font_size * 0.5 + spacing; + } + } + "right" => { + let x = base_x + margin_left + plot_w + 4.0; + let mut y = base_y + margin_top + 2.0; + for (i, name) in names.iter().enumerate() { + let color = parse_color(data.colors.get(i).map(|s| s.as_str()).unwrap_or("#4F46E5")); + chart_rect(surface, x, y, swatch_size, swatch_size, color); + chart_text_start(surface, x + item_gap, y + font_size * 0.7, name, font_size, "#666666", fonts, measurer); + y += font_size + 2.0; + } + } + _ => { + // bottom (default) + let y = base_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::() - spacing; + let mut x = base_x + (total_w - total_legend_w) / 2.0; + for (i, name) in names.iter().enumerate() { + let color = parse_color(data.colors.get(i).map(|s| s.as_str()).unwrap_or("#4F46E5")); + chart_rect(surface, x, y - font_size * 0.3, swatch_size, swatch_size, color); + chart_text_start(surface, x + item_gap, y + font_size * 0.3, name, font_size, "#666666", fonts, measurer); + x += item_gap + name.len() as f64 * font_size * 0.5 + spacing; + } + } + } +} + /// Bar chart — tum koordinatlar mm cinsinden (mutlak sayfa pozisyonu) fn render_chart_bar( surface: &mut krilla::surface::Surface<'_>, data: &crate::ChartRenderData, px: f64, py: f64, pw: f64, ph: f64, + fonts: &FontCollection, measurer: &mut TextMeasurer, ) { if data.categories.is_empty() || data.series.is_empty() { return; } let (min_val, max_val) = chart_value_range(data); let range = if (max_val - min_val).abs() < 1e-10 { 1.0 } else { max_val - min_val }; + let show_grid = data.show_grid; + let grid_color = data.grid_color.as_deref().unwrap_or("#E5E7EB"); + + // Grid + Y axis labels + render_chart_y_axis(surface, min_val, max_val, px, py, pw, ph, show_grid, grid_color, fonts, measurer); + let n_cats = data.categories.len(); let n_series = data.series.len(); let cat_width = pw / n_cats as f64; let bar_gap = data.bar_gap.unwrap_or(0.2).clamp(0.0, 0.8); let group_width = cat_width * (1.0 - bar_gap); - // Grid - if data.show_grid { - let gc = parse_color(data.grid_color.as_deref().unwrap_or("#E5E7EB")); - for i in 0..=5 { - let frac = i as f64 / 5.0; - let gy = py + ph - frac * ph; - chart_line_seg(surface, px, gy, px + pw, gy, gc, 0.4); - } - } - - // Axis lines - let ac = parse_color("#9CA3AF"); - chart_line_seg(surface, px, py + ph, px + pw, py + ph, ac, 0.8); - chart_line_seg(surface, px, py, px, py + ph, ac, 0.8); + let show_labels = data.show_labels; + let label_font = data.label_font_size.unwrap_or(2.2); + let label_color = data.label_color.as_deref().unwrap_or("#333333"); // Bars if data.stacked { @@ -898,6 +1184,10 @@ fn render_chart_bar( let bx = px + ci as f64 * cat_width + cat_width * bar_gap / 2.0; let color = parse_color(data.colors.get(si).map(|s| s.as_str()).unwrap_or("#4F46E5")); chart_rect(surface, bx, by, group_width, bh.max(0.0), color); + if show_labels && val > 0.0 { + let label = chart_format_value(val); + chart_text_centered(surface, bx + group_width / 2.0, by + bh / 2.0 + label_font * 0.15, &label, label_font, label_color, fonts, measurer); + } y_off += bh; } } @@ -911,9 +1201,20 @@ fn render_chart_bar( let by = py + ph - bh; let color = parse_color(data.colors.get(si).map(|s| s.as_str()).unwrap_or("#4F46E5")); chart_rect(surface, bx, by, bar_w.max(0.1), bh.max(0.0), color); + if show_labels { + let label = chart_format_value(val); + chart_text_centered(surface, bx + bar_w / 2.0, by - 0.8, &label, label_font, label_color, fonts, measurer); + } } } } + + // X axis category labels + render_chart_x_labels(surface, &data.categories, px, py + ph, pw, fonts, measurer); + + // X axis line + let ac = parse_color("#9CA3AF"); + chart_line_seg(surface, px, py + ph, px + pw, py + ph, ac, 0.8); } /// Line chart — tum koordinatlar mm cinsinden (mutlak sayfa pozisyonu) @@ -921,6 +1222,7 @@ fn render_chart_line( surface: &mut krilla::surface::Surface<'_>, data: &crate::ChartRenderData, px: f64, py: f64, pw: f64, ph: f64, + fonts: &FontCollection, measurer: &mut TextMeasurer, ) { if data.categories.is_empty() || data.series.is_empty() { return; } @@ -930,19 +1232,15 @@ fn render_chart_line( let line_w = data.line_width.unwrap_or(0.5); let show_points = data.show_points.unwrap_or(true); - // Grid - if data.show_grid { - let gc = parse_color(data.grid_color.as_deref().unwrap_or("#E5E7EB")); - for i in 0..=5 { - let frac = i as f64 / 5.0; - let gy = py + ph - frac * ph; - chart_line_seg(surface, px, gy, px + pw, gy, gc, 0.4); - } - } + let show_grid = data.show_grid; + let grid_color = data.grid_color.as_deref().unwrap_or("#E5E7EB"); - // Axis - let ac = parse_color("#9CA3AF"); - chart_line_seg(surface, px, py + ph, px + pw, py + ph, ac, 0.8); + // Grid + Y axis labels + render_chart_y_axis(surface, min_val, max_val, px, py, pw, ph, show_grid, grid_color, fonts, measurer); + + let show_labels = data.show_labels; + let label_font = data.label_font_size.unwrap_or(2.2); + let label_color = data.label_color.as_deref().unwrap_or("#333333"); for (si, series) in data.series.iter().enumerate() { let color = parse_color(data.colors.get(si).map(|s| s.as_str()).unwrap_or("#4F46E5")); @@ -993,7 +1291,23 @@ fn render_chart_line( if let Some(p) = circle { surface.draw_path(&p); } } } + + // Value labels on points + if show_labels { + for (ci, val) in series.values.iter().enumerate() { + let (lx, ly) = points[ci]; + let label = chart_format_value(*val); + chart_text_centered(surface, lx, ly - 1.5, &label, label_font, label_color, fonts, measurer); + } + } } + + // X axis category labels + render_chart_x_labels_line(surface, &data.categories, px, py + ph, pw, fonts, measurer); + + // Axis line + let ac = parse_color("#9CA3AF"); + chart_line_seg(surface, px, py + ph, px + pw, py + ph, ac, 0.8); } /// Pie/donut chart — tum koordinatlar mm cinsinden @@ -1001,6 +1315,7 @@ fn render_chart_pie( surface: &mut krilla::surface::Surface<'_>, data: &crate::ChartRenderData, px: f64, py: f64, pw: f64, ph: f64, + fonts: &FontCollection, measurer: &mut TextMeasurer, ) { let values: Vec = if data.series.len() == 1 { data.series[0].values.clone() @@ -1015,10 +1330,14 @@ fn render_chart_pie( let cx = px + pw / 2.0; let cy = py + ph / 2.0; - let radius = pw.min(ph) / 2.0 * 0.9; + 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 show_labels = data.show_labels; + let label_font = data.label_font_size.unwrap_or(3.0); + let label_color = data.label_color.as_deref().unwrap_or("#333333"); + let mut start_angle = -std::f64::consts::FRAC_PI_2; for (i, val) in values.iter().enumerate() { @@ -1038,6 +1357,41 @@ fn render_chart_pie( let path = build_arc_path(cx, cy, radius, inner_r, start_angle, end_angle); if let Some(p) = path { surface.draw_path(&p); } + // 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(); + let label = format!("{}%", pct); + chart_text_centered(surface, lx, ly, &label, label_font, label_color, fonts, measurer); + } + + // 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; + let line_end_r = radius + 3.0; + let text_r = radius + 4.0; + + // Leader line + 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(); + chart_line_seg(surface, lx1, ly1, lx2, ly2, parse_color("#999999"), 0.5); + + // Category text + let tx = cx + text_r * mid_angle.cos(); + let ty = cy + text_r * mid_angle.sin(); + if mid_angle.cos() >= 0.0 { + chart_text_start(surface, tx, ty, &data.categories[i], 2.5, "#555555", fonts, measurer); + } else { + chart_text_end(surface, tx, ty, &data.categories[i], 2.5, "#555555", fonts, measurer); + } + } + start_angle = end_angle; } } diff --git a/layout-engine/src/tree.rs b/layout-engine/src/tree.rs index 6665ccf..1eb752e 100644 --- a/layout-engine/src/tree.rs +++ b/layout-engine/src/tree.rs @@ -771,6 +771,13 @@ fn collect_layout( show_points: cd.style.show_points, line_width: cd.style.line_width, background_color: cd.style.background_color.clone(), + label_color: cd.labels.as_ref().and_then(|l| l.color.clone()), + legend_show: cd.legend.as_ref().is_some_and(|l| l.show), + legend_position: cd.legend.as_ref().and_then(|l| l.position.clone()), + legend_font_size: cd.legend.as_ref().and_then(|l| l.font_size), + x_label: cd.axis.as_ref().and_then(|a| a.x_label.clone()), + y_label: cd.axis.as_ref().and_then(|a| a.y_label.clone()), + title_align: cd.title.as_ref().and_then(|t| t.align.clone()), }, } }) diff --git a/layout-engine/tests/fixtures/chart_test_data.json b/layout-engine/tests/fixtures/chart_test_data.json new file mode 100644 index 0000000..7cd40c7 --- /dev/null +++ b/layout-engine/tests/fixtures/chart_test_data.json @@ -0,0 +1,33 @@ +{ + "satis": [ + { "ay": "Ocak", "gelir": 15000, "kanal": "Online" }, + { "ay": "Ocak", "gelir": 8000, "kanal": "Magaza" }, + { "ay": "Ocak", "gelir": 3000, "kanal": "Toptan" }, + { "ay": "Subat", "gelir": 18000, "kanal": "Online" }, + { "ay": "Subat", "gelir": 9500, "kanal": "Magaza" }, + { "ay": "Subat", "gelir": 4200, "kanal": "Toptan" }, + { "ay": "Mart", "gelir": 22000, "kanal": "Online" }, + { "ay": "Mart", "gelir": 11000, "kanal": "Magaza" }, + { "ay": "Mart", "gelir": 5100, "kanal": "Toptan" }, + { "ay": "Nisan", "gelir": 19500, "kanal": "Online" }, + { "ay": "Nisan", "gelir": 10200, "kanal": "Magaza" }, + { "ay": "Nisan", "gelir": 4800, "kanal": "Toptan" } + ], + "trend": [ + { "hafta": "H1", "ziyaretci": 1200, "kaynak": "Organik" }, + { "hafta": "H1", "ziyaretci": 800, "kaynak": "Reklam" }, + { "hafta": "H2", "ziyaretci": 1500, "kaynak": "Organik" }, + { "hafta": "H2", "ziyaretci": 950, "kaynak": "Reklam" }, + { "hafta": "H3", "ziyaretci": 1350, "kaynak": "Organik" }, + { "hafta": "H3", "ziyaretci": 1100, "kaynak": "Reklam" }, + { "hafta": "H4", "ziyaretci": 1800, "kaynak": "Organik" }, + { "hafta": "H4", "ziyaretci": 1250, "kaynak": "Reklam" } + ], + "dagilim": [ + { "kategori": "Elektronik", "oran": 35 }, + { "kategori": "Giyim", "oran": 25 }, + { "kategori": "Gida", "oran": 20 }, + { "kategori": "Kozmetik", "oran": 12 }, + { "kategori": "Diger", "oran": 8 } + ] +} diff --git a/layout-engine/tests/fixtures/chart_test_template.json b/layout-engine/tests/fixtures/chart_test_template.json new file mode 100644 index 0000000..465a660 --- /dev/null +++ b/layout-engine/tests/fixtures/chart_test_template.json @@ -0,0 +1,131 @@ +{ + "id": "chart_test", + "name": "Chart Visual Test", + "page": { "width": 210, "height": 297 }, + "fonts": ["Noto Sans"], + "root": { + "id": "root", + "type": "container", + "position": { "type": "flow" }, + "size": { "width": { "type": "auto" }, "height": { "type": "auto" } }, + "direction": "column", + "gap": 8, + "padding": { "top": 15, "right": 15, "bottom": 15, "left": 15 }, + "align": "stretch", + "justify": "start", + "style": {}, + "children": [ + { + "id": "bar_chart", + "type": "chart", + "position": { "type": "flow" }, + "size": { + "width": { "type": "fr", "value": 1 }, + "height": { "type": "fixed", "value": 80 } + }, + "chartType": "bar", + "dataSource": { "path": "satis" }, + "categoryField": "ay", + "valueField": "gelir", + "groupField": "kanal", + "groupMode": "grouped", + "title": { + "text": "Aylik Satis Geliri", + "fontSize": 4.0, + "color": "#1a1a1a" + }, + "legend": { + "show": true, + "position": "bottom", + "fontSize": 2.8 + }, + "labels": { + "show": true, + "fontSize": 2.2, + "color": "#333333" + }, + "axis": { + "xLabel": "Aylar", + "yLabel": "Gelir (TL)", + "showGrid": true, + "gridColor": "#E5E7EB" + }, + "style": { + "colors": ["#4F46E5", "#10B981", "#F59E0B"], + "backgroundColor": "#FFFFFF", + "barGap": 0.2 + } + }, + { + "id": "line_chart", + "type": "chart", + "position": { "type": "flow" }, + "size": { + "width": { "type": "fr", "value": 1 }, + "height": { "type": "fixed", "value": 80 } + }, + "chartType": "line", + "dataSource": { "path": "trend" }, + "categoryField": "hafta", + "valueField": "ziyaretci", + "groupField": "kaynak", + "title": { + "text": "Haftalik Ziyaretci Trendi", + "fontSize": 4.0, + "color": "#1a1a1a" + }, + "legend": { + "show": true, + "position": "bottom", + "fontSize": 2.8 + }, + "labels": { + "show": false + }, + "axis": { + "showGrid": true, + "gridColor": "#E5E7EB" + }, + "style": { + "colors": ["#EF4444", "#8B5CF6"], + "backgroundColor": "#FFFFFF", + "lineWidth": 0.5, + "showPoints": true + } + }, + { + "id": "pie_chart", + "type": "chart", + "position": { "type": "flow" }, + "size": { + "width": { "type": "fr", "value": 1 }, + "height": { "type": "fixed", "value": 80 } + }, + "chartType": "pie", + "dataSource": { "path": "dagilim" }, + "categoryField": "kategori", + "valueField": "oran", + "title": { + "text": "Kategori Dagilimi", + "fontSize": 4.0, + "color": "#1a1a1a" + }, + "legend": { + "show": true, + "position": "right", + "fontSize": 2.8 + }, + "labels": { + "show": true, + "fontSize": 2.5, + "color": "#FFFFFF" + }, + "style": { + "colors": ["#4F46E5", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"], + "backgroundColor": "#FFFFFF", + "innerRadius": 0.0 + } + } + ] + } +} diff --git a/layout-engine/tests/fixtures/comprehensive_test_data.json b/layout-engine/tests/fixtures/comprehensive_test_data.json new file mode 100644 index 0000000..1ea4a64 --- /dev/null +++ b/layout-engine/tests/fixtures/comprehensive_test_data.json @@ -0,0 +1,42 @@ +{ + "company": { + "name": "Teknova Yazilim A.S.", + "city": "Istanbul", + "revenue": 148200 + }, + "order": { + "code": "ORD-2026-0042" + }, + "meta": { + "version": "1.0.0" + }, + "products": [ + { "no": 1, "name": "Web Application Development", "qty": 1, "price": 45000, "total": 45000 }, + { "no": 2, "name": "Mobile App Development", "qty": 1, "price": 35000, "total": 35000 }, + { "no": 3, "name": "UI/UX Design Service", "qty": 40, "price": 750, "total": 30000 }, + { "no": 4, "name": "Server Maintenance (Annual)", "qty": 1, "price": 12000, "total": 12000 }, + { "no": 5, "name": "SSL Certificate", "qty": 3, "price": 500, "total": 1500 }, + { "no": 6, "name": "Cloud Hosting Setup", "qty": 1, "price": 8500, "total": 8500 }, + { "no": 7, "name": "Database Optimization", "qty": 2, "price": 6000, "total": 12000 } + ], + "distribution": [ + { "category": "Development", "value": 80000 }, + { "category": "Design", "value": 30000 }, + { "category": "Infrastructure", "value": 22000 }, + { "category": "Support", "value": 12000 } + ], + "trend": [ + { "month": "Jan", "series": "Revenue", "value": 18000 }, + { "month": "Feb", "series": "Revenue", "value": 22000 }, + { "month": "Mar", "series": "Revenue", "value": 19500 }, + { "month": "Apr", "series": "Revenue", "value": 28000 }, + { "month": "May", "series": "Revenue", "value": 32000 }, + { "month": "Jun", "series": "Revenue", "value": 35000 }, + { "month": "Jan", "series": "Costs", "value": 12000 }, + { "month": "Feb", "series": "Costs", "value": 14000 }, + { "month": "Mar", "series": "Costs", "value": 11000 }, + { "month": "Apr", "series": "Costs", "value": 16000 }, + { "month": "May", "series": "Costs", "value": 18000 }, + { "month": "Jun", "series": "Costs", "value": 20000 } + ] +} diff --git a/layout-engine/tests/fixtures/comprehensive_test_template.json b/layout-engine/tests/fixtures/comprehensive_test_template.json new file mode 100644 index 0000000..7ab440e --- /dev/null +++ b/layout-engine/tests/fixtures/comprehensive_test_template.json @@ -0,0 +1,466 @@ +{ + "id": "comprehensive_test", + "name": "Comprehensive Element Test", + "page": { "width": 210, "height": 297 }, + "fonts": ["Noto Sans", "Noto Sans Mono"], + "root": { + "id": "root", + "type": "container", + "position": { "type": "flow" }, + "size": { "width": { "type": "auto" }, "height": { "type": "auto" } }, + "direction": "column", + "gap": 4, + "padding": { "top": 12, "right": 12, "bottom": 12, "left": 12 }, + "align": "stretch", + "justify": "start", + "style": {}, + "children": [ + + { + "id": "title", + "type": "static_text", + "position": { "type": "flow" }, + "size": { "width": { "type": "auto" }, "height": { "type": "auto" } }, + "style": { "fontSize": 16, "fontWeight": "bold", "color": "#1a1a1a", "align": "center" }, + "content": "COMPREHENSIVE ELEMENT TEST" + }, + + { + "id": "subtitle", + "type": "static_text", + "position": { "type": "flow" }, + "size": { "width": { "type": "auto" }, "height": { "type": "auto" } }, + "style": { "fontSize": 9, "color": "#888888", "align": "center" }, + "content": "All element types in a single document" + }, + + { + "id": "line_top", + "type": "line", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } }, + "style": { "strokeColor": "#1e293b", "strokeWidth": 1 } + }, + + { + "id": "section_text", + "type": "container", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } }, + "direction": "column", + "gap": 2, + "padding": { "top": 2, "right": 4, "bottom": 2, "left": 4 }, + "align": "stretch", + "justify": "start", + "style": { "backgroundColor": "#f0f4ff", "borderColor": "#c7d2fe", "borderWidth": 0.5, "borderRadius": 2 }, + "children": [ + { + "id": "sec1_label", + "type": "static_text", + "position": { "type": "flow" }, + "size": { "width": { "type": "auto" }, "height": { "type": "auto" } }, + "style": { "fontSize": 8, "fontWeight": "bold", "color": "#4338ca" }, + "content": "TEXT ELEMENTS" + }, + { + "id": "row_texts", + "type": "container", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } }, + "direction": "row", + "gap": 4, + "padding": { "top": 0, "right": 0, "bottom": 0, "left": 0 }, + "align": "start", + "justify": "start", + "style": {}, + "children": [ + { + "id": "bound_text", + "type": "text", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } }, + "style": { "fontSize": 10, "color": "#333333" }, + "content": "Company: ", + "binding": { "path": "company.name" } + }, + { + "id": "bound_text2", + "type": "text", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } }, + "style": { "fontSize": 10, "color": "#333333" }, + "content": "City: ", + "binding": { "path": "company.city" } + } + ] + }, + { + "id": "rich_text_el", + "type": "rich_text", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } }, + "style": { "fontSize": 9, "color": "#333333" }, + "content": [ + { "text": "Rich text: ", "style": { "fontSize": 9, "fontWeight": "bold", "color": "#1e293b" } }, + { "text": "normal ", "style": { "fontSize": 9, "color": "#555555" } }, + { "text": "bold ", "style": { "fontSize": 9, "fontWeight": "bold", "color": "#dc2626" } }, + { "text": "large ", "style": { "fontSize": 12, "color": "#059669" } }, + { "text": "mono", "style": { "fontSize": 9, "fontFamily": "Noto Sans Mono", "color": "#7c3aed" } } + ] + }, + { + "id": "calc_text", + "type": "calculated_text", + "position": { "type": "flow" }, + "size": { "width": { "type": "auto" }, "height": { "type": "auto" } }, + "style": { "fontSize": 9, "color": "#333333" }, + "expression": "company.revenue * 0.20", + "format": "currency" + }, + { + "id": "row_date_page", + "type": "container", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } }, + "direction": "row", + "gap": 8, + "padding": { "top": 0, "right": 0, "bottom": 0, "left": 0 }, + "align": "center", + "justify": "start", + "style": {}, + "children": [ + { + "id": "date_el", + "type": "current_date", + "position": { "type": "flow" }, + "size": { "width": { "type": "auto" }, "height": { "type": "auto" } }, + "style": { "fontSize": 9, "color": "#666666" }, + "format": "DD.MM.YYYY" + }, + { + "id": "page_num", + "type": "page_number", + "position": { "type": "flow" }, + "size": { "width": { "type": "auto" }, "height": { "type": "auto" } }, + "style": { "fontSize": 9, "color": "#666666" }, + "format": "Page {current}/{total}" + } + ] + } + ] + }, + + { + "id": "line_thin", + "type": "line", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } }, + "style": { "strokeColor": "#e2e8f0", "strokeWidth": 0.3 } + }, + + { + "id": "section_shapes", + "type": "container", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } }, + "direction": "column", + "gap": 2, + "padding": { "top": 2, "right": 4, "bottom": 2, "left": 4 }, + "align": "stretch", + "justify": "start", + "style": { "backgroundColor": "#fef3c7", "borderColor": "#fbbf24", "borderWidth": 0.5, "borderRadius": 2 }, + "children": [ + { + "id": "sec2_label", + "type": "static_text", + "position": { "type": "flow" }, + "size": { "width": { "type": "auto" }, "height": { "type": "auto" } }, + "style": { "fontSize": 8, "fontWeight": "bold", "color": "#92400e" }, + "content": "SHAPES, CHECKBOXES & BARCODES" + }, + { + "id": "row_shapes", + "type": "container", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } }, + "direction": "row", + "gap": 4, + "padding": { "top": 0, "right": 0, "bottom": 0, "left": 0 }, + "align": "center", + "justify": "start", + "style": {}, + "children": [ + { + "id": "shape_rect", + "type": "shape", + "position": { "type": "flow" }, + "size": { "width": { "type": "fixed", "value": 15 }, "height": { "type": "fixed", "value": 10 } }, + "shapeType": "rectangle", + "style": { "backgroundColor": "#3b82f6", "borderColor": "#1d4ed8", "borderWidth": 0.5 } + }, + { + "id": "shape_ellipse", + "type": "shape", + "position": { "type": "flow" }, + "size": { "width": { "type": "fixed", "value": 15 }, "height": { "type": "fixed", "value": 10 } }, + "shapeType": "ellipse", + "style": { "backgroundColor": "#ef4444", "borderColor": "#b91c1c", "borderWidth": 0.5 } + }, + { + "id": "shape_rounded", + "type": "shape", + "position": { "type": "flow" }, + "size": { "width": { "type": "fixed", "value": 15 }, "height": { "type": "fixed", "value": 10 } }, + "shapeType": "rounded_rectangle", + "style": { "backgroundColor": "#10b981", "borderColor": "#047857", "borderWidth": 0.5, "borderRadius": 3 } + }, + { + "id": "cb_checked", + "type": "checkbox", + "position": { "type": "flow" }, + "size": { "width": { "type": "fixed", "value": 5 }, "height": { "type": "fixed", "value": 5 } }, + "checked": true, + "style": { "size": 5, "checkColor": "#059669", "borderColor": "#333333", "borderWidth": 0.3 } + }, + { + "id": "cb_label1", + "type": "static_text", + "position": { "type": "flow" }, + "size": { "width": { "type": "auto" }, "height": { "type": "auto" } }, + "style": { "fontSize": 8, "color": "#333333" }, + "content": "Checked" + }, + { + "id": "cb_unchecked", + "type": "checkbox", + "position": { "type": "flow" }, + "size": { "width": { "type": "fixed", "value": 5 }, "height": { "type": "fixed", "value": 5 } }, + "checked": false, + "style": { "size": 5, "checkColor": "#000000", "borderColor": "#333333", "borderWidth": 0.3 } + }, + { + "id": "cb_label2", + "type": "static_text", + "position": { "type": "flow" }, + "size": { "width": { "type": "auto" }, "height": { "type": "auto" } }, + "style": { "fontSize": 8, "color": "#333333" }, + "content": "Unchecked" + } + ] + }, + { + "id": "row_barcodes", + "type": "container", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } }, + "direction": "row", + "gap": 6, + "padding": { "top": 2, "right": 0, "bottom": 0, "left": 0 }, + "align": "start", + "justify": "start", + "style": {}, + "children": [ + { + "id": "barcode_qr", + "type": "barcode", + "position": { "type": "flow" }, + "size": { "width": { "type": "fixed", "value": 20 }, "height": { "type": "fixed", "value": 20 } }, + "format": "qr", + "value": "https://dreport.dev", + "style": {} + }, + { + "id": "barcode_128", + "type": "barcode", + "position": { "type": "flow" }, + "size": { "width": { "type": "fixed", "value": 40 }, "height": { "type": "fixed", "value": 15 } }, + "format": "code128", + "binding": { "path": "order.code" }, + "style": { "includeText": true } + }, + { + "id": "barcode_ean", + "type": "barcode", + "position": { "type": "flow" }, + "size": { "width": { "type": "fixed", "value": 35 }, "height": { "type": "fixed", "value": 15 } }, + "format": "ean13", + "value": "5901234123457", + "style": { "includeText": true } + } + ] + } + ] + }, + + { + "id": "section_table", + "type": "container", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } }, + "direction": "column", + "gap": 2, + "padding": { "top": 2, "right": 0, "bottom": 0, "left": 0 }, + "align": "stretch", + "justify": "start", + "style": {}, + "children": [ + { + "id": "sec3_label", + "type": "static_text", + "position": { "type": "flow" }, + "size": { "width": { "type": "auto" }, "height": { "type": "auto" } }, + "style": { "fontSize": 8, "fontWeight": "bold", "color": "#0f766e" }, + "content": "REPEATING TABLE" + }, + { + "id": "products_table", + "type": "repeating_table", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } }, + "dataSource": { "path": "products" }, + "columns": [ + { "id": "col_no", "field": "no", "title": "#", "width": { "type": "fixed", "value": 8 }, "align": "center" }, + { "id": "col_name", "field": "name", "title": "Product", "width": { "type": "fr", "value": 1 }, "align": "left" }, + { "id": "col_qty", "field": "qty", "title": "Qty", "width": { "type": "fixed", "value": 15 }, "align": "right" }, + { "id": "col_price", "field": "price", "title": "Price", "width": { "type": "fixed", "value": 25 }, "align": "right", "format": "currency" }, + { "id": "col_total", "field": "total", "title": "Total", "width": { "type": "fixed", "value": 25 }, "align": "right", "format": "currency" } + ], + "style": { + "fontSize": 8, + "headerFontSize": 8, + "headerBg": "#0f766e", + "headerColor": "#ffffff", + "zebraOdd": "#ffffff", + "zebraEven": "#f0fdfa", + "borderColor": "#99f6e4", + "borderWidth": 0.3 + } + } + ] + }, + + { + "id": "section_charts", + "type": "container", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } }, + "direction": "column", + "gap": 2, + "padding": { "top": 2, "right": 0, "bottom": 0, "left": 0 }, + "align": "stretch", + "justify": "start", + "style": {}, + "children": [ + { + "id": "sec4_label", + "type": "static_text", + "position": { "type": "flow" }, + "size": { "width": { "type": "auto" }, "height": { "type": "auto" } }, + "style": { "fontSize": 8, "fontWeight": "bold", "color": "#9333ea" }, + "content": "CHARTS" + }, + { + "id": "charts_row", + "type": "container", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } }, + "direction": "row", + "gap": 4, + "padding": { "top": 0, "right": 0, "bottom": 0, "left": 0 }, + "align": "start", + "justify": "start", + "style": {}, + "children": [ + { + "id": "chart_bar", + "type": "chart", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "fixed", "value": 45 } }, + "chartType": "bar", + "dataSource": { "path": "products" }, + "categoryField": "name", + "valueField": "total", + "title": { "text": "Revenue by Product", "fontSize": 3, "color": "#1e293b" }, + "legend": { "show": false }, + "labels": { "show": true, "fontSize": 2, "color": "#333" }, + "axis": { "showGrid": true }, + "style": { "colors": ["#6366f1", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6"] } + }, + { + "id": "chart_pie", + "type": "chart", + "position": { "type": "flow" }, + "size": { "width": { "type": "fixed", "value": 55 }, "height": { "type": "fixed", "value": 45 } }, + "chartType": "pie", + "dataSource": { "path": "distribution" }, + "categoryField": "category", + "valueField": "value", + "title": { "text": "Distribution", "fontSize": 3, "color": "#1e293b" }, + "legend": { "show": true, "position": "bottom", "fontSize": 2 }, + "labels": { "show": true, "fontSize": 2, "color": "#333" }, + "style": { "colors": ["#3b82f6", "#ef4444", "#10b981", "#f59e0b"], "innerRadius": 0.4 } + } + ] + }, + { + "id": "chart_line", + "type": "chart", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "fixed", "value": 40 } }, + "chartType": "line", + "dataSource": { "path": "trend" }, + "categoryField": "month", + "valueField": "value", + "groupField": "series", + "title": { "text": "Monthly Trend", "fontSize": 3, "color": "#1e293b" }, + "legend": { "show": true, "position": "top", "fontSize": 2 }, + "labels": { "show": false }, + "axis": { "showGrid": true }, + "style": { "colors": ["#6366f1", "#ef4444"], "lineWidth": 1.5, "showPoints": true } + } + ] + }, + + { + "id": "line_bottom", + "type": "line", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } }, + "style": { "strokeColor": "#1e293b", "strokeWidth": 0.5 } + }, + + { + "id": "footer_row", + "type": "container", + "position": { "type": "flow" }, + "size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } }, + "direction": "row", + "gap": 0, + "padding": { "top": 0, "right": 0, "bottom": 0, "left": 0 }, + "align": "center", + "justify": "space-between", + "style": {}, + "children": [ + { + "id": "footer_left", + "type": "static_text", + "position": { "type": "flow" }, + "size": { "width": { "type": "auto" }, "height": { "type": "auto" } }, + "style": { "fontSize": 7, "color": "#94a3b8" }, + "content": "Generated by dreport visual test suite" + }, + { + "id": "footer_right", + "type": "text", + "position": { "type": "flow" }, + "size": { "width": { "type": "auto" }, "height": { "type": "auto" } }, + "style": { "fontSize": 7, "color": "#94a3b8", "align": "right" }, + "content": "Version: ", + "binding": { "path": "meta.version" } + } + ] + } + + ] + } +} diff --git a/layout-engine/tests/snapshots/chart_test_reference.png b/layout-engine/tests/snapshots/chart_test_reference.png new file mode 100644 index 0000000..1e73514 Binary files /dev/null and b/layout-engine/tests/snapshots/chart_test_reference.png differ diff --git a/layout-engine/tests/snapshots/chart_test_svg.html b/layout-engine/tests/snapshots/chart_test_svg.html new file mode 100644 index 0000000..a999e0a --- /dev/null +++ b/layout-engine/tests/snapshots/chart_test_svg.html @@ -0,0 +1 @@ +

Chart SVG Preview (HTML render)

Aylik Satis Geliri04.6K9.2K13.9K18.5K23.1K15.0K8.0K3.0K18.0K9.5K4.2K22.0K11.0K5.1K19.5K10.2K4.8KOcakSubatMartNisanOnlineMagazaToptanAylarGelir (TL)
Haftalik Ziyaretci Trendi03787561.1K1.5K1.9KH1H2H3H4OrganikReklam
Kategori Dagilimi35%Elektronik25%Giyim20%Gida12%Kozmetik8%Diger
\ No newline at end of file diff --git a/layout-engine/tests/visual_test.rs b/layout-engine/tests/visual_test.rs index 3a8dafe..9180bd2 100644 --- a/layout-engine/tests/visual_test.rs +++ b/layout-engine/tests/visual_test.rs @@ -13,7 +13,7 @@ mod visual { use std::process::Command; use dreport_core::models::Template; - use dreport_layout::{compute_layout, FontData}; + use dreport_layout::{compute_layout, FontData, ResolvedContent}; use dreport_layout::pdf_render::render_pdf; fn fixtures_dir() -> std::path::PathBuf { @@ -156,17 +156,15 @@ mod visual { } } - #[test] - fn test_visual_snapshot_basic() { - let pdf_bytes = - generate_test_pdf("visual_test_template.json", "visual_test_data.json"); + fn run_visual_test(template_file: &str, data_file: &str, test_name: &str) { + let pdf_bytes = generate_test_pdf(template_file, data_file); assert!(!pdf_bytes.is_empty(), "PDF should not be empty"); let snap_dir = snapshots_dir(); fs::create_dir_all(&snap_dir).unwrap(); - let actual_png = snap_dir.join("visual_test_actual.png"); - let reference_png = snap_dir.join("visual_test_reference.png"); + let actual_png = snap_dir.join(format!("{}_actual.png", test_name)); + let reference_png = snap_dir.join(format!("{}_reference.png", test_name)); if !pdf_to_png(&pdf_bytes, &actual_png) { eprintln!("Skipping visual comparison - pdftoppm not available"); @@ -188,7 +186,8 @@ mod visual { match compare_images(&actual_png, &reference_png, 0.01) { Ok(diff) => { println!( - "Visual test passed: {:.4}% pixels differ", + "Visual test [{}] passed: {:.4}% pixels differ", + test_name, diff * 100.0 ); let _ = fs::remove_file(&actual_png); @@ -196,10 +195,99 @@ mod visual { Err(err) => { // Keep actual for debugging panic!( - "Visual regression detected: {}. Actual saved at {:?}", - err, actual_png + "Visual regression [{}]: {}. Actual saved at {:?}", + test_name, err, actual_png ); } } } + + /// SVG'yi standalone HTML'e sar — chart'ın HTML render'ını görmek icin + fn generate_chart_svg_html(template_file: &str, data_file: &str, output_path: &Path) { + let template_json = fs::read_to_string(fixtures_dir().join(template_file)).unwrap(); + let data_json = fs::read_to_string(fixtures_dir().join(data_file)).unwrap(); + + let template: Template = serde_json::from_str(&template_json).unwrap(); + 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 mut html = String::from("

Chart SVG Preview (HTML render)

"); + + for page in &layout.pages { + for el in &page.elements { + if let Some(ResolvedContent::Chart { svg, .. }) = &el.content { + html.push_str(&format!( + "
{}
", + el.width_mm, el.height_mm, svg + )); + } + } + } + + html.push_str(""); + fs::write(output_path, html).unwrap(); + } + + /// Cross-renderer reference PNG output directory + fn cross_renderer_dir() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .join("frontend/tests/visual/cross-renderer-refs") + } + + /// Generates PDF→PNG references for cross-renderer comparison with HTML render. + /// Run explicitly: cargo test -p dreport-layout --test visual_test -- generate_cross_renderer --ignored + #[test] + #[ignore] + fn generate_cross_renderer_refs() { + let fixtures = [ + ("visual_test_template.json", "visual_test_data.json", "visual_test"), + ("chart_test_template.json", "chart_test_data.json", "chart_test"), + ("comprehensive_test_template.json", "comprehensive_test_data.json", "comprehensive_test"), + ]; + + let out_dir = cross_renderer_dir(); + fs::create_dir_all(&out_dir).unwrap(); + + for (template_file, data_file, name) in &fixtures { + let pdf_bytes = generate_test_pdf(template_file, data_file); + assert!(!pdf_bytes.is_empty(), "PDF should not be empty for {}", name); + + let png_path = out_dir.join(format!("{}.png", name)); + if !pdf_to_png(&pdf_bytes, &png_path) { + panic!("pdftoppm failed for {} — install poppler-utils", name); + } + println!("Cross-renderer reference: {:?}", png_path); + } + } + + #[test] + fn test_visual_snapshot_basic() { + run_visual_test("visual_test_template.json", "visual_test_data.json", "visual_test"); + } + + #[test] + fn test_visual_snapshot_charts() { + let pdf_bytes = generate_test_pdf("chart_test_template.json", "chart_test_data.json"); + assert!(!pdf_bytes.is_empty(), "Chart PDF should not be empty"); + + let snap_dir = snapshots_dir(); + fs::create_dir_all(&snap_dir).unwrap(); + + // PDF ciktisini kaydet (inceleme icin) + let pdf_path = snap_dir.join("chart_test.pdf"); + fs::write(&pdf_path, &pdf_bytes).unwrap(); + println!("Chart PDF saved to {:?}", pdf_path); + + // SVG HTML ciktisini kaydet (karsilastirma icin) + let html_path = snap_dir.join("chart_test_svg.html"); + generate_chart_svg_html("chart_test_template.json", "chart_test_data.json", &html_path); + println!("Chart SVG HTML saved to {:?}", html_path); + + // Visual regression test + run_visual_test("chart_test_template.json", "chart_test_data.json", "chart_test"); + } }