From f04c39cb6916b9efb85f1c42dd7be7e7c52e622e Mon Sep 17 00:00:00 2001 From: Duhan BALCI Date: Mon, 6 Apr 2026 03:17:30 +0300 Subject: [PATCH] visual testing --- .gitignore | 11 +- Cargo.lock | 2 +- IMPROVEMENTS.md | 825 ++++++++++++++++++ backend/Cargo.toml | 1 + core/Cargo.toml | 3 + frontend/bun.lock | 9 + frontend/package.json | 6 +- frontend/playwright.config.ts | 17 + frontend/render-test.html | 48 + frontend/src/App.vue | 8 +- .../src/components/editor/ElementToolbar.vue | 95 +- .../components/editor/InteractionOverlay.vue | 1 + frontend/src/render-test/RenderTestPage.vue | 121 +++ frontend/src/render-test/main.ts | 4 + frontend/tests/visual/cross-renderer.spec.ts | 166 ++++ frontend/vite.config.ts | 9 + justfile | 25 + layout-engine/Cargo.toml | 5 +- layout-engine/src/chart_render.rs | 37 +- layout-engine/src/lib.rs | 18 + layout-engine/src/pdf_render.rs | 452 ++++++++-- layout-engine/src/tree.rs | 7 + .../tests/fixtures/chart_test_data.json | 33 + .../tests/fixtures/chart_test_template.json | 131 +++ .../fixtures/comprehensive_test_data.json | 42 + .../fixtures/comprehensive_test_template.json | 466 ++++++++++ .../tests/snapshots/chart_test_reference.png | Bin 0 -> 74413 bytes .../tests/snapshots/chart_test_svg.html | 1 + layout-engine/tests/visual_test.rs | 108 ++- 29 files changed, 2575 insertions(+), 76 deletions(-) create mode 100644 IMPROVEMENTS.md create mode 100644 frontend/render-test.html create mode 100644 frontend/src/render-test/RenderTestPage.vue create mode 100644 frontend/src/render-test/main.ts create mode 100644 frontend/tests/visual/cross-renderer.spec.ts create mode 100644 layout-engine/tests/fixtures/chart_test_data.json create mode 100644 layout-engine/tests/fixtures/chart_test_template.json create mode 100644 layout-engine/tests/fixtures/comprehensive_test_data.json create mode 100644 layout-engine/tests/fixtures/comprehensive_test_template.json create mode 100644 layout-engine/tests/snapshots/chart_test_reference.png create mode 100644 layout-engine/tests/snapshots/chart_test_svg.html 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 0000000000000000000000000000000000000000..1e735140e5194eba5df6d9863078fee25b615037 GIT binary patch literal 74413 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%0AJ05R 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

    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"); + } }