mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
visual testing
This commit is contained in:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -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/
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||
|
||||
825
IMPROVEMENTS.md
Normal file
825
IMPROVEMENTS.md
Normal file
@@ -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<bool>` 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<String>` 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<LayoutResult, LayoutError>` 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<string>` 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<ScalarBinding>` 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<any>` 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<WorkerMessage>) => {
|
||||
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<FontData> { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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.
|
||||
@@ -2,6 +2,7 @@
|
||||
name = "dreport-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
dreport-core = { path = "../core" }
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
Binary file not shown.
48
frontend/render-test.html
Normal file
48
frontend/render-test.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="tr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>dreport - Render Test</title>
|
||||
<style>
|
||||
/* @font-face declarations — must match the fonts used by PDF renderer */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
src: url('/fonts/NotoSans-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
src: url('/fonts/NotoSans-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
src: url('/fonts/NotoSans-Italic.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
src: url('/fonts/NotoSans-BoldItalic.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Mono';
|
||||
src: url('/fonts/NotoSansMono-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: white; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/render-test/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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() {
|
||||
<!-- Output -->
|
||||
<button class="header-btn" :disabled="pdfLoading" @click="downloadPdf">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="1" width="10" height="14" rx="1.5" /><path d="M6 5h4M6 8h4M6 11h2" /></svg>
|
||||
{{ pdfLoading ? 'Hazirlaniyor...' : 'PDF Indir' }}
|
||||
{{ pdfLoading ? 'Hazirlaniyor...' : 'PDF Onizle' }}
|
||||
</button>
|
||||
</header>
|
||||
<DreportEditor
|
||||
|
||||
@@ -3,12 +3,15 @@ import { computed } from 'vue'
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import { isContainer } from '../../core/types'
|
||||
import type { ContainerElement, TextStyle, RepeatingTableElement, TableStyle } from '../../core/types'
|
||||
import type { ElementLayout } from '../../core/layout-types'
|
||||
import type { ContainerElement, TextStyle, RepeatingTableElement, TableStyle, ChartElement, ChartType } from '../../core/types'
|
||||
import type { LayoutMapEntry } from '../../core/layout-types'
|
||||
|
||||
const PAGE_GAP_PX = 24
|
||||
|
||||
const props = defineProps<{
|
||||
scale: number
|
||||
layoutMap: Record<string, ElementLayout>
|
||||
layoutMap: Record<string, LayoutMapEntry>
|
||||
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 } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -300,6 +320,73 @@ function updateTableStyle(key: string, value: unknown) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ===== Chart ===== -->
|
||||
<template v-if="isChart && chartEl">
|
||||
<!-- Chart type -->
|
||||
<div class="et__group">
|
||||
<button class="et__btn" :class="{ 'et__btn--active': chartEl.chartType === 'bar' }" data-tip="Cubuk" @click="setChartType('bar')">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<rect x="2" y="6" width="3" height="6" rx="0.5" fill="currentColor"/>
|
||||
<rect x="5.5" y="3" width="3" height="9" rx="0.5" fill="currentColor"/>
|
||||
<rect x="9" y="5" width="3" height="7" rx="0.5" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="et__btn" :class="{ 'et__btn--active': chartEl.chartType === 'line' }" data-tip="Cizgi" @click="setChartType('line')">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<polyline points="2,10 5,5 8,7 12,3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<circle cx="2" cy="10" r="1.2" fill="currentColor"/><circle cx="5" cy="5" r="1.2" fill="currentColor"/><circle cx="8" cy="7" r="1.2" fill="currentColor"/><circle cx="12" cy="3" r="1.2" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="et__btn" :class="{ 'et__btn--active': chartEl.chartType === 'pie' }" data-tip="Pasta" @click="setChartType('pie')">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M7 2a5 5 0 1 1-3.54 1.46" stroke="currentColor" stroke-width="1.3" fill="none"/>
|
||||
<path d="M7 7V2A5 5 0 0 0 3.46 3.46Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="et__sep" />
|
||||
|
||||
<!-- Show labels -->
|
||||
<div class="et__group">
|
||||
<button class="et__btn" :class="{ 'et__btn--active': chartEl.labels?.show !== false }" data-tip="Etiketler" @click="update({ labels: { ...chartEl.labels, show: chartEl.labels?.show === false ? true : false } })">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<rect x="2" y="8" width="3" height="4" rx="0.5" fill="currentColor" opacity="0.4"/>
|
||||
<rect x="5.5" y="5" width="3" height="7" rx="0.5" fill="currentColor" opacity="0.4"/>
|
||||
<rect x="9" y="6" width="3" height="6" rx="0.5" fill="currentColor" opacity="0.4"/>
|
||||
<text x="3.5" y="7" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">3</text>
|
||||
<text x="7" y="4" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">7</text>
|
||||
<text x="10.5" y="5" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">5</text>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="et__sep" />
|
||||
|
||||
<!-- Show grid -->
|
||||
<div class="et__group">
|
||||
<button class="et__btn" :class="{ 'et__btn--active': chartEl.axis?.showGrid !== false }" data-tip="Izgara" @click="update({ axis: { ...chartEl.axis, showGrid: chartEl.axis?.showGrid === false ? true : false } })">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5"/>
|
||||
<line x1="2" y1="7" x2="12" y2="7" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5"/>
|
||||
<line x1="2" y1="11" x2="12" y2="11" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="et__sep" />
|
||||
|
||||
<!-- Background color -->
|
||||
<div class="et__group">
|
||||
<label class="et__color-wrap" data-tip="Arka Plan">
|
||||
<input type="color" class="et__color" :value="chartEl.style.backgroundColor ?? '#ffffff'" @input="(e) => updateChartStyle('backgroundColor', (e.target as HTMLInputElement).value)" />
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<rect x="2" y="2" width="10" height="10" rx="1.5" :fill="chartEl.style.backgroundColor ?? '#ffffff'" stroke="#94a3b8" stroke-width="0.8"/>
|
||||
</svg>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ===== Line ===== -->
|
||||
<template v-if="isLine">
|
||||
<!-- Stroke width -->
|
||||
|
||||
@@ -713,6 +713,7 @@ const isAnyDragActive = computed(() =>
|
||||
v-if="!isDragging && !isResizing"
|
||||
:scale="scale"
|
||||
:layout-map="layoutMap"
|
||||
:page-height-px="pageHeightPx"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
121
frontend/src/render-test/RenderTestPage.vue
Normal file
121
frontend/src/render-test/RenderTestPage.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, provide, onMounted } from 'vue'
|
||||
import LayoutRenderer from '../components/editor/LayoutRenderer.vue'
|
||||
import { useLayoutEngine } from '../composables/useLayoutEngine'
|
||||
import type { Template } from '../core/types'
|
||||
|
||||
// Fixture data is injected by Playwright via window.__DREPORT_FIXTURE__
|
||||
declare global {
|
||||
interface Window {
|
||||
__DREPORT_FIXTURE__?: { template: string; data: string }
|
||||
}
|
||||
}
|
||||
|
||||
// DPI matching: 150 DPI = 150/25.4 px/mm (must match pdftoppm -r 150)
|
||||
const DPI = 150
|
||||
const SCALE = DPI / 25.4
|
||||
|
||||
const ready = ref(false)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
|
||||
const template = ref<Template>({
|
||||
id: 'empty',
|
||||
name: 'empty',
|
||||
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: 0,
|
||||
padding: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
align: 'stretch', justify: 'start',
|
||||
style: {}, children: [],
|
||||
},
|
||||
})
|
||||
const data = ref<Record<string, unknown>>({})
|
||||
|
||||
const { layout, generateBarcode, error } = useLayoutEngine(template, data)
|
||||
|
||||
// Provide barcode generator for LayoutRenderer
|
||||
provide('generateBarcode', generateBarcode)
|
||||
|
||||
// Watch for layout computation to complete
|
||||
const checkReady = setInterval(() => {
|
||||
if (layout.value && layout.value.pages.length > 0) {
|
||||
ready.value = true
|
||||
clearInterval(checkReady)
|
||||
}
|
||||
if (error.value) {
|
||||
errorMsg.value = error.value
|
||||
clearInterval(checkReady)
|
||||
}
|
||||
}, 50)
|
||||
|
||||
// Timeout after 20 seconds
|
||||
setTimeout(() => {
|
||||
clearInterval(checkReady)
|
||||
if (!ready.value) {
|
||||
errorMsg.value = 'Layout computation timed out'
|
||||
}
|
||||
}, 20000)
|
||||
|
||||
onMounted(() => {
|
||||
const fixture = window.__DREPORT_FIXTURE__
|
||||
if (!fixture) {
|
||||
errorMsg.value = 'No fixture data found. Set window.__DREPORT_FIXTURE__ = { template, data }'
|
||||
return
|
||||
}
|
||||
try {
|
||||
template.value = JSON.parse(fixture.template)
|
||||
data.value = JSON.parse(fixture.data)
|
||||
} catch (e) {
|
||||
errorMsg.value = `Failed to parse fixture data: ${e}`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="render-test-root"
|
||||
:data-render-ready="ready || undefined"
|
||||
:data-render-error="errorMsg || undefined"
|
||||
>
|
||||
<LayoutRenderer
|
||||
v-if="layout"
|
||||
:layout="layout"
|
||||
:scale="SCALE"
|
||||
/>
|
||||
<div v-else-if="errorMsg" class="error">{{ errorMsg }}</div>
|
||||
<div v-else class="loading">Computing layout...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.render-test-root {
|
||||
/* No padding/margin — pixel-exact rendering */
|
||||
background: white;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
padding: 20px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #999;
|
||||
padding: 20px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Override layout-page styles for test — no shadow, no margin */
|
||||
.layout-page {
|
||||
box-shadow: none !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.layout-page + .layout-page {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
</style>
|
||||
4
frontend/src/render-test/main.ts
Normal file
4
frontend/src/render-test/main.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import RenderTestPage from './RenderTestPage.vue'
|
||||
|
||||
createApp(RenderTestPage).mount('#app')
|
||||
166
frontend/tests/visual/cross-renderer.spec.ts
Normal file
166
frontend/tests/visual/cross-renderer.spec.ts
Normal file
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
25
justfile
25
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
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="#999" stroke-width="0.2"/>"##,
|
||||
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##"<text x="{:.2}" y="{:.2}" font-size="2.5" fill="#555" text-anchor="{}" dominant-baseline="central">{}</text>"##,
|
||||
tx, ty, anchor, escape_xml(&data.categories[i])
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
start_angle = end_angle;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,24 @@ pub struct ChartRenderData {
|
||||
pub line_width: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub background_color: Option<String>,
|
||||
// Label color
|
||||
#[serde(default)]
|
||||
pub label_color: Option<String>,
|
||||
// Legend
|
||||
#[serde(default)]
|
||||
pub legend_show: bool,
|
||||
#[serde(default)]
|
||||
pub legend_position: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legend_font_size: Option<f64>,
|
||||
// Axis labels
|
||||
#[serde(default)]
|
||||
pub x_label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub y_label: Option<String>,
|
||||
// Title align
|
||||
#[serde(default)]
|
||||
pub title_align: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -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::<f64>() - 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<f64> = 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
33
layout-engine/tests/fixtures/chart_test_data.json
vendored
Normal file
33
layout-engine/tests/fixtures/chart_test_data.json
vendored
Normal file
@@ -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 }
|
||||
]
|
||||
}
|
||||
131
layout-engine/tests/fixtures/chart_test_template.json
vendored
Normal file
131
layout-engine/tests/fixtures/chart_test_template.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
42
layout-engine/tests/fixtures/comprehensive_test_data.json
vendored
Normal file
42
layout-engine/tests/fixtures/comprehensive_test_data.json
vendored
Normal file
@@ -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 }
|
||||
]
|
||||
}
|
||||
466
layout-engine/tests/fixtures/comprehensive_test_template.json
vendored
Normal file
466
layout-engine/tests/fixtures/comprehensive_test_template.json
vendored
Normal file
@@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
layout-engine/tests/snapshots/chart_test.pdf
Normal file
BIN
layout-engine/tests/snapshots/chart_test.pdf
Normal file
Binary file not shown.
BIN
layout-engine/tests/snapshots/chart_test_reference.png
Normal file
BIN
layout-engine/tests/snapshots/chart_test_reference.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
1
layout-engine/tests/snapshots/chart_test_svg.html
Normal file
1
layout-engine/tests/snapshots/chart_test_svg.html
Normal file
File diff suppressed because one or more lines are too long
@@ -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("<!DOCTYPE html><html><head><style>body{margin:20px;font-family:sans-serif;background:#f5f5f5}.chart-box{margin:10px 0;background:white;box-shadow:0 1px 3px rgba(0,0,0,.1)}</style></head><body><h2>Chart SVG Preview (HTML render)</h2>");
|
||||
|
||||
for page in &layout.pages {
|
||||
for el in &page.elements {
|
||||
if let Some(ResolvedContent::Chart { svg, .. }) = &el.content {
|
||||
html.push_str(&format!(
|
||||
"<div class='chart-box' style='width:{}mm;height:{}mm'>{}</div>",
|
||||
el.width_mm, el.height_mm, svg
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html.push_str("</body></html>");
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user