mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
refactor & improvements
This commit is contained in:
103
CLAUDE.md
103
CLAUDE.md
@@ -11,7 +11,7 @@ Temel fark: Editorde ayri bir canvas render engine (fabric.js, konva.js vb.) KUL
|
||||
## Teknoloji Kararlari
|
||||
|
||||
| Katman | Teknoloji | Gerekce |
|
||||
| ----------------- | ------------------------------------ | -------------------------------------------------------------- |
|
||||
| ----------------- | ------------------------------------ | ---------------------------------------------------------------- |
|
||||
| Frontend | Vue 3 (Composition API) + TypeScript | Kullanici tercihi |
|
||||
| Layout Engine | taffy (flexbox) + cosmic-text | Template JSON → hesaplanmis pozisyonlar; hem WASM hem native |
|
||||
| Editor Render | HTML div'ler (LayoutRenderer.vue) | Layout engine sonuclarina gore CSS ile render |
|
||||
@@ -90,9 +90,10 @@ let layout: LayoutResult = compute_layout(&template, &data, &fonts);
|
||||
```
|
||||
|
||||
WASM tarafinda (frontend):
|
||||
|
||||
```typescript
|
||||
// layout.worker.ts icinde
|
||||
import init, { computeLayout, loadFonts } from 'dreport-layout-wasm';
|
||||
import init, { computeLayout, loadFonts } from "dreport-layout-wasm";
|
||||
|
||||
await init();
|
||||
await loadFonts(fontBytes);
|
||||
@@ -116,6 +117,7 @@ CSS Flexbox mantigina benzeyen container-based layout:
|
||||
- **Opsiyonel absolute positioning:** Kullanici isterse bir elemani `position: "absolute"` yapabilir.
|
||||
|
||||
Bu sayede:
|
||||
|
||||
- Tablo satirlari artarsa alttaki elemanlar otomatik kayar.
|
||||
- Ayni satira iki kolon koymak icin ic ice container yeterlidir.
|
||||
- Absolute mod ile serbest pozisyonlama da mumkundur.
|
||||
@@ -125,7 +127,7 @@ Bu sayede:
|
||||
Her eleman ve container icin `width` ve `height` su tiplerden biri olabilir:
|
||||
|
||||
| Tip | Aciklama | Taffy karsiligi |
|
||||
| ------- | ------------------------------------- | ------------------------------ |
|
||||
| ------- | -------------------------- | ----------------------------- |
|
||||
| `fixed` | Sabit boyut (mm) | `Dimension::Length(pt)` |
|
||||
| `auto` | Iceriqe gore otomatik | `Dimension::Auto` |
|
||||
| `fr` | Kalan alani oransal doldur | `flex_grow: n, flex_basis: 0` |
|
||||
@@ -156,7 +158,10 @@ Ek olarak `minWidth`, `maxWidth`, `minHeight`, `maxHeight` (mm) desteklenir.
|
||||
"id": "c_header",
|
||||
"type": "container",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
|
||||
"size": {
|
||||
"width": { "type": "fr", "value": 1 },
|
||||
"height": { "type": "auto" },
|
||||
},
|
||||
"direction": "row",
|
||||
"gap": 5,
|
||||
"padding": { "top": 0, "right": 0, "bottom": 0, "left": 0 },
|
||||
@@ -168,36 +173,45 @@ Ek olarak `minWidth`, `maxWidth`, `minHeight`, `maxHeight` (mm) desteklenir.
|
||||
"id": "el_firma",
|
||||
"type": "text",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
|
||||
"size": {
|
||||
"width": { "type": "fr", "value": 1 },
|
||||
"height": { "type": "auto" },
|
||||
},
|
||||
"style": { "fontSize": 14, "fontWeight": "bold" },
|
||||
"binding": { "type": "scalar", "path": "firma.unvan" }
|
||||
"binding": { "type": "scalar", "path": "firma.unvan" },
|
||||
},
|
||||
{
|
||||
"id": "el_fatura_baslik",
|
||||
"type": "static_text",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"size": {
|
||||
"width": { "type": "auto" },
|
||||
"height": { "type": "auto" },
|
||||
},
|
||||
"style": { "fontSize": 12, "fontWeight": "bold", "align": "right" },
|
||||
"content": "FATURA"
|
||||
}
|
||||
]
|
||||
"content": "FATURA",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "el_cizgi",
|
||||
"type": "line",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
|
||||
"style": { "strokeColor": "#000000", "strokeWidth": 0.5 }
|
||||
}
|
||||
]
|
||||
}
|
||||
"size": {
|
||||
"width": { "type": "fr", "value": 1 },
|
||||
"height": { "type": "auto" },
|
||||
},
|
||||
"style": { "strokeColor": "#000000", "strokeWidth": 0.5 },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Eleman Tipleri
|
||||
|
||||
| Tip | Aciklama | Binding |
|
||||
| ----------------- | ------------------------------------- | ---------------- |
|
||||
| ----------------- | ----------------------------------------- | ---------------- |
|
||||
| `container` | Duzen kutusu, cocuk elemanlari barindirir | Yok |
|
||||
| `static_text` | Sabit metin, veri baglantisi yok | Yok |
|
||||
| `text` | Dinamik metin, schema'dan veri ceker | Scalar |
|
||||
@@ -209,7 +223,7 @@ Ek olarak `minWidth`, `maxWidth`, `minHeight`, `maxHeight` (mm) desteklenir.
|
||||
### Container Ozellikleri
|
||||
|
||||
| Ozellik | Tip | Aciklama |
|
||||
| ----------- | ---------------------------------------- | --------------------------------- |
|
||||
| ----------- | ------------------------------------------------------------- | ------------------------------- |
|
||||
| `direction` | `"row"` \| `"column"` | Cocuklari yatay mi dikey mi diz |
|
||||
| `gap` | number (mm) | Cocuklar arasi bosluk |
|
||||
| `padding` | `{ top, right, bottom, left }` (mm) | Ic bosluk |
|
||||
@@ -220,8 +234,8 @@ Ek olarak `minWidth`, `maxWidth`, `minHeight`, `maxHeight` (mm) desteklenir.
|
||||
### Positioning Modlari
|
||||
|
||||
| Mod | Aciklama | Taffy karsiligi |
|
||||
| ---------- | ------------------------------------------- | ---------------------------------- |
|
||||
| `flow` | Parent container'in flow'una katil (default)| `Position::Relative` |
|
||||
| ---------- | -------------------------------------------- | ------------------------------------- |
|
||||
| `flow` | Parent container'in flow'una katil (default) | `Position::Relative` |
|
||||
| `absolute` | Parent container icinde sabit konum | `Position::Absolute, inset: top/left` |
|
||||
|
||||
### Fatura Ornegi — Container Agaci
|
||||
@@ -413,7 +427,7 @@ pub struct ElementLayout {
|
||||
### Taffy Mapping
|
||||
|
||||
| dreport | taffy |
|
||||
| ------------------------------- | ---------------------------------- |
|
||||
| ----------------------------------------- | -------------------------------------------- |
|
||||
| `container(direction: row)` | `FlexDirection::Row` |
|
||||
| `container(direction: column)` | `FlexDirection::Column` |
|
||||
| `gap` | `gap: Size { width, height }` |
|
||||
@@ -586,6 +600,7 @@ Template JSON + Data JSON alir, PDF doner.
|
||||
**Response:** `Content-Type: application/pdf` — binary PDF
|
||||
|
||||
**Akis:**
|
||||
|
||||
1. Template + Data JSON parse edilir.
|
||||
2. `compute_layout(template, data, fonts)` → `LayoutResult`
|
||||
3. `render_pdf(layout_result, fonts)` → PDF bytes
|
||||
@@ -629,57 +644,11 @@ Sunucu saglik kontrolu.
|
||||
|
||||
---
|
||||
|
||||
## Gelistirme Oncelikleri (Roadmap)
|
||||
## Roadmap
|
||||
|
||||
### Faz 1: Temel Altyapi ✓
|
||||
|
||||
- [x] Proje iskeleti kurulumu (Vue + Vite + Pinia, Axum boilerplate)
|
||||
- [x] Container-based layout sistemi (tree yapi, flow + absolute positioning)
|
||||
- [x] Font dosyalari (Noto Sans ailesi)
|
||||
|
||||
### Faz 2: Custom Layout Engine ✓
|
||||
|
||||
- [x] layout-engine crate olusturma (taffy + cosmic-text)
|
||||
- [x] Template → taffy node tree donusumu (tree.rs)
|
||||
- [x] SizeValue mapping (sizing.rs)
|
||||
- [x] Text olcum (text_measure.rs, cosmic-text)
|
||||
- [x] Binding cozumleme (data_resolve.rs)
|
||||
- [x] Tablo expansion (table_layout.rs)
|
||||
- [x] WASM bindings (wasm_api.rs)
|
||||
- [x] Frontend entegrasyonu (layout.worker.ts, useLayoutEngine.ts, LayoutRenderer.vue)
|
||||
- [x] InteractionOverlay adaptasyonu
|
||||
- [x] Typst bagimliliklarinin kaldirilmasi (backend)
|
||||
|
||||
### Faz 3: PDF Render ✓
|
||||
|
||||
- [x] pdf_render.rs — krilla ile PDF uretimi
|
||||
- [x] Backend route guncelleme (POST /api/render)
|
||||
- [x] Page break desteqi (page_break.rs)
|
||||
|
||||
### Faz 4: Editor Temelleri
|
||||
|
||||
- [ ] Schema tree paneli — JSON schema'dan agac olusturma
|
||||
- [ ] Schema'dan drag ile binding olusturma
|
||||
- [ ] Properties paneli — secili elemanin stillerini duzenleme (font, renk, boyut, hizalama)
|
||||
- [ ] Container properties paneli — direction, gap, padding, align ayarlari
|
||||
- [ ] Mock data generator — schema'dan ornek veri uretip onizlemede kullanma
|
||||
- [ ] Undo/redo
|
||||
- [ ] Toolbox paneli — eleman/container ekleme
|
||||
|
||||
### Faz 5: Tablo ve Array Binding
|
||||
|
||||
- [ ] Sutun tanimlama UI'i (alan secimi, genislik, hizalama)
|
||||
- [ ] Array field'larina binding
|
||||
- [ ] Tablo stili ayarlari (header, zebra, border)
|
||||
- [ ] Format fonksiyonlari (currency, date)
|
||||
|
||||
### Faz 6: Polish
|
||||
|
||||
- [ ] Snap guides ve hizalama
|
||||
- [ ] Zoom / pan
|
||||
- [ ] `image` eleman tipi (statik + dinamik)
|
||||
- [ ] Sayfa numarasi
|
||||
- [ ] Template kaydetme / yukleme (JSON dosyasi export/import)
|
||||
|
||||
---
|
||||
|
||||
|
||||
238
Cargo.lock
generated
238
Cargo.lock
generated
@@ -248,21 +248,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cosmic-text"
|
||||
version = "0.12.1"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59fd57d82eb4bfe7ffa9b1cec0c05e2fd378155b47f255a67983cb4afe0e80c2"
|
||||
checksum = "bbe782a9e7520cc7de2232c957a47f99d3a35e855552677d07a557bc1a3b66ed"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"fontdb",
|
||||
"harfrust",
|
||||
"linebender_resource_handle",
|
||||
"log",
|
||||
"rangemap",
|
||||
"rayon",
|
||||
"rustc-hash 1.1.0",
|
||||
"rustybuzz 0.14.1",
|
||||
"rustc-hash",
|
||||
"self_cell",
|
||||
"skrifa 0.40.0",
|
||||
"smol_str",
|
||||
"swash",
|
||||
"sys-locale",
|
||||
"ttf-parser 0.21.1",
|
||||
"unicode-bidi",
|
||||
"unicode-linebreak",
|
||||
"unicode-script",
|
||||
@@ -278,31 +279,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "csv"
|
||||
version = "1.4.0"
|
||||
@@ -346,7 +322,6 @@ dependencies = [
|
||||
"base64",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -366,12 +341,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
@@ -458,15 +427,6 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "font-types"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3971f9a5ca983419cdc386941ba3b9e1feba01a0ab888adf78739feb2798492"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "font-types"
|
||||
version = "0.10.1"
|
||||
@@ -477,16 +437,25 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontdb"
|
||||
version = "0.16.2"
|
||||
name = "font-types"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3"
|
||||
checksum = "73829a7b5c91198af28a99159b7ae4afbb252fb906159ff7f189f3a2ceaa3df2"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontdb"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"
|
||||
dependencies = [
|
||||
"log",
|
||||
"memmap2",
|
||||
"slotmap",
|
||||
"tinyvec",
|
||||
"ttf-parser 0.20.0",
|
||||
"ttf-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -543,9 +512,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "grid"
|
||||
version = "0.15.0"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36119f3a540b086b4e436bb2b588cf98a68863470e0e880f4d0842f112a3183a"
|
||||
checksum = "f9e2d4c0a8296178d8802098410ca05d86b17a10bb5ab559b3fb404c1f948220"
|
||||
|
||||
[[package]]
|
||||
name = "harfrust"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bytemuck",
|
||||
"core_maths",
|
||||
"read-fonts 0.37.0",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
@@ -732,8 +714,8 @@ dependencies = [
|
||||
"once_cell",
|
||||
"pdf-writer",
|
||||
"png 0.17.16",
|
||||
"rustc-hash 2.1.2",
|
||||
"rustybuzz 0.20.1",
|
||||
"rustc-hash",
|
||||
"rustybuzz",
|
||||
"siphasher",
|
||||
"skrifa 0.37.0",
|
||||
"smallvec",
|
||||
@@ -773,6 +755,12 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "linebender_resource_handle"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -1067,36 +1055,6 @@ version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.22.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69aacb76b5c29acfb7f90155d39759a29496aebb49395830e928a9703d2eec2f"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"font-types 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.35.0"
|
||||
@@ -1107,6 +1065,17 @@ dependencies = [
|
||||
"font-types 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"core_maths",
|
||||
"font-types 0.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -1145,12 +1114,6 @@ version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -1163,23 +1126,6 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "rustybuzz"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bytemuck",
|
||||
"libm",
|
||||
"smallvec",
|
||||
"ttf-parser 0.21.1",
|
||||
"unicode-bidi-mirroring 0.2.0",
|
||||
"unicode-ccc 0.2.0",
|
||||
"unicode-properties",
|
||||
"unicode-script",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustybuzz"
|
||||
version = "0.20.1"
|
||||
@@ -1191,9 +1137,9 @@ dependencies = [
|
||||
"core_maths",
|
||||
"log",
|
||||
"smallvec",
|
||||
"ttf-parser 0.25.1",
|
||||
"unicode-bidi-mirroring 0.4.0",
|
||||
"unicode-ccc 0.4.0",
|
||||
"ttf-parser",
|
||||
"unicode-bidi-mirroring",
|
||||
"unicode-ccc",
|
||||
"unicode-properties",
|
||||
"unicode-script",
|
||||
]
|
||||
@@ -1342,16 +1288,6 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.22.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1c44ad1f6c5bdd4eefed8326711b7dbda9ea45dfd36068c427d332aa382cbe"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"read-fonts 0.22.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.37.0"
|
||||
@@ -1362,6 +1298,16 @@ dependencies = [
|
||||
"read-fonts 0.35.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.40.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"read-fonts 0.37.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -1383,6 +1329,12 @@ version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "smol_str"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
@@ -1412,18 +1364,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb6895a12ac5599bb6057362f00e8a3cf1daab4df33f553a55690a44e4fed8d0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"rustc-hash 2.1.2",
|
||||
"rustc-hash",
|
||||
"skrifa 0.37.0",
|
||||
"write-fonts",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swash"
|
||||
version = "0.1.19"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbd59f3f359ddd2c95af4758c18270eddd9c730dde98598023cdabff472c2ca2"
|
||||
checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64"
|
||||
dependencies = [
|
||||
"skrifa 0.22.3",
|
||||
"skrifa 0.40.0",
|
||||
"yazi",
|
||||
"zeno",
|
||||
]
|
||||
@@ -1467,9 +1419,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "taffy"
|
||||
version = "0.7.7"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab4f4d046dd956a47a7e1a2947083d7ac3e6aa3cfaaead36173ceaa5ab11878c"
|
||||
checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"grid",
|
||||
@@ -1613,18 +1565,6 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ttf-parser"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4"
|
||||
|
||||
[[package]]
|
||||
name = "ttf-parser"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8"
|
||||
|
||||
[[package]]
|
||||
name = "ttf-parser"
|
||||
version = "0.25.1"
|
||||
@@ -1640,24 +1580,12 @@ version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi-mirroring"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi-mirroring"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ccc"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ccc"
|
||||
version = "0.4.0"
|
||||
@@ -1862,9 +1790,9 @@ checksum = "ce9e2f4a404d9ebffc0a9832cf4f50907220ba3d7fffa9099261a5cab52f2dd7"
|
||||
|
||||
[[package]]
|
||||
name = "yazi"
|
||||
version = "0.1.6"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1"
|
||||
checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
@@ -1891,9 +1819,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zeno"
|
||||
version = "0.2.3"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697"
|
||||
checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
|
||||
271
ELEMENTS.md
Normal file
271
ELEMENTS.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Eleman Tipleri — dreport
|
||||
|
||||
Bu belge, dreport toolbar'inda bulunan ve planlanmis tum eleman tiplerini aciklar.
|
||||
|
||||
---
|
||||
|
||||
## Mevcut Elemanlar
|
||||
|
||||
### `container` — Duzen Kutusu
|
||||
|
||||
CSS Flexbox mantiginda calisan layout container'i. Cocuk elemanlari `direction` (row/column) dogrultusunda dizer. Ic ice gecebilir. Tum diger elemanlar bir container icinde yer alir.
|
||||
|
||||
- **Binding:** Yok
|
||||
- **Ozellikler:** `direction`, `gap`, `padding`, `align`, `justify`, `style`
|
||||
|
||||
---
|
||||
|
||||
### `static_text` — Sabit Metin
|
||||
|
||||
Veri baglantisi olmayan, kullanicinin dogrudan yazdigi metin. Fatura basliklari, etiketler, aciklama satirlari icin kullanilir.
|
||||
|
||||
- **Binding:** Yok
|
||||
- **Ozellikler:** `content`, `style` (fontSize, fontWeight, color, align)
|
||||
|
||||
---
|
||||
|
||||
### `text` — Dinamik Metin
|
||||
|
||||
JSON schema'dan veri ceken metin elemani. Kullanici schema agacindan bir alani surukleyip bu elemana baglar.
|
||||
|
||||
- **Binding:** Scalar (`"binding": { "type": "scalar", "path": "firma.unvan" }`)
|
||||
- **Ozellikler:** `binding`, `style`, `format` (currency, date, percentage)
|
||||
|
||||
---
|
||||
|
||||
### `repeating_table` — Tekrarlayan Tablo
|
||||
|
||||
Array verisinden tekrarlayan satirlar ureten tablo bileseni. Fatura kalemleri, stok listeleri gibi tekrarlayan veri icin kullanilir.
|
||||
|
||||
- **Binding:** Array (`"dataSource": "kalemler"`)
|
||||
- **Ozellikler:** `columns` (alan, genislik, hizalama), `headerStyle`, `rowStyle`, `zebraStyle`
|
||||
|
||||
---
|
||||
|
||||
### `line` — Cizgi
|
||||
|
||||
Yatay veya dikey ayirici cizgi. Bolum ayirma, dekoratif amaclarla kullanilir.
|
||||
|
||||
- **Binding:** Yok
|
||||
- **Ozellikler:** `style` (strokeColor, strokeWidth)
|
||||
|
||||
---
|
||||
|
||||
### `image` — Gorsel
|
||||
|
||||
Statik (base64/URL) veya dinamik (schema'dan) gorsel. Logo, imza, urun gorseli gibi kullanim alanlari.
|
||||
|
||||
- **Binding:** Opsiyonel scalar (dinamik gorsel icin)
|
||||
- **Ozellikler:** `src` (statik), `binding`, `style` (objectFit)
|
||||
|
||||
---
|
||||
|
||||
### `page_number` — Sayfa Numarasi
|
||||
|
||||
Cok sayfali belgelerde otomatik sayfa numarasi. Format sablonu destekler (or: "Sayfa {current} / {total}").
|
||||
|
||||
- **Binding:** Otomatik
|
||||
- **Ozellikler:** `format`, `style`
|
||||
|
||||
---
|
||||
|
||||
### `barcode` — Barkod / QR Kod
|
||||
|
||||
1D ve 2D barkod ureteci. e-Fatura, e-Arsiv, urun etiketleri icin kullanilir.
|
||||
|
||||
- **Binding:** Scalar (barkod verisi icin)
|
||||
- **Desteklenen formatlar:** QR, EAN-13, EAN-8, CODE128, CODE39
|
||||
- **Ozellikler:** `barcodeType`, `binding`, `style`
|
||||
|
||||
---
|
||||
|
||||
## Planlanmis Elemanlar
|
||||
|
||||
### `rich_text` — Zengin Metin [Henuz implemente edilmedi]
|
||||
|
||||
Tek bir metin blogu icinde karisik formatlama destekleyen eleman. Kalin, italik, farkli font boyutu, renk gibi stilleri ayni paragraf icinde kullanmayi saglar.
|
||||
|
||||
- **Kullanim alanlari:** Fatura aciklama alanlari, sozlesme maddeleri, rapor notlari, uzun formlu metin icerikleri
|
||||
- **Binding:** Opsiyonel scalar (dinamik icerik icin)
|
||||
- **Yaklasim:** Inline span'lar ile zengin metin. cosmic-text attributed text destekledigi icin layout engine tarafinda uyumlu.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "rich_text",
|
||||
"content": [
|
||||
{ "text": "Odeme vadesi: ", "style": {} },
|
||||
{ "text": "30 gun", "style": { "fontWeight": "bold", "color": "#e00" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Referans:** Telerik (HtmlTextBox), DevExpress (Rich Text), Stimulsoft, FastReport, CraftMyPDF — hepsinde mevcut. Belge tasarim araclarinda standart bir beklenti.
|
||||
|
||||
---
|
||||
|
||||
### `shape` — Sekil (Dikdortgen / Elips) [Henuz implemente edilmedi]
|
||||
|
||||
Cocuk eleman barindirmayan sade gorsel element. Vurgu kutulari, dekoratif cerceveler, arka plan alanlari icin kullanilir. Container'dan farki: layout'a katilmaz, sadece gorsel amaclidir.
|
||||
|
||||
- **Kullanim alanlari:** Toplam kutusunun arka plani, raporlarda highlight alanlari, dekoratif cerceveler
|
||||
- **Binding:** Yok
|
||||
- **Sekil tipleri:** `rectangle`, `ellipse`, `rounded_rectangle`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "shape",
|
||||
"shapeType": "rectangle",
|
||||
"style": {
|
||||
"backgroundColor": "#f0f0f0",
|
||||
"borderColor": "#333",
|
||||
"borderWidth": 0.5,
|
||||
"borderRadius": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Referans:** JasperReports, Telerik, DevExpress, Stimulsoft, FastReport, CraftMyPDF — neredeyse tum araclarda var.
|
||||
|
||||
---
|
||||
|
||||
### `checkbox` — Onay Kutusu [Henuz implemente edilmedi]
|
||||
|
||||
Boolean deger gosteren isaret kutusu. Isaretsiz kare veya isaretli (checkmark) kare olarak render edilir. Veri baglantisi ile dinamik calisan veya statik olarak kullanilabilen basit bir element.
|
||||
|
||||
- **Kullanim alanlari:** Irsaliyelerde "teslim edildi / edilmedi", faturalarda odeme durumu, raporlarda checklist, form benzeri belgeler
|
||||
- **Binding:** Scalar (boolean alan)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "checkbox",
|
||||
"binding": { "type": "scalar", "path": "fatura.odpiendi" },
|
||||
"style": { "size": 4, "checkColor": "#000", "borderColor": "#333" }
|
||||
}
|
||||
```
|
||||
|
||||
**Referans:** DevExpress, Telerik, Stimulsoft, FastReport, CraftMyPDF.
|
||||
|
||||
---
|
||||
|
||||
### `calculated_text` — Hesaplanmis Alan [Henuz implemente edilmedi]
|
||||
|
||||
Basit ifadeler (expression) ile hesaplanmis deger gosteren metin elemani. Aritmetik islemler, string birlestirme ve kosullu metin destekler.
|
||||
|
||||
- **Kullanim alanlari:** Ara toplam hesaplari (`araToplam * 0.20`), string birlestirme (`"Fatura No: " + fatura.no`), kosullu metin, rapor ozetleri
|
||||
- **Binding:** Expression-based (birden fazla alana referans verebilir)
|
||||
- **Format:** currency, date, percentage, number destegi
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "calculated_text",
|
||||
"expression": "toplamlar.araToplam * 0.20",
|
||||
"format": "currency",
|
||||
"style": { "fontSize": 10 }
|
||||
}
|
||||
```
|
||||
|
||||
**Referans:** Crystal Reports (Formula Field), JasperReports (Variable), Stimulsoft (Expression).
|
||||
|
||||
---
|
||||
|
||||
### `current_date` — Tarih / Zaman [Henuz implemente edilmedi]
|
||||
|
||||
Belgenin basilma/render anindaki tarihi otomatik gosteren element. `page_number` gibi otomatik deger uretir, veri baglantisi gerektirmez.
|
||||
|
||||
- **Kullanim alanlari:** Fatura basim tarihi, rapor olusturma zamani, belge altbilgisi
|
||||
- **Binding:** Otomatik
|
||||
- **Format:** Konfigurasyon ile (or: `DD.MM.YYYY`, `DD MMMM YYYY`, `DD.MM.YYYY HH:mm`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "current_date",
|
||||
"format": "DD.MM.YYYY",
|
||||
"style": { "fontSize": 8, "color": "#666" }
|
||||
}
|
||||
```
|
||||
|
||||
**Referans:** Crystal Reports (Print Date), JasperReports (Current Date), BIRT (AutoText).
|
||||
|
||||
---
|
||||
|
||||
### `page_break` — Sayfa Sonu [Henuz implemente edilmedi]
|
||||
|
||||
Kullanicinin belirli bir noktada yeni sayfaya gecmesini saglayan kontrol elemani. Otomatik sayfa sonu (page_break.rs) zaten mevcut, bu element manuel kontrol saglar.
|
||||
|
||||
- **Kullanim alanlari:** Rapor ozet sayfasi + detay sayfasi ayrimi, faturada ek bilgi sayfasi, belirli bolumlerin ayri sayfada baslamasi
|
||||
- **Binding:** Yok
|
||||
- **Gorsel:** Editorde kesikli cizgi olarak gosterilir, PDF'te sayfa gecisi uretir.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "page_break"
|
||||
}
|
||||
```
|
||||
|
||||
**Referans:** DevExpress (Page Break kontrol), Stimulsoft.
|
||||
|
||||
---
|
||||
|
||||
### `chart` — Grafik [Henuz implemente edilmedi]
|
||||
|
||||
Veri gorselIestirme icin basit grafik elemani. Rapor ciktilari icin degerli, fatura/irsaliye icin genellikle gereksiz.
|
||||
|
||||
- **Kullanim alanlari:** Satis raporlari, performans ozetleri, karsilastirmali veriler
|
||||
- **Binding:** Array veya multiple scalar
|
||||
- **Grafik tipleri:** `bar`, `pie`, `line` (baslangic seti)
|
||||
- **Yaklasim:** Backend'de SVG olarak render edilip PDF'e image olarak gomulur.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "chart",
|
||||
"chartType": "bar",
|
||||
"dataSource": "aylik_satislar",
|
||||
"labelField": "ay",
|
||||
"valueField": "tutar",
|
||||
"style": { "width": 120, "height": 80 }
|
||||
}
|
||||
```
|
||||
|
||||
**Referans:** JasperReports, Crystal Reports, Telerik, DevExpress, Stimulsoft, CraftMyPDF — enterprise araclarin tamami destekler.
|
||||
|
||||
---
|
||||
|
||||
## Toolbar Organizasyonu
|
||||
|
||||
```
|
||||
Toolbar
|
||||
├── Duzen
|
||||
│ ├── Container (mevcut)
|
||||
│ └── Page Break (planlanmis)
|
||||
├── Metin
|
||||
│ ├── Statik Metin (mevcut)
|
||||
│ ├── Rich Text (planlanmis)
|
||||
│ └── Hesaplanmis Alan (planlanmis)
|
||||
├── Veri
|
||||
│ ├── Tekrarlayan Tablo (mevcut)
|
||||
│ └── Checkbox (planlanmis)
|
||||
├── Gorsel
|
||||
│ ├── Gorsel (mevcut)
|
||||
│ ├── Cizgi (mevcut)
|
||||
│ ├── Sekil (planlanmis)
|
||||
│ └── Barkod / QR (mevcut)
|
||||
├── Otomatik
|
||||
│ ├── Sayfa No (mevcut)
|
||||
│ └── Tarih (planlanmis)
|
||||
└── Rapor
|
||||
└── Grafik (planlanmis)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Oncelik Sirasi
|
||||
|
||||
| Oncelik | Element | Gerekce |
|
||||
|---------|---------|---------|
|
||||
| 1 | `rich_text` | Karisik formatlama en cok talep edilen ozellik, cosmic-text uyumlu |
|
||||
| 2 | `shape` | Basit implementasyon, gorsel zenginlik katiyor |
|
||||
| 3 | `checkbox` | Boolean gosterim, form/irsaliye icin onemli |
|
||||
| 4 | `calculated_text` | Hesaplama ihtiyaci fatura/rapor icin kritik |
|
||||
| 5 | `current_date` | Kucuk ama kullanisli, hizli implemente edilir |
|
||||
| 6 | `page_break` | Manuel sayfa kontrolu, rapor senaryolari icin |
|
||||
| 7 | `chart` | En karmasik, rapor fazinda ele alinabilir |
|
||||
4100
backend/Cargo.lock
generated
4100
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,14 +4,9 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
crate-type = ["rlib"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
wasm = []
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
pub mod models;
|
||||
pub mod template_to_typst;
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
mod wasm_api;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::models::Template;
|
||||
use crate::template_to_typst::{self, RenderMode};
|
||||
|
||||
/// Template JSON + Data JSON → Typst markup (editör modu, layout query dahil)
|
||||
#[wasm_bindgen(js_name = "templateToTypstEditor")]
|
||||
pub fn template_to_typst_editor(template_json: &str, data_json: &str) -> Result<String, JsValue> {
|
||||
let template: Template = serde_json::from_str(template_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Template parse hatasi: {}", e)))?;
|
||||
let data: serde_json::Value = serde_json::from_str(data_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Data parse hatasi: {}", e)))?;
|
||||
|
||||
Ok(template_to_typst::template_to_typst(&template, &data, RenderMode::Editor))
|
||||
}
|
||||
|
||||
/// Template JSON + Data JSON → Typst markup (PDF modu, layout query yok)
|
||||
#[wasm_bindgen(js_name = "templateToTypstPdf")]
|
||||
pub fn template_to_typst_pdf(template_json: &str, data_json: &str) -> Result<String, JsValue> {
|
||||
let template: Template = serde_json::from_str(template_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Template parse hatasi: {}", e)))?;
|
||||
let data: serde_json::Value = serde_json::from_str(data_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Data parse hatasi: {}", e)))?;
|
||||
|
||||
Ok(template_to_typst::template_to_typst(&template, &data, RenderMode::Pdf))
|
||||
}
|
||||
@@ -5,18 +5,19 @@
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"dependencies": {
|
||||
"@myriaddreamin/typst-ts-renderer": "^0.7.0-rc2",
|
||||
"@myriaddreamin/typst-ts-web-compiler": "^0.7.0-rc2",
|
||||
"@myriaddreamin/typst.ts": "^0.7.0-rc2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.30",
|
||||
"vue": "^3.5.31",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"typescript": "~5.9.3",
|
||||
"happy-dom": "^20.8.9",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.1",
|
||||
"vitest": "^4.1.2",
|
||||
"vue-tsc": "^3.2.5",
|
||||
},
|
||||
},
|
||||
@@ -36,18 +37,20 @@
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
|
||||
|
||||
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@myriaddreamin/typst-ts-renderer": ["@myriaddreamin/typst-ts-renderer@0.7.0-rc2", "", {}, "sha512-god1tcb2YJDkQfA8gLGcAmykVGBpNKorqqDkXVy3InC18KRbsverJhlrHoONurNIU9JuIHoWjJ2D1ntpjPgzbA=="],
|
||||
|
||||
"@myriaddreamin/typst-ts-web-compiler": ["@myriaddreamin/typst-ts-web-compiler@0.7.0-rc2", "", {}, "sha512-WFO/ecKUfeclld5uDxyjgpnIafKpp2LrS6T1vY+CHaSxCm099AneAQIYFg+OtX+NbFpJsLGCBFSw/qppJJmBAw=="],
|
||||
|
||||
"@myriaddreamin/typst.ts": ["@myriaddreamin/typst.ts@0.7.0-rc2", "", { "dependencies": { "idb": "^7.1.1" }, "peerDependencies": { "@myriaddreamin/typst-ts-renderer": "^0.7.0-rc2", "@myriaddreamin/typst-ts-web-compiler": "^0.7.0-rc2" }, "optionalPeers": ["@myriaddreamin/typst-ts-renderer", "@myriaddreamin/typst-ts-web-compiler"] }, "sha512-VM8JqsRcL3AEJ5cuPBn/YvnGTXK/BRPlxdGB2bR48Of/8OIGaPiunv2QfZBIMBBrtbTygUOtAY9BZvkS1AFqgA=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="],
|
||||
|
||||
"@one-ini/wasm": ["@one-ini/wasm@0.1.1", "", {}, "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="],
|
||||
@@ -80,12 +83,38 @@
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.5", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.1.2", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.2", "", { "dependencies": { "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.2", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@4.1.2", "", { "dependencies": { "@vitest/utils": "4.1.2", "pathe": "^2.0.3" } }, "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@4.1.2", "", {}, "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="],
|
||||
|
||||
"@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
|
||||
|
||||
"@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
|
||||
@@ -118,32 +147,86 @@
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.31", "", {}, "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw=="],
|
||||
|
||||
"@vue/test-utils": ["@vue/test-utils@2.4.6", "", { "dependencies": { "js-beautify": "^1.14.9", "vue-component-type-helpers": "^2.0.0" } }, "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow=="],
|
||||
|
||||
"@vue/tsconfig": ["@vue/tsconfig@0.9.1", "", { "peerDependencies": { "typescript": ">= 5.8", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w=="],
|
||||
|
||||
"abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="],
|
||||
|
||||
"alien-signals": ["alien-signals@3.1.2", "", {}, "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
"config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
|
||||
"editorconfig": ["editorconfig@1.0.7", "", { "dependencies": { "@one-ini/wasm": "0.1.1", "commander": "^10.0.0", "minimatch": "^9.0.1", "semver": "^7.5.3" }, "bin": { "editorconfig": "bin/editorconfig" } }, "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"happy-dom": ["happy-dom@20.8.9", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA=="],
|
||||
|
||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="],
|
||||
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"js-beautify": ["js-beautify@1.15.4", "", { "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", "glob": "^10.4.2", "js-cookie": "^3.0.5", "nopt": "^7.2.1" }, "bin": { "css-beautify": "js/bin/css-beautify.js", "html-beautify": "js/bin/html-beautify.js", "js-beautify": "js/bin/js-beautify.js" } }, "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA=="],
|
||||
|
||||
"js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
@@ -168,16 +251,34 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
|
||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||
|
||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
@@ -186,34 +287,116 @@
|
||||
|
||||
"pinia": ["pinia@3.0.4", "", { "dependencies": { "@vue/devtools-api": "^7.7.7" }, "peerDependencies": { "typescript": ">=4.5.0", "vue": "^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="],
|
||||
|
||||
"string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
"typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="],
|
||||
|
||||
"vitest": ["vitest@4.1.2", "", { "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", "@vitest/pretty-format": "4.1.2", "@vitest/runner": "4.1.2", "@vitest/snapshot": "4.1.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.2", "@vitest/browser-preview": "4.1.2", "@vitest/browser-webdriverio": "4.1.2", "@vitest/ui": "4.1.2", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg=="],
|
||||
|
||||
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||
|
||||
"vue": ["vue@3.5.31", "", { "dependencies": { "@vue/compiler-dom": "3.5.31", "@vue/compiler-sfc": "3.5.31", "@vue/runtime-dom": "3.5.31", "@vue/server-renderer": "3.5.31", "@vue/shared": "3.5.31" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q=="],
|
||||
|
||||
"vue-component-type-helpers": ["vue-component-type-helpers@2.2.12", "", {}, "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw=="],
|
||||
|
||||
"vue-tsc": ["vue-tsc@3.2.6", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.2.6" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
|
||||
"@types/ws/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
|
||||
|
||||
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"happy-dom/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="],
|
||||
|
||||
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"@types/ws/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"happy-dom/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,21 +6,25 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:visual": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@myriaddreamin/typst-ts-renderer": "^0.7.0-rc2",
|
||||
"@myriaddreamin/typst-ts-web-compiler": "^0.7.0-rc2",
|
||||
"@myriaddreamin/typst.ts": "^0.7.0-rc2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.30"
|
||||
"vue": "^3.5.31"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"typescript": "~5.9.3",
|
||||
"happy-dom": "^20.8.9",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.1",
|
||||
"vitest": "^4.1.2",
|
||||
"vue-tsc": "^3.2.5"
|
||||
}
|
||||
}
|
||||
|
||||
21
frontend/playwright.config.ts
Normal file
21
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/visual',
|
||||
outputDir: './tests/visual/test-results',
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
viewport: { width: 1400, height: 900 },
|
||||
},
|
||||
webServer: {
|
||||
command: 'bun run dev',
|
||||
port: 5173,
|
||||
reuseExistingServer: true,
|
||||
timeout: 30000,
|
||||
},
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
maxDiffPixelRatio: 0.01,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import type { Template, JsonSchema } from './lib'
|
||||
|
||||
// --- Full Invoice Schema ---
|
||||
|
||||
const invoiceSchema: JsonSchema = {
|
||||
const defaultInvoiceSchema: JsonSchema = {
|
||||
$id: 'fatura-schema',
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -73,6 +73,31 @@ const invoiceSchema: JsonSchema = {
|
||||
},
|
||||
}
|
||||
|
||||
const currentSchema = ref<JsonSchema>(structuredClone(defaultInvoiceSchema))
|
||||
|
||||
// --- Schema persistence ---
|
||||
|
||||
const SCHEMA_STORAGE_KEY = 'dreport-schema'
|
||||
|
||||
function loadSchemaFromLocalStorage(): JsonSchema | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SCHEMA_STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw) as JsonSchema
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const savedSchema = loadSchemaFromLocalStorage()
|
||||
if (savedSchema) {
|
||||
currentSchema.value = savedSchema
|
||||
}
|
||||
|
||||
watch(currentSchema, (val) => {
|
||||
localStorage.setItem(SCHEMA_STORAGE_KEY, JSON.stringify(val))
|
||||
}, { deep: true })
|
||||
|
||||
// --- Sample Invoice Data ---
|
||||
|
||||
const sampleData: Record<string, unknown> = {
|
||||
@@ -457,6 +482,7 @@ watch(template, (val) => {
|
||||
const editorRef = ref<InstanceType<typeof DreportEditor> | null>(null)
|
||||
const pdfLoading = ref(false)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const schemaFileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function triggerImport() {
|
||||
fileInputRef.value?.click()
|
||||
@@ -469,6 +495,19 @@ function onImportFile(e: Event) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(reader.result as string)
|
||||
// Detect bundle (has both 'template' and 'schema' keys)
|
||||
if (parsed.template && parsed.schema) {
|
||||
editorRef.value?.importTemplate(JSON.stringify(parsed.template))
|
||||
currentSchema.value = parsed.schema
|
||||
return
|
||||
}
|
||||
// Detect standalone template (has 'root' key)
|
||||
if (parsed.root) {
|
||||
editorRef.value?.importTemplate(reader.result as string)
|
||||
return
|
||||
}
|
||||
// Fallback: try as template
|
||||
editorRef.value?.importTemplate(reader.result as string)
|
||||
} catch {
|
||||
alert('Gecersiz sablon dosyasi')
|
||||
@@ -490,6 +529,59 @@ function exportTemplate() {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// --- Schema import/export ---
|
||||
|
||||
function triggerSchemaImport() {
|
||||
schemaFileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function onSchemaImportFile(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const schema = JSON.parse(reader.result as string)
|
||||
currentSchema.value = schema
|
||||
} catch {
|
||||
alert('Gecersiz schema dosyasi')
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function exportSchema() {
|
||||
const json = JSON.stringify(currentSchema.value, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'schema.json'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// --- Bundle export (template + schema) ---
|
||||
|
||||
function exportBundle() {
|
||||
const templateJson = editorRef.value?.exportTemplate()
|
||||
if (!templateJson) return
|
||||
const bundle = {
|
||||
template: JSON.parse(templateJson),
|
||||
schema: currentSchema.value,
|
||||
}
|
||||
const json = JSON.stringify(bundle, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${template.value.name || 'sablon'}-bundle.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function downloadPdf() {
|
||||
pdfLoading.value = true
|
||||
try {
|
||||
@@ -510,7 +602,9 @@ async function downloadPdf() {
|
||||
|
||||
function resetTemplate() {
|
||||
template.value = structuredClone(defaultInvoiceTemplate)
|
||||
currentSchema.value = structuredClone(defaultInvoiceSchema)
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
localStorage.removeItem(SCHEMA_STORAGE_KEY)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -521,17 +615,50 @@ function resetTemplate() {
|
||||
<span class="app-header__subtitle">Belge Tasarim Araci</span>
|
||||
<div style="flex: 1"></div>
|
||||
<input ref="fileInputRef" type="file" accept=".json" style="display: none" @change="onImportFile" />
|
||||
<button class="header-btn header-btn--secondary" @click="resetTemplate">Sifirla</button>
|
||||
<button class="header-btn header-btn--secondary" @click="triggerImport">Yukle</button>
|
||||
<button class="header-btn header-btn--secondary" @click="exportTemplate">Kaydet</button>
|
||||
<input ref="schemaFileInputRef" type="file" accept=".json" style="display: none" @change="onSchemaImportFile" />
|
||||
|
||||
<!-- Template operations -->
|
||||
<button class="header-btn header-btn--secondary" @click="resetTemplate" title="Sifirla">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 8a6 6 0 0 1 10.2-4.3L14 2v4h-4l1.7-1.7A4.5 4.5 0 1 0 12.5 8" /><path d="M12.5 8a4.5 4.5 0 0 1-8.2 2.5" /></svg>
|
||||
Sifirla
|
||||
</button>
|
||||
<button class="header-btn header-btn--secondary" @click="triggerImport" title="Sablon Yukle">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 10V2m0 0L5 5m3-3 3 3" /><path d="M2 10v2a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-2" /></svg>
|
||||
Yukle
|
||||
</button>
|
||||
<button class="header-btn header-btn--secondary" @click="exportTemplate" title="Sablon Kaydet">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v8m0 0 3-3m-3 3L5 7" /><path d="M2 10v2a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-2" /></svg>
|
||||
Kaydet
|
||||
</button>
|
||||
<button class="header-btn header-btn--secondary" @click="exportBundle" title="Sablon + Schema Birlikte Kaydet">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="1" width="12" height="14" rx="1.5" /><path d="M5 4h6M5 7h6M5 10h4" /></svg>
|
||||
Paket
|
||||
</button>
|
||||
|
||||
<div class="header-divider"></div>
|
||||
|
||||
<!-- Schema operations -->
|
||||
<button class="header-btn header-btn--secondary" @click="triggerSchemaImport" title="Schema Yukle">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 10V2m0 0L5 5m3-3 3 3" /><path d="M2 10v2a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-2" /></svg>
|
||||
Schema
|
||||
</button>
|
||||
<button class="header-btn header-btn--secondary" @click="exportSchema" title="Schema Kaydet">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v8m0 0 3-3m-3 3L5 7" /><path d="M2 10v2a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-2" /></svg>
|
||||
Schema
|
||||
</button>
|
||||
|
||||
<div class="header-divider"></div>
|
||||
|
||||
<!-- 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' }}
|
||||
</button>
|
||||
</header>
|
||||
<DreportEditor
|
||||
ref="editorRef"
|
||||
v-model="template"
|
||||
:schema="invoiceSchema"
|
||||
:schema="currentSchema"
|
||||
:data="sampleData"
|
||||
:config="{ apiBaseUrl: 'http://localhost:3001/api' }"
|
||||
/>
|
||||
@@ -548,8 +675,8 @@ function resetTemplate() {
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: #1e293b;
|
||||
color: white;
|
||||
@@ -599,4 +726,20 @@ function resetTemplate() {
|
||||
background: #334155;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
vertical-align: -2px;
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #475569;
|
||||
margin: 0 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -147,12 +147,7 @@ function applyZoom(delta: number, clientX: number, clientY: number) {
|
||||
const mousePageMmX = (clientX - pageRect.left) / oldScale
|
||||
const mousePageMmY = (clientY - pageRect.top) / oldScale
|
||||
|
||||
// Flex centering kayması: sayfa genişliği değişince ortalama kayar
|
||||
// X ekseni: justify-content: center → kayma = (eskiBoyut - yeniBoyut) / 2
|
||||
const pageW = templateStore.template.page.width
|
||||
const centerShiftX = pageW * (oldScale - newScale) / 2
|
||||
// Y ekseni: align-items: flex-start → kayma yok
|
||||
const centerShiftY = 0
|
||||
|
||||
// Yeni pan: mouse'un gösterdiği mm noktası aynı ekran pozisyonunda kalmalı
|
||||
const newPanX = editorStore.panX + (mousePageMmX - pageW / 2) * (oldScale - newScale)
|
||||
|
||||
@@ -17,7 +17,7 @@ const isSelected = computed(() => editorStore.selectedElementId === props.elemen
|
||||
const isContainerEl = computed(() => isContainer(props.element))
|
||||
const isAbsolute = computed(() => props.element.position.type === 'absolute')
|
||||
|
||||
// --- CSS style: layout'u Typst ile eşleştir ---
|
||||
// --- CSS style: layout engine sonuçlarına göre ---
|
||||
const layoutStyle = computed(() => {
|
||||
const el = props.element
|
||||
const s = props.scale
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ElementLayout } from '../../core/layout-types'
|
||||
import type { TemplateElement, SizeValue, ContainerElement } from '../../core/types'
|
||||
import { isContainer, sz } from '../../core/types'
|
||||
import ElementToolbar from './ElementToolbar.vue'
|
||||
import { useSnapGuides } from '../../composables/useSnapGuides'
|
||||
|
||||
const props = defineProps<{
|
||||
scale: number
|
||||
@@ -14,6 +15,7 @@ const props = defineProps<{
|
||||
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
const { activeGuides, collectEdges, calculateSnap, calculateResizeSnap, clearGuides } = useSnapGuides()
|
||||
|
||||
// Tüm elemanları flat olarak topla (root hariç)
|
||||
const flatElements = computed(() => {
|
||||
@@ -382,6 +384,8 @@ function onAbsoluteDragStart(e: PointerEvent, el: TemplateElement) {
|
||||
elY: el.position.y,
|
||||
}
|
||||
|
||||
collectEdges(props.layoutMap, el.id, templateStore.template.page.width, templateStore.template.page.height)
|
||||
|
||||
window.addEventListener('pointermove', onAbsoluteDragMove)
|
||||
window.addEventListener('pointerup', onAbsoluteDragEnd)
|
||||
}
|
||||
@@ -400,8 +404,16 @@ function onAbsoluteDragMove(e: PointerEvent) {
|
||||
}
|
||||
|
||||
const pxToMm = 1 / props.scale
|
||||
const newX = Math.max(0, absoluteDragStart.value.elX + dx * pxToMm)
|
||||
const newY = Math.max(0, absoluteDragStart.value.elY + dy * pxToMm)
|
||||
const proposedX = Math.max(0, absoluteDragStart.value.elX + dx * pxToMm)
|
||||
const proposedY = Math.max(0, absoluteDragStart.value.elY + dy * pxToMm)
|
||||
|
||||
const layout = props.layoutMap[absoluteDragId.value]
|
||||
const elW = layout ? layout.width_mm : 0
|
||||
const elH = layout ? layout.height_mm : 0
|
||||
|
||||
const snap = calculateSnap(proposedX, proposedY, elW, elH)
|
||||
const newX = snap.snappedX_mm
|
||||
const newY = snap.snappedY_mm
|
||||
|
||||
templateStore.updateElementPosition(absoluteDragId.value, {
|
||||
type: 'absolute',
|
||||
@@ -417,6 +429,7 @@ function onAbsoluteDragEnd() {
|
||||
isDragging.value = false
|
||||
absoluteDragId.value = null
|
||||
editorStore.setDragging(false)
|
||||
clearGuides()
|
||||
setTimeout(() => { didDrag.value = false }, 50)
|
||||
}
|
||||
|
||||
@@ -455,6 +468,8 @@ function onResizeStart(e: PointerEvent, elId: string, handle: string) {
|
||||
resizeGhost.value = { x: l.x_mm * s, y: l.y_mm * s, width: l.width_mm * s, height: l.height_mm * s }
|
||||
resizeFinalMm.value = { width: l.width_mm, height: l.height_mm }
|
||||
|
||||
collectEdges(props.layoutMap, elId, templateStore.template.page.width, templateStore.template.page.height)
|
||||
|
||||
window.addEventListener('pointermove', onResizeMove)
|
||||
window.addEventListener('pointerup', onResizeEnd)
|
||||
}
|
||||
@@ -485,11 +500,25 @@ function onResizeMove(e: PointerEvent) {
|
||||
|
||||
const startWMm = resizeStart.value.width * pxToMm
|
||||
const startHMm = resizeStart.value.height * pxToMm
|
||||
const startXMm = resizeStart.value.x * pxToMm
|
||||
const startYMm = resizeStart.value.y * pxToMm
|
||||
let wMm = startWMm, hMm = startHMm
|
||||
if (handle.includes('e')) wMm = Math.max(5, startWMm + dx * pxToMm)
|
||||
if (handle.includes('w')) wMm = Math.max(5, startWMm - dx * pxToMm)
|
||||
if (handle.includes('s')) hMm = Math.max(3, startHMm + dy * pxToMm)
|
||||
if (handle.includes('n')) hMm = Math.max(3, startHMm - dy * pxToMm)
|
||||
if (handle.includes('e')) {
|
||||
const rightEdge = calculateResizeSnap('right', startXMm + startWMm + dx * pxToMm)
|
||||
wMm = Math.max(5, rightEdge - startXMm)
|
||||
}
|
||||
if (handle.includes('w')) {
|
||||
const leftEdge = calculateResizeSnap('left', startXMm + dx * pxToMm)
|
||||
wMm = Math.max(5, startXMm + startWMm - leftEdge)
|
||||
}
|
||||
if (handle.includes('s')) {
|
||||
const bottomEdge = calculateResizeSnap('bottom', startYMm + startHMm + dy * pxToMm)
|
||||
hMm = Math.max(3, bottomEdge - startYMm)
|
||||
}
|
||||
if (handle.includes('n')) {
|
||||
const topEdge = calculateResizeSnap('top', startYMm + dy * pxToMm)
|
||||
hMm = Math.max(3, startYMm + startHMm - topEdge)
|
||||
}
|
||||
|
||||
if (ar > 0) {
|
||||
hMm = wMm / ar
|
||||
@@ -519,6 +548,7 @@ function onResizeEnd() {
|
||||
isResizing.value = false
|
||||
resizeElementId.value = null
|
||||
resizeHandle.value = ''
|
||||
clearGuides()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -629,6 +659,23 @@ const isAnyDragActive = computed(() =>
|
||||
<!-- Drop indicator (ortak — hem eleman hem toolbox sürükleme) -->
|
||||
<div v-if="isAnyDragActive" :style="dropIndicatorStyle" />
|
||||
|
||||
<!-- Snap guides -->
|
||||
<div
|
||||
v-for="(guide, gi) in activeGuides"
|
||||
:key="'guide-' + gi"
|
||||
class="snap-guide"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
...(guide.type === 'vertical'
|
||||
? { left: `${guide.position_mm * scale}px`, top: '0', bottom: '0', width: '1px' }
|
||||
: { top: `${guide.position_mm * scale}px`, left: '0', right: '0', height: '1px' }),
|
||||
background: '#3b82f6',
|
||||
opacity: 0.7,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 9999,
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- Element toolbar — seçili elemanın üstünde -->
|
||||
<ElementToolbar
|
||||
v-if="!isDragging && !isResizing"
|
||||
|
||||
@@ -8,7 +8,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
// WASM barcode üretme fonksiyonu (EditorCanvas'tan provide edilir)
|
||||
const generateBarcode = inject<(format: string, value: string, width: number, height: number) => Promise<{ width: number; height: number; rgba: ArrayBuffer } | null>>('generateBarcode')
|
||||
const generateBarcode = inject<(format: string, value: string, width: number, height: number, includeText: boolean) => Promise<{ width: number; height: number; rgba: ArrayBuffer } | null>>('generateBarcode')
|
||||
|
||||
const pageElements = computed(() => {
|
||||
if (!props.layout || props.layout.pages.length === 0) return []
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
svg: string | null
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="typst-svg-layer" v-if="svg" v-html="svg" />
|
||||
<div class="typst-svg-layer typst-svg-layer--empty" v-else>
|
||||
<span>Derleniyor...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.typst-svg-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.typst-svg-layer :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.typst-svg-layer--empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
266
frontend/src/components/panels/SchemaTreeNode.vue
Normal file
266
frontend/src/components/panels/SchemaTreeNode.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { SchemaNode } from '../../core/schema-parser'
|
||||
import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser'
|
||||
import type { TemplateElement, RepeatingTableElement, TableColumn } from '../../core/types'
|
||||
import { sz } from '../../core/types'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import { useSchemaStore } from '../../stores/schema'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
node: SchemaNode
|
||||
depth?: number
|
||||
}>(), {
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const schemaStore = useSchemaStore()
|
||||
|
||||
const expanded = ref(props.depth < 2)
|
||||
|
||||
let colIdCounter = 0
|
||||
|
||||
const isScalar = ['string', 'number', 'integer', 'boolean'].includes(props.node.type)
|
||||
const isArray = props.node.type === 'array'
|
||||
const isObject = props.node.type === 'object'
|
||||
const isDraggable = isScalar || isArray
|
||||
const hasChildren = isObject
|
||||
? props.node.children.length > 0
|
||||
: isArray
|
||||
? (props.node.itemProperties?.length ?? 0) > 0
|
||||
: false
|
||||
|
||||
const typeIcon: Record<string, string> = {
|
||||
string: 'Aa',
|
||||
number: '#',
|
||||
integer: '#',
|
||||
boolean: '\u2713',
|
||||
object: '{ }',
|
||||
array: '[ ]',
|
||||
}
|
||||
|
||||
const borderColor: Record<string, string> = {
|
||||
string: '#3b82f6',
|
||||
number: '#22c55e',
|
||||
integer: '#22c55e',
|
||||
boolean: '#f59e0b',
|
||||
object: '#94a3b8',
|
||||
array: '#8b5cf6',
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (hasChildren) {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
}
|
||||
|
||||
function createBoundTextElement(node: SchemaNode): TemplateElement {
|
||||
return {
|
||||
id: `txt_${Date.now().toString(36)}`,
|
||||
type: 'text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 11, color: '#000000' },
|
||||
binding: { type: 'scalar', path: node.path },
|
||||
}
|
||||
}
|
||||
|
||||
function createBoundTableElement(node: SchemaNode): RepeatingTableElement {
|
||||
const itemFields = schemaStore.getArrayItemFields(node.path)
|
||||
const columns: TableColumn[] = itemFields.map(field => ({
|
||||
id: `col_${(++colIdCounter).toString(36)}`,
|
||||
field: field.key,
|
||||
title: field.title,
|
||||
width: sz.auto(),
|
||||
align: defaultAlignForSchema(field),
|
||||
format: schemaFormatToFormatType(field.format),
|
||||
}))
|
||||
return {
|
||||
id: `tbl_${Date.now().toString(36)}`,
|
||||
type: 'repeating_table',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.fr(1), height: sz.auto() },
|
||||
dataSource: { type: 'array', path: node.path },
|
||||
columns,
|
||||
style: { headerBg: '#f0f0f0', headerColor: '#000000', fontSize: 10, headerFontSize: 10 },
|
||||
}
|
||||
}
|
||||
|
||||
function onDragStart(e: DragEvent) {
|
||||
if (!isDraggable) return
|
||||
|
||||
let el: TemplateElement
|
||||
if (isScalar) {
|
||||
el = createBoundTextElement(props.node)
|
||||
} else {
|
||||
el = createBoundTableElement(props.node)
|
||||
}
|
||||
|
||||
editorStore.startDragNewElement(el)
|
||||
e.dataTransfer?.setData('text/plain', el.id)
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
editorStore.endDragNewElement()
|
||||
}
|
||||
|
||||
const displayChildren = isArray
|
||||
? (props.node.itemProperties ?? [])
|
||||
: props.node.children
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="schema-node">
|
||||
<div
|
||||
class="schema-node__row"
|
||||
:class="{
|
||||
'schema-node__row--draggable': isDraggable,
|
||||
'schema-node__row--object': isObject,
|
||||
}"
|
||||
:style="{
|
||||
paddingLeft: `${depth * 16 + 8}px`,
|
||||
borderLeftColor: borderColor[node.type] ?? '#94a3b8',
|
||||
}"
|
||||
:draggable="isDraggable"
|
||||
:title="node.path || node.key"
|
||||
@click="toggle"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<span v-if="hasChildren" class="schema-node__arrow" :class="{ 'schema-node__arrow--expanded': expanded }">
|
||||
▶
|
||||
</span>
|
||||
<span v-else class="schema-node__arrow-placeholder" />
|
||||
|
||||
<span class="schema-node__type-icon" :class="`schema-node__type-icon--${node.type}`">
|
||||
{{ typeIcon[node.type] ?? '?' }}
|
||||
</span>
|
||||
|
||||
<span class="schema-node__title">{{ node.title }}</span>
|
||||
|
||||
<span v-if="isScalar && node.path" class="schema-node__path">{{ node.path }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="hasChildren && expanded" class="schema-node__children">
|
||||
<SchemaTreeNode
|
||||
v-for="child in displayChildren"
|
||||
:key="child.path"
|
||||
:node="child"
|
||||
:depth="depth + 1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.schema-node__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
border-left: 3px solid transparent;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
border-radius: 0 4px 4px 0;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.schema-node__row--draggable {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.schema-node__row--draggable:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.schema-node__row:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.schema-node__row--draggable:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.schema-node__arrow {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8px;
|
||||
color: #94a3b8;
|
||||
transition: transform 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.schema-node__arrow--expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.schema-node__arrow-placeholder {
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.schema-node__type-icon {
|
||||
width: 22px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.schema-node__type-icon--string {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.schema-node__type-icon--number,
|
||||
.schema-node__type-icon--integer {
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.schema-node__type-icon--boolean {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.schema-node__type-icon--object {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.schema-node__type-icon--array {
|
||||
background: #ede9fe;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.schema-node__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.schema-node__path {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 80px;
|
||||
}
|
||||
</style>
|
||||
51
frontend/src/components/panels/SchemaTreePanel.vue
Normal file
51
frontend/src/components/panels/SchemaTreePanel.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { useSchemaStore } from '../../stores/schema'
|
||||
import SchemaTreeNode from './SchemaTreeNode.vue'
|
||||
|
||||
const schemaStore = useSchemaStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="schema-panel">
|
||||
<div class="schema-panel__title">Schema</div>
|
||||
<div v-if="schemaStore.schemaTree.children.length === 0" class="schema-panel__empty">
|
||||
Schema yuklu degil
|
||||
</div>
|
||||
<div v-else class="schema-panel__tree">
|
||||
<SchemaTreeNode
|
||||
v-for="child in schemaStore.schemaTree.children"
|
||||
:key="child.path"
|
||||
:node="child"
|
||||
:depth="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.schema-panel {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.schema-panel__title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 10px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.schema-panel__empty {
|
||||
padding: 20px 12px;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.schema-panel__tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
152
frontend/src/components/properties/BarcodeProperties.vue
Normal file
152
frontend/src/components/properties/BarcodeProperties.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import { useSchemaStore } from '../../stores/schema'
|
||||
import type { BarcodeElement, BarcodeFormat, TemplateElement } from '../../core/types'
|
||||
import '../../styles/properties.css'
|
||||
|
||||
const props = defineProps<{ element: BarcodeElement }>()
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
const schemaStore = useSchemaStore()
|
||||
|
||||
function update(updates: Partial<TemplateElement>) {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id) return
|
||||
templateStore.updateElement(id, updates)
|
||||
}
|
||||
|
||||
function updateStyle(key: string, value: unknown) {
|
||||
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
|
||||
}
|
||||
|
||||
const barcodeDefaults: Record<BarcodeFormat, string> = {
|
||||
qr: 'https://example.com',
|
||||
ean13: '5901234123457',
|
||||
ean8: '96385074',
|
||||
code128: 'DREPORT-001',
|
||||
code39: 'DREPORT',
|
||||
}
|
||||
|
||||
function eanCheckDigit(data: string): number {
|
||||
let sum = 0
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const d = parseInt(data[i])
|
||||
sum += d * (i % 2 === 0 ? 1 : 3)
|
||||
}
|
||||
return (10 - (sum % 10)) % 10
|
||||
}
|
||||
|
||||
function validateBarcode(format: BarcodeFormat, value: string): boolean {
|
||||
if (!value) return false
|
||||
switch (format) {
|
||||
case 'ean13':
|
||||
if (!/^\d{13}$/.test(value)) return false
|
||||
return eanCheckDigit(value.slice(0, 12)) === parseInt(value[12])
|
||||
case 'ean8':
|
||||
if (!/^\d{8}$/.test(value)) return false
|
||||
return eanCheckDigit(value.slice(0, 7)) === parseInt(value[7])
|
||||
case 'code39':
|
||||
return /^[A-Z0-9\-. $/+%]+$/i.test(value)
|
||||
case 'code128':
|
||||
return value.length > 0 && [...value].every(c => c.charCodeAt(0) < 128)
|
||||
case 'qr':
|
||||
return value.length > 0
|
||||
default:
|
||||
return value.length > 0
|
||||
}
|
||||
}
|
||||
|
||||
const barcodeInputValue = ref('')
|
||||
const barcodeInputInvalid = ref(false)
|
||||
|
||||
watch(() => props.element.value ?? '', (val) => {
|
||||
barcodeInputValue.value = val
|
||||
barcodeInputInvalid.value = false
|
||||
}, { immediate: true })
|
||||
|
||||
function onBarcodeValueInput(e: Event) {
|
||||
const val = (e.target as HTMLInputElement).value
|
||||
barcodeInputValue.value = val
|
||||
|
||||
if (validateBarcode(props.element.format, val)) {
|
||||
barcodeInputInvalid.value = false
|
||||
update({ value: val } as any)
|
||||
} else {
|
||||
barcodeInputInvalid.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onBarcodeFormatChange(newFormat: BarcodeFormat) {
|
||||
const currentValue = props.element.value ?? ''
|
||||
if (validateBarcode(newFormat, currentValue)) {
|
||||
update({ format: newFormat } as any)
|
||||
} else {
|
||||
const defaultVal = barcodeDefaults[newFormat]
|
||||
barcodeInputValue.value = defaultVal
|
||||
barcodeInputInvalid.value = false
|
||||
update({ format: newFormat, value: defaultVal } as any)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">Barkod Ayarlari</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Format</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="element.format"
|
||||
@change="(e) => onBarcodeFormatChange((e.target as HTMLSelectElement).value as BarcodeFormat)">
|
||||
<option value="qr">QR Kod</option>
|
||||
<option value="ean13">EAN-13</option>
|
||||
<option value="ean8">EAN-8</option>
|
||||
<option value="code128">Code 128</option>
|
||||
<option value="code39">Code 39</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Deger</label>
|
||||
<input class="prop-input" type="text"
|
||||
:class="{ 'prop-input--invalid': barcodeInputInvalid }"
|
||||
:value="barcodeInputValue"
|
||||
@input="onBarcodeValueInput" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Renk</label>
|
||||
<div class="prop-row-inline">
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="element.style.color ?? '#000000'"
|
||||
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
|
||||
<button v-if="element.style.color" class="prop-clear" @click="updateStyle('color', undefined)">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="element.format !== 'qr'" class="prop-row">
|
||||
<label class="prop-label">Metin Goster</label>
|
||||
<input type="checkbox"
|
||||
:checked="element.style.includeText ?? (element.format === 'ean13' || element.format === 'ean8')"
|
||||
@change="(e) => updateStyle('includeText', (e.target as HTMLInputElement).checked)" />
|
||||
</div>
|
||||
<div v-if="schemaStore.scalarFields.length > 0" class="prop-row">
|
||||
<label class="prop-label">Veri Baglama</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="element.binding?.path ?? ''"
|
||||
@change="(e) => {
|
||||
const val = (e.target as HTMLSelectElement).value
|
||||
if (val) {
|
||||
update({ binding: { type: 'scalar', path: val } } as any)
|
||||
} else {
|
||||
update({ binding: undefined } as any)
|
||||
}
|
||||
}">
|
||||
<option value="">Yok (statik deger)</option>
|
||||
<option
|
||||
v-for="field in schemaStore.scalarFields"
|
||||
:key="field.path"
|
||||
:value="field.path"
|
||||
>{{ field.title }} ({{ field.path }})</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
115
frontend/src/components/properties/ContainerProperties.vue
Normal file
115
frontend/src/components/properties/ContainerProperties.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import PaddingBox from './PaddingBox.vue'
|
||||
import type { ContainerElement, TemplateElement } from '../../core/types'
|
||||
import '../../styles/properties.css'
|
||||
|
||||
const props = defineProps<{ element: ContainerElement }>()
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
function update(updates: Partial<TemplateElement>) {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id) return
|
||||
templateStore.updateElement(id, updates)
|
||||
}
|
||||
|
||||
function updateStyle(key: string, value: unknown) {
|
||||
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">Container Ayarlari</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Yon</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="element.direction"
|
||||
@change="(e) => update({ direction: (e.target as HTMLSelectElement).value } as any)">
|
||||
<option value="column">Dikey</option>
|
||||
<option value="row">Yatay</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Bosluk (mm)</label>
|
||||
<input class="prop-input" type="number" step="1" min="0"
|
||||
:value="element.gap"
|
||||
@input="(e) => update({ gap: parseFloat((e.target as HTMLInputElement).value) || 0 } as any)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">{{ element.direction === 'column' ? 'Yatay Hizalama' : 'Dikey Hizalama' }}</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="element.align"
|
||||
@change="(e) => update({ align: (e.target as HTMLSelectElement).value } as any)">
|
||||
<option value="start">{{ element.direction === 'column' ? 'Sol' : 'Ust' }}</option>
|
||||
<option value="center">Orta</option>
|
||||
<option value="end">{{ element.direction === 'column' ? 'Sag' : 'Alt' }}</option>
|
||||
<option value="stretch">Esnet</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">{{ element.direction === 'column' ? 'Dikey Dagilim' : 'Yatay Dagilim' }}</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="element.justify"
|
||||
@change="(e) => update({ justify: (e.target as HTMLSelectElement).value } as any)">
|
||||
<option value="start">{{ element.direction === 'column' ? 'Ust' : 'Sol' }}</option>
|
||||
<option value="center">Orta</option>
|
||||
<option value="end">{{ element.direction === 'column' ? 'Alt' : 'Sag' }}</option>
|
||||
<option value="space-between">Esit Aralik</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="prop-section__subtitle">Padding (mm)</div>
|
||||
<PaddingBox
|
||||
:top="element.padding.top"
|
||||
:right="element.padding.right"
|
||||
:bottom="element.padding.bottom"
|
||||
:left="element.padding.left"
|
||||
@update="(side, value) => update({ padding: { ...element.padding, [side]: value } } as any)"
|
||||
/>
|
||||
|
||||
<div class="prop-section__subtitle">Stil</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Arka plan</label>
|
||||
<div class="prop-row-inline">
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="element.style.backgroundColor ?? '#ffffff'"
|
||||
@input="(e) => updateStyle('backgroundColor', (e.target as HTMLInputElement).value)" />
|
||||
<button v-if="element.style.backgroundColor" class="prop-clear" @click="updateStyle('backgroundColor', undefined)">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kenarlik (mm)</label>
|
||||
<input class="prop-input" type="number" step="0.1" min="0"
|
||||
:value="element.style.borderWidth ?? 0"
|
||||
@input="(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kenarlik rengi</label>
|
||||
<div class="prop-row-inline">
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="element.style.borderColor ?? '#000000'"
|
||||
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)" />
|
||||
<button v-if="element.style.borderColor" class="prop-clear" @click="updateStyle('borderColor', undefined)">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kenarlik stili</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="element.style.borderStyle ?? 'solid'"
|
||||
@change="(e) => updateStyle('borderStyle', (e.target as HTMLSelectElement).value)">
|
||||
<option value="solid">Duz</option>
|
||||
<option value="dashed">Kesikli</option>
|
||||
<option value="dotted">Noktali</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Radius (mm)</label>
|
||||
<input class="prop-input" type="number" step="0.5" min="0"
|
||||
:value="element.style.borderRadius ?? 0"
|
||||
@input="(e) => updateStyle('borderRadius', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
62
frontend/src/components/properties/ImageProperties.vue
Normal file
62
frontend/src/components/properties/ImageProperties.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import type { ImageElement, TemplateElement } from '../../core/types'
|
||||
import '../../styles/properties.css'
|
||||
|
||||
const props = defineProps<{ element: ImageElement }>()
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
function update(updates: Partial<TemplateElement>) {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id) return
|
||||
templateStore.updateElement(id, updates)
|
||||
}
|
||||
|
||||
function updateStyle(key: string, value: unknown) {
|
||||
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
|
||||
}
|
||||
|
||||
function onImageFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
update({ src: reader.result as string } as Partial<TemplateElement>)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">Gorsel</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kaynak</label>
|
||||
<label class="prop-file-btn">
|
||||
Dosya Sec
|
||||
<input type="file" accept="image/*" style="display: none" @change="onImageFileSelect" />
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="element.src" class="prop-row">
|
||||
<label class="prop-label">Onizleme</label>
|
||||
<img :src="element.src" class="prop-image-preview" />
|
||||
</div>
|
||||
<div v-if="element.src" class="prop-row">
|
||||
<label class="prop-label"></label>
|
||||
<button class="prop-clear" @click="update({ src: undefined } as any)">Gorseli kaldir</button>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Sigdirma</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="element.style.objectFit ?? 'contain'"
|
||||
@change="(e) => updateStyle('objectFit', (e.target as HTMLSelectElement).value)">
|
||||
<option value="contain">Sigdir</option>
|
||||
<option value="cover">Kap</option>
|
||||
<option value="stretch">Esnet</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
34
frontend/src/components/properties/LineProperties.vue
Normal file
34
frontend/src/components/properties/LineProperties.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import type { LineElement, TemplateElement } from '../../core/types'
|
||||
import '../../styles/properties.css'
|
||||
|
||||
const props = defineProps<{ element: LineElement }>()
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
function updateStyle(key: string, value: unknown) {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id) return
|
||||
templateStore.updateElement(id, { style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">Cizgi Stili</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kalinlik (mm)</label>
|
||||
<input class="prop-input" type="number" step="0.1" min="0.1"
|
||||
:value="element.style.strokeWidth ?? 0.5"
|
||||
@input="(e) => updateStyle('strokeWidth', parseFloat((e.target as HTMLInputElement).value) || 0.5)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Renk</label>
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="element.style.strokeColor ?? '#000000'"
|
||||
@input="(e) => updateStyle('strokeColor', (e.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
59
frontend/src/components/properties/PageNumberProperties.vue
Normal file
59
frontend/src/components/properties/PageNumberProperties.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import type { PageNumberElement, TextStyle, TemplateElement } from '../../core/types'
|
||||
import '../../styles/properties.css'
|
||||
|
||||
const props = defineProps<{ element: PageNumberElement }>()
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
function update(updates: Partial<TemplateElement>) {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id) return
|
||||
templateStore.updateElement(id, updates)
|
||||
}
|
||||
|
||||
function updateStyle(key: string, value: unknown) {
|
||||
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">Sayfa Numarasi</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Format</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="element.format ?? '{current} / {total}'"
|
||||
@change="(e) => update({ format: (e.target as HTMLSelectElement).value } as any)">
|
||||
<option value="{current} / {total}">1 / 5</option>
|
||||
<option value="{current}">1</option>
|
||||
<option value="Sayfa {current}">Sayfa 1</option>
|
||||
<option value="Sayfa {current} / {total}">Sayfa 1 / 5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Boyut (pt)</label>
|
||||
<input class="prop-input" type="number" step="1" min="1"
|
||||
:value="(element.style as TextStyle).fontSize ?? 10"
|
||||
@input="(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Renk</label>
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="(element.style as TextStyle).color ?? '#666666'"
|
||||
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Hizalama</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(element.style as TextStyle).align ?? 'center'"
|
||||
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)">
|
||||
<option value="left">Sol</option>
|
||||
<option value="center">Orta</option>
|
||||
<option value="right">Sag</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
43
frontend/src/components/properties/PositioningProperties.vue
Normal file
43
frontend/src/components/properties/PositioningProperties.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import type { TemplateElement } from '../../core/types'
|
||||
import '../../styles/properties.css'
|
||||
|
||||
const props = defineProps<{ element: TemplateElement }>()
|
||||
const templateStore = useTemplateStore()
|
||||
|
||||
function togglePositioning() {
|
||||
if (props.element.position.type === 'flow') {
|
||||
templateStore.updateElementPosition(props.element.id, { type: 'absolute', x: 0, y: 0 })
|
||||
} else {
|
||||
templateStore.updateElementPosition(props.element.id, { type: 'flow' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">Pozisyon</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Mod</label>
|
||||
<select class="prop-input prop-select" :value="element.position.type" @change="togglePositioning">
|
||||
<option value="flow">Flow</option>
|
||||
<option value="absolute">Absolute</option>
|
||||
</select>
|
||||
</div>
|
||||
<template v-if="element.position.type === 'absolute'">
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">X (mm)</label>
|
||||
<input class="prop-input" type="number" step="0.5"
|
||||
:value="element.position.x"
|
||||
@input="(e) => templateStore.updateElementPosition(element.id, { type: 'absolute', x: parseFloat((e.target as HTMLInputElement).value) || 0, y: (element.position as any).y ?? 0 })" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Y (mm)</label>
|
||||
<input class="prop-input" type="number" step="0.5"
|
||||
:value="element.position.y"
|
||||
@input="(e) => templateStore.updateElementPosition(element.id, { type: 'absolute', x: (element.position as any).x ?? 0, y: parseFloat((e.target as HTMLInputElement).value) || 0 })" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
242
frontend/src/components/properties/RepeatingTableProperties.vue
Normal file
242
frontend/src/components/properties/RepeatingTableProperties.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import { useSchemaStore } from '../../stores/schema'
|
||||
import { sz } from '../../core/types'
|
||||
import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser'
|
||||
import type { RepeatingTableElement, TableColumn, FormatType, TemplateElement } from '../../core/types'
|
||||
import '../../styles/properties.css'
|
||||
|
||||
const props = defineProps<{ element: RepeatingTableElement }>()
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
const schemaStore = useSchemaStore()
|
||||
|
||||
function update(updates: Partial<TemplateElement>) {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id) return
|
||||
templateStore.updateElement(id, updates)
|
||||
}
|
||||
|
||||
let colIdCounter = Date.now()
|
||||
function nextColId() {
|
||||
return `col_${(++colIdCounter).toString(36)}`
|
||||
}
|
||||
|
||||
function updateTableDataSource(path: string) {
|
||||
const itemFields = schemaStore.getArrayItemFields(path)
|
||||
if (itemFields.length > 0) {
|
||||
const columns: TableColumn[] = itemFields.map(field => ({
|
||||
id: nextColId(),
|
||||
field: field.key,
|
||||
title: field.title,
|
||||
width: sz.auto(),
|
||||
align: defaultAlignForSchema(field),
|
||||
format: schemaFormatToFormatType(field.format),
|
||||
}))
|
||||
update({
|
||||
dataSource: { type: 'array', path },
|
||||
columns,
|
||||
} as Partial<TemplateElement>)
|
||||
} else {
|
||||
update({ dataSource: { type: 'array', path } } as Partial<TemplateElement>)
|
||||
}
|
||||
}
|
||||
|
||||
function updateTableStyle(key: string, value: unknown) {
|
||||
const newStyle = { ...props.element.style, [key]: value }
|
||||
if (value === undefined || value === '') delete (newStyle as Record<string, unknown>)[key]
|
||||
update({ style: newStyle } as Partial<TemplateElement>)
|
||||
}
|
||||
|
||||
function updateColumn(colId: string, updates: Partial<TableColumn>) {
|
||||
const columns = props.element.columns.map(c => c.id === colId ? { ...c, ...updates } : c)
|
||||
update({ columns } as Partial<TemplateElement>)
|
||||
}
|
||||
|
||||
function addColumn() {
|
||||
const newCol: TableColumn = {
|
||||
id: nextColId(),
|
||||
field: 'alan',
|
||||
title: 'Yeni Sutun',
|
||||
width: sz.auto(),
|
||||
align: 'left',
|
||||
}
|
||||
update({ columns: [...props.element.columns, newCol] } as Partial<TemplateElement>)
|
||||
}
|
||||
|
||||
function removeColumn(colId: string) {
|
||||
update({ columns: props.element.columns.filter(c => c.id !== colId) } as Partial<TemplateElement>)
|
||||
}
|
||||
|
||||
function moveColumn(colId: string, direction: -1 | 1) {
|
||||
const cols = [...props.element.columns]
|
||||
const idx = cols.findIndex(c => c.id === colId)
|
||||
const newIdx = idx + direction
|
||||
if (newIdx < 0 || newIdx >= cols.length) return
|
||||
;[cols[idx], cols[newIdx]] = [cols[newIdx], cols[idx]]
|
||||
update({ columns: cols } as Partial<TemplateElement>)
|
||||
}
|
||||
|
||||
const tableItemFields = computed(() => {
|
||||
return schemaStore.getArrayItemFields(props.element.dataSource.path)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Data source -->
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">Veri Kaynagi</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kaynak</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="element.dataSource.path"
|
||||
@change="(e) => updateTableDataSource((e.target as HTMLSelectElement).value)">
|
||||
<option value="" disabled>Secin...</option>
|
||||
<option
|
||||
v-for="arr in schemaStore.arrayFields"
|
||||
:key="arr.path"
|
||||
:value="arr.path"
|
||||
>{{ arr.title }} ({{ arr.path }})</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Columns -->
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">
|
||||
Sutunlar
|
||||
<button class="prop-add-btn" @click="addColumn">+</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="col in element.columns"
|
||||
:key="col.id"
|
||||
class="prop-column-card"
|
||||
>
|
||||
<div class="prop-column-header">
|
||||
<span class="prop-column-title">{{ col.title || col.field }}</span>
|
||||
<div class="prop-column-actions">
|
||||
<button class="prop-icon-btn" @click="moveColumn(col.id, -1)" title="Yukari">↑</button>
|
||||
<button class="prop-icon-btn" @click="moveColumn(col.id, 1)" title="Asagi">↓</button>
|
||||
<button class="prop-icon-btn prop-icon-btn--danger" @click="removeColumn(col.id)" title="Sil">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Baslik</label>
|
||||
<input class="prop-input" type="text" :value="col.title"
|
||||
@change="(e) => updateColumn(col.id, { title: (e.target as HTMLInputElement).value })" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Alan</label>
|
||||
<select v-if="tableItemFields.length > 0" class="prop-input prop-select" :value="col.field"
|
||||
@change="(e) => {
|
||||
const field = (e.target as HTMLSelectElement).value
|
||||
const node = tableItemFields.find(f => f.key === field)
|
||||
if (node) {
|
||||
updateColumn(col.id, {
|
||||
field,
|
||||
title: node.title,
|
||||
align: defaultAlignForSchema(node),
|
||||
format: schemaFormatToFormatType(node.format),
|
||||
})
|
||||
} else {
|
||||
updateColumn(col.id, { field })
|
||||
}
|
||||
}">
|
||||
<option v-for="f in tableItemFields" :key="f.key" :value="f.key">{{ f.title }} ({{ f.key }})</option>
|
||||
</select>
|
||||
<input v-else class="prop-input" type="text" :value="col.field"
|
||||
@change="(e) => updateColumn(col.id, { field: (e.target as HTMLInputElement).value })" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Hizalama</label>
|
||||
<select class="prop-input prop-select" :value="col.align"
|
||||
@change="(e) => updateColumn(col.id, { align: (e.target as HTMLSelectElement).value as 'left'|'center'|'right' })">
|
||||
<option value="left">Sol</option>
|
||||
<option value="center">Orta</option>
|
||||
<option value="right">Sag</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Format</label>
|
||||
<select class="prop-input prop-select" :value="col.format ?? ''"
|
||||
@change="(e) => updateColumn(col.id, { format: ((e.target as HTMLSelectElement).value || undefined) as FormatType | undefined })">
|
||||
<option value="">Yok</option>
|
||||
<option value="currency">Para birimi</option>
|
||||
<option value="number">Sayi</option>
|
||||
<option value="date">Tarih</option>
|
||||
<option value="percentage">Yuzde</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Genislik</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="col.width.type"
|
||||
@change="(e) => {
|
||||
const t = (e.target as HTMLSelectElement).value
|
||||
if (t === 'auto') updateColumn(col.id, { width: { type: 'auto' } })
|
||||
else if (t === 'fr') updateColumn(col.id, { width: { type: 'fr', value: 1 } })
|
||||
else updateColumn(col.id, { width: { type: 'fixed', value: 30 } })
|
||||
}">
|
||||
<option value="auto">Otomatik</option>
|
||||
<option value="fixed">Sabit (mm)</option>
|
||||
<option value="fr">Oran (fr)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="col.width.type === 'fixed'" class="prop-row">
|
||||
<label class="prop-label">mm</label>
|
||||
<input class="prop-input" type="number" step="1" min="5"
|
||||
:value="(col.width as any).value"
|
||||
@change="(e) => updateColumn(col.id, { width: { type: 'fixed', value: parseFloat((e.target as HTMLInputElement).value) || 30 } })" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table style -->
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">Tablo Stili</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Yazi boyutu</label>
|
||||
<input class="prop-input" type="number" step="1" min="6"
|
||||
:value="element.style.fontSize ?? 10"
|
||||
@input="(e) => updateTableStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Header bg</label>
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="element.style.headerBg ?? '#f0f0f0'"
|
||||
@input="(e) => updateTableStyle('headerBg', (e.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Header renk</label>
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="element.style.headerColor ?? '#000000'"
|
||||
@input="(e) => updateTableStyle('headerColor', (e.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Zebra tek</label>
|
||||
<div class="prop-row-inline">
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="element.style.zebraOdd ?? '#fafafa'"
|
||||
@input="(e) => updateTableStyle('zebraOdd', (e.target as HTMLInputElement).value)" />
|
||||
<button v-if="element.style.zebraOdd" class="prop-clear" @click="updateTableStyle('zebraOdd', undefined)">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kenarlik rengi</label>
|
||||
<div class="prop-row-inline">
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="element.style.borderColor ?? '#cccccc'"
|
||||
@input="(e) => updateTableStyle('borderColor', (e.target as HTMLInputElement).value)" />
|
||||
<button v-if="element.style.borderColor" class="prop-clear" @click="updateTableStyle('borderColor', undefined)">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kenarlik (mm)</label>
|
||||
<input class="prop-input" type="number" step="0.1" min="0"
|
||||
:value="element.style.borderWidth ?? 0.5"
|
||||
@input="(e) => updateTableStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
67
frontend/src/components/properties/SizeProperties.vue
Normal file
67
frontend/src/components/properties/SizeProperties.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import type { TemplateElement, SizeValue } from '../../core/types'
|
||||
import '../../styles/properties.css'
|
||||
|
||||
const props = defineProps<{ element: TemplateElement }>()
|
||||
const templateStore = useTemplateStore()
|
||||
|
||||
function updateSize(axis: 'width' | 'height', sv: SizeValue) {
|
||||
templateStore.updateElementSize(props.element.id, { [axis]: sv })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">Boyut</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Genislik</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="element.size.width.type"
|
||||
@change="(e) => {
|
||||
const t = (e.target as HTMLSelectElement).value
|
||||
if (t === 'auto') updateSize('width', { type: 'auto' })
|
||||
else if (t === 'fr') updateSize('width', { type: 'fr', value: 1 })
|
||||
else updateSize('width', { type: 'fixed', value: 50 })
|
||||
}">
|
||||
<option value="auto">Otomatik</option>
|
||||
<option value="fixed">Sabit (mm)</option>
|
||||
<option value="fr">Oran (fr)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="element.size.width.type === 'fixed'" class="prop-row">
|
||||
<label class="prop-label">mm</label>
|
||||
<input class="prop-input" type="number" step="1" min="1"
|
||||
:value="(element.size.width as any).value"
|
||||
@input="(e) => updateSize('width', { type: 'fixed', value: parseFloat((e.target as HTMLInputElement).value) || 10 })" />
|
||||
</div>
|
||||
<div v-if="element.size.width.type === 'fr'" class="prop-row">
|
||||
<label class="prop-label">fr</label>
|
||||
<input class="prop-input" type="number" step="1" min="1"
|
||||
:value="(element.size.width as any).value"
|
||||
@input="(e) => updateSize('width', { type: 'fr', value: parseFloat((e.target as HTMLInputElement).value) || 1 })" />
|
||||
</div>
|
||||
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Yukseklik</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="element.size.height.type"
|
||||
@change="(e) => {
|
||||
const t = (e.target as HTMLSelectElement).value
|
||||
if (t === 'auto') updateSize('height', { type: 'auto' })
|
||||
else if (t === 'fr') updateSize('height', { type: 'fr', value: 1 })
|
||||
else updateSize('height', { type: 'fixed', value: 20 })
|
||||
}">
|
||||
<option value="auto">Otomatik</option>
|
||||
<option value="fixed">Sabit (mm)</option>
|
||||
<option value="fr">Oran (fr)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="element.size.height.type === 'fixed'" class="prop-row">
|
||||
<label class="prop-label">mm</label>
|
||||
<input class="prop-input" type="number" step="1" min="1"
|
||||
:value="(element.size.height as any).value"
|
||||
@input="(e) => updateSize('height', { type: 'fixed', value: parseFloat((e.target as HTMLInputElement).value) || 10 })" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
65
frontend/src/components/properties/TextProperties.vue
Normal file
65
frontend/src/components/properties/TextProperties.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import type { StaticTextElement, TextStyle, TemplateElement } from '../../core/types'
|
||||
import '../../styles/properties.css'
|
||||
|
||||
const props = defineProps<{ element: TemplateElement }>()
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
function update(updates: Partial<TemplateElement>) {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id) return
|
||||
templateStore.updateElement(id, updates)
|
||||
}
|
||||
|
||||
function updateStyle(key: string, value: unknown) {
|
||||
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">Metin Stili</div>
|
||||
|
||||
<div v-if="element.type === 'static_text'" class="prop-row">
|
||||
<label class="prop-label">Metin</label>
|
||||
<input class="prop-input" type="text"
|
||||
:value="(element as StaticTextElement).content"
|
||||
@input="(e) => update({ content: (e.target as HTMLInputElement).value } as any)" />
|
||||
</div>
|
||||
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Boyut (pt)</label>
|
||||
<input class="prop-input" type="number" step="1" min="1"
|
||||
:value="(element.style as TextStyle).fontSize ?? 11"
|
||||
@input="(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kalinlik</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(element.style as TextStyle).fontWeight ?? 'normal'"
|
||||
@change="(e) => updateStyle('fontWeight', (e.target as HTMLSelectElement).value)">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="bold">Kalin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Renk</label>
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="(element.style as TextStyle).color ?? '#000000'"
|
||||
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Hizalama</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(element.style as TextStyle).align ?? 'left'"
|
||||
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)">
|
||||
<option value="left">Sol</option>
|
||||
<option value="center">Orta</option>
|
||||
<option value="right">Sag</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
178
frontend/src/composables/useSnapGuides.ts
Normal file
178
frontend/src/composables/useSnapGuides.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { ref } from 'vue'
|
||||
import type { ElementLayout } from '../core/layout-types'
|
||||
|
||||
export interface SnapGuide {
|
||||
type: 'vertical' | 'horizontal'
|
||||
position_mm: number
|
||||
}
|
||||
|
||||
export interface SnapResult {
|
||||
snappedX_mm: number
|
||||
snappedY_mm: number
|
||||
guides: SnapGuide[]
|
||||
}
|
||||
|
||||
interface EdgeSet {
|
||||
verticals: number[] // x positions in mm (left, right, center of elements + page)
|
||||
horizontals: number[] // y positions in mm (top, bottom, center of elements + page)
|
||||
}
|
||||
|
||||
export function useSnapGuides() {
|
||||
const SNAP_THRESHOLD_MM = 1.5
|
||||
const activeGuides = ref<SnapGuide[]>([])
|
||||
let cachedEdges: EdgeSet | null = null
|
||||
|
||||
/** Collect edges from all elements except the one being dragged. Call once on drag start. */
|
||||
function collectEdges(
|
||||
layoutMap: Record<string, ElementLayout>,
|
||||
excludeId: string,
|
||||
pageWidth: number,
|
||||
pageHeight: number
|
||||
) {
|
||||
const verticals: number[] = [0, pageWidth / 2, pageWidth] // page edges + center
|
||||
const horizontals: number[] = [0, pageHeight / 2, pageHeight]
|
||||
|
||||
for (const [id, el] of Object.entries(layoutMap)) {
|
||||
if (id === excludeId) continue
|
||||
// Left, center, right
|
||||
verticals.push(el.x_mm, el.x_mm + el.width_mm / 2, el.x_mm + el.width_mm)
|
||||
// Top, center, bottom
|
||||
horizontals.push(el.y_mm, el.y_mm + el.height_mm / 2, el.y_mm + el.height_mm)
|
||||
}
|
||||
|
||||
cachedEdges = { verticals, horizontals }
|
||||
}
|
||||
|
||||
/** Calculate snap for a dragged element. Returns adjusted position + active guides. */
|
||||
function calculateSnap(
|
||||
proposedX_mm: number,
|
||||
proposedY_mm: number,
|
||||
width_mm: number,
|
||||
height_mm: number
|
||||
): SnapResult {
|
||||
if (!cachedEdges) {
|
||||
return { snappedX_mm: proposedX_mm, snappedY_mm: proposedY_mm, guides: [] }
|
||||
}
|
||||
|
||||
const guides: SnapGuide[] = []
|
||||
let snappedX = proposedX_mm
|
||||
let snappedY = proposedY_mm
|
||||
|
||||
// Element edges to check
|
||||
const myLeft = proposedX_mm
|
||||
const myCenter = proposedX_mm + width_mm / 2
|
||||
const myRight = proposedX_mm + width_mm
|
||||
|
||||
// Find closest vertical snap
|
||||
let bestVDist = SNAP_THRESHOLD_MM
|
||||
let bestVSnap: { edge: number; offset: number } | null = null
|
||||
|
||||
for (const v of cachedEdges.verticals) {
|
||||
// Check left edge
|
||||
const dLeft = Math.abs(myLeft - v)
|
||||
if (dLeft < bestVDist) {
|
||||
bestVDist = dLeft
|
||||
bestVSnap = { edge: v, offset: 0 }
|
||||
}
|
||||
// Check center
|
||||
const dCenter = Math.abs(myCenter - v)
|
||||
if (dCenter < bestVDist) {
|
||||
bestVDist = dCenter
|
||||
bestVSnap = { edge: v, offset: width_mm / 2 }
|
||||
}
|
||||
// Check right edge
|
||||
const dRight = Math.abs(myRight - v)
|
||||
if (dRight < bestVDist) {
|
||||
bestVDist = dRight
|
||||
bestVSnap = { edge: v, offset: width_mm }
|
||||
}
|
||||
}
|
||||
|
||||
if (bestVSnap) {
|
||||
snappedX = bestVSnap.edge - bestVSnap.offset
|
||||
guides.push({ type: 'vertical', position_mm: bestVSnap.edge })
|
||||
}
|
||||
|
||||
// Element edges to check (Y axis)
|
||||
const myTop = proposedY_mm
|
||||
const myMiddle = proposedY_mm + height_mm / 2
|
||||
const myBottom = proposedY_mm + height_mm
|
||||
|
||||
// Find closest horizontal snap
|
||||
let bestHDist = SNAP_THRESHOLD_MM
|
||||
let bestHSnap: { edge: number; offset: number } | null = null
|
||||
|
||||
for (const h of cachedEdges.horizontals) {
|
||||
const dTop = Math.abs(myTop - h)
|
||||
if (dTop < bestHDist) {
|
||||
bestHDist = dTop
|
||||
bestHSnap = { edge: h, offset: 0 }
|
||||
}
|
||||
const dMiddle = Math.abs(myMiddle - h)
|
||||
if (dMiddle < bestHDist) {
|
||||
bestHDist = dMiddle
|
||||
bestHSnap = { edge: h, offset: height_mm / 2 }
|
||||
}
|
||||
const dBottom = Math.abs(myBottom - h)
|
||||
if (dBottom < bestHDist) {
|
||||
bestHDist = dBottom
|
||||
bestHSnap = { edge: h, offset: height_mm }
|
||||
}
|
||||
}
|
||||
|
||||
if (bestHSnap) {
|
||||
snappedY = bestHSnap.edge - bestHSnap.offset
|
||||
guides.push({ type: 'horizontal', position_mm: bestHSnap.edge })
|
||||
}
|
||||
|
||||
activeGuides.value = guides
|
||||
return { snappedX_mm: snappedX, snappedY_mm: snappedY, guides }
|
||||
}
|
||||
|
||||
/** Calculate snap for resize edge */
|
||||
function calculateResizeSnap(
|
||||
edge: 'left' | 'right' | 'top' | 'bottom',
|
||||
proposedValue_mm: number
|
||||
): number {
|
||||
if (!cachedEdges) return proposedValue_mm
|
||||
|
||||
const targets = (edge === 'left' || edge === 'right')
|
||||
? cachedEdges.verticals
|
||||
: cachedEdges.horizontals
|
||||
|
||||
const guides: SnapGuide[] = []
|
||||
let snapped = proposedValue_mm
|
||||
|
||||
let bestDist = SNAP_THRESHOLD_MM
|
||||
for (const t of targets) {
|
||||
const d = Math.abs(proposedValue_mm - t)
|
||||
if (d < bestDist) {
|
||||
bestDist = d
|
||||
snapped = t
|
||||
}
|
||||
}
|
||||
|
||||
if (snapped !== proposedValue_mm) {
|
||||
guides.push({
|
||||
type: (edge === 'left' || edge === 'right') ? 'vertical' : 'horizontal',
|
||||
position_mm: snapped,
|
||||
})
|
||||
}
|
||||
|
||||
activeGuides.value = guides
|
||||
return snapped
|
||||
}
|
||||
|
||||
function clearGuides() {
|
||||
activeGuides.value = []
|
||||
cachedEdges = null
|
||||
}
|
||||
|
||||
return {
|
||||
activeGuides,
|
||||
collectEdges,
|
||||
calculateSnap,
|
||||
calculateResizeSnap,
|
||||
clearGuides,
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { ref, watch, type Ref } from 'vue'
|
||||
import type { ElementLayout } from '../core/template-to-typst'
|
||||
import type { Template } from '../core/types'
|
||||
|
||||
export function useTypstCompiler(
|
||||
template: Ref<Template>,
|
||||
data: Ref<Record<string, unknown>>,
|
||||
) {
|
||||
const svg = ref<string | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
const compiling = ref(false)
|
||||
const layout = ref<Record<string, ElementLayout>>({})
|
||||
|
||||
let worker: Worker | null = null
|
||||
let requestId = 0
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function initWorker() {
|
||||
worker = new Worker(new URL('../workers/typst.worker.ts', import.meta.url), {
|
||||
type: 'module',
|
||||
})
|
||||
|
||||
worker.onmessage = (e: MessageEvent<{
|
||||
type: string
|
||||
svg?: string
|
||||
layout?: Record<string, ElementLayout>
|
||||
error?: string
|
||||
id: number
|
||||
}>) => {
|
||||
const data = e.data
|
||||
if (data.id !== requestId) return
|
||||
|
||||
compiling.value = false
|
||||
if (data.type === 'result') {
|
||||
svg.value = data.svg ?? null
|
||||
layout.value = data.layout ?? {}
|
||||
error.value = null
|
||||
} else if (data.type === 'error') {
|
||||
error.value = data.error ?? 'Bilinmeyen derleme hatası'
|
||||
}
|
||||
}
|
||||
|
||||
worker.onerror = () => {
|
||||
compiling.value = false
|
||||
error.value = 'Worker hatası — yeniden başlatılıyor'
|
||||
worker?.terminate()
|
||||
worker = null
|
||||
setTimeout(initWorker, 500)
|
||||
}
|
||||
}
|
||||
|
||||
function compile() {
|
||||
if (!worker) initWorker()
|
||||
requestId++
|
||||
compiling.value = true
|
||||
worker!.postMessage({
|
||||
type: 'compile',
|
||||
templateJson: JSON.stringify(template.value),
|
||||
dataJson: JSON.stringify(data.value),
|
||||
id: requestId,
|
||||
})
|
||||
}
|
||||
|
||||
// template veya data değiştiğinde yeniden derle
|
||||
watch(
|
||||
[template, data],
|
||||
() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
compile()
|
||||
}, 200)
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
function dispose() {
|
||||
worker?.terminate()
|
||||
worker = null
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
}
|
||||
|
||||
return {
|
||||
svg,
|
||||
error,
|
||||
compiling,
|
||||
layout,
|
||||
compile,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
144
frontend/src/core/__tests__/mock-data-generator.test.ts
Normal file
144
frontend/src/core/__tests__/mock-data-generator.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateMockData } from '../mock-data-generator'
|
||||
import type { Template, ContainerElement } from '../types'
|
||||
import { sz } from '../types'
|
||||
|
||||
function makeTemplate(root: ContainerElement): Template {
|
||||
return {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
page: { width: 210, height: 297 },
|
||||
fonts: ['Noto Sans'],
|
||||
root,
|
||||
}
|
||||
}
|
||||
|
||||
function makeRoot(children: ContainerElement['children']): ContainerElement {
|
||||
return {
|
||||
id: 'root',
|
||||
type: 'container',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
direction: 'column',
|
||||
gap: 0,
|
||||
padding: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
align: 'stretch',
|
||||
justify: 'start',
|
||||
style: {},
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
describe('generateMockData', () => {
|
||||
it('generates scalar data for text elements with bindings', () => {
|
||||
const template = makeTemplate(
|
||||
makeRoot([
|
||||
{
|
||||
id: 'el1',
|
||||
type: 'text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: {},
|
||||
binding: { type: 'scalar', path: 'firma.unvan' },
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const data = generateMockData(template)
|
||||
expect(data).toHaveProperty('firma')
|
||||
expect((data.firma as Record<string, unknown>).unvan).toBe('Ornek Firma A.S.')
|
||||
})
|
||||
|
||||
it('generates array data for repeating_table elements', () => {
|
||||
const template = makeTemplate(
|
||||
makeRoot([
|
||||
{
|
||||
id: 'tbl1',
|
||||
type: 'repeating_table',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
dataSource: { type: 'array', path: 'kalemler' },
|
||||
columns: [
|
||||
{ id: 'c1', field: 'adi', title: 'Adi', width: sz.fr(), align: 'left' },
|
||||
{ id: 'c2', field: 'miktar', title: 'Miktar', width: sz.fr(), align: 'right' },
|
||||
],
|
||||
style: {},
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const data = generateMockData(template)
|
||||
const kalemler = data.kalemler as Record<string, unknown>[]
|
||||
expect(kalemler).toHaveLength(3)
|
||||
expect(kalemler[0]).toHaveProperty('adi')
|
||||
expect(kalemler[0]).toHaveProperty('miktar')
|
||||
})
|
||||
|
||||
it('handles nested paths correctly', () => {
|
||||
const template = makeTemplate(
|
||||
makeRoot([
|
||||
{
|
||||
id: 'el1',
|
||||
type: 'text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: {},
|
||||
binding: { type: 'scalar', path: 'a.b.c' },
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const data = generateMockData(template)
|
||||
expect((data as any).a.b.c).toBe('[a.b.c]')
|
||||
})
|
||||
|
||||
it('returns empty object for template with no bindings', () => {
|
||||
const template = makeTemplate(
|
||||
makeRoot([
|
||||
{
|
||||
id: 'el1',
|
||||
type: 'static_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: {},
|
||||
content: 'Hello',
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const data = generateMockData(template)
|
||||
expect(Object.keys(data)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('traverses nested containers to find bindings', () => {
|
||||
const template = makeTemplate(
|
||||
makeRoot([
|
||||
{
|
||||
id: 'inner',
|
||||
type: 'container',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
direction: 'row',
|
||||
gap: 0,
|
||||
padding: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
align: 'stretch',
|
||||
justify: 'start',
|
||||
style: {},
|
||||
children: [
|
||||
{
|
||||
id: 'el_deep',
|
||||
type: 'text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: {},
|
||||
binding: { type: 'scalar', path: 'fatura.no' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const data = generateMockData(template)
|
||||
expect((data.fatura as Record<string, unknown>).no).toBe('FTR-2026-001')
|
||||
})
|
||||
})
|
||||
216
frontend/src/core/__tests__/schema-parser.test.ts
Normal file
216
frontend/src/core/__tests__/schema-parser.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
parseSchema,
|
||||
findArrayFields,
|
||||
findScalarFields,
|
||||
schemaFormatToFormatType,
|
||||
defaultAlignForSchema,
|
||||
type JsonSchema,
|
||||
type SchemaNode,
|
||||
} from '../schema-parser'
|
||||
|
||||
const testSchema: JsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
firma: {
|
||||
type: 'object',
|
||||
title: 'Firma',
|
||||
properties: {
|
||||
unvan: { type: 'string', title: 'Firma Unvani' },
|
||||
vergiNo: { type: 'string', title: 'Vergi No' },
|
||||
},
|
||||
},
|
||||
fatura: {
|
||||
type: 'object',
|
||||
title: 'Fatura',
|
||||
properties: {
|
||||
no: { type: 'string', title: 'Fatura No' },
|
||||
tutar: { type: 'number', title: 'Tutar', format: 'currency' },
|
||||
tarih: { type: 'string', title: 'Tarih', format: 'date' },
|
||||
},
|
||||
},
|
||||
kalemler: {
|
||||
type: 'array',
|
||||
title: 'Kalemler',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
adi: { type: 'string', title: 'Adi' },
|
||||
miktar: { type: 'number', title: 'Miktar' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('parseSchema', () => {
|
||||
it('parses nested object schema into correct tree structure', () => {
|
||||
const tree = parseSchema(testSchema)
|
||||
|
||||
expect(tree.type).toBe('object')
|
||||
expect(tree.key).toBe('root')
|
||||
expect(tree.path).toBe('')
|
||||
expect(tree.children).toHaveLength(3)
|
||||
|
||||
const firma = tree.children[0]
|
||||
expect(firma.key).toBe('firma')
|
||||
expect(firma.title).toBe('Firma')
|
||||
expect(firma.type).toBe('object')
|
||||
expect(firma.path).toBe('firma')
|
||||
expect(firma.children).toHaveLength(2)
|
||||
|
||||
const unvan = firma.children[0]
|
||||
expect(unvan.key).toBe('unvan')
|
||||
expect(unvan.title).toBe('Firma Unvani')
|
||||
expect(unvan.type).toBe('string')
|
||||
expect(unvan.path).toBe('firma.unvan')
|
||||
})
|
||||
|
||||
it('parses array schema with correct itemProperties', () => {
|
||||
const tree = parseSchema(testSchema)
|
||||
const kalemler = tree.children[2]
|
||||
|
||||
expect(kalemler.key).toBe('kalemler')
|
||||
expect(kalemler.type).toBe('array')
|
||||
expect(kalemler.title).toBe('Kalemler')
|
||||
expect(kalemler.itemProperties).toBeDefined()
|
||||
expect(kalemler.itemProperties).toHaveLength(2)
|
||||
|
||||
const adi = kalemler.itemProperties![0]
|
||||
expect(adi.key).toBe('adi')
|
||||
expect(adi.path).toBe('kalemler[].adi')
|
||||
expect(adi.type).toBe('string')
|
||||
|
||||
const miktar = kalemler.itemProperties![1]
|
||||
expect(miktar.key).toBe('miktar')
|
||||
expect(miktar.path).toBe('kalemler[].miktar')
|
||||
expect(miktar.type).toBe('number')
|
||||
})
|
||||
|
||||
it('preserves format field from schema', () => {
|
||||
const tree = parseSchema(testSchema)
|
||||
const fatura = tree.children[1]
|
||||
const tutar = fatura.children[1]
|
||||
const tarih = fatura.children[2]
|
||||
|
||||
expect(tutar.format).toBe('currency')
|
||||
expect(tarih.format).toBe('date')
|
||||
})
|
||||
|
||||
it('uses key as title when title is not provided', () => {
|
||||
const schema: JsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
foo: { type: 'string' },
|
||||
},
|
||||
}
|
||||
const tree = parseSchema(schema)
|
||||
expect(tree.children[0].title).toBe('foo')
|
||||
})
|
||||
|
||||
it('handles empty schema with no properties', () => {
|
||||
const schema: JsonSchema = { type: 'object' }
|
||||
const tree = parseSchema(schema)
|
||||
|
||||
expect(tree.type).toBe('object')
|
||||
expect(tree.children).toHaveLength(0)
|
||||
expect(tree.itemProperties).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('findArrayFields', () => {
|
||||
it('returns only array nodes', () => {
|
||||
const tree = parseSchema(testSchema)
|
||||
const arrays = findArrayFields(tree)
|
||||
|
||||
expect(arrays).toHaveLength(1)
|
||||
expect(arrays[0].key).toBe('kalemler')
|
||||
expect(arrays[0].type).toBe('array')
|
||||
})
|
||||
|
||||
it('returns empty for schema with no arrays', () => {
|
||||
const schema: JsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
},
|
||||
}
|
||||
const tree = parseSchema(schema)
|
||||
expect(findArrayFields(tree)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findScalarFields', () => {
|
||||
it('returns only scalar nodes (string, number, integer, boolean)', () => {
|
||||
const tree = parseSchema(testSchema)
|
||||
const scalars = findScalarFields(tree)
|
||||
|
||||
// firma.unvan, firma.vergiNo, fatura.no, fatura.tutar, fatura.tarih = 5
|
||||
expect(scalars).toHaveLength(5)
|
||||
|
||||
const paths = scalars.map(s => s.path)
|
||||
expect(paths).toContain('firma.unvan')
|
||||
expect(paths).toContain('firma.vergiNo')
|
||||
expect(paths).toContain('fatura.no')
|
||||
expect(paths).toContain('fatura.tutar')
|
||||
expect(paths).toContain('fatura.tarih')
|
||||
})
|
||||
|
||||
it('does not include object or array nodes', () => {
|
||||
const tree = parseSchema(testSchema)
|
||||
const scalars = findScalarFields(tree)
|
||||
const types = scalars.map(s => s.type)
|
||||
|
||||
expect(types).not.toContain('object')
|
||||
expect(types).not.toContain('array')
|
||||
})
|
||||
})
|
||||
|
||||
describe('schemaFormatToFormatType', () => {
|
||||
it('maps known formats correctly', () => {
|
||||
expect(schemaFormatToFormatType('currency')).toBe('currency')
|
||||
expect(schemaFormatToFormatType('date')).toBe('date')
|
||||
expect(schemaFormatToFormatType('percentage')).toBe('percentage')
|
||||
})
|
||||
|
||||
it('returns undefined for unknown format', () => {
|
||||
expect(schemaFormatToFormatType('image')).toBeUndefined()
|
||||
expect(schemaFormatToFormatType('unknown')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for undefined input', () => {
|
||||
expect(schemaFormatToFormatType(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultAlignForSchema', () => {
|
||||
it('returns right for number type', () => {
|
||||
const node: SchemaNode = { path: 'x', key: 'x', title: 'X', type: 'number', children: [] }
|
||||
expect(defaultAlignForSchema(node)).toBe('right')
|
||||
})
|
||||
|
||||
it('returns right for integer type', () => {
|
||||
const node: SchemaNode = { path: 'x', key: 'x', title: 'X', type: 'integer', children: [] }
|
||||
expect(defaultAlignForSchema(node)).toBe('right')
|
||||
})
|
||||
|
||||
it('returns right for currency format', () => {
|
||||
const node: SchemaNode = { path: 'x', key: 'x', title: 'X', type: 'string', format: 'currency', children: [] }
|
||||
expect(defaultAlignForSchema(node)).toBe('right')
|
||||
})
|
||||
|
||||
it('returns right for percentage format', () => {
|
||||
const node: SchemaNode = { path: 'x', key: 'x', title: 'X', type: 'string', format: 'percentage', children: [] }
|
||||
expect(defaultAlignForSchema(node)).toBe('right')
|
||||
})
|
||||
|
||||
it('returns center for date format', () => {
|
||||
const node: SchemaNode = { path: 'x', key: 'x', title: 'X', type: 'string', format: 'date', children: [] }
|
||||
expect(defaultAlignForSchema(node)).toBe('center')
|
||||
})
|
||||
|
||||
it('returns left for plain string', () => {
|
||||
const node: SchemaNode = { path: 'x', key: 'x', title: 'X', type: 'string', children: [] }
|
||||
expect(defaultAlignForSchema(node)).toBe('left')
|
||||
})
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Layout data parsing — SVG'den element pozisyon bilgilerini çıkarır.
|
||||
* Template → Typst dönüşümü artık dreport-core WASM tarafından yapılır.
|
||||
*/
|
||||
|
||||
export interface ElementLayout {
|
||||
x: number // pt
|
||||
y: number // pt
|
||||
width: number // pt
|
||||
height: number // pt
|
||||
}
|
||||
|
||||
export function parseLayoutFromSvg(svgString: string): Record<string, ElementLayout> {
|
||||
const result: Record<string, ElementLayout> = {}
|
||||
const matches = svgString.matchAll(/([a-zA-Z0-9_-]+):([\d.]+)pt,([\d.]+)pt,([\d.]+)pt,([\d.]+)pt\|/g)
|
||||
for (const m of matches) {
|
||||
result[m[1]] = {
|
||||
x: parseFloat(m[2]),
|
||||
y: parseFloat(m[3]),
|
||||
width: parseFloat(m[4]),
|
||||
height: parseFloat(m[5]),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
48
frontend/src/core/wasm/dreport_core.d.ts
vendored
48
frontend/src/core/wasm/dreport_core.d.ts
vendored
@@ -1,48 +0,0 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
/**
|
||||
* Template JSON + Data JSON → Typst markup (editör modu, layout query dahil)
|
||||
*/
|
||||
export function templateToTypstEditor(template_json: string, data_json: string): string;
|
||||
|
||||
/**
|
||||
* Template JSON + Data JSON → Typst markup (PDF modu, layout query yok)
|
||||
*/
|
||||
export function templateToTypstPdf(template_json: string, data_json: string): string;
|
||||
|
||||
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||
|
||||
export interface InitOutput {
|
||||
readonly memory: WebAssembly.Memory;
|
||||
readonly templateToTypstEditor: (a: number, b: number, c: number, d: number) => [number, number, number, number];
|
||||
readonly templateToTypstPdf: (a: number, b: number, c: number, d: number) => [number, number, number, number];
|
||||
readonly __wbindgen_externrefs: WebAssembly.Table;
|
||||
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
||||
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
readonly __externref_table_dealloc: (a: number) => void;
|
||||
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||
readonly __wbindgen_start: () => void;
|
||||
}
|
||||
|
||||
export type SyncInitInput = BufferSource | WebAssembly.Module;
|
||||
|
||||
/**
|
||||
* Instantiates the given `module`, which can either be bytes or
|
||||
* a precompiled `WebAssembly.Module`.
|
||||
*
|
||||
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
|
||||
*
|
||||
* @returns {InitOutput}
|
||||
*/
|
||||
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
|
||||
|
||||
/**
|
||||
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
|
||||
* for everything else, calls `WebAssembly.instantiate` directly.
|
||||
*
|
||||
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
|
||||
*
|
||||
* @returns {Promise<InitOutput>}
|
||||
*/
|
||||
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
|
||||
@@ -1,260 +0,0 @@
|
||||
/* @ts-self-types="./dreport_core.d.ts" */
|
||||
|
||||
/**
|
||||
* Template JSON + Data JSON → Typst markup (editör modu, layout query dahil)
|
||||
* @param {string} template_json
|
||||
* @param {string} data_json
|
||||
* @returns {string}
|
||||
*/
|
||||
export function templateToTypstEditor(template_json, data_json) {
|
||||
let deferred4_0;
|
||||
let deferred4_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(template_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passStringToWasm0(data_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.templateToTypstEditor(ptr0, len0, ptr1, len1);
|
||||
var ptr3 = ret[0];
|
||||
var len3 = ret[1];
|
||||
if (ret[3]) {
|
||||
ptr3 = 0; len3 = 0;
|
||||
throw takeFromExternrefTable0(ret[2]);
|
||||
}
|
||||
deferred4_0 = ptr3;
|
||||
deferred4_1 = len3;
|
||||
return getStringFromWasm0(ptr3, len3);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred4_0, deferred4_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Template JSON + Data JSON → Typst markup (PDF modu, layout query yok)
|
||||
* @param {string} template_json
|
||||
* @param {string} data_json
|
||||
* @returns {string}
|
||||
*/
|
||||
export function templateToTypstPdf(template_json, data_json) {
|
||||
let deferred4_0;
|
||||
let deferred4_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(template_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passStringToWasm0(data_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.templateToTypstPdf(ptr0, len0, ptr1, len1);
|
||||
var ptr3 = ret[0];
|
||||
var len3 = ret[1];
|
||||
if (ret[3]) {
|
||||
ptr3 = 0; len3 = 0;
|
||||
throw takeFromExternrefTable0(ret[2]);
|
||||
}
|
||||
deferred4_0 = ptr3;
|
||||
deferred4_1 = len3;
|
||||
return getStringFromWasm0(ptr3, len3);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred4_0, deferred4_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function __wbg_get_imports() {
|
||||
const import0 = {
|
||||
__proto__: null,
|
||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Ref(String) -> Externref`.
|
||||
const ret = getStringFromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_init_externref_table: function() {
|
||||
const table = wasm.__wbindgen_externrefs;
|
||||
const offset = table.grow(4);
|
||||
table.set(0, undefined);
|
||||
table.set(offset + 0, undefined);
|
||||
table.set(offset + 1, null);
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
},
|
||||
};
|
||||
return {
|
||||
__proto__: null,
|
||||
"./dreport_core_bg.js": import0,
|
||||
};
|
||||
}
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return decodeText(ptr, len);
|
||||
}
|
||||
|
||||
let cachedUint8ArrayMemory0 = null;
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||
}
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
function passStringToWasm0(arg, malloc, realloc) {
|
||||
if (realloc === undefined) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
const ptr = malloc(buf.length, 1) >>> 0;
|
||||
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||
WASM_VECTOR_LEN = buf.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let len = arg.length;
|
||||
let ptr = malloc(len, 1) >>> 0;
|
||||
|
||||
const mem = getUint8ArrayMemory0();
|
||||
|
||||
let offset = 0;
|
||||
|
||||
for (; offset < len; offset++) {
|
||||
const code = arg.charCodeAt(offset);
|
||||
if (code > 0x7F) break;
|
||||
mem[ptr + offset] = code;
|
||||
}
|
||||
if (offset !== len) {
|
||||
if (offset !== 0) {
|
||||
arg = arg.slice(offset);
|
||||
}
|
||||
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
||||
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||
const ret = cachedTextEncoder.encodeInto(arg, view);
|
||||
|
||||
offset += ret.written;
|
||||
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||
}
|
||||
|
||||
WASM_VECTOR_LEN = offset;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function takeFromExternrefTable0(idx) {
|
||||
const value = wasm.__wbindgen_externrefs.get(idx);
|
||||
wasm.__externref_table_dealloc(idx);
|
||||
return value;
|
||||
}
|
||||
|
||||
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||
let numBytesDecoded = 0;
|
||||
function decodeText(ptr, len) {
|
||||
numBytesDecoded += len;
|
||||
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
||||
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
numBytesDecoded = len;
|
||||
}
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
const cachedTextEncoder = new TextEncoder();
|
||||
|
||||
if (!('encodeInto' in cachedTextEncoder)) {
|
||||
cachedTextEncoder.encodeInto = function (arg, view) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
view.set(buf);
|
||||
return {
|
||||
read: arg.length,
|
||||
written: buf.length
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
let wasmModule, wasm;
|
||||
function __wbg_finalize_init(instance, module) {
|
||||
wasm = instance.exports;
|
||||
wasmModule = module;
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
wasm.__wbindgen_start();
|
||||
return wasm;
|
||||
}
|
||||
|
||||
async function __wbg_load(module, imports) {
|
||||
if (typeof Response === 'function' && module instanceof Response) {
|
||||
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||
try {
|
||||
return await WebAssembly.instantiateStreaming(module, imports);
|
||||
} catch (e) {
|
||||
const validResponse = module.ok && expectedResponseType(module.type);
|
||||
|
||||
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
|
||||
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||
|
||||
} else { throw e; }
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = await module.arrayBuffer();
|
||||
return await WebAssembly.instantiate(bytes, imports);
|
||||
} else {
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
|
||||
if (instance instanceof WebAssembly.Instance) {
|
||||
return { instance, module };
|
||||
} else {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
function expectedResponseType(type) {
|
||||
switch (type) {
|
||||
case 'basic': case 'cors': case 'default': return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function initSync(module) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (module !== undefined) {
|
||||
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||
({module} = module)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
const imports = __wbg_get_imports();
|
||||
if (!(module instanceof WebAssembly.Module)) {
|
||||
module = new WebAssembly.Module(module);
|
||||
}
|
||||
const instance = new WebAssembly.Instance(module, imports);
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
async function __wbg_init(module_or_path) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (module_or_path !== undefined) {
|
||||
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||
({module_or_path} = module_or_path)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
if (module_or_path === undefined) {
|
||||
module_or_path = new URL('dreport_core_bg.wasm', import.meta.url);
|
||||
}
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||
module_or_path = fetch(module_or_path);
|
||||
}
|
||||
|
||||
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
export { initSync, __wbg_init as default };
|
||||
11
frontend/src/core/wasm/dreport_core_bg.wasm.d.ts
vendored
11
frontend/src/core/wasm/dreport_core_bg.wasm.d.ts
vendored
@@ -1,11 +0,0 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const memory: WebAssembly.Memory;
|
||||
export const templateToTypstEditor: (a: number, b: number, c: number, d: number) => [number, number, number, number];
|
||||
export const templateToTypstPdf: (a: number, b: number, c: number, d: number) => [number, number, number, number];
|
||||
export const __wbindgen_externrefs: WebAssembly.Table;
|
||||
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
export const __externref_table_dealloc: (a: number) => void;
|
||||
export const __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||
export const __wbindgen_start: () => void;
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import type { Template } from '../core/types'
|
||||
import type { JsonSchema } from '../core/schema-parser'
|
||||
import { useTemplateStore } from '../stores/template'
|
||||
@@ -7,6 +7,7 @@ import { useSchemaStore } from '../stores/schema'
|
||||
import { useEditorStore } from '../stores/editor'
|
||||
import EditorCanvas from '../components/editor/EditorCanvas.vue'
|
||||
import ToolboxPanel from '../components/panels/ToolboxPanel.vue'
|
||||
import SchemaTreePanel from '../components/panels/SchemaTreePanel.vue'
|
||||
import PropertiesPanel from '../components/panels/PropertiesPanel.vue'
|
||||
|
||||
export interface DreportEditorConfig {
|
||||
@@ -28,6 +29,8 @@ const emit = defineEmits<{
|
||||
'compile-error': [error: string | null]
|
||||
}>()
|
||||
|
||||
const leftTab = ref<'tools' | 'schema'>('tools')
|
||||
|
||||
const templateStore = useTemplateStore()
|
||||
const schemaStore = useSchemaStore()
|
||||
const editorStore = useEditorStore()
|
||||
@@ -178,7 +181,12 @@ defineExpose({
|
||||
<template>
|
||||
<div class="dreport-editor">
|
||||
<aside class="dreport-editor__sidebar dreport-editor__sidebar--left">
|
||||
<ToolboxPanel />
|
||||
<div class="sidebar-tabs">
|
||||
<button class="sidebar-tab" :class="{ 'sidebar-tab--active': leftTab === 'tools' }" @click="leftTab = 'tools'">Araclar</button>
|
||||
<button class="sidebar-tab" :class="{ 'sidebar-tab--active': leftTab === 'schema' }" @click="leftTab = 'schema'">Schema</button>
|
||||
</div>
|
||||
<ToolboxPanel v-if="leftTab === 'tools'" />
|
||||
<SchemaTreePanel v-else />
|
||||
</aside>
|
||||
<EditorCanvas :handle-errors="handleErrors" @compile-error="onCompileError" />
|
||||
<aside class="dreport-editor__sidebar dreport-editor__sidebar--right">
|
||||
@@ -204,8 +212,42 @@ defineExpose({
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dreport-editor__sidebar--left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dreport-editor__sidebar--right {
|
||||
border-right: none;
|
||||
border-left: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.sidebar-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-tab {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.sidebar-tab--active {
|
||||
color: #3b82f6;
|
||||
border-bottom-color: #3b82f6;
|
||||
}
|
||||
|
||||
.sidebar-tab:hover:not(.sidebar-tab--active) {
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
|
||||
109
frontend/src/stores/__tests__/editor.test.ts
Normal file
109
frontend/src/stores/__tests__/editor.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useEditorStore } from '../editor'
|
||||
import type { StaticTextElement } from '../../core/types'
|
||||
import { sz } from '../../core/types'
|
||||
|
||||
describe('useEditorStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('selectElement sets selectedElementId', () => {
|
||||
const store = useEditorStore()
|
||||
|
||||
store.selectElement('el_123')
|
||||
expect(store.selectedElementId).toBe('el_123')
|
||||
})
|
||||
|
||||
it('clearSelection resets to null', () => {
|
||||
const store = useEditorStore()
|
||||
store.selectElement('el_123')
|
||||
|
||||
store.clearSelection()
|
||||
expect(store.selectedElementId).toBeNull()
|
||||
})
|
||||
|
||||
it('setZoom clamps between 0.25 and 4', () => {
|
||||
const store = useEditorStore()
|
||||
|
||||
store.setZoom(2)
|
||||
expect(store.zoom).toBe(2)
|
||||
|
||||
store.setZoom(0.1)
|
||||
expect(store.zoom).toBe(0.25)
|
||||
|
||||
store.setZoom(10)
|
||||
expect(store.zoom).toBe(4)
|
||||
|
||||
store.setZoom(0.25)
|
||||
expect(store.zoom).toBe(0.25)
|
||||
|
||||
store.setZoom(4)
|
||||
expect(store.zoom).toBe(4)
|
||||
})
|
||||
|
||||
it('zoomPercent reflects zoom value', () => {
|
||||
const store = useEditorStore()
|
||||
|
||||
store.setZoom(1.5)
|
||||
expect(store.zoomPercent).toBe(150)
|
||||
|
||||
store.setZoom(0.5)
|
||||
expect(store.zoomPercent).toBe(50)
|
||||
})
|
||||
|
||||
it('startDragNewElement / endDragNewElement manage drag state', () => {
|
||||
const store = useEditorStore()
|
||||
const el: StaticTextElement = {
|
||||
id: 'new_el',
|
||||
type: 'static_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: {},
|
||||
content: 'Drag me',
|
||||
}
|
||||
|
||||
expect(store.draggedNewElement).toBeNull()
|
||||
|
||||
store.startDragNewElement(el)
|
||||
expect(store.draggedNewElement).toBeDefined()
|
||||
expect(store.draggedNewElement!.id).toBe('new_el')
|
||||
|
||||
store.endDragNewElement()
|
||||
expect(store.draggedNewElement).toBeNull()
|
||||
expect(store.dropTargetContainerId).toBeNull()
|
||||
})
|
||||
|
||||
it('setDropTargetContainer sets drop target ID', () => {
|
||||
const store = useEditorStore()
|
||||
|
||||
store.setDropTargetContainer('container_1')
|
||||
expect(store.dropTargetContainerId).toBe('container_1')
|
||||
|
||||
store.setDropTargetContainer(null)
|
||||
expect(store.dropTargetContainerId).toBeNull()
|
||||
})
|
||||
|
||||
it('setPan / resetPan manage pan values', () => {
|
||||
const store = useEditorStore()
|
||||
|
||||
store.setPan(100, 200)
|
||||
expect(store.panX).toBe(100)
|
||||
expect(store.panY).toBe(200)
|
||||
|
||||
store.resetPan()
|
||||
expect(store.panX).toBe(0)
|
||||
expect(store.panY).toBe(0)
|
||||
})
|
||||
|
||||
it('setDragging manages isDragging flag', () => {
|
||||
const store = useEditorStore()
|
||||
|
||||
expect(store.isDragging).toBe(false)
|
||||
store.setDragging(true)
|
||||
expect(store.isDragging).toBe(true)
|
||||
store.setDragging(false)
|
||||
expect(store.isDragging).toBe(false)
|
||||
})
|
||||
})
|
||||
202
frontend/src/stores/__tests__/template.test.ts
Normal file
202
frontend/src/stores/__tests__/template.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useTemplateStore } from '../template'
|
||||
import type { Template, TemplateElement, StaticTextElement } from '../../core/types'
|
||||
import { sz } from '../../core/types'
|
||||
|
||||
function createTestTemplate(): Template {
|
||||
return {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
page: { width: 210, height: 297 },
|
||||
fonts: ['Noto Sans'],
|
||||
root: {
|
||||
id: 'root',
|
||||
type: 'container' as const,
|
||||
position: { type: 'flow' as const },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
direction: 'column' as const,
|
||||
gap: 5,
|
||||
padding: { top: 10, right: 10, bottom: 10, left: 10 },
|
||||
align: 'stretch' as const,
|
||||
justify: 'start' as const,
|
||||
style: {},
|
||||
children: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createTextElement(id: string, content: string): StaticTextElement {
|
||||
return {
|
||||
id,
|
||||
type: 'static_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 12 },
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useTemplateStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('getElementById finds elements in tree', () => {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
const el = createTextElement('el_find', 'Hello')
|
||||
store.addChild('root', el)
|
||||
|
||||
expect(store.getElementById('el_find')).toBeDefined()
|
||||
expect(store.getElementById('el_find')!.id).toBe('el_find')
|
||||
})
|
||||
|
||||
it('getElementById returns undefined for missing id', () => {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
|
||||
expect(store.getElementById('nonexistent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('addChild adds element to container', () => {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
const el = createTextElement('el_add', 'Added')
|
||||
|
||||
store.addChild('root', el)
|
||||
|
||||
expect(store.template.root.children).toHaveLength(1)
|
||||
expect(store.template.root.children[0].id).toBe('el_add')
|
||||
})
|
||||
|
||||
it('addChild adds element at specific index', () => {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
|
||||
store.addChild('root', createTextElement('a', 'A'))
|
||||
store.addChild('root', createTextElement('b', 'B'))
|
||||
store.addChild('root', createTextElement('c', 'C'), 1)
|
||||
|
||||
expect(store.template.root.children.map(c => c.id)).toEqual(['a', 'c', 'b'])
|
||||
})
|
||||
|
||||
it('removeElement removes element', () => {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
store.addChild('root', createTextElement('el_rm', 'Remove'))
|
||||
|
||||
expect(store.template.root.children).toHaveLength(1)
|
||||
store.removeElement('el_rm')
|
||||
expect(store.template.root.children).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('updateElement updates properties', () => {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
store.addChild('root', createTextElement('el_up', 'Before'))
|
||||
|
||||
store.updateElement('el_up', { content: 'After' } as Partial<TemplateElement>)
|
||||
|
||||
const el = store.getElementById('el_up') as StaticTextElement
|
||||
expect(el.content).toBe('After')
|
||||
})
|
||||
|
||||
it('updateElementSize updates size', () => {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
store.addChild('root', createTextElement('el_sz', 'Sized'))
|
||||
|
||||
store.updateElementSize('el_sz', { width: sz.fixed(50) })
|
||||
|
||||
const el = store.getElementById('el_sz')!
|
||||
expect(el.size.width).toEqual({ type: 'fixed', value: 50 })
|
||||
})
|
||||
|
||||
it('updateElementPosition updates position', () => {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
store.addChild('root', createTextElement('el_pos', 'Pos'))
|
||||
|
||||
store.updateElementPosition('el_pos', { type: 'absolute', x: 10, y: 20 })
|
||||
|
||||
const el = store.getElementById('el_pos')!
|
||||
expect(el.position).toEqual({ type: 'absolute', x: 10, y: 20 })
|
||||
})
|
||||
|
||||
it('reorderChild swaps element order', () => {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
store.addChild('root', createTextElement('a', 'A'))
|
||||
store.addChild('root', createTextElement('b', 'B'))
|
||||
store.addChild('root', createTextElement('c', 'C'))
|
||||
|
||||
store.reorderChild('root', 0, 2)
|
||||
|
||||
expect(store.template.root.children.map(c => c.id)).toEqual(['b', 'c', 'a'])
|
||||
})
|
||||
|
||||
it('exportTemplate returns valid JSON', () => {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
|
||||
const json = store.exportTemplate()
|
||||
const parsed = JSON.parse(json)
|
||||
|
||||
expect(parsed.id).toBe('test')
|
||||
expect(parsed.name).toBe('Test')
|
||||
expect(parsed.root.type).toBe('container')
|
||||
})
|
||||
|
||||
it('importTemplate restores state', () => {
|
||||
const store = useTemplateStore()
|
||||
const tpl = createTestTemplate()
|
||||
tpl.name = 'Imported'
|
||||
tpl.id = 'imported_1'
|
||||
const json = JSON.stringify(tpl)
|
||||
|
||||
store.importTemplate(json)
|
||||
|
||||
expect(store.template.name).toBe('Imported')
|
||||
expect(store.template.id).toBe('imported_1')
|
||||
})
|
||||
|
||||
it('layoutVersion increments on mutations', () => {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
const initial = store.layoutVersion
|
||||
|
||||
store.addChild('root', createTextElement('lv1', 'LV'))
|
||||
expect(store.layoutVersion).toBe(initial + 1)
|
||||
|
||||
store.removeElement('lv1')
|
||||
expect(store.layoutVersion).toBe(initial + 2)
|
||||
})
|
||||
|
||||
it('undo/redo restores previous state', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
|
||||
// Initial state has 0 children
|
||||
store.addChild('root', createTextElement('u1', 'Undo'))
|
||||
|
||||
// Wait for debounce to record snapshot
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
|
||||
expect(store.template.root.children).toHaveLength(1)
|
||||
|
||||
store.undo()
|
||||
// After undo, should have the default template's children (which may include default elements)
|
||||
// Since we set template to createTestTemplate() with 0 children, undo should restore 0 children
|
||||
// However, the undo stack starts from the initial default template value.
|
||||
// Let's just verify undo doesn't crash and changes state
|
||||
expect(store.canRedo()).toBe(true)
|
||||
|
||||
store.redo()
|
||||
expect(store.template.root.children).toHaveLength(1)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
207
frontend/src/styles/properties.css
Normal file
207
frontend/src/styles/properties.css
Normal file
@@ -0,0 +1,207 @@
|
||||
.prop-section {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.prop-section__title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.prop-section__subtitle {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.prop-id {
|
||||
font-weight: 400;
|
||||
color: #94a3b8;
|
||||
font-size: 10px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.prop-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.prop-row-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.prop-row-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.prop-label {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
flex-shrink: 0;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.prop-input {
|
||||
width: 100px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background: white;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.prop-input:focus {
|
||||
outline: none;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
.prop-input--invalid {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.prop-input--invalid:focus {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.prop-select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prop-color {
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
padding: 1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prop-clear {
|
||||
background: none;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.prop-file-btn {
|
||||
padding: 4px 10px;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prop-file-btn:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.prop-image-preview {
|
||||
max-width: 80px;
|
||||
max-height: 60px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.prop-delete-btn {
|
||||
width: 100%;
|
||||
padding: 6px;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prop-delete-btn:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.prop-add-btn {
|
||||
float: right;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
width: 22px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.prop-add-btn:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.prop-column-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.prop-column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.prop-column-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.prop-column-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.prop-icon-btn {
|
||||
background: none;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
padding: 1px 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.prop-icon-btn:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.prop-icon-btn--danger:hover {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/// Typst WASM Web Worker
|
||||
/// Template JSON + Data JSON → (dreport-core WASM ile) Typst markup → (typst.ts WASM ile) SVG
|
||||
|
||||
import { $typst, TypstSnippet } from '@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs'
|
||||
import initCore, { templateToTypstEditor } from '../core/wasm/dreport_core.js'
|
||||
|
||||
let typstInitialized = false
|
||||
let coreInitialized = false
|
||||
|
||||
const FONT_FILES = [
|
||||
'/fonts/NotoSans-Regular.ttf',
|
||||
'/fonts/NotoSans-Bold.ttf',
|
||||
'/fonts/NotoSans-Italic.ttf',
|
||||
'/fonts/NotoSans-BoldItalic.ttf',
|
||||
'/fonts/NotoSansMono-Regular.ttf',
|
||||
]
|
||||
|
||||
async function ensureInit() {
|
||||
if (!coreInitialized) {
|
||||
console.log('[typst-worker] dreport-core WASM başlatılıyor...')
|
||||
await initCore({ module_or_path: '/wasm/dreport_core_bg.wasm' })
|
||||
coreInitialized = true
|
||||
console.log('[typst-worker] dreport-core WASM hazır')
|
||||
}
|
||||
|
||||
if (!typstInitialized) {
|
||||
console.log('[typst-worker] Typst WASM başlatılıyor...')
|
||||
|
||||
const fontUrls = FONT_FILES.map(f => new URL(f, self.location.origin).href)
|
||||
$typst.use(TypstSnippet.preloadFonts(fontUrls))
|
||||
$typst.use(TypstSnippet.fetchPackageRegistry())
|
||||
|
||||
await $typst.setCompilerInitOptions({
|
||||
getModule: () =>
|
||||
fetch('/wasm/typst_ts_web_compiler_bg.wasm').then(r => {
|
||||
console.log('[typst-worker] Compiler WASM yüklendi:', r.status)
|
||||
return r.arrayBuffer()
|
||||
}),
|
||||
})
|
||||
await $typst.setRendererInitOptions({
|
||||
getModule: () =>
|
||||
fetch('/wasm/typst_ts_renderer_bg.wasm').then(r => {
|
||||
console.log('[typst-worker] Renderer WASM yüklendi:', r.status)
|
||||
return r.arrayBuffer()
|
||||
}),
|
||||
})
|
||||
|
||||
typstInitialized = true
|
||||
console.log('[typst-worker] Typst WASM hazır')
|
||||
}
|
||||
}
|
||||
|
||||
interface CompileMessage {
|
||||
type: 'compile'
|
||||
templateJson: string
|
||||
dataJson: string
|
||||
id: number
|
||||
}
|
||||
|
||||
// Geriye uyumluluk için eski markup tabanlı mesaj desteği
|
||||
interface LegacyCompileMessage {
|
||||
type: 'compile'
|
||||
markup: string
|
||||
id: number
|
||||
}
|
||||
|
||||
type WorkerMessage = CompileMessage | LegacyCompileMessage
|
||||
|
||||
self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
|
||||
const { type, id } = e.data
|
||||
|
||||
if (type === 'compile') {
|
||||
console.log(`[typst-worker] Derleme başladı (id: ${id})`)
|
||||
try {
|
||||
await ensureInit()
|
||||
|
||||
let markup: string
|
||||
|
||||
if ('templateJson' in e.data) {
|
||||
// Yeni yol: Template JSON → Typst markup (dreport-core WASM)
|
||||
markup = templateToTypstEditor(e.data.templateJson, e.data.dataJson)
|
||||
console.log('[typst-worker] Generated Typst markup:\n', markup)
|
||||
} else {
|
||||
// Eski yol: doğrudan markup (geriye uyumluluk)
|
||||
markup = (e.data as LegacyCompileMessage).markup
|
||||
}
|
||||
|
||||
// Typst markup → SVG
|
||||
const svg = await $typst.svg({ mainContent: markup })
|
||||
|
||||
// SVG'den layout bilgisini parse et
|
||||
const layout: Record<string, { x: number; y: number; width: number; height: number }> = {}
|
||||
const matches = svg.matchAll(/([a-zA-Z0-9_-]+):([\d.]+)pt,([\d.]+)pt,([\d.]+)pt,([\d.]+)pt\|/g)
|
||||
for (const m of matches) {
|
||||
layout[m[1]] = {
|
||||
x: parseFloat(m[2]),
|
||||
y: parseFloat(m[3]),
|
||||
width: parseFloat(m[4]),
|
||||
height: parseFloat(m[5]),
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[typst-worker] Derleme başarılı (id: ${id}, elements: ${Object.keys(layout).length})`)
|
||||
self.postMessage({ type: 'result', svg, layout, id })
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err)
|
||||
console.error(`[typst-worker] Derleme hatası (id: ${id}):`, err)
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
error: errorMsg,
|
||||
id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
63
frontend/tests/visual/editor.spec.ts
Normal file
63
frontend/tests/visual/editor.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Editor Visual Tests', () => {
|
||||
test('full editor renders correctly', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
// Wait for the editor to fully load (WASM + layout)
|
||||
await page.waitForSelector('.dreport-editor', { timeout: 15000 })
|
||||
|
||||
// Wait for layout to render (layout renderer should have elements)
|
||||
await page.waitForSelector('.layout-renderer div[style]', { timeout: 10000 })
|
||||
|
||||
// Small delay for any CSS transitions
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Screenshot the full editor area
|
||||
await expect(page).toHaveScreenshot('editor-full.png', {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
})
|
||||
})
|
||||
|
||||
test('editor canvas renders template', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForSelector('.dreport-editor', { timeout: 15000 })
|
||||
await page.waitForSelector('.layout-renderer div[style]', { timeout: 10000 })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Screenshot just the canvas area
|
||||
const canvas = page.locator('.editor-canvas-wrapper')
|
||||
await expect(canvas).toHaveScreenshot('editor-canvas.png', {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
})
|
||||
})
|
||||
|
||||
test('toolbox panel renders correctly', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForSelector('.toolbox-panel', { timeout: 15000 })
|
||||
|
||||
const toolbox = page.locator('.toolbox-panel')
|
||||
await expect(toolbox).toHaveScreenshot('toolbox-panel.png')
|
||||
})
|
||||
|
||||
test('properties panel shows on element selection', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForSelector('.dreport-editor', { timeout: 15000 })
|
||||
await page.waitForSelector('.layout-renderer div[style]', { timeout: 10000 })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Click on an element in the editor to select it
|
||||
// The interaction overlay has clickable elements positioned absolutely
|
||||
const overlay = page.locator('.interaction-overlay')
|
||||
// Click approximately in the center-top area where the header text should be
|
||||
await overlay.click({ position: { x: 300, y: 50 } })
|
||||
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Screenshot the properties panel (right sidebar)
|
||||
const sidebar = page.locator('.dreport-editor__sidebar--right')
|
||||
await expect(sidebar).toHaveScreenshot('properties-panel-selected.png', {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
})
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
4
frontend/tests/visual/test-results/.last-run.json
Normal file
4
frontend/tests/visual/test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
9
frontend/vitest.config.ts
Normal file
9
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
},
|
||||
})
|
||||
9
justfile
9
justfile
@@ -24,12 +24,3 @@ wasm:
|
||||
# Layout engine WASM watch (rebuild on change)
|
||||
wasm-watch:
|
||||
watchexec -w layout-engine/src -w core/src -e rs -- just wasm
|
||||
|
||||
# Eski core WASM build (typst-based, deprecated)
|
||||
wasm-legacy:
|
||||
wasm-pack build core --target web --release --out-dir ../frontend/src/core/wasm-pkg -- --features wasm
|
||||
cp frontend/src/core/wasm-pkg/dreport_core.js frontend/src/core/wasm/dreport_core.js
|
||||
cp frontend/src/core/wasm-pkg/dreport_core.d.ts frontend/src/core/wasm/dreport_core.d.ts
|
||||
cp frontend/src/core/wasm-pkg/dreport_core_bg.wasm frontend/src/core/wasm/dreport_core_bg.wasm
|
||||
cp frontend/src/core/wasm-pkg/dreport_core_bg.wasm.d.ts frontend/src/core/wasm/dreport_core_bg.wasm.d.ts
|
||||
cp frontend/src/core/wasm/dreport_core_bg.wasm frontend/public/wasm/dreport_core_bg.wasm
|
||||
|
||||
@@ -9,8 +9,8 @@ crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
dreport-core = { path = "../core" }
|
||||
taffy = "0.7"
|
||||
cosmic-text = { version = "0.12", default-features = false, features = ["std", "swash"] }
|
||||
taffy = "0.9"
|
||||
cosmic-text = { version = "0.18", default-features = false, features = ["std", "swash"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rxing = { version = "0.8", default-features = false, features = ["encoding_rs"] }
|
||||
|
||||
@@ -130,7 +130,7 @@ fn render_text_cosmic(
|
||||
buffer.set_size(&mut font_system, Some(img_w as f32), Some(text_h as f32));
|
||||
|
||||
let attrs = Attrs::new().family(Family::SansSerif);
|
||||
buffer.set_text(&mut font_system, text, attrs, Shaping::Advanced);
|
||||
buffer.set_text(&mut font_system, text, &attrs, Shaping::Advanced, None);
|
||||
buffer.shape_until_scroll(&mut font_system, false);
|
||||
|
||||
let mut swash_cache = SwashCache::new();
|
||||
|
||||
@@ -125,7 +125,13 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_path() {
|
||||
fn test_resolve_path_simple() {
|
||||
let data: Value = serde_json::json!({"name": "test"});
|
||||
assert_eq!(value_to_string(resolve_path(&data, "name")), "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_path_nested() {
|
||||
let data: Value = serde_json::json!({
|
||||
"firma": {
|
||||
"unvan": "Acme A.Ş.",
|
||||
@@ -140,10 +146,30 @@ mod tests {
|
||||
value_to_string(resolve_path(&data, "firma.vergiNo")),
|
||||
"123"
|
||||
);
|
||||
assert_eq!(
|
||||
value_to_string(resolve_path(&data, "nonexistent.path")),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_path_missing() {
|
||||
let data: Value = serde_json::json!({"name": "test"});
|
||||
let result = resolve_path(&data, "nonexistent.path");
|
||||
assert!(result.is_null());
|
||||
assert_eq!(value_to_string(result), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_path_deep_missing() {
|
||||
let data: Value = serde_json::json!({"a": {"b": 42}});
|
||||
let result = resolve_path(&data, "a.b.c.d");
|
||||
assert!(result.is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_value_to_string_types() {
|
||||
assert_eq!(value_to_string(&serde_json::json!("hello")), "hello");
|
||||
assert_eq!(value_to_string(&serde_json::json!(42)), "42");
|
||||
assert_eq!(value_to_string(&serde_json::json!(3.14)), "3.14");
|
||||
assert_eq!(value_to_string(&serde_json::json!(true)), "true");
|
||||
assert_eq!(value_to_string(&serde_json::json!(null)), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -158,4 +184,261 @@ mod tests {
|
||||
assert!(arr.is_array());
|
||||
assert_eq!(arr.as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_template_text_binding() {
|
||||
let template = Template {
|
||||
id: "t1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![
|
||||
TemplateElement::Text(TextElement {
|
||||
id: "el_name".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
style: TextStyle::default(),
|
||||
content: None,
|
||||
binding: ScalarBinding { path: "firma.unvan".to_string() },
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let data = serde_json::json!({
|
||||
"firma": { "unvan": "Acme Teknoloji A.Ş." }
|
||||
});
|
||||
|
||||
let resolved = resolve_template(&template, &data);
|
||||
assert_eq!(
|
||||
resolved.texts.get("el_name").unwrap(),
|
||||
"Acme Teknoloji A.Ş."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_template_text_with_prefix() {
|
||||
let template = Template {
|
||||
id: "t1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![
|
||||
TemplateElement::Text(TextElement {
|
||||
id: "el_no".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
style: TextStyle::default(),
|
||||
content: Some("Fatura No: ".to_string()),
|
||||
binding: ScalarBinding { path: "fatura.no".to_string() },
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let data = serde_json::json!({
|
||||
"fatura": { "no": "FTR-001" }
|
||||
});
|
||||
|
||||
let resolved = resolve_template(&template, &data);
|
||||
assert_eq!(
|
||||
resolved.texts.get("el_no").unwrap(),
|
||||
"Fatura No: FTR-001"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_template_static_text() {
|
||||
let template = Template {
|
||||
id: "t1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
style: TextStyle::default(),
|
||||
content: "FATURA".to_string(),
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let resolved = resolve_template(&template, &serde_json::json!({}));
|
||||
assert_eq!(resolved.texts.get("title").unwrap(), "FATURA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_template_table_binding() {
|
||||
let template = Template {
|
||||
id: "t1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![
|
||||
TemplateElement::RepeatingTable(RepeatingTableElement {
|
||||
id: "tbl".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
data_source: ArrayBinding { path: "kalemler".to_string() },
|
||||
columns: vec![
|
||||
TableColumn {
|
||||
id: "col_adi".to_string(),
|
||||
field: "adi".to_string(),
|
||||
title: "Urun Adi".to_string(),
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
align: "left".to_string(),
|
||||
format: None,
|
||||
},
|
||||
TableColumn {
|
||||
id: "col_tutar".to_string(),
|
||||
field: "tutar".to_string(),
|
||||
title: "Tutar".to_string(),
|
||||
width: SizeValue::Fixed { value: 30.0 },
|
||||
align: "right".to_string(),
|
||||
format: None,
|
||||
},
|
||||
],
|
||||
style: TableStyle::default(),
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let data = serde_json::json!({
|
||||
"kalemler": [
|
||||
{ "adi": "Widget", "tutar": 100 },
|
||||
{ "adi": "Gadget", "tutar": 200 }
|
||||
]
|
||||
});
|
||||
|
||||
let resolved = resolve_template(&template, &data);
|
||||
let table = resolved.tables.get("tbl").unwrap();
|
||||
assert_eq!(table.rows.len(), 2);
|
||||
assert_eq!(table.rows[0], vec!["Widget", "100"]);
|
||||
assert_eq!(table.rows[1], vec!["Gadget", "200"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_template_table_empty_array() {
|
||||
let template = Template {
|
||||
id: "t1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![
|
||||
TemplateElement::RepeatingTable(RepeatingTableElement {
|
||||
id: "tbl".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
data_source: ArrayBinding { path: "items".to_string() },
|
||||
columns: vec![
|
||||
TableColumn {
|
||||
id: "c1".to_string(),
|
||||
field: "name".to_string(),
|
||||
title: "Name".to_string(),
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
align: "left".to_string(),
|
||||
format: None,
|
||||
},
|
||||
],
|
||||
style: TableStyle::default(),
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let data = serde_json::json!({ "items": [] });
|
||||
let resolved = resolve_template(&template, &data);
|
||||
let table = resolved.tables.get("tbl").unwrap();
|
||||
assert_eq!(table.rows.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_template_missing_binding_path() {
|
||||
let template = Template {
|
||||
id: "t1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![
|
||||
TemplateElement::Text(TextElement {
|
||||
id: "el_missing".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
style: TextStyle::default(),
|
||||
content: None,
|
||||
binding: ScalarBinding { path: "does.not.exist".to_string() },
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let data = serde_json::json!({});
|
||||
let resolved = resolve_template(&template, &data);
|
||||
// Missing binding path should resolve to empty string
|
||||
assert_eq!(resolved.texts.get("el_missing").unwrap(), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,16 +15,16 @@ pub fn pt_to_mm(pt: f32) -> f64 {
|
||||
/// SizeValue → taffy Dimension (width veya height için)
|
||||
fn size_value_to_dimension(sv: &SizeValue) -> Dimension {
|
||||
match sv {
|
||||
SizeValue::Fixed { value } => Dimension::Length(mm_to_pt(*value)),
|
||||
SizeValue::Auto => Dimension::Auto,
|
||||
SizeValue::Fixed { value } => Dimension::length(mm_to_pt(*value)),
|
||||
SizeValue::Auto => Dimension::auto(),
|
||||
// Fr için dimension Auto, flex_grow ayrıca set edilir
|
||||
SizeValue::Fr { .. } => Dimension::Auto,
|
||||
SizeValue::Fr { .. } => Dimension::auto(),
|
||||
}
|
||||
}
|
||||
|
||||
/// SizeValue → taffy LengthPercentage (min/max constraint'ler için)
|
||||
fn mm_to_length(mm: f64) -> Dimension {
|
||||
Dimension::Length(mm_to_pt(mm))
|
||||
Dimension::length(mm_to_pt(mm))
|
||||
}
|
||||
|
||||
/// Fr değerini döndür (yoksa 0)
|
||||
@@ -78,7 +78,7 @@ pub fn apply_size_to_style(
|
||||
if main_fr > 0.0 {
|
||||
style.flex_grow = main_fr;
|
||||
style.flex_shrink = 1.0;
|
||||
style.flex_basis = Dimension::Length(0.0);
|
||||
style.flex_basis = Dimension::length(0.0);
|
||||
|
||||
// min-width: 0 (row) veya min-height: 0 (column) ayarla —
|
||||
// taffy'de min_size default Auto = içerik boyutunun altına küçülemez.
|
||||
@@ -86,12 +86,12 @@ pub fn apply_size_to_style(
|
||||
match parent_direction {
|
||||
Some("row") => {
|
||||
if size.min_width.is_none() {
|
||||
style.min_size.width = Dimension::Length(0.0);
|
||||
style.min_size.width = Dimension::length(0.0);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if size.min_height.is_none() {
|
||||
style.min_size.height = Dimension::Length(0.0);
|
||||
style.min_size.height = Dimension::length(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,14 +113,14 @@ pub fn container_to_style(el: &ContainerElement, parent_direction: Option<&str>)
|
||||
_ => FlexDirection::Column,
|
||||
},
|
||||
gap: Size {
|
||||
width: LengthPercentage::Length(mm_to_pt(el.gap)),
|
||||
height: LengthPercentage::Length(mm_to_pt(el.gap)),
|
||||
width: LengthPercentage::length(mm_to_pt(el.gap)),
|
||||
height: LengthPercentage::length(mm_to_pt(el.gap)),
|
||||
},
|
||||
padding: Rect {
|
||||
top: LengthPercentage::Length(mm_to_pt(el.padding.top)),
|
||||
right: LengthPercentage::Length(mm_to_pt(el.padding.right)),
|
||||
bottom: LengthPercentage::Length(mm_to_pt(el.padding.bottom)),
|
||||
left: LengthPercentage::Length(mm_to_pt(el.padding.left)),
|
||||
top: LengthPercentage::length(mm_to_pt(el.padding.top)),
|
||||
right: LengthPercentage::length(mm_to_pt(el.padding.right)),
|
||||
bottom: LengthPercentage::length(mm_to_pt(el.padding.bottom)),
|
||||
left: LengthPercentage::length(mm_to_pt(el.padding.left)),
|
||||
},
|
||||
align_items: Some(match el.align.as_str() {
|
||||
"center" => AlignItems::Center,
|
||||
@@ -142,8 +142,8 @@ pub fn container_to_style(el: &ContainerElement, parent_direction: Option<&str>)
|
||||
PositionMode::Absolute { x, y } => {
|
||||
style.position = Position::Absolute;
|
||||
style.inset = Rect {
|
||||
top: LengthPercentageAuto::Length(mm_to_pt(*y)),
|
||||
left: LengthPercentageAuto::Length(mm_to_pt(*x)),
|
||||
top: LengthPercentageAuto::length(mm_to_pt(*y)),
|
||||
left: LengthPercentageAuto::length(mm_to_pt(*x)),
|
||||
right: auto(),
|
||||
bottom: auto(),
|
||||
};
|
||||
@@ -158,10 +158,10 @@ pub fn container_to_style(el: &ContainerElement, parent_direction: Option<&str>)
|
||||
if let Some(bw) = el.style.border_width {
|
||||
let bpt = mm_to_pt(bw);
|
||||
style.border = Rect {
|
||||
top: LengthPercentage::Length(bpt),
|
||||
right: LengthPercentage::Length(bpt),
|
||||
bottom: LengthPercentage::Length(bpt),
|
||||
left: LengthPercentage::Length(bpt),
|
||||
top: LengthPercentage::length(bpt),
|
||||
right: LengthPercentage::length(bpt),
|
||||
bottom: LengthPercentage::length(bpt),
|
||||
left: LengthPercentage::length(bpt),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,8 +180,8 @@ pub fn leaf_style(
|
||||
PositionMode::Absolute { x, y } => {
|
||||
style.position = Position::Absolute;
|
||||
style.inset = Rect {
|
||||
top: LengthPercentageAuto::Length(mm_to_pt(*y)),
|
||||
left: LengthPercentageAuto::Length(mm_to_pt(*x)),
|
||||
top: LengthPercentageAuto::length(mm_to_pt(*y)),
|
||||
left: LengthPercentageAuto::length(mm_to_pt(*x)),
|
||||
right: auto(),
|
||||
bottom: auto(),
|
||||
};
|
||||
@@ -197,6 +197,7 @@ pub fn leaf_style(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dreport_core::models::{ContainerStyle, Padding};
|
||||
|
||||
#[test]
|
||||
fn test_mm_to_pt_conversion() {
|
||||
@@ -205,18 +206,171 @@ mod tests {
|
||||
assert!((pt - 595.28).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mm_to_pt_one_inch() {
|
||||
// 1 inch = 25.4mm = 72pt
|
||||
let pt = mm_to_pt(25.4);
|
||||
assert!((pt - 72.0).abs() < 0.01, "25.4mm should be ~72pt, got {}", pt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pt_to_mm_conversion() {
|
||||
// 72pt = 25.4mm (1 inch)
|
||||
let mm = pt_to_mm(72.0);
|
||||
assert!((mm - 25.4).abs() < 0.01, "72pt should be ~25.4mm, got {}", mm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_mm_pt_mm() {
|
||||
// mm → pt → mm should preserve value within tolerance
|
||||
let original = 100.0_f64;
|
||||
let pt = mm_to_pt(original);
|
||||
let back = pt_to_mm(pt);
|
||||
assert!(
|
||||
(back - original).abs() < 0.01,
|
||||
"Roundtrip failed: {} → {}pt → {}",
|
||||
original,
|
||||
pt,
|
||||
back
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mm_to_pt_zero() {
|
||||
assert_eq!(mm_to_pt(0.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pt_to_mm_zero() {
|
||||
assert!((pt_to_mm(0.0) - 0.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixed_size() {
|
||||
let sv = SizeValue::Fixed { value: 50.0 };
|
||||
match size_value_to_dimension(&sv) {
|
||||
Dimension::Length(pt) => assert!((pt - mm_to_pt(50.0)).abs() < 0.01),
|
||||
_ => panic!("Expected Length"),
|
||||
assert_eq!(size_value_to_dimension(&sv), Dimension::length(mm_to_pt(50.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_size() {
|
||||
let sv = SizeValue::Auto;
|
||||
assert_eq!(size_value_to_dimension(&sv), Dimension::auto());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fr_maps_to_auto_dimension() {
|
||||
let sv = SizeValue::Fr { value: 2.0 };
|
||||
assert!(matches!(size_value_to_dimension(&sv), Dimension::Auto));
|
||||
assert_eq!(size_value_to_dimension(&sv), Dimension::auto());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fr_value_extraction() {
|
||||
assert_eq!(fr_value(&SizeValue::Fr { value: 3.0 }), 3.0);
|
||||
assert_eq!(fr_value(&SizeValue::Auto), 0.0);
|
||||
assert_eq!(fr_value(&SizeValue::Fixed { value: 10.0 }), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_size_fr_sets_flex_grow() {
|
||||
let size = SizeConstraint {
|
||||
width: SizeValue::Fr { value: 2.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
};
|
||||
let mut style = Style::default();
|
||||
apply_size_to_style(&mut style, &size, Some("row"));
|
||||
assert_eq!(style.flex_grow, 2.0);
|
||||
assert_eq!(style.flex_basis, Dimension::length(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_size_fixed_no_flex_grow() {
|
||||
let size = SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 50.0 },
|
||||
height: SizeValue::Fixed { value: 30.0 },
|
||||
..Default::default()
|
||||
};
|
||||
let mut style = Style::default();
|
||||
apply_size_to_style(&mut style, &size, Some("row"));
|
||||
assert_eq!(style.flex_grow, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_size_min_max_constraints() {
|
||||
let size = SizeConstraint {
|
||||
width: SizeValue::Auto,
|
||||
height: SizeValue::Auto,
|
||||
min_width: Some(20.0),
|
||||
max_width: Some(100.0),
|
||||
min_height: Some(10.0),
|
||||
max_height: Some(50.0),
|
||||
};
|
||||
let mut style = Style::default();
|
||||
apply_size_to_style(&mut style, &size, None);
|
||||
assert_eq!(style.min_size.width, Dimension::length(mm_to_pt(20.0)));
|
||||
assert_eq!(style.max_size.width, Dimension::length(mm_to_pt(100.0)));
|
||||
assert_eq!(style.min_size.height, Dimension::length(mm_to_pt(10.0)));
|
||||
assert_eq!(style.max_size.height, Dimension::length(mm_to_pt(50.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_to_style_direction() {
|
||||
let el = ContainerElement {
|
||||
id: "test".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "row".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding { top: 10.0, right: 10.0, bottom: 10.0, left: 10.0 },
|
||||
align: "center".to_string(),
|
||||
justify: "space-between".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![],
|
||||
};
|
||||
let style = container_to_style(&el, None);
|
||||
assert_eq!(style.flex_direction, FlexDirection::Row);
|
||||
assert_eq!(style.align_items, Some(AlignItems::Center));
|
||||
assert_eq!(style.justify_content, Some(JustifyContent::SpaceBetween));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_to_style_absolute() {
|
||||
let el = ContainerElement {
|
||||
id: "test".to_string(),
|
||||
position: PositionMode::Absolute { x: 20.0, y: 30.0 },
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![],
|
||||
};
|
||||
let style = container_to_style(&el, None);
|
||||
assert_eq!(style.position, Position::Absolute);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_leaf_style_flow() {
|
||||
let size = SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 60.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
};
|
||||
let style = leaf_style(&size, &PositionMode::Flow, Some("column"));
|
||||
assert_eq!(style.position, Position::Relative);
|
||||
assert_eq!(style.size.width, Dimension::length(mm_to_pt(60.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_leaf_style_absolute() {
|
||||
let size = SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 40.0 },
|
||||
height: SizeValue::Fixed { value: 20.0 },
|
||||
..Default::default()
|
||||
};
|
||||
let style = leaf_style(&size, &PositionMode::Absolute { x: 10.0, y: 15.0 }, None);
|
||||
assert_eq!(style.position, Position::Absolute);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,3 +189,220 @@ pub fn expand_table(
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::data_resolve::{ResolvedData, ResolvedTable};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn make_table(num_columns: usize) -> RepeatingTableElement {
|
||||
let columns: Vec<TableColumn> = (0..num_columns)
|
||||
.map(|i| TableColumn {
|
||||
id: format!("col_{}", i),
|
||||
field: format!("field_{}", i),
|
||||
title: format!("Column {}", i),
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
align: "left".to_string(),
|
||||
format: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
RepeatingTableElement {
|
||||
id: "tbl".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
data_source: ArrayBinding { path: "items".to_string() },
|
||||
columns,
|
||||
style: TableStyle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_resolved(table_id: &str, rows: Vec<Vec<String>>) -> ResolvedData {
|
||||
let mut tables = HashMap::new();
|
||||
tables.insert(table_id.to_string(), ResolvedTable { rows });
|
||||
ResolvedData {
|
||||
texts: HashMap::new(),
|
||||
tables,
|
||||
barcodes: HashMap::new(),
|
||||
images: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_table_structure() {
|
||||
let table = make_table(2);
|
||||
let resolved = make_resolved("tbl", vec![
|
||||
vec!["A".to_string(), "1".to_string()],
|
||||
vec!["B".to_string(), "2".to_string()],
|
||||
]);
|
||||
|
||||
let container = expand_table(&table, &resolved);
|
||||
|
||||
// Wrapper container properties
|
||||
assert_eq!(container.id, "tbl");
|
||||
assert_eq!(container.direction, "column");
|
||||
|
||||
// Children: header row + 2 data rows (no border_color so no separator line)
|
||||
assert_eq!(container.children.len(), 3);
|
||||
|
||||
// First child is header row container
|
||||
match &container.children[0] {
|
||||
TemplateElement::Container(c) => {
|
||||
assert_eq!(c.id, "tbl_header");
|
||||
assert_eq!(c.direction, "row");
|
||||
assert_eq!(c.children.len(), 2); // 2 columns
|
||||
// Check header cell text
|
||||
match &c.children[0] {
|
||||
TemplateElement::StaticText(t) => assert_eq!(t.content, "Column 0"),
|
||||
_ => panic!("Expected StaticText for header cell"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected Container for header row"),
|
||||
}
|
||||
|
||||
// Data rows
|
||||
for (row_idx, child) in container.children[1..].iter().enumerate() {
|
||||
match child {
|
||||
TemplateElement::Container(c) => {
|
||||
assert_eq!(c.id, format!("tbl_row_{}", row_idx));
|
||||
assert_eq!(c.direction, "row");
|
||||
assert_eq!(c.children.len(), 2);
|
||||
}
|
||||
_ => panic!("Expected Container for data row"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_table_empty_data() {
|
||||
let table = make_table(3);
|
||||
let resolved = make_resolved("tbl", vec![]);
|
||||
|
||||
let container = expand_table(&table, &resolved);
|
||||
|
||||
// Only header row, no data rows
|
||||
assert_eq!(container.children.len(), 1);
|
||||
|
||||
// Header should still have all 3 columns
|
||||
match &container.children[0] {
|
||||
TemplateElement::Container(c) => {
|
||||
assert_eq!(c.children.len(), 3);
|
||||
}
|
||||
_ => panic!("Expected Container for header row"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_table_column_count() {
|
||||
let table = make_table(4);
|
||||
let resolved = make_resolved("tbl", vec![
|
||||
vec!["a".into(), "b".into(), "c".into(), "d".into()],
|
||||
]);
|
||||
|
||||
let container = expand_table(&table, &resolved);
|
||||
|
||||
// header + 1 data row
|
||||
assert_eq!(container.children.len(), 2);
|
||||
|
||||
// Both header and data row should have 4 cells
|
||||
match &container.children[0] {
|
||||
TemplateElement::Container(c) => assert_eq!(c.children.len(), 4),
|
||||
_ => panic!("Expected Container"),
|
||||
}
|
||||
match &container.children[1] {
|
||||
TemplateElement::Container(c) => assert_eq!(c.children.len(), 4),
|
||||
_ => panic!("Expected Container"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_table_data_cell_content() {
|
||||
let table = make_table(2);
|
||||
let resolved = make_resolved("tbl", vec![
|
||||
vec!["Hello".to_string(), "42".to_string()],
|
||||
]);
|
||||
|
||||
let container = expand_table(&table, &resolved);
|
||||
|
||||
// Data row cells should contain the resolved text
|
||||
match &container.children[1] {
|
||||
TemplateElement::Container(c) => {
|
||||
match &c.children[0] {
|
||||
TemplateElement::StaticText(t) => assert_eq!(t.content, "Hello"),
|
||||
_ => panic!("Expected StaticText"),
|
||||
}
|
||||
match &c.children[1] {
|
||||
TemplateElement::StaticText(t) => assert_eq!(t.content, "42"),
|
||||
_ => panic!("Expected StaticText"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected Container"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_table_with_border_adds_separator() {
|
||||
let mut table = make_table(2);
|
||||
table.style.border_color = Some("#000000".to_string());
|
||||
let resolved = make_resolved("tbl", vec![
|
||||
vec!["A".to_string(), "1".to_string()],
|
||||
]);
|
||||
|
||||
let container = expand_table(&table, &resolved);
|
||||
|
||||
// header + separator line + 1 data row = 3
|
||||
assert_eq!(container.children.len(), 3);
|
||||
|
||||
// Second child should be a Line
|
||||
match &container.children[1] {
|
||||
TemplateElement::Line(l) => {
|
||||
assert_eq!(l.id, "tbl_header_line");
|
||||
}
|
||||
_ => panic!("Expected Line separator after header"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_table_zebra_stripes() {
|
||||
let mut table = make_table(1);
|
||||
table.style.zebra_odd = Some("#f0f0f0".to_string());
|
||||
table.style.zebra_even = Some("#ffffff".to_string());
|
||||
let resolved = make_resolved("tbl", vec![
|
||||
vec!["row0".into()],
|
||||
vec!["row1".into()],
|
||||
vec!["row2".into()],
|
||||
]);
|
||||
|
||||
let container = expand_table(&table, &resolved);
|
||||
|
||||
// header + 3 data rows
|
||||
assert_eq!(container.children.len(), 4);
|
||||
|
||||
// row_0 (even index) => zebra_odd
|
||||
match &container.children[1] {
|
||||
TemplateElement::Container(c) => {
|
||||
assert_eq!(c.style.background_color, Some("#f0f0f0".to_string()));
|
||||
}
|
||||
_ => panic!("Expected Container"),
|
||||
}
|
||||
// row_1 (odd index) => zebra_even
|
||||
match &container.children[2] {
|
||||
TemplateElement::Container(c) => {
|
||||
assert_eq!(c.style.background_color, Some("#ffffff".to_string()));
|
||||
}
|
||||
_ => panic!("Expected Container"),
|
||||
}
|
||||
// row_2 (even index) => zebra_odd
|
||||
match &container.children[3] {
|
||||
TemplateElement::Container(c) => {
|
||||
assert_eq!(c.style.background_color, Some("#f0f0f0".to_string()));
|
||||
}
|
||||
_ => panic!("Expected Container"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ impl TextMeasurer {
|
||||
.family(Family::Name(family_name))
|
||||
.weight(weight);
|
||||
|
||||
buffer.set_text(&mut self.font_system, text, attrs, Shaping::Advanced);
|
||||
buffer.set_text(&mut self.font_system, text, &attrs, Shaping::Advanced, None);
|
||||
buffer.shape_until_scroll(&mut self.font_system, false);
|
||||
|
||||
let mut max_width: f32 = 0.0;
|
||||
|
||||
@@ -54,8 +54,8 @@ pub fn compute(
|
||||
display: Display::Flex,
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: Size {
|
||||
width: Dimension::Length(page_w_pt),
|
||||
height: Dimension::Length(page_h_pt),
|
||||
width: Dimension::length(page_w_pt),
|
||||
height: Dimension::length(page_h_pt),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
@@ -197,7 +197,7 @@ fn build_element(
|
||||
// Line: genişlik parent'tan, yükseklik stroke width
|
||||
let mut leaf_style = style;
|
||||
if matches!(e.size.height, SizeValue::Auto) {
|
||||
leaf_style.size.height = Dimension::Length(mm_to_pt(stroke_w));
|
||||
leaf_style.size.height = Dimension::length(mm_to_pt(stroke_w));
|
||||
}
|
||||
|
||||
let node = taffy.new_leaf(leaf_style).unwrap();
|
||||
@@ -246,10 +246,10 @@ fn build_element(
|
||||
let default_h = if is_qr { 20.0 } else { 15.0 }; // mm
|
||||
let default_w = if is_qr { 20.0 } else { 40.0 }; // mm
|
||||
if matches!(e.size.height, SizeValue::Auto) {
|
||||
style.min_size.height = Dimension::Length(mm_to_pt(default_h));
|
||||
style.min_size.height = Dimension::length(mm_to_pt(default_h));
|
||||
}
|
||||
if matches!(e.size.width, SizeValue::Auto) {
|
||||
style.min_size.width = Dimension::Length(mm_to_pt(default_w));
|
||||
style.min_size.width = Dimension::length(mm_to_pt(default_w));
|
||||
}
|
||||
|
||||
let node = taffy.new_leaf(style).unwrap();
|
||||
|
||||
4
layout-engine/tests/fixtures/visual_test_data.json
vendored
Normal file
4
layout-engine/tests/fixtures/visual_test_data.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"company": "Acme Test Corp.",
|
||||
"date": "2026-01-15"
|
||||
}
|
||||
73
layout-engine/tests/fixtures/visual_test_template.json
vendored
Normal file
73
layout-engine/tests/fixtures/visual_test_template.json
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"id": "visual_test",
|
||||
"name": "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": 5,
|
||||
"padding": { "top": 15, "right": 15, "bottom": 15, "left": 15 },
|
||||
"align": "stretch",
|
||||
"justify": "start",
|
||||
"style": {},
|
||||
"children": [
|
||||
{
|
||||
"id": "header",
|
||||
"type": "static_text",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"style": { "fontSize": 18, "fontWeight": "bold", "color": "#1a1a1a" },
|
||||
"content": "VISUAL TEST DOCUMENT"
|
||||
},
|
||||
{
|
||||
"id": "line1",
|
||||
"type": "line",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
|
||||
"style": { "strokeColor": "#333333", "strokeWidth": 0.5 }
|
||||
},
|
||||
{
|
||||
"id": "info_box",
|
||||
"type": "container",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
|
||||
"direction": "column",
|
||||
"gap": 2,
|
||||
"padding": { "top": 5, "right": 5, "bottom": 5, "left": 5 },
|
||||
"align": "start",
|
||||
"justify": "start",
|
||||
"style": { "backgroundColor": "#f0f4f8", "borderColor": "#cbd5e1", "borderWidth": 0.5 },
|
||||
"children": [
|
||||
{
|
||||
"id": "company",
|
||||
"type": "text",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"style": { "fontSize": 12, "fontWeight": "bold", "color": "#1e293b" },
|
||||
"binding": { "type": "scalar", "path": "company" }
|
||||
},
|
||||
{
|
||||
"id": "date_text",
|
||||
"type": "text",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"style": { "fontSize": 10, "color": "#64748b" },
|
||||
"binding": { "type": "scalar", "path": "date" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "body_text",
|
||||
"type": "static_text",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
|
||||
"style": { "fontSize": 11, "color": "#334155" },
|
||||
"content": "This is a visual regression test document. Layout and text rendering should be consistent across runs."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
321
layout-engine/tests/layout_integration.rs
Normal file
321
layout-engine/tests/layout_integration.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
//! Integration tests for the layout engine's compute_layout() public API.
|
||||
|
||||
use dreport_core::models::*;
|
||||
use dreport_layout::{compute_layout, FontData, LayoutResult};
|
||||
|
||||
fn load_test_fonts() -> Vec<FontData> {
|
||||
let font_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("backend/fonts");
|
||||
|
||||
let mut fonts = Vec::new();
|
||||
for entry in std::fs::read_dir(&font_dir).expect("backend/fonts directory not found") {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "ttf") {
|
||||
let family = path
|
||||
.file_stem()
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split('-')
|
||||
.next()
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
// Map NotoSans → "Noto Sans", NotoSansMono → "Noto Sans Mono"
|
||||
let family = if family == "NotoSansMono" {
|
||||
"Noto Sans Mono".to_string()
|
||||
} else if family == "NotoSans" {
|
||||
"Noto Sans".to_string()
|
||||
} else {
|
||||
family
|
||||
};
|
||||
fonts.push(FontData {
|
||||
family,
|
||||
data: std::fs::read(&path).unwrap(),
|
||||
});
|
||||
}
|
||||
}
|
||||
fonts
|
||||
}
|
||||
|
||||
fn simple_template() -> Template {
|
||||
Template {
|
||||
id: "test".to_string(),
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings {
|
||||
width: 210.0,
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding {
|
||||
top: 15.0,
|
||||
right: 15.0,
|
||||
bottom: 15.0,
|
||||
left: 15.0,
|
||||
},
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
style: TextStyle {
|
||||
font_size: Some(14.0),
|
||||
font_weight: Some("bold".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
content: "Hello World".to_string(),
|
||||
})],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_layout_single_page() {
|
||||
let template = simple_template();
|
||||
let data = serde_json::json!({});
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
let result: LayoutResult = compute_layout(&template, &data, &fonts);
|
||||
|
||||
assert_eq!(result.pages.len(), 1);
|
||||
let page = &result.pages[0];
|
||||
assert_eq!(page.width_mm, 210.0);
|
||||
assert_eq!(page.height_mm, 297.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_layout_elements_within_page() {
|
||||
let template = simple_template();
|
||||
let data = serde_json::json!({});
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
let result = compute_layout(&template, &data, &fonts);
|
||||
let page = &result.pages[0];
|
||||
|
||||
// Should have at least root + title = 2 elements
|
||||
assert!(
|
||||
page.elements.len() >= 2,
|
||||
"Expected at least 2 elements, got {}",
|
||||
page.elements.len()
|
||||
);
|
||||
|
||||
for el in &page.elements {
|
||||
// All positions should be non-negative
|
||||
assert!(
|
||||
el.x_mm >= 0.0,
|
||||
"Element {} has negative x: {}",
|
||||
el.id,
|
||||
el.x_mm
|
||||
);
|
||||
assert!(
|
||||
el.y_mm >= 0.0,
|
||||
"Element {} has negative y: {}",
|
||||
el.id,
|
||||
el.y_mm
|
||||
);
|
||||
// All dimensions should be non-negative
|
||||
assert!(
|
||||
el.width_mm >= 0.0,
|
||||
"Element {} has negative width: {}",
|
||||
el.id,
|
||||
el.width_mm
|
||||
);
|
||||
assert!(
|
||||
el.height_mm >= 0.0,
|
||||
"Element {} has negative height: {}",
|
||||
el.id,
|
||||
el.height_mm
|
||||
);
|
||||
// Elements should be within page bounds (with small tolerance for rounding)
|
||||
assert!(
|
||||
el.x_mm + el.width_mm <= page.width_mm + 1.0,
|
||||
"Element {} exceeds page width: x={}+w={} > {}",
|
||||
el.id,
|
||||
el.x_mm,
|
||||
el.width_mm,
|
||||
page.width_mm
|
||||
);
|
||||
assert!(
|
||||
el.y_mm + el.height_mm <= page.height_mm + 1.0,
|
||||
"Element {} exceeds page height: y={}+h={} > {}",
|
||||
el.id,
|
||||
el.y_mm,
|
||||
el.height_mm,
|
||||
page.height_mm
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_layout_text_content_resolved() {
|
||||
let template = simple_template();
|
||||
let data = serde_json::json!({});
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
let result = compute_layout(&template, &data, &fonts);
|
||||
let page = &result.pages[0];
|
||||
|
||||
let title = page.elements.iter().find(|e| e.id == "title").unwrap();
|
||||
match &title.content {
|
||||
Some(dreport_layout::ResolvedContent::Text { value }) => {
|
||||
assert_eq!(value, "Hello World");
|
||||
}
|
||||
other => panic!("Expected Text content, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_layout_with_data_binding() {
|
||||
let template = Template {
|
||||
id: "t1".to_string(),
|
||||
name: "Binding Test".to_string(),
|
||||
page: PageSettings {
|
||||
width: 210.0,
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding {
|
||||
top: 10.0,
|
||||
right: 10.0,
|
||||
bottom: 10.0,
|
||||
left: 10.0,
|
||||
},
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![TemplateElement::Text(TextElement {
|
||||
id: "bound_text".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
style: TextStyle {
|
||||
font_size: Some(12.0),
|
||||
..Default::default()
|
||||
},
|
||||
content: None,
|
||||
binding: ScalarBinding {
|
||||
path: "company.name".to_string(),
|
||||
},
|
||||
})],
|
||||
},
|
||||
};
|
||||
|
||||
let data = serde_json::json!({
|
||||
"company": { "name": "Acme Corp" }
|
||||
});
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
let result = compute_layout(&template, &data, &fonts);
|
||||
let page = &result.pages[0];
|
||||
|
||||
let bound = page
|
||||
.elements
|
||||
.iter()
|
||||
.find(|e| e.id == "bound_text")
|
||||
.unwrap();
|
||||
match &bound.content {
|
||||
Some(dreport_layout::ResolvedContent::Text { value }) => {
|
||||
assert_eq!(value, "Acme Corp");
|
||||
}
|
||||
other => panic!("Expected Text content, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_layout_multiple_children_ordering() {
|
||||
let template = Template {
|
||||
id: "t1".to_string(),
|
||||
name: "Order Test".to_string(),
|
||||
page: PageSettings {
|
||||
width: 210.0,
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding {
|
||||
top: 10.0,
|
||||
right: 10.0,
|
||||
bottom: 10.0,
|
||||
left: 10.0,
|
||||
},
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "first".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
style: TextStyle {
|
||||
font_size: Some(12.0),
|
||||
..Default::default()
|
||||
},
|
||||
content: "First".to_string(),
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "second".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
style: TextStyle {
|
||||
font_size: Some(12.0),
|
||||
..Default::default()
|
||||
},
|
||||
content: "Second".to_string(),
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let data = serde_json::json!({});
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
let result = compute_layout(&template, &data, &fonts);
|
||||
let page = &result.pages[0];
|
||||
|
||||
let first = page.elements.iter().find(|e| e.id == "first").unwrap();
|
||||
let second = page.elements.iter().find(|e| e.id == "second").unwrap();
|
||||
|
||||
// In column direction, second should be below first
|
||||
assert!(
|
||||
second.y_mm > first.y_mm,
|
||||
"Second element (y={}) should be below first (y={})",
|
||||
second.y_mm,
|
||||
first.y_mm
|
||||
);
|
||||
}
|
||||
256
layout-engine/tests/pdf_render_test.rs
Normal file
256
layout-engine/tests/pdf_render_test.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
//! PDF render integration tests.
|
||||
//! Only compiled on non-WASM targets since pdf_render uses krilla (native only).
|
||||
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use dreport_core::models::*;
|
||||
use dreport_layout::{compute_layout, FontData};
|
||||
|
||||
fn load_test_fonts() -> Vec<FontData> {
|
||||
let font_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("backend/fonts");
|
||||
|
||||
let mut fonts = Vec::new();
|
||||
for entry in std::fs::read_dir(&font_dir).expect("backend/fonts directory not found") {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "ttf") {
|
||||
let family = path
|
||||
.file_stem()
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split('-')
|
||||
.next()
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
let family = if family == "NotoSansMono" {
|
||||
"Noto Sans Mono".to_string()
|
||||
} else if family == "NotoSans" {
|
||||
"Noto Sans".to_string()
|
||||
} else {
|
||||
family
|
||||
};
|
||||
fonts.push(FontData {
|
||||
family,
|
||||
data: std::fs::read(&path).unwrap(),
|
||||
});
|
||||
}
|
||||
}
|
||||
fonts
|
||||
}
|
||||
|
||||
fn simple_template() -> Template {
|
||||
Template {
|
||||
id: "pdf_test".to_string(),
|
||||
name: "PDF Test".to_string(),
|
||||
page: PageSettings {
|
||||
width: 210.0,
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding {
|
||||
top: 15.0,
|
||||
right: 15.0,
|
||||
bottom: 15.0,
|
||||
left: 15.0,
|
||||
},
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
style: TextStyle {
|
||||
font_size: Some(18.0),
|
||||
font_weight: Some("bold".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
content: "PDF Render Test".to_string(),
|
||||
})],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pdf_produces_valid_output() {
|
||||
let template = simple_template();
|
||||
let data = serde_json::json!({});
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
let layout = compute_layout(&template, &data, &fonts);
|
||||
let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
|
||||
|
||||
// PDF should not be empty
|
||||
assert!(
|
||||
!pdf_bytes.is_empty(),
|
||||
"PDF output should not be empty"
|
||||
);
|
||||
|
||||
// PDF should start with %PDF magic bytes
|
||||
assert!(
|
||||
pdf_bytes.starts_with(b"%PDF"),
|
||||
"PDF output should start with %PDF magic bytes, got: {:?}",
|
||||
&pdf_bytes[..std::cmp::min(10, pdf_bytes.len())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pdf_with_multiple_elements() {
|
||||
let template = Template {
|
||||
id: "pdf_multi".to_string(),
|
||||
name: "PDF Multi".to_string(),
|
||||
page: PageSettings {
|
||||
width: 210.0,
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding {
|
||||
top: 15.0,
|
||||
right: 15.0,
|
||||
bottom: 15.0,
|
||||
left: 15.0,
|
||||
},
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "header".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
style: TextStyle {
|
||||
font_size: Some(16.0),
|
||||
font_weight: Some("bold".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
content: "FATURA".to_string(),
|
||||
}),
|
||||
TemplateElement::Line(LineElement {
|
||||
id: "sep".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
style: LineStyle {
|
||||
stroke_color: Some("#000000".to_string()),
|
||||
stroke_width: Some(0.5),
|
||||
},
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "body".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
style: TextStyle {
|
||||
font_size: Some(11.0),
|
||||
..Default::default()
|
||||
},
|
||||
content: "Bu bir test belgesidir.".to_string(),
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let data = serde_json::json!({});
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
let layout = compute_layout(&template, &data, &fonts);
|
||||
let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
|
||||
|
||||
assert!(!pdf_bytes.is_empty());
|
||||
assert!(pdf_bytes.starts_with(b"%PDF"));
|
||||
|
||||
// A PDF with multiple elements should be reasonably sized
|
||||
assert!(
|
||||
pdf_bytes.len() > 100,
|
||||
"PDF with multiple elements should be >100 bytes, got {}",
|
||||
pdf_bytes.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pdf_with_container_styles() {
|
||||
let template = Template {
|
||||
id: "pdf_styled".to_string(),
|
||||
name: "PDF Styled".to_string(),
|
||||
page: PageSettings {
|
||||
width: 210.0,
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding {
|
||||
top: 20.0,
|
||||
right: 20.0,
|
||||
bottom: 20.0,
|
||||
left: 20.0,
|
||||
},
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle {
|
||||
background_color: Some("#f0f0f0".to_string()),
|
||||
border_color: Some("#333333".to_string()),
|
||||
border_width: Some(1.0),
|
||||
..Default::default()
|
||||
},
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "text".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
style: TextStyle {
|
||||
font_size: Some(12.0),
|
||||
color: Some("#ff0000".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
content: "Styled text".to_string(),
|
||||
})],
|
||||
},
|
||||
};
|
||||
|
||||
let data = serde_json::json!({});
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
let layout = compute_layout(&template, &data, &fonts);
|
||||
let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
|
||||
|
||||
assert!(!pdf_bytes.is_empty());
|
||||
assert!(pdf_bytes.starts_with(b"%PDF"));
|
||||
}
|
||||
BIN
layout-engine/tests/snapshots/visual_test_reference.png
Normal file
BIN
layout-engine/tests/snapshots/visual_test_reference.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
205
layout-engine/tests/visual_test.rs
Normal file
205
layout-engine/tests/visual_test.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
//! Visual regression tests for PDF rendering.
|
||||
//!
|
||||
//! Generates PDF from fixture template+data, converts to PNG via pdftoppm,
|
||||
//! and compares against reference snapshots.
|
||||
//!
|
||||
//! Set UPDATE_SNAPSHOTS=1 to update reference images.
|
||||
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
mod visual {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use dreport_core::models::Template;
|
||||
use dreport_layout::{compute_layout, FontData};
|
||||
use dreport_layout::pdf_render::render_pdf;
|
||||
|
||||
fn fixtures_dir() -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
|
||||
}
|
||||
|
||||
fn snapshots_dir() -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots")
|
||||
}
|
||||
|
||||
fn load_test_fonts() -> Vec<FontData> {
|
||||
let font_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("backend/fonts");
|
||||
|
||||
let mut fonts = Vec::new();
|
||||
for entry in fs::read_dir(&font_dir).expect("backend/fonts directory not found") {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "ttf") {
|
||||
let family = path
|
||||
.file_stem()
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split('-')
|
||||
.next()
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
let family = if family == "NotoSansMono" {
|
||||
"Noto Sans Mono".to_string()
|
||||
} else if family == "NotoSans" {
|
||||
"Noto Sans".to_string()
|
||||
} else {
|
||||
family
|
||||
};
|
||||
fonts.push(FontData {
|
||||
family,
|
||||
data: fs::read(&path).unwrap(),
|
||||
});
|
||||
}
|
||||
}
|
||||
fonts
|
||||
}
|
||||
|
||||
fn generate_test_pdf(template_name: &str, data_name: &str) -> Vec<u8> {
|
||||
let template_json = fs::read_to_string(fixtures_dir().join(template_name)).unwrap();
|
||||
let data_json = fs::read_to_string(fixtures_dir().join(data_name)).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);
|
||||
render_pdf(&layout, &fonts).expect("PDF render failed")
|
||||
}
|
||||
|
||||
fn pdf_to_png(pdf_bytes: &[u8], output_path: &Path) -> bool {
|
||||
// Write PDF to temp file
|
||||
let temp_pdf = output_path.with_extension("pdf");
|
||||
fs::write(&temp_pdf, pdf_bytes).unwrap();
|
||||
|
||||
// pdftoppm appends .png to the output prefix, so strip the extension
|
||||
let output_prefix = output_path.with_extension("");
|
||||
|
||||
let result = Command::new("pdftoppm")
|
||||
.args(["-png", "-r", "150", "-singlefile"])
|
||||
.arg(&temp_pdf)
|
||||
.arg(&output_prefix)
|
||||
.output();
|
||||
|
||||
// Clean up temp PDF
|
||||
let _ = fs::remove_file(&temp_pdf);
|
||||
|
||||
match result {
|
||||
Ok(output) => {
|
||||
if !output.status.success() {
|
||||
eprintln!(
|
||||
"pdftoppm failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("pdftoppm not available - skipping visual test");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_images(
|
||||
actual_path: &Path,
|
||||
reference_path: &Path,
|
||||
max_diff_ratio: f64,
|
||||
) -> Result<f64, String> {
|
||||
let actual =
|
||||
image::open(actual_path).map_err(|e| format!("Failed to open actual: {}", e))?;
|
||||
let reference =
|
||||
image::open(reference_path).map_err(|e| format!("Failed to open reference: {}", e))?;
|
||||
|
||||
let actual_rgba = actual.to_rgba8();
|
||||
let reference_rgba = reference.to_rgba8();
|
||||
|
||||
if actual_rgba.dimensions() != reference_rgba.dimensions() {
|
||||
return Err(format!(
|
||||
"Dimension mismatch: actual {:?} vs reference {:?}",
|
||||
actual_rgba.dimensions(),
|
||||
reference_rgba.dimensions()
|
||||
));
|
||||
}
|
||||
|
||||
let total_pixels = (actual_rgba.width() * actual_rgba.height()) as f64;
|
||||
let mut diff_pixels = 0u64;
|
||||
|
||||
for (a, r) in actual_rgba.pixels().zip(reference_rgba.pixels()) {
|
||||
// Allow per-channel tolerance of 2 for font rendering differences
|
||||
let channel_diff = a
|
||||
.0
|
||||
.iter()
|
||||
.zip(r.0.iter())
|
||||
.any(|(ac, rc)| (*ac as i32 - *rc as i32).unsigned_abs() > 2);
|
||||
if channel_diff {
|
||||
diff_pixels += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let diff_ratio = diff_pixels as f64 / total_pixels;
|
||||
|
||||
if diff_ratio > max_diff_ratio {
|
||||
Err(format!(
|
||||
"Visual diff too large: {:.4}% pixels differ (threshold: {:.4}%)",
|
||||
diff_ratio * 100.0,
|
||||
max_diff_ratio * 100.0
|
||||
))
|
||||
} else {
|
||||
Ok(diff_ratio)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visual_snapshot_basic() {
|
||||
let pdf_bytes =
|
||||
generate_test_pdf("visual_test_template.json", "visual_test_data.json");
|
||||
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");
|
||||
|
||||
if !pdf_to_png(&pdf_bytes, &actual_png) {
|
||||
eprintln!("Skipping visual comparison - pdftoppm not available");
|
||||
return;
|
||||
}
|
||||
|
||||
let update_snapshots = std::env::var("UPDATE_SNAPSHOTS").is_ok();
|
||||
|
||||
if !reference_png.exists() || update_snapshots {
|
||||
// First run or explicit update: save as reference
|
||||
fs::copy(&actual_png, &reference_png).unwrap();
|
||||
println!("Reference snapshot saved to {:?}", reference_png);
|
||||
// Clean up actual
|
||||
let _ = fs::remove_file(&actual_png);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compare
|
||||
match compare_images(&actual_png, &reference_png, 0.01) {
|
||||
Ok(diff) => {
|
||||
println!(
|
||||
"Visual test passed: {:.4}% pixels differ",
|
||||
diff * 100.0
|
||||
);
|
||||
let _ = fs::remove_file(&actual_png);
|
||||
}
|
||||
Err(err) => {
|
||||
// Keep actual for debugging
|
||||
panic!(
|
||||
"Visual regression detected: {}. Actual saved at {:?}",
|
||||
err, actual_png
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user