visual testing

This commit is contained in:
2026-04-06 03:17:30 +03:00
parent 889b66978a
commit f1357b6dbe
31 changed files with 2575 additions and 76 deletions

11
.gitignore vendored
View File

@@ -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
View File

@@ -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
View 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.

View File

@@ -2,6 +2,7 @@
name = "dreport-backend"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
dreport-core = { path = "../core" }

View File

@@ -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"]

View File

@@ -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=="],

View File

@@ -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",

View File

@@ -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 },
},
},
],
})

48
frontend/render-test.html Normal file
View 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>

View File

@@ -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

View File

@@ -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 -->

View File

@@ -713,6 +713,7 @@ const isAnyDragActive = computed(() =>
v-if="!isDragging && !isResizing"
:scale="scale"
:layout-map="layoutMap"
:page-height-px="pageHeightPx"
/>
</div>
</template>

View 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>

View File

@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import RenderTestPage from './RenderTestPage.vue'
createApp(RenderTestPage).mount('#app')

View 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
}

View File

@@ -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',

View File

@@ -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

View File

@@ -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"] }

View File

@@ -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;
}
}

View File

@@ -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)]

View File

@@ -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;
}
}

View File

@@ -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()),
},
}
})

View 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 }
]
}

View 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
}
}
]
}
}

View 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 }
]
}

View 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" }
}
]
}
]
}
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

File diff suppressed because one or more lines are too long

View File

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