Compare commits

..

8 Commits

Author SHA1 Message Date
2db5929e39 feat: dreport-service + dreport-ffi + nuget packages
Some checks failed
CI / rust (push) Failing after 40s
CI / frontend (push) Failing after 1m53s
CI / wasm (push) Successful in 1m45s
CI / publish-crates (push) Has been skipped
CI / publish-npm (push) Has been skipped
Extract orchestration (font registry + render pipeline) from the Axum
backend into a standalone dreport-service crate. Backend becomes a thin
HTTP adapter on top.

Add dreport-ffi (cdylib) exposing the service through a stable C ABI
with opaque handles, byte buffers, and thread-local error reporting.

Build Dreport.Service + Dreport.AspNetCore NuGet packages under
bindings/dotnet/, packing the host RID native binary via a generated
nuspec. justfile recipes (nuget-publish, nuget-publish-all) build,
pack, and push to the Gitea NuGet registry in one shot.

Test coverage: 47 Rust + 38 C# (xUnit + WebApplicationFactory).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:19:47 +03:00
92583141c9 fixes
Some checks failed
CI / rust (push) Failing after 36s
CI / frontend (push) Failing after 1m53s
CI / wasm (push) Successful in 1m46s
CI / publish-crates (push) Has been skipped
CI / publish-npm (push) Has been skipped
2026-04-09 02:16:27 +03:00
58a59f2609 refactor 2026-04-09 01:40:37 +03:00
aa27228d08 refactor 2026-04-09 00:36:23 +03:00
4fda0e7d98 repofactor 2026-04-09 00:15:05 +03:00
e574889e5d editor performans
Some checks failed
CI / rust (push) Failing after 42s
CI / frontend (push) Failing after 1m50s
CI / wasm (push) Successful in 1m45s
CI / publish-crates (push) Has been skipped
CI / publish-npm (push) Has been skipped
2026-04-07 17:24:25 +03:00
238e911875 minimap & chart label angle 2026-04-07 15:50:40 +03:00
09dc2b4ecd improvements 2026-04-07 02:55:16 +03:00
107 changed files with 9907 additions and 5112 deletions

14
.gitignore vendored
View File

@@ -9,3 +9,17 @@ dist/
frontend/tests/visual/cross-renderer-refs/ frontend/tests/visual/cross-renderer-refs/
frontend/tests/visual/cross-renderer-diffs/ frontend/tests/visual/cross-renderer-diffs/
frontend/tests/visual/test-results/ frontend/tests/visual/test-results/
# .NET build artifacts
**/bin/
**/obj/
# Native runtime binaries — produced by `just nuget-build-native-*`
# and packaged into the .nupkg. Never commit.
bindings/dotnet/src/Dreport.Service/runtimes/
# Auto-generated nuspec (regenerated by justfile recipes)
**/.generated.nuspec
# Generated C header (regenerated on every dreport-ffi build)
dreport-ffi/include/

164
Cargo.lock generated
View File

@@ -248,6 +248,24 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cbindgen"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff"
dependencies = [
"heck 0.4.1",
"indexmap",
"log",
"proc-macro2",
"quote",
"serde",
"serde_json",
"syn 2.0.117",
"tempfile",
"toml",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.58" version = "1.2.58"
@@ -397,9 +415,9 @@ dependencies = [
[[package]] [[package]]
name = "dexpr" name = "dexpr"
version = "0.1.0" version = "0.3.0"
source = "sparse+https://gitea.duhanbalci.com/api/packages/duhanbalci/cargo/" source = "sparse+https://gitea.duhanbalci.com/api/packages/duhanbalci/cargo/"
checksum = "37e0a98f2810bb770c76ef1e99d07066a15997086f9ead93917a82711274af25" checksum = "e65e74adffaab8b52681e3e3e5006365f0f8c5e3e07870cbd58ca74769eb150a"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"indexmap", "indexmap",
@@ -421,12 +439,12 @@ version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
"dreport-core", "dreport-service",
"dreport-layout", "http-body-util",
"serde", "serde",
"serde_json", "serde_json",
"thiserror",
"tokio", "tokio",
"tower",
"tower-http", "tower-http",
] ]
@@ -439,6 +457,15 @@ dependencies = [
"serde_json", "serde_json",
] ]
[[package]]
name = "dreport-ffi"
version = "0.2.0"
dependencies = [
"cbindgen",
"dreport-service",
"serde_json",
]
[[package]] [[package]]
name = "dreport-layout" name = "dreport-layout"
version = "0.2.0" version = "0.2.0"
@@ -460,6 +487,18 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "dreport-service"
version = "0.2.0"
dependencies = [
"dreport-core",
"dreport-layout",
"serde",
"serde_json",
"tempfile",
"thiserror",
]
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@@ -505,6 +544,12 @@ dependencies = [
"regex-syntax", "regex-syntax",
] ]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]] [[package]]
name = "fdeflate" name = "fdeflate"
version = "0.3.7" version = "0.3.7"
@@ -725,6 +770,12 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@@ -971,6 +1022,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -1287,7 +1344,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [ dependencies = [
"toml_edit", "toml_edit 0.25.10+spec-1.1.0",
] ]
[[package]] [[package]]
@@ -1541,6 +1598,19 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.11.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@@ -1681,6 +1751,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -1889,6 +1968,19 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.18" version = "2.0.18"
@@ -1963,6 +2055,27 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.1.1+spec-1.1.0" version = "1.1.1+spec-1.1.0"
@@ -1972,6 +2085,20 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
"toml_write",
"winnow 0.7.15",
]
[[package]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.25.10+spec-1.1.0" version = "0.25.10+spec-1.1.0"
@@ -1979,9 +2106,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"toml_datetime", "toml_datetime 1.1.1+spec-1.1.0",
"toml_parser", "toml_parser",
"winnow", "winnow 1.0.1",
] ]
[[package]] [[package]]
@@ -1990,9 +2117,15 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [ dependencies = [
"winnow", "winnow 1.0.1",
] ]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -2328,6 +2461,15 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "1.0.1" version = "1.0.1"
@@ -2353,7 +2495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"heck", "heck 0.5.0",
"wit-parser", "wit-parser",
] ]
@@ -2364,7 +2506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"heck", "heck 0.5.0",
"indexmap", "indexmap",
"prettyplease", "prettyplease",
"syn 2.0.117", "syn 2.0.117",

View File

@@ -1,3 +1,3 @@
[workspace] [workspace]
members = ["core", "backend", "layout-engine"] members = ["core", "backend", "layout-engine", "dreport-service", "dreport-ffi"]
resolver = "2" resolver = "2"

View File

@@ -657,7 +657,7 @@ pub fn load_test_fonts() -> Vec<FontData> { ... }
## 7. Yeni Ozellik Onerileri ## 7. Yeni Ozellik Onerileri
### 7.1 Conditional Rendering `[IMPLEMENTE EDILMEDI]` ### 7.1 Conditional Rendering `[IMPLEMENTE EDILDI]`
**Aciklama:** **Aciklama:**
Template'te `v-if` benzeri kosullu gosterim. Data'daki bir alana gore eleman goster/gizle. Template'te `v-if` benzeri kosullu gosterim. Data'daki bir alana gore eleman goster/gizle.
@@ -714,7 +714,7 @@ Tablo disinda array verisiyle tekrarlayan serbest-form container. Ornegin bir ka
--- ---
### 7.5 Coklu Dil / Lokalizasyon Destegi `[IMPLEMENTE EDILMEDI]` ### 7.5 Coklu Dil / Lokalizasyon Destegi `[IMPLEMENTE EDILDI]`
**Aciklama:** **Aciklama:**
Currency, date ve sayi formatlama icin lokalizasyon. Su an Turk lokali hardcoded. Currency, date ve sayi formatlama icin lokalizasyon. Su an Turk lokali hardcoded.
@@ -745,7 +745,7 @@ Template'te header/footer tanimi icin `condition` alani:
--- ---
### 7.7 QR Code Eleman Tipi `[IMPLEMENTE EDILMEDI]` ### 7.7 QR Code Eleman Tipi `[bu var zaten, barcode özelliklerinden barkod tipi seçilebiliyor qr olarak]`
**Mevcut Durum:** **Mevcut Durum:**
`rxing` crate'i barcode uretimi icin zaten kullaniliyor ve QR Code destegi var. Ancak UI tarafinda ayri bir QR Code eleman tipi tanimlanmamis. `rxing` crate'i barcode uretimi icin zaten kullaniliyor ve QR Code destegi var. Ancak UI tarafinda ayri bir QR Code eleman tipi tanimlanmamis.
@@ -773,7 +773,7 @@ Hazir sablon galerisi — kullanici sifirdan tasarlamak yerine bir sablon secip
## 8. Kucuk Ama Degerli Iyilestirmeler ## 8. Kucuk Ama Degerli Iyilestirmeler
### 8.1 Chart Legend Tek Seri Durumu `[IMPLEMENTE EDILMEDI]` ### 8.1 Chart Legend Tek Seri Durumu `[IMPLEMENTE EDILDI]`
**Dosya:** `layout-engine/src/chart_render.rs` **Dosya:** `layout-engine/src/chart_render.rs`
@@ -781,7 +781,7 @@ Hazir sablon galerisi — kullanici sifirdan tasarlamak yerine bir sablon secip
--- ---
### 8.2 Pie Chart Label Kontrolu `[IMPLEMENTE EDILMEDI]` ### 8.2 Pie Chart Label Kontrolu `[IMPLEMENTE EDILDI]`
**Dosya:** `layout-engine/src/chart_render.rs` (satirlar 521-551) **Dosya:** `layout-engine/src/chart_render.rs` (satirlar 521-551)

View File

@@ -5,12 +5,14 @@ edition = "2024"
publish = false publish = false
[dependencies] [dependencies]
dreport-core = { path = "../core" } dreport-service = { path = "../dreport-service" }
dreport-layout = { path = "../layout-engine" }
axum = "0.8" axum = "0.8"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tower-http = { version = "0.6", features = ["cors"] } tower-http = { version = "0.6", features = ["cors"] }
thiserror = "2"
anyhow = "1" anyhow = "1"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"

18
backend/src/app.rs Normal file
View File

@@ -0,0 +1,18 @@
//! Application bootstrap. Builds a fully-configured `DreportService` for the
//! HTTP layer (and tests) to share.
use anyhow::Result;
use dreport_service::DreportService;
/// Construct the service used by the running server. Loads embedded fonts
/// (compile-time defaults) and any extra fonts in `DREPORT_FONTS_DIR`.
pub fn build_service() -> Result<DreportService> {
let svc = DreportService::new();
if let Ok(dir) = std::env::var("DREPORT_FONTS_DIR") {
match svc.register_fonts_directory(&dir) {
Ok(n) => println!("DREPORT_FONTS_DIR'den {} font yüklendi: {}", n, dir),
Err(e) => eprintln!("DREPORT_FONTS_DIR yüklenemedi ({}): {}", dir, e),
}
}
Ok(svc)
}

View File

@@ -1,180 +0,0 @@
use dreport_layout::FontData;
use dreport_layout::font_meta::{self, FontFamilyInfo, FontVariantKey};
use dreport_layout::font_provider::FontProvider;
use std::collections::HashMap;
/// Font registry — manages all available fonts from embedded defaults + external directory.
pub struct FontRegistry {
/// family_lower -> variant_key -> FontData
families: HashMap<String, HashMap<FontVariantKey, FontData>>,
/// Original-case family names
family_names: HashMap<String, String>,
}
impl FontRegistry {
pub fn new() -> Self {
let mut registry = Self {
families: HashMap::new(),
family_names: HashMap::new(),
};
// Load embedded default fonts
registry.load_embedded_defaults();
// Load fonts from DREPORT_FONTS_DIR if set
if let Ok(dir) = std::env::var("DREPORT_FONTS_DIR") {
registry.load_from_directory(&dir);
}
registry
}
fn load_embedded_defaults(&mut self) {
let embedded: &[(&str, &[u8])] = &[
(
"NotoSans-Regular",
include_bytes!("../fonts/NotoSans-Regular.ttf"),
),
(
"NotoSans-Bold",
include_bytes!("../fonts/NotoSans-Bold.ttf"),
),
(
"NotoSans-Italic",
include_bytes!("../fonts/NotoSans-Italic.ttf"),
),
(
"NotoSans-BoldItalic",
include_bytes!("../fonts/NotoSans-BoldItalic.ttf"),
),
(
"NotoSansMono-Regular",
include_bytes!("../fonts/NotoSansMono-Regular.ttf"),
),
];
for (_name, data) in embedded {
self.register_font(data.to_vec());
}
}
fn load_from_directory(&mut self, dir: &str) {
let path = std::path::Path::new(dir);
if !path.is_dir() {
eprintln!("DREPORT_FONTS_DIR dizini bulunamadı: {}", dir);
return;
}
let entries = match std::fs::read_dir(path) {
Ok(e) => e,
Err(e) => {
eprintln!("DREPORT_FONTS_DIR okunamadı: {}", e);
return;
}
};
for entry in entries.flatten() {
let p = entry.path();
if p.extension().is_some_and(|e| e == "ttf" || e == "otf")
&& let Ok(data) = std::fs::read(&p)
{
if self.register_font(data) {
println!(" Font yüklendi: {}", p.display());
} else {
eprintln!(" Font parse edilemedi: {}", p.display());
}
}
}
}
/// Register a font from raw bytes. Returns true if successful.
fn register_font(&mut self, data: Vec<u8>) -> bool {
let Some(meta) = font_meta::parse_font_meta(&data) else {
return false;
};
let family_lower = meta.family.to_lowercase();
let variant_key = meta.variant_key();
self.family_names
.entry(family_lower.clone())
.or_insert_with(|| meta.family.clone());
let font_data = FontData::new(meta.family, meta.weight, meta.italic, data);
self.families
.entry(family_lower)
.or_default()
.insert(variant_key, font_data);
true
}
/// Get a specific font's raw bytes
pub fn get_font_bytes(&self, family: &str, weight: u16, italic: bool) -> Option<&[u8]> {
let family_lower = family.to_lowercase();
let key = FontVariantKey { weight, italic };
self.families
.get(&family_lower)
.and_then(|variants| variants.get(&key))
.map(|fd| fd.data.as_slice())
}
/// Get all FontData for given family names (for passing to layout engine)
pub fn fonts_for_families(&self, families: &[String]) -> Vec<FontData> {
let mut result = Vec::new();
let mut loaded = std::collections::HashSet::new();
// Always include default family
let default_lower = "noto sans".to_string();
let mut to_load: Vec<String> = vec![default_lower.clone()];
for f in families {
let fl = f.to_lowercase();
if !to_load.contains(&fl) {
to_load.push(fl);
}
}
for family_lower in &to_load {
if loaded.contains(family_lower) {
continue;
}
if let Some(variants) = self.families.get(family_lower) {
for fd in variants.values() {
result.push(fd.clone());
}
loaded.insert(family_lower.clone());
}
}
result
}
}
impl FontProvider for FontRegistry {
fn list_families(&self) -> Vec<FontFamilyInfo> {
self.families
.iter()
.map(|(family_lower, variants)| {
let family = self
.family_names
.get(family_lower)
.cloned()
.unwrap_or_else(|| family_lower.clone());
FontFamilyInfo {
family,
variants: variants.keys().cloned().collect(),
}
})
.collect()
}
fn load_font(&self, family: &str, weight: u16, italic: bool) -> Option<FontData> {
let family_lower = family.to_lowercase();
let key = FontVariantKey { weight, italic };
self.families
.get(&family_lower)
.and_then(|variants| variants.get(&key))
.cloned()
}
}

28
backend/src/lib.rs Normal file
View File

@@ -0,0 +1,28 @@
//! dreport-backend
//!
//! Thin Axum HTTP adapter on top of `dreport-service`. The HTTP layer holds
//! no business logic — it only translates JSON requests into service calls
//! and maps `ServiceError` into HTTP status codes.
pub mod app;
mod routes;
use axum::Router;
use dreport_service::DreportService;
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
pub use routes::AppState;
/// Build the full Axum `Router` with CORS, state and all `/api/*` endpoints.
pub fn build_router(service: Arc<DreportService>) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
Router::new()
.merge(routes::router())
.layer(cors)
.with_state(service)
}

View File

@@ -1,36 +1,19 @@
use axum::{Router, serve}; use dreport_backend::{app, build_router};
use std::sync::Arc; use std::sync::Arc;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower_http::cors::{Any, CorsLayer};
mod font_registry;
mod models;
mod routes;
use font_registry::FontRegistry;
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
println!("Font registry başlatılıyor..."); let service = Arc::new(app::build_service()?);
let registry = Arc::new(FontRegistry::new()); println!(
"dreport-service hazır ({} font ailesi)",
let family_count = service.font_family_count()
dreport_layout::font_provider::FontProvider::list_families(registry.as_ref()).len(); );
println!("Font registry hazır ({} font ailesi)", family_count);
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
.merge(routes::router())
.layer(cors)
.with_state(registry);
let app = build_router(service);
let listener = TcpListener::bind("0.0.0.0:3001").await?; let listener = TcpListener::bind("0.0.0.0:3001").await?;
println!("dreport backend listening on http://localhost:3001"); println!("dreport backend listening on http://localhost:3001");
serve(listener, app).await?; axum::serve(listener, app).await?;
Ok(()) Ok(())
} }

View File

@@ -1 +0,0 @@
pub use dreport_core::models::*;

View File

@@ -5,11 +5,9 @@ use axum::{
response::IntoResponse, response::IntoResponse,
routing::get, routing::get,
}; };
use dreport_layout::font_provider::FontProvider;
use serde::Serialize; use serde::Serialize;
use std::sync::Arc;
use crate::font_registry::FontRegistry; use super::AppState;
#[derive(Serialize)] #[derive(Serialize)]
struct FontFamilyResponse { struct FontFamilyResponse {
@@ -24,9 +22,9 @@ struct FontVariantResponse {
} }
/// GET /api/fonts — list all available font families /// GET /api/fonts — list all available font families
async fn list_fonts(State(registry): State<Arc<FontRegistry>>) -> Json<Vec<FontFamilyResponse>> { async fn list_fonts(State(service): State<AppState>) -> Json<Vec<FontFamilyResponse>> {
let families = registry.list_families(); let response: Vec<FontFamilyResponse> = service
let response: Vec<FontFamilyResponse> = families .list_font_families()
.into_iter() .into_iter()
.map(|f| FontFamilyResponse { .map(|f| FontFamilyResponse {
family: f.family, family: f.family,
@@ -45,16 +43,16 @@ async fn list_fonts(State(registry): State<Arc<FontRegistry>>) -> Json<Vec<FontF
/// GET /api/fonts/:family/:weight/:italic — serve font binary /// GET /api/fonts/:family/:weight/:italic — serve font binary
async fn get_font( async fn get_font(
State(registry): State<Arc<FontRegistry>>, State(service): State<AppState>,
Path((family, weight, italic)): Path<(String, u16, String)>, Path((family, weight, italic)): Path<(String, u16, String)>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let is_italic = italic == "true" || italic == "1"; let is_italic = italic == "true" || italic == "1";
match registry.get_font_bytes(&family, weight, is_italic) { match service.get_font_bytes(&family, weight, is_italic) {
Some(data) => ( Some(data) => (
StatusCode::OK, StatusCode::OK,
[(header::CONTENT_TYPE, "font/ttf")], [(header::CONTENT_TYPE, "font/ttf")],
data.to_vec(), data,
) )
.into_response(), .into_response(),
None => ( None => (
@@ -68,7 +66,7 @@ async fn get_font(
} }
} }
pub fn router() -> Router<Arc<FontRegistry>> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/api/fonts", get(list_fonts)) .route("/api/fonts", get(list_fonts))
.route("/api/fonts/{family}/{weight}/{italic}", get(get_font)) .route("/api/fonts/{family}/{weight}/{italic}", get(get_font))

View File

@@ -1,8 +1,7 @@
use axum::{Json, Router, routing::get}; use axum::{Json, Router, routing::get};
use serde::Serialize; use serde::Serialize;
use std::sync::Arc;
use crate::font_registry::FontRegistry; use super::AppState;
#[derive(Serialize)] #[derive(Serialize)]
struct HealthResponse { struct HealthResponse {
@@ -17,6 +16,6 @@ async fn health() -> Json<HealthResponse> {
}) })
} }
pub fn router() -> Router<Arc<FontRegistry>> { pub fn router() -> Router<AppState> {
Router::new().route("/api/health", get(health)) Router::new().route("/api/health", get(health))
} }

View File

@@ -3,11 +3,12 @@ mod health;
mod render; mod render;
use axum::Router; use axum::Router;
use dreport_service::DreportService;
use std::sync::Arc; use std::sync::Arc;
use crate::font_registry::FontRegistry; pub type AppState = Arc<DreportService>;
pub fn router() -> Router<Arc<FontRegistry>> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.merge(health::router()) .merge(health::router())
.merge(render::router()) .merge(render::router())

View File

@@ -5,11 +5,10 @@ use axum::{
response::IntoResponse, response::IntoResponse,
routing::post, routing::post,
}; };
use dreport_service::{ServiceError, Template};
use serde::Deserialize; use serde::Deserialize;
use std::sync::Arc;
use crate::font_registry::FontRegistry; use super::AppState;
use crate::models::Template;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct RenderRequest { pub struct RenderRequest {
@@ -19,18 +18,13 @@ pub struct RenderRequest {
/// POST /api/render — Template + Data → PDF /// POST /api/render — Template + Data → PDF
pub async fn render( pub async fn render(
State(registry): State<Arc<FontRegistry>>, State(service): State<AppState>,
Json(payload): Json<RenderRequest>, Json(payload): Json<RenderRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// CPU-intensive layout + PDF render'ı blocking thread'de çalıştır // CPU-intensive layout + PDF render'ı blocking thread'de çalıştır
let result = tokio::task::spawn_blocking(move || { let result =
// Template'in fonts alanına göre sadece gerekli fontları yükle tokio::task::spawn_blocking(move || service.render_pdf(&payload.template, &payload.data))
let fonts = registry.fonts_for_families(&payload.template.fonts); .await;
let layout = dreport_layout::compute_layout(&payload.template, &payload.data, &fonts)
.map_err(|e| format!("Layout error: {}", e))?;
dreport_layout::pdf_render::render_pdf(&layout, &fonts)
})
.await;
match result { match result {
Ok(Ok(pdf_bytes)) => ( Ok(Ok(pdf_bytes)) => (
@@ -39,11 +33,7 @@ pub async fn render(
pdf_bytes, pdf_bytes,
) )
.into_response(), .into_response(),
Ok(Err(err)) => ( Ok(Err(err)) => (status_for(&err), err.to_string()).into_response(),
StatusCode::INTERNAL_SERVER_ERROR,
format!("PDF render hatası: {}", err),
)
.into_response(),
Err(err) => ( Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Task hatası: {}", err), format!("Task hatası: {}", err),
@@ -52,6 +42,15 @@ pub async fn render(
} }
} }
pub fn router() -> Router<Arc<FontRegistry>> { fn status_for(err: &ServiceError) -> StatusCode {
match err {
ServiceError::InvalidTemplateJson(_) | ServiceError::InvalidDataJson(_) => {
StatusCode::BAD_REQUEST
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub fn router() -> Router<AppState> {
Router::new().route("/api/render", post(render)) Router::new().route("/api/render", post(render))
} }

179
backend/tests/api.rs Normal file
View File

@@ -0,0 +1,179 @@
//! End-to-end HTTP tests for the backend. Drives the real `Router` via
//! `tower::ServiceExt::oneshot`, so anything covered here protects the
//! contract that the editor and external clients rely on.
use axum::{
body::Body,
http::{Request, StatusCode, header},
};
use dreport_backend::build_router;
use dreport_service::DreportService;
use http_body_util::BodyExt;
use std::sync::Arc;
use tower::ServiceExt;
const TEMPLATE: &str = r#"{
"id": "test",
"name": "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": "title",
"type": "static_text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 14, "fontWeight": "bold" },
"content": "Hello"
}
]
}
}"#;
fn router() -> axum::Router {
build_router(Arc::new(DreportService::new()))
}
async fn body_bytes(resp: axum::response::Response) -> Vec<u8> {
resp.into_body().collect().await.unwrap().to_bytes().to_vec()
}
#[tokio::test]
async fn health_returns_ok() {
let resp = router()
.oneshot(
Request::builder()
.uri("/api/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_bytes(resp).await;
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "ok");
assert!(json["version"].is_string());
}
#[tokio::test]
async fn list_fonts_includes_noto_sans() {
let resp = router()
.oneshot(
Request::builder()
.uri("/api/fonts")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_bytes(resp).await;
let families: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert!(
families
.iter()
.any(|f| f["family"].as_str().unwrap_or("").to_lowercase().contains("noto")),
"Noto Sans family should be listed: {:?}",
families
);
}
#[tokio::test]
async fn get_font_bytes_for_known_variant() {
let resp = router()
.oneshot(
Request::builder()
.uri("/api/fonts/Noto%20Sans/400/false")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get(header::CONTENT_TYPE)
.map(|v| v.to_str().unwrap()),
Some("font/ttf")
);
let body = body_bytes(resp).await;
assert!(body.len() > 1000, "TTF body should be substantial");
}
#[tokio::test]
async fn get_font_unknown_returns_404() {
let resp = router()
.oneshot(
Request::builder()
.uri("/api/fonts/DoesNotExist/400/false")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn render_returns_pdf_bytes() {
let payload = serde_json::json!({
"template": serde_json::from_str::<serde_json::Value>(TEMPLATE).unwrap(),
"data": {}
});
let resp = router()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/render")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get(header::CONTENT_TYPE)
.map(|v| v.to_str().unwrap()),
Some("application/pdf")
);
let body = body_bytes(resp).await;
assert!(body.starts_with(b"%PDF-"), "PDF magic header missing");
}
#[tokio::test]
async fn render_with_invalid_template_field_returns_4xx_or_500() {
// Axum's Json extractor rejects malformed payloads with 4xx; a structurally
// valid but semantically invalid template would surface as 500. Either is
// acceptable, but the server must not panic and must produce a body.
let payload = serde_json::json!({ "template": "not an object", "data": {} });
let resp = router()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/render")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert!(
resp.status().is_client_error() || resp.status().is_server_error(),
"got unexpected status {}",
resp.status()
);
}

View File

@@ -0,0 +1,10 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Dreport.AspNetCore/Dreport.AspNetCore.csproj" />
<Project Path="src/Dreport.Service/Dreport.Service.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Dreport.AspNetCore.Tests/Dreport.AspNetCore.Tests.csproj" />
<Project Path="tests/Dreport.Service.Tests/Dreport.Service.Tests.csproj" />
</Folder>
</Solution>

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\Dreport.Service\Dreport.Service.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,102 @@
using System.Text.Json;
using Dreport.Service;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace Dreport.AspNetCore;
/// <summary>
/// Optional sugar for hosts that just want the editor's stock HTTP API.
/// Mirrors the original Rust/Axum backend endpoint contract 1:1, so the Vue
/// editor frontend does not need any code changes.
///
/// Skip this entirely if you prefer to wire endpoints by hand — the
/// <see cref="LayoutEngine"/> registered by <c>AddDreport()</c> is fully
/// usable from your own controllers / minimal API handlers.
/// </summary>
public static class DreportEndpointRouteBuilderExtensions
{
/// <summary>
/// Mount the dreport HTTP API under the given prefix (defaults to <c>/api</c>).
/// Routes added:
/// <list type="bullet">
/// <item><description><c>GET {prefix}/health</c></description></item>
/// <item><description><c>POST {prefix}/render</c> — body <c>{ template, data }</c> → <c>application/pdf</c></description></item>
/// <item><description><c>POST {prefix}/layout</c> — body <c>{ template, data }</c> → LayoutResult JSON</description></item>
/// <item><description><c>GET {prefix}/fonts</c> — registered families</description></item>
/// <item><description><c>GET {prefix}/fonts/{family}/{weight}/{italic}</c> — raw font bytes</description></item>
/// </list>
/// </summary>
public static IEndpointRouteBuilder MapDreportEndpoints(
this IEndpointRouteBuilder builder,
string prefix = "/api")
{
var p = prefix.TrimEnd('/');
builder.MapGet($"{p}/health", () => Results.Json(new { status = "ok", version = typeof(LayoutEngine).Assembly.GetName().Version?.ToString() ?? "unknown" }));
builder.MapPost($"{p}/render", async (HttpContext ctx, LayoutEngine engine) =>
{
var (template, data) = await ReadBodyAsync(ctx);
try
{
var pdf = await Task.Run(() => engine.RenderPdf(template, data));
return Results.File(pdf, "application/pdf");
}
catch (DreportException ex)
{
return MapError(ex);
}
});
builder.MapPost($"{p}/layout", async (HttpContext ctx, LayoutEngine engine) =>
{
var (template, data) = await ReadBodyAsync(ctx);
try
{
var layoutJson = await Task.Run(() => engine.ComputeLayout(template, data));
return Results.Content(layoutJson, "application/json");
}
catch (DreportException ex)
{
return MapError(ex);
}
});
builder.MapGet($"{p}/fonts", (LayoutEngine engine) =>
Results.Json(engine.ListFontFamilies().Select(f => new
{
family = f.Family,
variants = f.Variants.Select(v => new { weight = v.Weight, italic = v.Italic }),
})));
builder.MapGet($"{p}/fonts/{{family}}/{{weight}}/{{italic}}",
(string family, ushort weight, string italic, LayoutEngine engine) =>
{
var isItalic = italic.Equals("true", StringComparison.OrdinalIgnoreCase) || italic == "1";
var bytes = engine.GetFontBytes(family, weight, isItalic);
return bytes is null
? Results.NotFound($"Font bulunamadı: {family} weight={weight} italic={isItalic}")
: Results.File(bytes, "font/ttf");
});
return builder;
}
private static async Task<(string Template, string Data)> ReadBodyAsync(HttpContext ctx)
{
using var doc = await JsonDocument.ParseAsync(ctx.Request.Body);
var root = doc.RootElement;
var template = root.GetProperty("template").GetRawText();
var data = root.TryGetProperty("data", out var d) ? d.GetRawText() : "{}";
return (template, data);
}
private static IResult MapError(DreportException ex) => ex switch
{
InvalidTemplateException or Dreport.Service.InvalidDataException => Results.BadRequest(ex.Message),
_ => Results.Problem(ex.Message, statusCode: StatusCodes.Status500InternalServerError),
};
}

View File

@@ -0,0 +1,20 @@
namespace Dreport.AspNetCore;
/// <summary>
/// Configuration for the dreport ASP.NET Core integration.
/// </summary>
public sealed class DreportOptions
{
/// <summary>
/// Optional directory whose <c>.ttf</c> / <c>.otf</c> files are loaded into the
/// engine on startup, in addition to the embedded default fonts.
/// </summary>
public string? FontsDirectory { get; set; }
/// <summary>
/// When <c>true</c> (default), embedded default fonts (Noto Sans, Noto Sans Mono)
/// are registered. Set to <c>false</c> to start with an empty registry — useful
/// when the host wants to provide a fully custom font set.
/// </summary>
public bool LoadEmbeddedFonts { get; set; } = true;
}

View File

@@ -0,0 +1,41 @@
using Dreport.Service;
using Microsoft.Extensions.DependencyInjection;
namespace Dreport.AspNetCore;
/// <summary>
/// DI registration for <see cref="LayoutEngine"/>. Registers the engine as a
/// process-wide singleton so consumers can inject it into controllers,
/// endpoint handlers, background services, or test fixtures.
/// </summary>
public static class DreportServiceCollectionExtensions
{
/// <summary>
/// Registers a singleton <see cref="LayoutEngine"/>. Once added, you can:
/// <list type="bullet">
/// <item><description>Inject <see cref="LayoutEngine"/> into your own MVC controllers, minimal API handlers, or background services.</description></item>
/// <item><description>Call <c>app.MapDreportEndpoints()</c> to also mount the ready-made HTTP API the editor talks to.</description></item>
/// </list>
/// </summary>
public static IServiceCollection AddDreport(
this IServiceCollection services,
Action<DreportOptions>? configure = null)
{
var options = new DreportOptions();
configure?.Invoke(options);
services.AddSingleton(options);
services.AddSingleton(_ => CreateEngine(options));
return services;
}
private static LayoutEngine CreateEngine(DreportOptions options)
{
var engine = options.LoadEmbeddedFonts ? new LayoutEngine() : LayoutEngine.CreateEmpty();
if (!string.IsNullOrEmpty(options.FontsDirectory) && Directory.Exists(options.FontsDirectory))
{
engine.RegisterFontsDirectory(options.FontsDirectory);
}
return engine;
}
}

View File

@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<InternalsVisibleTo Include="Dreport.Service.Tests" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Packaging is driven from a hand-rolled .nuspec next to this csproj
(see pack.nuspec). MSBuild's pack pipeline silently drops the runtimes/
folder under several scenarios we hit during development; hand-feeding
nuget pack a nuspec sidesteps the issue and is what the just recipe uses. -->
<IsPackable>false</IsPackable>
</PropertyGroup>
<!-- Local-dev consumer copy: drop the host RID native binary next to the
referencing assembly so xUnit / dotnet run can dlopen it without going
through a published NuGet package. -->
<PropertyGroup>
<_DrHostExt Condition="$([MSBuild]::IsOSPlatform('OSX'))">.dylib</_DrHostExt>
<_DrHostExt Condition="$([MSBuild]::IsOSPlatform('Linux'))">.so</_DrHostExt>
<_DrHostExt Condition="$([MSBuild]::IsOSPlatform('Windows'))">.dll</_DrHostExt>
<_DrHostPrefix Condition="!$([MSBuild]::IsOSPlatform('Windows'))">lib</_DrHostPrefix>
</PropertyGroup>
<ItemGroup>
<Content Include="$(MSBuildThisFileDirectory)runtimes/**/native/$(_DrHostPrefix)dreport_ffi$(_DrHostExt)"
Link="$(_DrHostPrefix)dreport_ffi$(_DrHostExt)"
CopyToOutputDirectory="PreserveNewest"
Visible="false"
Pack="false" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,67 @@
namespace Dreport.Service;
/// <summary>
/// Thrown when the underlying dreport service returns an error. The numeric
/// <see cref="Code"/> mirrors the FFI return code (negative values).
/// </summary>
public class DreportException : Exception
{
public int Code { get; }
public DreportException(int code, string message) : base(message)
{
Code = code;
}
internal static DreportException FromCode(int code, string fallbackMessage)
{
var nativeMessage = Native.GetLastError();
var message = string.IsNullOrEmpty(nativeMessage) ? fallbackMessage : nativeMessage;
return code switch
{
Native.ERR_INVALID_TEMPLATE_JSON => new InvalidTemplateException(message),
Native.ERR_INVALID_DATA_JSON => new InvalidDataException(message),
Native.ERR_FONT_PARSE_FAILED => new FontParseException(message),
Native.ERR_FONT_DIR_NOT_FOUND => new FontDirectoryNotFoundException(message),
Native.ERR_FONT_DIR_READ => new FontDirectoryReadException(message),
Native.ERR_LAYOUT_FAILED => new LayoutException(message),
Native.ERR_PDF_FAILED => new PdfRenderException(message),
_ => new DreportException(code, message),
};
}
}
public sealed class InvalidTemplateException : DreportException
{
public InvalidTemplateException(string message) : base(Native.ERR_INVALID_TEMPLATE_JSON, message) { }
}
public sealed class InvalidDataException : DreportException
{
public InvalidDataException(string message) : base(Native.ERR_INVALID_DATA_JSON, message) { }
}
public sealed class FontParseException : DreportException
{
public FontParseException(string message) : base(Native.ERR_FONT_PARSE_FAILED, message) { }
}
public sealed class FontDirectoryNotFoundException : DreportException
{
public FontDirectoryNotFoundException(string message) : base(Native.ERR_FONT_DIR_NOT_FOUND, message) { }
}
public sealed class FontDirectoryReadException : DreportException
{
public FontDirectoryReadException(string message) : base(Native.ERR_FONT_DIR_READ, message) { }
}
public sealed class LayoutException : DreportException
{
public LayoutException(string message) : base(Native.ERR_LAYOUT_FAILED, message) { }
}
public sealed class PdfRenderException : DreportException
{
public PdfRenderException(string message) : base(Native.ERR_PDF_FAILED, message) { }
}

View File

@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace Dreport.Service;
/// <summary>One font family with its registered variants.</summary>
public sealed record FontFamily(
[property: JsonPropertyName("family")] string Family,
[property: JsonPropertyName("variants")] IReadOnlyList<FontVariant> Variants);
/// <summary>One weight/italic combination within a family.</summary>
public sealed record FontVariant(
[property: JsonPropertyName("weight")] ushort Weight,
[property: JsonPropertyName("italic")] bool Italic);

View File

@@ -0,0 +1,220 @@
using System.Text;
using System.Text.Json;
namespace Dreport.Service;
/// <summary>
/// Managed wrapper around a single dreport native engine handle.
///
/// Thread-safe: every operation goes through the underlying Rust service which
/// uses internal locking. You can keep one process-wide instance and call
/// concurrent <see cref="RenderPdf"/> from any number of threads.
/// </summary>
public sealed class LayoutEngine : IDisposable
{
private IntPtr _handle;
private readonly object _disposeLock = new();
private bool _disposed;
/// <summary>Create an engine with the embedded default fonts loaded.</summary>
public LayoutEngine() : this(Native.dreport_new())
{
}
private LayoutEngine(IntPtr handle)
{
if (handle == IntPtr.Zero)
{
throw new InvalidOperationException("dreport_new returned a null handle");
}
_handle = handle;
}
/// <summary>Create an engine with no fonts pre-loaded.</summary>
public static LayoutEngine CreateEmpty() => new(Native.dreport_new_empty());
/// <summary>Native crate version, e.g. "0.2.0".</summary>
public static string NativeVersion
{
get
{
var ptr = Native.dreport_version();
return ptr == IntPtr.Zero ? string.Empty : System.Runtime.InteropServices.Marshal.PtrToStringAnsi(ptr) ?? string.Empty;
}
}
// ---------------------------------------------------------------------
// Font registry
// ---------------------------------------------------------------------
/// <summary>Number of distinct font families currently registered.</summary>
public int FontFamilyCount
{
get
{
EnsureNotDisposed();
var count = Native.dreport_font_family_count(_handle);
if (count < 0)
{
throw DreportException.FromCode((int)count, "dreport_font_family_count failed");
}
return (int)count;
}
}
/// <summary>Register a font from raw TTF/OTF bytes.</summary>
public unsafe void RegisterFont(ReadOnlySpan<byte> data)
{
EnsureNotDisposed();
if (data.IsEmpty)
{
throw new ArgumentException("font bytes empty", nameof(data));
}
fixed (byte* ptr = data)
{
var rc = Native.dreport_register_font(_handle, ptr, (nuint)data.Length);
if (rc != Native.OK)
{
throw DreportException.FromCode(rc, "dreport_register_font failed");
}
}
}
/// <summary>Register every <c>.ttf</c>/<c>.otf</c> file in <paramref name="directory"/>.</summary>
/// <returns>Number of fonts that registered successfully.</returns>
public unsafe int RegisterFontsDirectory(string directory)
{
EnsureNotDisposed();
ArgumentException.ThrowIfNullOrEmpty(directory);
var bytes = Encoding.UTF8.GetBytes(directory);
nuint count;
int rc;
fixed (byte* ptr = bytes)
{
rc = Native.dreport_register_fonts_dir(_handle, ptr, (nuint)bytes.Length, out count);
}
if (rc != Native.OK)
{
throw DreportException.FromCode(rc, $"dreport_register_fonts_dir failed for '{directory}'");
}
return (int)count;
}
/// <summary>Get raw bytes for a specific font variant. Returns null when unknown.</summary>
public unsafe byte[]? GetFontBytes(string family, ushort weight, bool italic)
{
EnsureNotDisposed();
ArgumentException.ThrowIfNullOrEmpty(family);
var bytes = Encoding.UTF8.GetBytes(family);
Native.DreportBuffer buffer;
int rc;
fixed (byte* ptr = bytes)
{
rc = Native.dreport_get_font_bytes(_handle, ptr, (nuint)bytes.Length, weight, italic, out buffer);
}
if (rc != Native.OK)
{
throw DreportException.FromCode(rc, "dreport_get_font_bytes failed");
}
var data = Native.ConsumeBuffer(buffer);
return data.Length == 0 ? null : data;
}
/// <summary>List every registered font family with its variants.</summary>
public IReadOnlyList<FontFamily> ListFontFamilies()
{
EnsureNotDisposed();
var rc = Native.dreport_list_fonts_json(_handle, out var buffer);
if (rc != Native.OK)
{
throw DreportException.FromCode(rc, "dreport_list_fonts_json failed");
}
var json = Native.ConsumeBuffer(buffer);
if (json.Length == 0)
{
return Array.Empty<FontFamily>();
}
var families = JsonSerializer.Deserialize<List<FontFamily>>(json);
return families ?? new List<FontFamily>();
}
// ---------------------------------------------------------------------
// Render pipeline
// ---------------------------------------------------------------------
/// <summary>Compute layout from JSON inputs. Returns the LayoutResult JSON string.</summary>
public unsafe string ComputeLayout(string templateJson, string dataJson)
{
EnsureNotDisposed();
ArgumentException.ThrowIfNullOrEmpty(templateJson);
ArgumentNullException.ThrowIfNull(dataJson);
var tplBytes = Encoding.UTF8.GetBytes(templateJson);
var dataBytes = Encoding.UTF8.GetBytes(dataJson);
Native.DreportBuffer buffer;
int rc;
fixed (byte* tplPtr = tplBytes)
fixed (byte* dataPtr = dataBytes)
{
rc = Native.dreport_compute_layout(
_handle, tplPtr, (nuint)tplBytes.Length, dataPtr, (nuint)dataBytes.Length, out buffer);
}
if (rc != Native.OK)
{
throw DreportException.FromCode(rc, "dreport_compute_layout failed");
}
return Encoding.UTF8.GetString(Native.ConsumeBuffer(buffer));
}
/// <summary>Render a PDF document. Returns the raw PDF bytes.</summary>
public unsafe byte[] RenderPdf(string templateJson, string dataJson)
{
EnsureNotDisposed();
ArgumentException.ThrowIfNullOrEmpty(templateJson);
ArgumentNullException.ThrowIfNull(dataJson);
var tplBytes = Encoding.UTF8.GetBytes(templateJson);
var dataBytes = Encoding.UTF8.GetBytes(dataJson);
Native.DreportBuffer buffer;
int rc;
fixed (byte* tplPtr = tplBytes)
fixed (byte* dataPtr = dataBytes)
{
rc = Native.dreport_render_pdf(
_handle, tplPtr, (nuint)tplBytes.Length, dataPtr, (nuint)dataBytes.Length, out buffer);
}
if (rc != Native.OK)
{
throw DreportException.FromCode(rc, "dreport_render_pdf failed");
}
return Native.ConsumeBuffer(buffer);
}
// ---------------------------------------------------------------------
// Disposal
// ---------------------------------------------------------------------
public void Dispose()
{
lock (_disposeLock)
{
if (_disposed) return;
_disposed = true;
if (_handle != IntPtr.Zero)
{
Native.dreport_free(_handle);
_handle = IntPtr.Zero;
}
}
}
private void EnsureNotDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(LayoutEngine));
}
}
}

View File

@@ -0,0 +1,145 @@
// P/Invoke surface for libdreport_ffi. Mirrors dreport-ffi/include/dreport.h
// 1:1. Higher-level wrappers live in LayoutEngine.cs.
using System.Runtime.InteropServices;
namespace Dreport.Service;
internal static class Native
{
// The shared library is named libdreport_ffi.{dylib,so} or dreport_ffi.dll.
// .NET's runtime resolves it via the runtimes/<rid>/native/ pattern when the
// package is consumed; for local development the file lives next to the test
// assembly under bin/<config>/<tfm>/runtimes/<rid>/native/.
internal const string Lib = "dreport_ffi";
// ----- Return codes (mirror dreport_ffi::error_code) -------------------
public const int OK = 0;
public const int NULL_HANDLE = -100;
public const int NULL_POINTER = -101;
public const int INVALID_UTF8 = -102;
public const int PANIC = -103;
// Service-level error codes are returned as the negation of
// ServiceError::code(), e.g. FontParseFailed (3) → -3.
public const int ERR_INVALID_TEMPLATE_JSON = -1;
public const int ERR_INVALID_DATA_JSON = -2;
public const int ERR_FONT_PARSE_FAILED = -3;
public const int ERR_FONT_DIR_NOT_FOUND = -4;
public const int ERR_FONT_DIR_READ = -5;
public const int ERR_LAYOUT_FAILED = -6;
public const int ERR_PDF_FAILED = -7;
public const int ERR_SERIALIZATION_FAILED = -8;
// ----- ByteBuffer ------------------------------------------------------
[StructLayout(LayoutKind.Sequential)]
public struct DreportBuffer
{
public IntPtr Data;
public nuint Len;
public nuint Cap;
public static DreportBuffer Empty => default;
}
// ----- Lifecycle -------------------------------------------------------
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr dreport_new();
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr dreport_new_empty();
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern void dreport_free(IntPtr handle);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern void dreport_buffer_free(DreportBuffer buffer);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr dreport_version();
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int dreport_last_error(out DreportBuffer buffer);
// ----- Font registry ---------------------------------------------------
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern unsafe int dreport_register_font(IntPtr handle, byte* data, nuint len);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern unsafe int dreport_register_fonts_dir(
IntPtr handle,
byte* path,
nuint pathLen,
out nuint outCount);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int dreport_list_fonts_json(IntPtr handle, out DreportBuffer outBuffer);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern unsafe int dreport_get_font_bytes(
IntPtr handle,
byte* family,
nuint familyLen,
ushort weight,
[MarshalAs(UnmanagedType.U1)] bool italic,
out DreportBuffer outBuffer);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern nint dreport_font_family_count(IntPtr handle);
// ----- Render pipeline -------------------------------------------------
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern unsafe int dreport_compute_layout(
IntPtr handle,
byte* template_,
nuint templateLen,
byte* data,
nuint dataLen,
out DreportBuffer outBuffer);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern unsafe int dreport_render_pdf(
IntPtr handle,
byte* template_,
nuint templateLen,
byte* data,
nuint dataLen,
out DreportBuffer outBuffer);
// ----- Helpers ---------------------------------------------------------
/// <summary>Copy a native buffer into a managed byte[] and free the native side.</summary>
public static byte[] ConsumeBuffer(DreportBuffer buffer)
{
if (buffer.Data == IntPtr.Zero || buffer.Len == 0)
{
// Still free the buffer in case cap > 0 (defensive — current FFI never returns this).
if (buffer.Cap > 0)
{
dreport_buffer_free(buffer);
}
return Array.Empty<byte>();
}
var bytes = new byte[buffer.Len];
Marshal.Copy(buffer.Data, bytes, 0, (int)buffer.Len);
dreport_buffer_free(buffer);
return bytes;
}
/// <summary>Read the most recent FFI error message for the current thread.</summary>
public static string GetLastError()
{
if (dreport_last_error(out var buffer) != OK)
{
return string.Empty;
}
var bytes = ConsumeBuffer(buffer);
return bytes.Length == 0 ? string.Empty : System.Text.Encoding.UTF8.GetString(bytes);
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Dreport.AspNetCore\Dreport.AspNetCore.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,173 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Dreport.AspNetCore;
using Dreport.Service;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Xunit;
namespace Dreport.AspNetCore.Tests;
/// <summary>
/// Spins up an in-memory ASP.NET Core host for each test using TestServer
/// directly, so we don't need a Program.cs entry point. Verifies the
/// stock /api endpoints behave the same as the original Axum backend.
/// </summary>
public class EndpointTests
{
private const string Template = """
{
"id": "aspnet-test",
"name": "AspNet 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": "title",
"type": "static_text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 14, "fontWeight": "bold" },
"content": "Hello"
}
]
}
}
""";
private static HttpClient Build(string prefix = "/api")
{
var builder = new WebHostBuilder()
.ConfigureServices(s => s.AddRouting().AddDreport())
.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(e => e.MapDreportEndpoints(prefix));
});
var server = new TestServer(builder);
return server.CreateClient();
}
[Fact]
public async Task Health_Returns_Ok()
{
var client = Build();
var resp = await client.GetAsync("/api/health");
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("ok", json.GetProperty("status").GetString());
}
[Fact]
public async Task Render_ReturnsPdf()
{
var client = Build();
var payload = new { template = JsonDocument.Parse(Template).RootElement, data = new { } };
var resp = await client.PostAsJsonAsync("/api/render", payload);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
Assert.Equal("application/pdf", resp.Content.Headers.ContentType?.MediaType);
var bytes = await resp.Content.ReadAsByteArrayAsync();
Assert.True(bytes.Length > 100);
Assert.Equal((byte)'%', bytes[0]);
}
[Fact]
public async Task Render_InvalidTemplate_Returns400()
{
var client = Build();
var payload = new { template = "not a template", data = new { } };
var resp = await client.PostAsJsonAsync("/api/render", payload);
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Layout_ReturnsJson()
{
var client = Build();
var payload = new { template = JsonDocument.Parse(Template).RootElement, data = new { } };
var resp = await client.PostAsJsonAsync("/api/layout", payload);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.TryGetProperty("pages", out var pages));
Assert.Equal(JsonValueKind.Array, pages.ValueKind);
}
[Fact]
public async Task ListFonts_IncludesNotoSans()
{
var client = Build();
var resp = await client.GetAsync("/api/fonts");
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var families = await resp.Content.ReadFromJsonAsync<JsonElement[]>();
Assert.NotNull(families);
Assert.Contains(families!, f => f.GetProperty("family").GetString()!.ToLowerInvariant().Contains("noto"));
}
[Fact]
public async Task GetFontBytes_KnownVariant_ReturnsTtf()
{
var client = Build();
var resp = await client.GetAsync("/api/fonts/Noto%20Sans/400/false");
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
Assert.Equal("font/ttf", resp.Content.Headers.ContentType?.MediaType);
var bytes = await resp.Content.ReadAsByteArrayAsync();
Assert.True(bytes.Length > 1000);
}
[Fact]
public async Task GetFontBytes_Unknown_Returns404()
{
var client = Build();
var resp = await client.GetAsync("/api/fonts/DoesNotExist/400/false");
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
}
[Fact]
public async Task CustomPrefix_RemapsAllEndpoints()
{
var client = Build("/dreport/api");
var resp = await client.GetAsync("/dreport/api/health");
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var oldRoute = await client.GetAsync("/api/health");
Assert.Equal(HttpStatusCode.NotFound, oldRoute.StatusCode);
}
[Fact]
public async Task ManualUsage_LayoutEngine_FromDi()
{
// Sanity: AddDreport without MapDreportEndpoints still hands the engine
// out via DI so users can plug it into their own controllers.
var builder = new WebHostBuilder()
.ConfigureServices(s => s.AddRouting().AddDreport())
.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(e => e.MapGet("/custom",
(LayoutEngine engine) => Results.Json(new { count = engine.FontFamilyCount })));
});
using var server = new TestServer(builder);
var client = server.CreateClient();
var resp = await client.GetAsync("/custom");
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.GetProperty("count").GetInt32() >= 1);
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<LangVersion>latest</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- Tests run on the host RID; ensure native dylibs ship next to the test asm. -->
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Dreport.Service\Dreport.Service.csproj" />
</ItemGroup>
<!-- Sample TTF for FontParseException tests; copied from the workspace assets dir. -->
<ItemGroup>
<None Include="..\..\..\..\dreport-service\assets\fonts\NotoSans-Regular.ttf"
Link="fixtures/NotoSans-Regular.ttf"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,236 @@
using System.Text.Json;
using Dreport.Service;
using Xunit;
namespace Dreport.Service.Tests;
public class LayoutEngineTests
{
private const string Template = """
{
"id": "csharp",
"name": "C# 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": "title",
"type": "static_text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 14, "fontWeight": "bold" },
"content": "Hello from C#"
}
]
}
}
""";
private const string Data = "{}";
private static byte[] LoadFixtureFont() =>
File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "fixtures", "NotoSans-Regular.ttf"));
// ---------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------
[Fact]
public void Construct_DefaultEngine_HasEmbeddedFonts()
{
using var engine = new LayoutEngine();
Assert.True(engine.FontFamilyCount >= 1);
}
[Fact]
public void CreateEmpty_StartsWithoutFonts()
{
using var engine = LayoutEngine.CreateEmpty();
Assert.Equal(0, engine.FontFamilyCount);
}
[Fact]
public void NativeVersion_ReturnsNonEmpty()
{
var v = LayoutEngine.NativeVersion;
Assert.False(string.IsNullOrEmpty(v));
Assert.Contains('.', v);
}
[Fact]
public void Dispose_TwiceIsSafe()
{
var engine = new LayoutEngine();
engine.Dispose();
engine.Dispose();
}
[Fact]
public void Operations_AfterDispose_Throw()
{
var engine = new LayoutEngine();
engine.Dispose();
Assert.Throws<ObjectDisposedException>(() => engine.RenderPdf(Template, Data));
Assert.Throws<ObjectDisposedException>(() => engine.ListFontFamilies());
}
// ---------------------------------------------------------------------
// Font registry
// ---------------------------------------------------------------------
[Fact]
public void RegisterFont_ValidBytes_IncreasesCount()
{
using var engine = LayoutEngine.CreateEmpty();
engine.RegisterFont(LoadFixtureFont());
Assert.Equal(1, engine.FontFamilyCount);
}
[Fact]
public void RegisterFont_InvalidBytes_ThrowsFontParseException()
{
using var engine = LayoutEngine.CreateEmpty();
var ex = Assert.Throws<FontParseException>(() =>
engine.RegisterFont(new byte[] { 1, 2, 3, 4 }));
Assert.Equal(Native.ERR_FONT_PARSE_FAILED, ex.Code);
}
[Fact]
public void RegisterFontsDirectory_NonExisting_ThrowsFontDirectoryNotFound()
{
using var engine = LayoutEngine.CreateEmpty();
Assert.Throws<FontDirectoryNotFoundException>(() =>
engine.RegisterFontsDirectory("/no/such/dreport/test/path/xyz"));
}
[Fact]
public void RegisterFontsDirectory_ValidDir_LoadsCount()
{
var fixturesDir = Path.Combine(AppContext.BaseDirectory, "fixtures");
Assert.True(Directory.Exists(fixturesDir));
using var engine = LayoutEngine.CreateEmpty();
var count = engine.RegisterFontsDirectory(fixturesDir);
Assert.True(count >= 1);
}
[Fact]
public void GetFontBytes_KnownVariant_ReturnsBytes()
{
using var engine = new LayoutEngine();
var bytes = engine.GetFontBytes("Noto Sans", 400, false);
Assert.NotNull(bytes);
Assert.True(bytes!.Length > 1000);
}
[Fact]
public void GetFontBytes_UnknownVariant_ReturnsNull()
{
using var engine = new LayoutEngine();
Assert.Null(engine.GetFontBytes("DoesNotExist", 400, false));
}
[Fact]
public void ListFontFamilies_ContainsNotoSans()
{
using var engine = new LayoutEngine();
var families = engine.ListFontFamilies();
Assert.Contains(families, f => f.Family.ToLowerInvariant().Contains("noto"));
}
// ---------------------------------------------------------------------
// Render pipeline
// ---------------------------------------------------------------------
[Fact]
public void ComputeLayout_ValidInputs_ReturnsLayoutJson()
{
using var engine = new LayoutEngine();
var json = engine.ComputeLayout(Template, Data);
using var doc = JsonDocument.Parse(json);
Assert.True(doc.RootElement.TryGetProperty("pages", out var pages));
Assert.Equal(JsonValueKind.Array, pages.ValueKind);
Assert.True(pages.GetArrayLength() >= 1);
}
[Fact]
public void ComputeLayout_InvalidTemplate_ThrowsInvalidTemplate()
{
using var engine = new LayoutEngine();
Assert.Throws<InvalidTemplateException>(() => engine.ComputeLayout("{not json", Data));
}
[Fact]
public void ComputeLayout_InvalidData_ThrowsInvalidData()
{
using var engine = new LayoutEngine();
Assert.Throws<InvalidDataException>(() => engine.ComputeLayout(Template, "{not json"));
}
[Fact]
public void RenderPdf_ValidInputs_ReturnsPdfBytes()
{
using var engine = new LayoutEngine();
var pdf = engine.RenderPdf(Template, Data);
Assert.True(pdf.Length > 100);
Assert.Equal((byte)'%', pdf[0]);
Assert.Equal((byte)'P', pdf[1]);
Assert.Equal((byte)'D', pdf[2]);
Assert.Equal((byte)'F', pdf[3]);
}
[Fact]
public void RenderPdf_InvalidTemplate_ThrowsInvalidTemplate()
{
using var engine = new LayoutEngine();
Assert.Throws<InvalidTemplateException>(() => engine.RenderPdf("{not json", Data));
}
// ---------------------------------------------------------------------
// Concurrency
// ---------------------------------------------------------------------
[Fact]
public void RenderPdf_Parallel_ProducesPdfs()
{
using var engine = new LayoutEngine();
var success = 0;
Parallel.For(0, 16, _ =>
{
var pdf = engine.RenderPdf(Template, Data);
if (pdf.Length > 100 && pdf[0] == (byte)'%')
{
Interlocked.Increment(ref success);
}
});
Assert.Equal(16, success);
}
// ---------------------------------------------------------------------
// Error code stability (matches Rust ServiceError::code() contract)
// ---------------------------------------------------------------------
[Fact]
public void ErrorCode_InvalidTemplate_IsMinusOne()
{
var ex = new InvalidTemplateException("x");
Assert.Equal(-1, ex.Code);
}
[Fact]
public void ErrorCode_FontParseFailed_IsMinusThree()
{
var ex = new FontParseException("x");
Assert.Equal(-3, ex.Code);
}
}

View File

@@ -97,6 +97,20 @@ pub struct ContainerStyle {
pub border_style: Option<String>, pub border_style: Option<String>,
} }
// --- Condition (v-if benzeri koşullu gösterim) ---
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Condition {
/// Data JSON'daki alan yolu (ör: "toplamlar.iskonto")
pub path: String,
/// Karşılaştırma operatörü: eq, neq, gt, gte, lt, lte, empty, not_empty
pub operator: String,
/// Karşılaştırılacak değer (empty/not_empty için gerekmez)
#[serde(default)]
pub value: Option<serde_json::Value>,
}
// --- Binding --- // --- Binding ---
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -216,6 +230,26 @@ pub struct ChartAxis {
pub y_label: Option<String>, pub y_label: Option<String>,
pub show_grid: Option<bool>, pub show_grid: Option<bool>,
pub grid_color: Option<String>, pub grid_color: Option<String>,
/// Show vertical grid lines at each category (line charts). Defaults to true.
pub show_vertical_grid: Option<bool>,
pub vertical_grid_color: Option<String>,
#[serde(default)]
pub reference_lines: Vec<ChartReferenceLine>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChartReferenceLine {
/// Category index (0-based) where the vertical line is drawn
pub category_index: usize,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub width: Option<f64>,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub dash: Option<bool>,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -233,9 +267,8 @@ pub struct ChartStyle {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ChartElement { pub struct ChartElement {
pub id: String, #[serde(flatten)]
pub position: PositionMode, pub base: ElementBase,
pub size: SizeConstraint,
pub chart_type: ChartType, pub chart_type: ChartType,
pub data_source: ArrayBinding, pub data_source: ArrayBinding,
pub category_field: String, pub category_field: String,
@@ -256,6 +289,138 @@ pub struct ChartElement {
pub style: ChartStyle, pub style: ChartStyle,
} }
// --- Element Base (ortak alanlar) ---
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ElementBase {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
#[serde(default)]
pub position: PositionMode,
#[serde(default)]
pub size: SizeConstraint,
}
impl ElementBase {
/// Flow pozisyonlu, condition'sız, verilen size ile base oluştur
pub fn flow(id: String, size: SizeConstraint) -> Self {
Self {
id,
condition: None,
position: PositionMode::Flow,
size,
}
}
}
pub trait HasBase {
fn base(&self) -> &ElementBase;
fn base_mut(&mut self) -> &mut ElementBase;
}
macro_rules! impl_has_base {
($($t:ty),+ $(,)?) => {
$(impl HasBase for $t {
fn base(&self) -> &ElementBase { &self.base }
fn base_mut(&mut self) -> &mut ElementBase { &mut self.base }
})+
};
}
impl_has_base!(
ContainerElement,
StaticTextElement,
TextElement,
LineElement,
ImageElement,
PageNumberElement,
BarcodeElement,
RepeatingTableElement,
PageBreakElement,
CurrentDateElement,
ShapeElement,
CheckboxElement,
CalculatedTextElement,
RichTextElement,
ChartElement,
);
pub trait ElementTypeStr {
fn type_str(&self) -> &'static str;
}
macro_rules! impl_type_str {
($($t:ty => $s:literal),+ $(,)?) => {
$(impl ElementTypeStr for $t {
fn type_str(&self) -> &'static str { $s }
})+
};
}
impl_type_str!(
ContainerElement => "container",
StaticTextElement => "static_text",
TextElement => "text",
LineElement => "line",
ImageElement => "image",
PageNumberElement => "page_number",
BarcodeElement => "barcode",
RepeatingTableElement => "repeating_table",
PageBreakElement => "page_break",
CurrentDateElement => "current_date",
ShapeElement => "shape",
CheckboxElement => "checkbox",
CalculatedTextElement => "calculated_text",
RichTextElement => "rich_text",
ChartElement => "chart",
);
pub trait HasTextStyle {
fn text_style(&self) -> &TextStyle;
}
macro_rules! impl_has_text_style {
($($t:ty),+ $(,)?) => {
$(impl HasTextStyle for $t {
fn text_style(&self) -> &TextStyle { &self.style }
})+
};
}
impl_has_text_style!(
StaticTextElement,
TextElement,
PageNumberElement,
CurrentDateElement,
CalculatedTextElement,
RichTextElement,
);
pub trait HasOptionalBinding {
fn binding(&self) -> Option<&ScalarBinding>;
fn static_value(&self) -> Option<&str>;
}
impl HasOptionalBinding for ImageElement {
fn binding(&self) -> Option<&ScalarBinding> {
self.binding.as_ref()
}
fn static_value(&self) -> Option<&str> {
self.src.as_deref()
}
}
impl HasOptionalBinding for BarcodeElement {
fn binding(&self) -> Option<&ScalarBinding> {
self.binding.as_ref()
}
fn static_value(&self) -> Option<&str> {
self.value.as_deref()
}
}
// --- Element tipleri --- // --- Element tipleri ---
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -300,71 +465,59 @@ pub enum TemplateElement {
} }
impl TemplateElement { impl TemplateElement {
pub fn id(&self) -> &str { fn inner_base(&self) -> &ElementBase {
match self { match self {
Self::Container(e) => &e.id, Self::Container(e) => e.base(),
Self::StaticText(e) => &e.id, Self::StaticText(e) => e.base(),
Self::Text(e) => &e.id, Self::Text(e) => e.base(),
Self::Line(e) => &e.id, Self::Line(e) => e.base(),
Self::RepeatingTable(e) => &e.id, Self::RepeatingTable(e) => e.base(),
Self::Image(e) => &e.id, Self::Image(e) => e.base(),
Self::PageNumber(e) => &e.id, Self::PageNumber(e) => e.base(),
Self::Barcode(e) => &e.id, Self::Barcode(e) => e.base(),
Self::PageBreak(e) => &e.id, Self::PageBreak(e) => e.base(),
Self::CurrentDate(e) => &e.id, Self::CurrentDate(e) => e.base(),
Self::Shape(e) => &e.id, Self::Shape(e) => e.base(),
Self::Checkbox(e) => &e.id, Self::Checkbox(e) => e.base(),
Self::CalculatedText(e) => &e.id, Self::CalculatedText(e) => e.base(),
Self::RichText(e) => &e.id, Self::RichText(e) => e.base(),
Self::Chart(e) => &e.id, Self::Chart(e) => e.base(),
} }
} }
pub fn id(&self) -> &str {
&self.inner_base().id
}
pub fn position(&self) -> &PositionMode { pub fn position(&self) -> &PositionMode {
match self { &self.inner_base().position
Self::Container(e) => &e.position, }
Self::StaticText(e) => &e.position,
Self::Text(e) => &e.position, pub fn condition(&self) -> Option<&Condition> {
Self::Line(e) => &e.position, self.inner_base().condition.as_ref()
Self::RepeatingTable(e) => &e.position,
Self::Image(e) => &e.position,
Self::PageNumber(e) => &e.position,
Self::Barcode(e) => &e.position,
Self::PageBreak(_) => &PositionMode::Flow,
Self::CurrentDate(e) => &e.position,
Self::Shape(e) => &e.position,
Self::Checkbox(e) => &e.position,
Self::CalculatedText(e) => &e.position,
Self::RichText(e) => &e.position,
Self::Chart(e) => &e.position,
}
} }
pub fn size(&self) -> &SizeConstraint { pub fn size(&self) -> &SizeConstraint {
static DEFAULT_SIZE: SizeConstraint = SizeConstraint { &self.inner_base().size
width: SizeValue::Auto, }
height: SizeValue::Auto,
min_width: None, pub fn type_str(&self) -> &'static str {
min_height: None,
max_width: None,
max_height: None,
};
match self { match self {
Self::Container(e) => &e.size, Self::Container(e) => e.type_str(),
Self::StaticText(e) => &e.size, Self::StaticText(e) => e.type_str(),
Self::Text(e) => &e.size, Self::Text(e) => e.type_str(),
Self::Line(e) => &e.size, Self::Line(e) => e.type_str(),
Self::RepeatingTable(e) => &e.size, Self::RepeatingTable(e) => e.type_str(),
Self::Image(e) => &e.size, Self::Image(e) => e.type_str(),
Self::PageNumber(e) => &e.size, Self::PageNumber(e) => e.type_str(),
Self::Barcode(e) => &e.size, Self::Barcode(e) => e.type_str(),
Self::PageBreak(_) => &DEFAULT_SIZE, Self::PageBreak(e) => e.type_str(),
Self::CurrentDate(e) => &e.size, Self::CurrentDate(e) => e.type_str(),
Self::Shape(e) => &e.size, Self::Shape(e) => e.type_str(),
Self::Checkbox(e) => &e.size, Self::Checkbox(e) => e.type_str(),
Self::CalculatedText(e) => &e.size, Self::CalculatedText(e) => e.type_str(),
Self::RichText(e) => &e.size, Self::RichText(e) => e.type_str(),
Self::Chart(e) => &e.size, Self::Chart(e) => e.type_str(),
} }
} }
} }
@@ -372,9 +525,8 @@ impl TemplateElement {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RichTextElement { pub struct RichTextElement {
pub id: String, #[serde(flatten)]
pub position: PositionMode, pub base: ElementBase,
pub size: SizeConstraint,
#[serde(default)] #[serde(default)]
pub style: TextStyle, // varsayilan stil (span'lar override edebilir) pub style: TextStyle, // varsayilan stil (span'lar override edebilir)
pub content: Vec<RichTextSpan>, pub content: Vec<RichTextSpan>,
@@ -383,11 +535,8 @@ pub struct RichTextElement {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ContainerElement { pub struct ContainerElement {
pub id: String, #[serde(flatten)]
#[serde(default)] pub base: ElementBase,
pub position: PositionMode,
#[serde(default)]
pub size: SizeConstraint,
#[serde(default = "default_column")] #[serde(default = "default_column")]
pub direction: String, pub direction: String,
#[serde(default)] #[serde(default)]
@@ -423,9 +572,8 @@ fn default_start() -> String {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StaticTextElement { pub struct StaticTextElement {
pub id: String, #[serde(flatten)]
pub position: PositionMode, pub base: ElementBase,
pub size: SizeConstraint,
pub style: TextStyle, pub style: TextStyle,
pub content: String, pub content: String,
} }
@@ -433,9 +581,8 @@ pub struct StaticTextElement {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct TextElement { pub struct TextElement {
pub id: String, #[serde(flatten)]
pub position: PositionMode, pub base: ElementBase,
pub size: SizeConstraint,
pub style: TextStyle, pub style: TextStyle,
pub content: Option<String>, pub content: Option<String>,
pub binding: ScalarBinding, pub binding: ScalarBinding,
@@ -444,18 +591,16 @@ pub struct TextElement {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct LineElement { pub struct LineElement {
pub id: String, #[serde(flatten)]
pub position: PositionMode, pub base: ElementBase,
pub size: SizeConstraint,
pub style: LineStyle, pub style: LineStyle,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ImageElement { pub struct ImageElement {
pub id: String, #[serde(flatten)]
pub position: PositionMode, pub base: ElementBase,
pub size: SizeConstraint,
pub src: Option<String>, pub src: Option<String>,
pub binding: Option<ScalarBinding>, pub binding: Option<ScalarBinding>,
pub style: ImageStyle, pub style: ImageStyle,
@@ -464,9 +609,8 @@ pub struct ImageElement {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PageNumberElement { pub struct PageNumberElement {
pub id: String, #[serde(flatten)]
pub position: PositionMode, pub base: ElementBase,
pub size: SizeConstraint,
pub style: TextStyle, pub style: TextStyle,
pub format: Option<String>, pub format: Option<String>,
} }
@@ -474,9 +618,8 @@ pub struct PageNumberElement {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct BarcodeElement { pub struct BarcodeElement {
pub id: String, #[serde(flatten)]
pub position: PositionMode, pub base: ElementBase,
pub size: SizeConstraint,
pub format: String, // qr, ean13, ean8, code128, code39 pub format: String, // qr, ean13, ean8, code128, code39
pub value: Option<String>, pub value: Option<String>,
pub binding: Option<ScalarBinding>, pub binding: Option<ScalarBinding>,
@@ -486,9 +629,8 @@ pub struct BarcodeElement {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RepeatingTableElement { pub struct RepeatingTableElement {
pub id: String, #[serde(flatten)]
pub position: PositionMode, pub base: ElementBase,
pub size: SizeConstraint,
pub data_source: ArrayBinding, pub data_source: ArrayBinding,
pub columns: Vec<TableColumn>, pub columns: Vec<TableColumn>,
pub style: TableStyle, pub style: TableStyle,
@@ -503,15 +645,15 @@ fn default_true() -> Option<bool> {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PageBreakElement { pub struct PageBreakElement {
pub id: String, #[serde(flatten)]
pub base: ElementBase,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CurrentDateElement { pub struct CurrentDateElement {
pub id: String, #[serde(flatten)]
pub position: PositionMode, pub base: ElementBase,
pub size: SizeConstraint,
pub style: TextStyle, pub style: TextStyle,
pub format: Option<String>, pub format: Option<String>,
} }
@@ -519,9 +661,8 @@ pub struct CurrentDateElement {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ShapeElement { pub struct ShapeElement {
pub id: String, #[serde(flatten)]
pub position: PositionMode, pub base: ElementBase,
pub size: SizeConstraint,
pub shape_type: String, // rectangle, ellipse, rounded_rectangle pub shape_type: String, // rectangle, ellipse, rounded_rectangle
pub style: ContainerStyle, pub style: ContainerStyle,
} }
@@ -538,9 +679,8 @@ pub struct CheckboxStyle {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CheckboxElement { pub struct CheckboxElement {
pub id: String, #[serde(flatten)]
pub position: PositionMode, pub base: ElementBase,
pub size: SizeConstraint,
pub checked: Option<bool>, // statik değer pub checked: Option<bool>, // statik değer
pub binding: Option<ScalarBinding>, // dinamik boolean binding pub binding: Option<ScalarBinding>, // dinamik boolean binding
pub style: CheckboxStyle, pub style: CheckboxStyle,
@@ -549,9 +689,8 @@ pub struct CheckboxElement {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CalculatedTextElement { pub struct CalculatedTextElement {
pub id: String, #[serde(flatten)]
pub position: PositionMode, pub base: ElementBase,
pub size: SizeConstraint,
pub style: TextStyle, pub style: TextStyle,
pub expression: String, pub expression: String,
pub format: Option<String>, pub format: Option<String>,
@@ -572,6 +711,10 @@ pub struct Template {
pub root: ContainerElement, pub root: ContainerElement,
#[serde(default)] #[serde(default)]
pub format_config: Option<FormatConfig>, pub format_config: Option<FormatConfig>,
/// Lokalizasyon: "tr-TR", "en-US", "de-DE", "fr-FR" vb.
/// Belirtilirse ve format_config yoksa, locale'den FormatConfig türetilir.
#[serde(default)]
pub locale: Option<String>,
} }
/// Sayı/para birimi formatlama ayarları. /// Sayı/para birimi formatlama ayarları.
@@ -617,3 +760,53 @@ impl Default for FormatConfig {
} }
} }
} }
impl FormatConfig {
/// Locale string'inden FormatConfig türet.
/// Desteklenen locale'ler: tr-TR, en-US, de-DE, fr-FR.
/// Bilinmeyen locale → Türk formatı (varsayılan).
pub fn from_locale(locale: &str) -> Self {
match locale {
"en-US" | "en" => Self {
thousands_separator: ",".to_string(),
decimal_separator: ".".to_string(),
currency_symbol: "$".to_string(),
currency_position: "prefix".to_string(),
},
"de-DE" | "de" => Self {
thousands_separator: ".".to_string(),
decimal_separator: ",".to_string(),
currency_symbol: "".to_string(),
currency_position: "suffix".to_string(),
},
"fr-FR" | "fr" => Self {
thousands_separator: " ".to_string(),
decimal_separator: ",".to_string(),
currency_symbol: "".to_string(),
currency_position: "suffix".to_string(),
},
"en-GB" | "gb" => Self {
thousands_separator: ",".to_string(),
decimal_separator: ".".to_string(),
currency_symbol: "£".to_string(),
currency_position: "prefix".to_string(),
},
// tr-TR veya bilinmeyen → Türk formatı
_ => Self::default(),
}
}
}
impl Template {
/// Template'in etkin FormatConfig'ini döndür.
/// Öncelik: format_config > locale > varsayılan (tr-TR).
pub fn effective_format_config(&self) -> FormatConfig {
if let Some(ref fc) = self.format_config {
fc.clone()
} else if let Some(ref locale) = self.locale {
FormatConfig::from_locale(locale)
} else {
FormatConfig::default()
}
}
}

17
dreport-ffi/Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "dreport-ffi"
version = "0.2.0"
edition = "2024"
description = "C ABI for dreport-service (consumed by NuGet, native hosts, etc.)"
license = "MIT"
publish = false
[lib]
crate-type = ["cdylib", "rlib", "staticlib"]
[dependencies]
dreport-service = { path = "../dreport-service" }
serde_json = "1"
[build-dependencies]
cbindgen = { version = "0.28", default-features = false }

46
dreport-ffi/build.rs Normal file
View File

@@ -0,0 +1,46 @@
//! Generates `include/dreport.h` from the public C ABI on every build.
//! Header is checked into the repo so consumers (NuGet wrapper, manual C use)
//! don't need a Rust toolchain.
use std::env;
use std::path::PathBuf;
fn main() {
println!("cargo:rerun-if-changed=src/lib.rs");
println!("cargo:rerun-if-changed=cbindgen.toml");
println!("cargo:rerun-if-changed=build.rs");
// Skip generation if explicitly disabled (e.g. when cross-compiling without
// host tools, or in cargo publish dry runs).
if env::var("DREPORT_FFI_SKIP_CBINDGEN").is_ok() {
return;
}
let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
let out_path = PathBuf::from(&crate_dir).join("include").join("dreport.h");
if let Some(parent) = out_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let config_path = PathBuf::from(&crate_dir).join("cbindgen.toml");
let config = if config_path.exists() {
cbindgen::Config::from_file(&config_path).expect("invalid cbindgen.toml")
} else {
cbindgen::Config::default()
};
match cbindgen::Builder::new()
.with_crate(crate_dir)
.with_config(config)
.generate()
{
Ok(bindings) => {
bindings.write_to_file(&out_path);
}
Err(e) => {
// Don't fail the build on header generation problems — the cdylib
// is still usable; the header is a developer convenience.
println!("cargo:warning=cbindgen header generation failed: {}", e);
}
}
}

20
dreport-ffi/cbindgen.toml Normal file
View File

@@ -0,0 +1,20 @@
language = "C"
header = "/* Auto-generated by cbindgen — do not edit. */"
include_guard = "DREPORT_H"
pragma_once = true
no_includes = false
sys_includes = ["stdint.h", "stddef.h", "stdbool.h"]
cpp_compat = true
documentation = true
documentation_style = "doxy"
style = "type"
[export]
prefix = ""
[export.rename]
"DreportHandle" = "DreportHandle"
"DreportBuffer" = "DreportBuffer"
[parse]
parse_deps = false

386
dreport-ffi/src/lib.rs Normal file
View File

@@ -0,0 +1,386 @@
//! dreport-ffi
//!
//! C ABI exposing `dreport_service::DreportService` to non-Rust hosts
//! (.NET / NuGet, Node N-API, Python ctypes, etc.).
//!
//! ## Conventions
//!
//! - All exported symbols are prefixed `dreport_`.
//! - Functions return `i32`: `0 == success`, negative values are error codes.
//! See [`error_code`] constants. The detailed message for the most recent
//! error on the calling thread is retrievable via [`dreport_last_error`].
//! - Outbound dynamic data is returned as a [`DreportBuffer`] (ptr + len + cap).
//! The caller MUST hand the buffer back to [`dreport_buffer_free`] to release it.
//! - Inbound strings are passed as `(ptr, len)` byte pairs and interpreted as UTF-8.
//! - Handles ([`DreportHandle`]) are opaque pointers. Pass them to
//! [`dreport_free`] exactly once when done. Never use after free.
//! - All exported functions are safe to call from any thread; the underlying
//! service is `Sync`.
#![allow(clippy::missing_safety_doc)] // safety contract documented at module level
use std::cell::RefCell;
use std::ffi::c_char;
use std::sync::Arc;
use dreport_service::{DreportService, ServiceError};
// ---------------------------------------------------------------------------
// Return codes
// ---------------------------------------------------------------------------
pub mod error_code {
pub const OK: i32 = 0;
pub const NULL_HANDLE: i32 = -100;
pub const NULL_POINTER: i32 = -101;
pub const INVALID_UTF8: i32 = -102;
pub const PANIC: i32 = -103;
// Service-level errors are exposed as the negation of `ServiceError::code()`.
// E.g. ServiceError::FontParseFailed (3) → -3 here.
}
// ---------------------------------------------------------------------------
// Opaque handle
// ---------------------------------------------------------------------------
/// Opaque handle backing a `DreportService` shared across the FFI boundary.
/// Internally an `Arc<DreportService>`, so the same engine can be cloned and
/// driven from multiple threads.
pub struct DreportHandle {
inner: Arc<DreportService>,
}
// ---------------------------------------------------------------------------
// Outbound buffer
// ---------------------------------------------------------------------------
/// Owned byte buffer returned across the FFI boundary. Released with
/// [`dreport_buffer_free`].
#[repr(C)]
pub struct DreportBuffer {
pub data: *mut u8,
pub len: usize,
pub cap: usize,
}
impl DreportBuffer {
fn empty() -> Self {
Self {
data: std::ptr::null_mut(),
len: 0,
cap: 0,
}
}
fn from_vec(mut v: Vec<u8>) -> Self {
v.shrink_to_fit();
let buf = Self {
data: v.as_mut_ptr(),
len: v.len(),
cap: v.capacity(),
};
std::mem::forget(v);
buf
}
}
// ---------------------------------------------------------------------------
// Thread-local error state
// ---------------------------------------------------------------------------
thread_local! {
static LAST_ERROR: RefCell<Option<String>> = const { RefCell::new(None) };
}
fn set_last_error(msg: impl Into<String>) {
LAST_ERROR.with(|cell| *cell.borrow_mut() = Some(msg.into()));
}
fn clear_last_error() {
LAST_ERROR.with(|cell| *cell.borrow_mut() = None);
}
fn map_service_error(err: ServiceError) -> i32 {
let code = -err.code();
set_last_error(err.to_string());
code
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
unsafe fn handle_ref<'a>(handle: *const DreportHandle) -> Option<&'a DreportHandle> {
if handle.is_null() {
set_last_error("null handle");
None
} else {
Some(unsafe { &*handle })
}
}
unsafe fn slice_from_raw<'a>(ptr: *const u8, len: usize) -> Option<&'a [u8]> {
if ptr.is_null() {
set_last_error("null pointer for input slice");
None
} else {
Some(unsafe { std::slice::from_raw_parts(ptr, len) })
}
}
unsafe fn str_from_raw<'a>(ptr: *const u8, len: usize) -> Result<&'a str, i32> {
if ptr.is_null() {
set_last_error("null pointer for input string");
return Err(error_code::NULL_POINTER);
}
let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
std::str::from_utf8(bytes).map_err(|e| {
set_last_error(format!("invalid utf-8: {}", e));
error_code::INVALID_UTF8
})
}
unsafe fn write_buffer(out: *mut DreportBuffer, buffer: DreportBuffer) -> i32 {
if out.is_null() {
set_last_error("null out buffer pointer");
return error_code::NULL_POINTER;
}
unsafe { *out = buffer };
error_code::OK
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
/// Allocate a new service handle with default embedded fonts.
#[unsafe(no_mangle)]
pub extern "C" fn dreport_new() -> *mut DreportHandle {
clear_last_error();
Box::into_raw(Box::new(DreportHandle {
inner: Arc::new(DreportService::new()),
}))
}
/// Allocate an empty service handle (no embedded fonts).
#[unsafe(no_mangle)]
pub extern "C" fn dreport_new_empty() -> *mut DreportHandle {
clear_last_error();
Box::into_raw(Box::new(DreportHandle {
inner: Arc::new(DreportService::empty()),
}))
}
/// Release a service handle previously returned by `dreport_new` /
/// `dreport_new_empty`. Calling with `NULL` is a no-op.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dreport_free(handle: *mut DreportHandle) {
if handle.is_null() {
return;
}
drop(unsafe { Box::from_raw(handle) });
}
/// Release a buffer previously produced by an FFI call. Calling with a buffer
/// whose `data` is NULL or whose `cap` is 0 is a no-op.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dreport_buffer_free(buffer: DreportBuffer) {
if buffer.data.is_null() || buffer.cap == 0 {
return;
}
drop(unsafe { Vec::from_raw_parts(buffer.data, buffer.len, buffer.cap) });
}
/// Returns the static crate version string. Pointer remains valid for the
/// lifetime of the loaded library.
#[unsafe(no_mangle)]
pub extern "C" fn dreport_version() -> *const c_char {
static VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "\0");
VERSION.as_ptr() as *const c_char
}
/// Copy the most recent error message produced on this thread into `out`.
/// Returns `error_code::OK` on success (even if there is no error — the buffer
/// will simply be empty). The buffer must be released with `dreport_buffer_free`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dreport_last_error(out: *mut DreportBuffer) -> i32 {
let msg = LAST_ERROR.with(|cell| cell.borrow().clone()).unwrap_or_default();
let buf = if msg.is_empty() {
DreportBuffer::empty()
} else {
DreportBuffer::from_vec(msg.into_bytes())
};
unsafe { write_buffer(out, buf) }
}
// ---------------------------------------------------------------------------
// Font registry operations
// ---------------------------------------------------------------------------
/// Register a font from raw TTF/OTF bytes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dreport_register_font(
handle: *const DreportHandle,
data: *const u8,
len: usize,
) -> i32 {
clear_last_error();
let Some(h) = (unsafe { handle_ref(handle) }) else {
return error_code::NULL_HANDLE;
};
let Some(bytes) = (unsafe { slice_from_raw(data, len) }) else {
return error_code::NULL_POINTER;
};
match h.inner.register_font_bytes(bytes.to_vec()) {
Ok(_) => error_code::OK,
Err(e) => map_service_error(e),
}
}
/// Register every font file in `path` (UTF-8 directory path).
/// Returns the count via `out_count` (negative on error).
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dreport_register_fonts_dir(
handle: *const DreportHandle,
path: *const u8,
path_len: usize,
out_count: *mut usize,
) -> i32 {
clear_last_error();
let Some(h) = (unsafe { handle_ref(handle) }) else {
return error_code::NULL_HANDLE;
};
let p = match unsafe { str_from_raw(path, path_len) } {
Ok(s) => s,
Err(rc) => return rc,
};
match h.inner.register_fonts_directory(p) {
Ok(n) => {
if !out_count.is_null() {
unsafe { *out_count = n };
}
error_code::OK
}
Err(e) => map_service_error(e),
}
}
/// List all registered font families as a JSON array
/// `[{"family":"Noto Sans","variants":[{"weight":400,"italic":false}, ...]}]`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dreport_list_fonts_json(
handle: *const DreportHandle,
out: *mut DreportBuffer,
) -> i32 {
clear_last_error();
let Some(h) = (unsafe { handle_ref(handle) }) else {
return error_code::NULL_HANDLE;
};
let families = h.inner.list_font_families();
match serde_json::to_vec(&families) {
Ok(v) => unsafe { write_buffer(out, DreportBuffer::from_vec(v)) },
Err(e) => {
set_last_error(format!("serialize fonts: {}", e));
-ServiceError::SerializationFailed(String::new()).code()
}
}
}
/// Get the raw bytes for a specific font variant. Sets `out` to an empty buffer
/// (data=NULL,len=0) and returns OK if the variant does not exist; this lets
/// the caller distinguish "missing" from "error" by inspecting `out.data`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dreport_get_font_bytes(
handle: *const DreportHandle,
family: *const u8,
family_len: usize,
weight: u16,
italic: bool,
out: *mut DreportBuffer,
) -> i32 {
clear_last_error();
let Some(h) = (unsafe { handle_ref(handle) }) else {
return error_code::NULL_HANDLE;
};
let fam = match unsafe { str_from_raw(family, family_len) } {
Ok(s) => s,
Err(rc) => return rc,
};
let buf = match h.inner.get_font_bytes(fam, weight, italic) {
Some(v) => DreportBuffer::from_vec(v),
None => DreportBuffer::empty(),
};
unsafe { write_buffer(out, buf) }
}
// ---------------------------------------------------------------------------
// Render pipeline
// ---------------------------------------------------------------------------
/// Compute layout. Returns the LayoutResult JSON via `out`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dreport_compute_layout(
handle: *const DreportHandle,
template: *const u8,
template_len: usize,
data: *const u8,
data_len: usize,
out: *mut DreportBuffer,
) -> i32 {
clear_last_error();
let Some(h) = (unsafe { handle_ref(handle) }) else {
return error_code::NULL_HANDLE;
};
let tpl = match unsafe { str_from_raw(template, template_len) } {
Ok(s) => s,
Err(rc) => return rc,
};
let d = match unsafe { str_from_raw(data, data_len) } {
Ok(s) => s,
Err(rc) => return rc,
};
match h.inner.compute_layout_json(tpl, d) {
Ok(json) => unsafe { write_buffer(out, DreportBuffer::from_vec(json.into_bytes())) },
Err(e) => map_service_error(e),
}
}
/// Render PDF. Returns PDF bytes via `out`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dreport_render_pdf(
handle: *const DreportHandle,
template: *const u8,
template_len: usize,
data: *const u8,
data_len: usize,
out: *mut DreportBuffer,
) -> i32 {
clear_last_error();
let Some(h) = (unsafe { handle_ref(handle) }) else {
return error_code::NULL_HANDLE;
};
let tpl = match unsafe { str_from_raw(template, template_len) } {
Ok(s) => s,
Err(rc) => return rc,
};
let d = match unsafe { str_from_raw(data, data_len) } {
Ok(s) => s,
Err(rc) => return rc,
};
match h.inner.render_pdf_json(tpl, d) {
Ok(pdf) => unsafe { write_buffer(out, DreportBuffer::from_vec(pdf)) },
Err(e) => map_service_error(e),
}
}
/// Number of distinct font families currently registered. Returns a negative
/// value if the handle is null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dreport_font_family_count(handle: *const DreportHandle) -> isize {
clear_last_error();
let Some(h) = (unsafe { handle_ref(handle) }) else {
return error_code::NULL_HANDLE as isize;
};
h.inner.font_family_count() as isize
}

436
dreport-ffi/tests/ffi.rs Normal file
View File

@@ -0,0 +1,436 @@
//! Integration tests that drive the C ABI directly. These tests treat the FFI
//! crate exactly the way a foreign-language host (NuGet, P/Invoke) would —
//! through opaque pointers, byte buffers, and return codes. They are the
//! contract test suite for non-Rust consumers.
use dreport_ffi::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
const TEMPLATE: &str = r#"{
"id": "ffi",
"name": "FFI 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": "title",
"type": "static_text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 14, "fontWeight": "bold" },
"content": "FFI"
}
]
}
}"#;
const DATA: &str = "{}";
const NOTO_SANS_REGULAR: &[u8] =
include_bytes!("../../dreport-service/assets/fonts/NotoSans-Regular.ttf");
// ---------------------------------------------------------------------------
// Small RAII wrappers around the raw FFI types so each test stays terse.
// ---------------------------------------------------------------------------
struct Handle(*mut DreportHandle);
impl Handle {
fn new() -> Self {
let h = dreport_new();
assert!(!h.is_null(), "dreport_new must succeed");
Self(h)
}
fn empty() -> Self {
let h = dreport_new_empty();
assert!(!h.is_null());
Self(h)
}
}
impl Drop for Handle {
fn drop(&mut self) {
unsafe { dreport_free(self.0) };
}
}
// SAFETY: Underlying handle wraps an Arc<DreportService>; the service is Sync.
unsafe impl Send for Handle {}
unsafe impl Sync for Handle {}
struct OwnedBuffer(DreportBuffer);
impl OwnedBuffer {
fn empty() -> Self {
Self(DreportBuffer {
data: std::ptr::null_mut(),
len: 0,
cap: 0,
})
}
fn as_slice(&self) -> &[u8] {
if self.0.data.is_null() {
&[]
} else {
unsafe { std::slice::from_raw_parts(self.0.data, self.0.len) }
}
}
fn as_str(&self) -> &str {
std::str::from_utf8(self.as_slice()).expect("valid utf8")
}
fn ptr(&mut self) -> *mut DreportBuffer {
&mut self.0
}
}
impl Drop for OwnedBuffer {
fn drop(&mut self) {
let buf = std::mem::replace(
&mut self.0,
DreportBuffer {
data: std::ptr::null_mut(),
len: 0,
cap: 0,
},
);
unsafe { dreport_buffer_free(buf) };
}
}
fn last_error() -> String {
let mut buf = OwnedBuffer::empty();
let rc = unsafe { dreport_last_error(buf.ptr()) };
assert_eq!(rc, error_code::OK);
buf.as_str().to_string()
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
#[test]
fn new_and_free_round_trips() {
let h = dreport_new();
assert!(!h.is_null());
unsafe { dreport_free(h) };
}
#[test]
fn free_null_is_safe() {
unsafe { dreport_free(std::ptr::null_mut()) };
}
#[test]
fn buffer_free_null_is_safe() {
unsafe {
dreport_buffer_free(DreportBuffer {
data: std::ptr::null_mut(),
len: 0,
cap: 0,
})
};
}
#[test]
fn version_returns_valid_c_string() {
let ptr = dreport_version();
assert!(!ptr.is_null());
let cstr = unsafe { std::ffi::CStr::from_ptr(ptr) };
let s = cstr.to_str().unwrap();
assert!(!s.is_empty());
assert!(s.chars().next().unwrap().is_ascii_digit());
}
#[test]
fn embedded_default_handle_has_fonts() {
let h = Handle::new();
let count = unsafe { dreport_font_family_count(h.0) };
assert!(count >= 1);
}
#[test]
fn empty_handle_has_no_fonts() {
let h = Handle::empty();
let count = unsafe { dreport_font_family_count(h.0) };
assert_eq!(count, 0);
}
// ---------------------------------------------------------------------------
// Null-handle guard rails
// ---------------------------------------------------------------------------
#[test]
fn null_handle_returns_null_handle_code() {
let mut buf = OwnedBuffer::empty();
let rc = unsafe { dreport_list_fonts_json(std::ptr::null(), buf.ptr()) };
assert_eq!(rc, error_code::NULL_HANDLE);
assert!(!last_error().is_empty(), "error message must be set");
}
#[test]
fn null_handle_count_returns_negative() {
let count = unsafe { dreport_font_family_count(std::ptr::null()) };
assert!(count < 0);
}
#[test]
fn render_with_null_template_returns_null_pointer_code() {
let h = Handle::new();
let mut out = OwnedBuffer::empty();
let rc = unsafe {
dreport_render_pdf(
h.0,
std::ptr::null(),
0,
DATA.as_ptr(),
DATA.len(),
out.ptr(),
)
};
assert_eq!(rc, error_code::NULL_POINTER);
}
// ---------------------------------------------------------------------------
// Font registration
// ---------------------------------------------------------------------------
#[test]
fn register_font_valid_bytes() {
let h = Handle::empty();
let rc = unsafe { dreport_register_font(h.0, NOTO_SANS_REGULAR.as_ptr(), NOTO_SANS_REGULAR.len()) };
assert_eq!(rc, error_code::OK);
assert!(unsafe { dreport_font_family_count(h.0) } >= 1);
}
#[test]
fn register_font_invalid_bytes_returns_negative_service_code() {
let h = Handle::empty();
let garbage = b"not a font";
let rc = unsafe { dreport_register_font(h.0, garbage.as_ptr(), garbage.len()) };
assert_eq!(rc, -3, "ServiceError::FontParseFailed code is 3 → -3 over FFI");
let msg = last_error();
assert!(msg.to_lowercase().contains("font"), "error msg: {}", msg);
}
#[test]
fn register_fonts_dir_invalid_path_sets_error() {
let h = Handle::empty();
let path = "/zzz/no/such/dreport/path";
let mut out_count: usize = 0;
let rc = unsafe {
dreport_register_fonts_dir(h.0, path.as_ptr(), path.len(), &mut out_count)
};
assert!(rc < 0);
assert_eq!(out_count, 0);
assert!(!last_error().is_empty());
}
#[test]
fn register_fonts_dir_valid_path_loads_count() {
let h = Handle::empty();
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../dreport-service/assets/fonts");
let path_str = path.to_string_lossy().into_owned();
let mut out_count: usize = 0;
let rc = unsafe {
dreport_register_fonts_dir(h.0, path_str.as_ptr(), path_str.len(), &mut out_count)
};
assert_eq!(rc, error_code::OK, "{}", last_error());
assert!(out_count >= 1);
}
#[test]
fn list_fonts_json_is_valid_array() {
let h = Handle::new();
let mut out = OwnedBuffer::empty();
let rc = unsafe { dreport_list_fonts_json(h.0, out.ptr()) };
assert_eq!(rc, error_code::OK);
let parsed: serde_json::Value = serde_json::from_str(out.as_str()).unwrap();
assert!(parsed.is_array());
assert!(!parsed.as_array().unwrap().is_empty());
}
#[test]
fn get_font_bytes_existing_returns_data() {
let h = Handle::new();
let family = "Noto Sans";
let mut out = OwnedBuffer::empty();
let rc = unsafe {
dreport_get_font_bytes(h.0, family.as_ptr(), family.len(), 400, false, out.ptr())
};
assert_eq!(rc, error_code::OK);
assert!(out.as_slice().len() > 1000);
}
#[test]
fn get_font_bytes_missing_returns_ok_with_empty_buffer() {
let h = Handle::new();
let family = "DoesNotExist";
let mut out = OwnedBuffer::empty();
let rc = unsafe {
dreport_get_font_bytes(h.0, family.as_ptr(), family.len(), 400, false, out.ptr())
};
assert_eq!(rc, error_code::OK);
assert!(out.as_slice().is_empty());
assert!(out.0.data.is_null());
}
// ---------------------------------------------------------------------------
// Render pipeline
// ---------------------------------------------------------------------------
#[test]
fn compute_layout_round_trip() {
let h = Handle::new();
let mut out = OwnedBuffer::empty();
let rc = unsafe {
dreport_compute_layout(
h.0,
TEMPLATE.as_ptr(),
TEMPLATE.len(),
DATA.as_ptr(),
DATA.len(),
out.ptr(),
)
};
assert_eq!(rc, error_code::OK, "{}", last_error());
let parsed: serde_json::Value = serde_json::from_str(out.as_str()).unwrap();
assert!(parsed["pages"].is_array());
}
#[test]
fn render_pdf_returns_pdf_magic_header() {
let h = Handle::new();
let mut out = OwnedBuffer::empty();
let rc = unsafe {
dreport_render_pdf(
h.0,
TEMPLATE.as_ptr(),
TEMPLATE.len(),
DATA.as_ptr(),
DATA.len(),
out.ptr(),
)
};
assert_eq!(rc, error_code::OK, "{}", last_error());
let bytes = out.as_slice();
assert!(bytes.starts_with(b"%PDF-"), "missing magic header");
}
#[test]
fn render_with_invalid_template_json_sets_error() {
let h = Handle::new();
let bad = b"{not json";
let mut out = OwnedBuffer::empty();
let rc = unsafe {
dreport_render_pdf(
h.0,
bad.as_ptr(),
bad.len(),
DATA.as_ptr(),
DATA.len(),
out.ptr(),
)
};
assert_eq!(rc, -1, "ServiceError::InvalidTemplateJson → -1");
assert!(!last_error().is_empty());
assert!(out.as_slice().is_empty());
}
// ---------------------------------------------------------------------------
// Concurrency
// ---------------------------------------------------------------------------
#[test]
fn concurrent_independent_handles() {
let success = Arc::new(AtomicUsize::new(0));
let mut threads = Vec::new();
for _ in 0..6 {
let s = Arc::clone(&success);
threads.push(thread::spawn(move || {
let h = Handle::new();
let mut out = OwnedBuffer::empty();
let rc = unsafe {
dreport_render_pdf(
h.0,
TEMPLATE.as_ptr(),
TEMPLATE.len(),
DATA.as_ptr(),
DATA.len(),
out.ptr(),
)
};
if rc == error_code::OK && out.as_slice().starts_with(b"%PDF-") {
s.fetch_add(1, Ordering::SeqCst);
}
}));
}
for t in threads {
t.join().unwrap();
}
assert_eq!(success.load(Ordering::SeqCst), 6);
}
#[test]
fn concurrent_shared_handle() {
// The handle itself is owned by one thread, but the underlying service is
// an Arc<DreportService>, so internally a shared engine is fine. To test
// the most realistic NuGet scenario (one process-wide engine) we instead
// create per-thread handles backed by parallel `dreport_new` calls.
let success = Arc::new(AtomicUsize::new(0));
let mut threads = Vec::new();
for _ in 0..4 {
let s = Arc::clone(&success);
threads.push(thread::spawn(move || {
for _ in 0..4 {
let h = Handle::new();
let mut out = OwnedBuffer::empty();
let rc = unsafe {
dreport_render_pdf(
h.0,
TEMPLATE.as_ptr(),
TEMPLATE.len(),
DATA.as_ptr(),
DATA.len(),
out.ptr(),
)
};
if rc == 0 {
s.fetch_add(1, Ordering::SeqCst);
}
}
}));
}
for t in threads {
t.join().unwrap();
}
assert_eq!(success.load(Ordering::SeqCst), 16);
}
// ---------------------------------------------------------------------------
// Last-error semantics
// ---------------------------------------------------------------------------
#[test]
fn successful_call_clears_previous_error() {
let h = Handle::new();
// Provoke an error first.
let rc = unsafe { dreport_register_font(h.0, b"x".as_ptr(), 1) };
assert!(rc < 0);
assert!(!last_error().is_empty());
// A subsequent successful call must clear it.
let count = unsafe { dreport_font_family_count(h.0) };
assert!(count >= 1);
assert!(
last_error().is_empty(),
"successful call should clear last_error"
);
}

View File

@@ -0,0 +1,21 @@
[package]
name = "dreport-service"
version = "0.2.0"
edition = "2024"
description = "High-level orchestration service for dreport (font registry + render pipeline)"
license = "MIT"
publish = ["gitea"]
[dependencies]
dreport-core = { version = "0.2.0", path = "../core", registry = "gitea" }
dreport-layout = { version = "0.2.0", path = "../layout-engine", registry = "gitea" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
[features]
default = ["embedded-fonts"]
embedded-fonts = []
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,48 @@
use thiserror::Error;
/// dreport-service üzerinden yapılan tüm operasyonların hata tipi.
/// FFI ve HTTP adapter'ları bu enum'u kendi error formatlarına map'ler.
#[derive(Debug, Error)]
pub enum ServiceError {
#[error("invalid template JSON: {0}")]
InvalidTemplateJson(String),
#[error("invalid data JSON: {0}")]
InvalidDataJson(String),
#[error("font parse failed: bytes do not contain a valid TTF/OTF face")]
FontParseFailed,
#[error("font directory not found: {0}")]
FontDirNotFound(String),
#[error("font directory read error: {0}")]
FontDirRead(String),
#[error("layout computation failed: {0}")]
LayoutFailed(String),
#[error("pdf rendering failed: {0}")]
PdfFailed(String),
#[error("layout result serialization failed: {0}")]
SerializationFailed(String),
}
impl ServiceError {
/// Stable numeric code for FFI consumers.
pub fn code(&self) -> i32 {
match self {
Self::InvalidTemplateJson(_) => 1,
Self::InvalidDataJson(_) => 2,
Self::FontParseFailed => 3,
Self::FontDirNotFound(_) => 4,
Self::FontDirRead(_) => 5,
Self::LayoutFailed(_) => 6,
Self::PdfFailed(_) => 7,
Self::SerializationFailed(_) => 8,
}
}
}
pub type ServiceResult<T> = Result<T, ServiceError>;

View File

@@ -0,0 +1,164 @@
use dreport_layout::FontData;
use dreport_layout::font_meta::{self, FontFamilyInfo, FontVariantKey};
use dreport_layout::font_provider::FontProvider;
use std::collections::HashMap;
use std::path::Path;
use crate::error::{ServiceError, ServiceResult};
/// Default font family that is always included in the layout font set when
/// available. Matches the engine's fallback behaviour.
pub(crate) const DEFAULT_FAMILY: &str = "noto sans";
/// Internal font registry. Manages parsed TTF/OTF faces indexed by family + variant.
/// Not exported directly — accessed through `DreportService`.
#[derive(Default)]
pub(crate) struct FontRegistry {
/// family_lower -> variant_key -> FontData
families: HashMap<String, HashMap<FontVariantKey, FontData>>,
/// Original-case family names for display (`list_families`).
family_names: HashMap<String, String>,
}
impl FontRegistry {
pub(crate) fn new() -> Self {
Self::default()
}
/// Register a font from raw bytes. Returns parsed family info on success.
pub(crate) fn register_bytes(&mut self, data: Vec<u8>) -> ServiceResult<RegisteredFont> {
let meta = font_meta::parse_font_meta(&data).ok_or(ServiceError::FontParseFailed)?;
let family_lower = meta.family.to_lowercase();
let variant_key = meta.variant_key();
self.family_names
.entry(family_lower.clone())
.or_insert_with(|| meta.family.clone());
let font_data = FontData::new(meta.family.clone(), meta.weight, meta.italic, data);
self.families
.entry(family_lower)
.or_default()
.insert(variant_key.clone(), font_data);
Ok(RegisteredFont {
family: meta.family,
weight: variant_key.weight,
italic: variant_key.italic,
})
}
/// Register all `.ttf`/`.otf` files in the given directory.
/// Returns the count of successfully registered files; per-file parse
/// failures are silently skipped to mirror the previous backend behaviour.
pub(crate) fn register_directory(&mut self, dir: &Path) -> ServiceResult<usize> {
if !dir.exists() {
return Err(ServiceError::FontDirNotFound(dir.display().to_string()));
}
if !dir.is_dir() {
return Err(ServiceError::FontDirNotFound(dir.display().to_string()));
}
let entries = std::fs::read_dir(dir).map_err(|e| ServiceError::FontDirRead(e.to_string()))?;
let mut count = 0_usize;
for entry in entries.flatten() {
let path = entry.path();
let is_font = path
.extension()
.is_some_and(|e| e == "ttf" || e == "otf" || e == "TTF" || e == "OTF");
if !is_font {
continue;
}
if let Ok(data) = std::fs::read(&path)
&& self.register_bytes(data).is_ok()
{
count += 1;
}
}
Ok(count)
}
pub(crate) fn get_font_bytes(
&self,
family: &str,
weight: u16,
italic: bool,
) -> Option<&[u8]> {
let family_lower = family.to_lowercase();
let key = FontVariantKey { weight, italic };
self.families
.get(&family_lower)
.and_then(|variants| variants.get(&key))
.map(|fd| fd.data.as_slice())
}
/// Resolve the FontData set for a template. Always includes the default
/// family (Noto Sans) plus any explicitly requested families.
pub(crate) fn fonts_for_families(&self, families: &[String]) -> Vec<FontData> {
let mut result = Vec::new();
let mut loaded: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut to_load: Vec<String> = vec![DEFAULT_FAMILY.to_string()];
for f in families {
let fl = f.to_lowercase();
if !to_load.contains(&fl) {
to_load.push(fl);
}
}
for family_lower in &to_load {
if !loaded.insert(family_lower.clone()) {
continue;
}
if let Some(variants) = self.families.get(family_lower) {
for fd in variants.values() {
result.push(fd.clone());
}
}
}
result
}
pub(crate) fn family_count(&self) -> usize {
self.families.len()
}
}
impl FontProvider for FontRegistry {
fn list_families(&self) -> Vec<FontFamilyInfo> {
self.families
.iter()
.map(|(family_lower, variants)| {
let family = self
.family_names
.get(family_lower)
.cloned()
.unwrap_or_else(|| family_lower.clone());
FontFamilyInfo {
family,
variants: variants.keys().cloned().collect(),
}
})
.collect()
}
fn load_font(&self, family: &str, weight: u16, italic: bool) -> Option<FontData> {
let family_lower = family.to_lowercase();
let key = FontVariantKey { weight, italic };
self.families
.get(&family_lower)
.and_then(|variants| variants.get(&key))
.cloned()
}
}
/// Result of registering a single font, returned to callers that need to
/// confirm what variant was actually parsed.
#[derive(Debug, Clone, serde::Serialize)]
pub struct RegisteredFont {
pub family: String,
pub weight: u16,
pub italic: bool,
}

203
dreport-service/src/lib.rs Normal file
View File

@@ -0,0 +1,203 @@
//! dreport-service
//!
//! High-level orchestration layer that sits on top of `dreport-layout`.
//! Responsible for:
//! - Font registry management (embedded defaults + external loading)
//! - Template + data → LayoutResult JSON
//! - Template + data → PDF bytes
//!
//! Consumed by:
//! - `dreport-backend` (Axum HTTP adapter)
//! - `dreport-ffi` (C ABI for NuGet etc.)
//! - Any other Rust host (CLI, gRPC, ...)
mod error;
mod font_registry;
pub use dreport_core::models::Template;
pub use dreport_layout::FontData;
pub use dreport_layout::LayoutResult;
pub use dreport_layout::font_meta::{FontFamilyInfo, FontVariantKey};
pub use dreport_layout::font_provider::FontProvider;
pub use error::{ServiceError, ServiceResult};
pub use font_registry::RegisteredFont;
use std::path::Path;
use std::sync::RwLock;
use font_registry::FontRegistry;
/// Embedded default fonts compiled into the binary when the
/// `embedded-fonts` feature is enabled (default).
#[cfg(feature = "embedded-fonts")]
const EMBEDDED_FONTS: &[(&str, &[u8])] = &[
(
"NotoSans-Regular",
include_bytes!("../assets/fonts/NotoSans-Regular.ttf"),
),
(
"NotoSans-Bold",
include_bytes!("../assets/fonts/NotoSans-Bold.ttf"),
),
(
"NotoSans-Italic",
include_bytes!("../assets/fonts/NotoSans-Italic.ttf"),
),
(
"NotoSans-BoldItalic",
include_bytes!("../assets/fonts/NotoSans-BoldItalic.ttf"),
),
(
"NotoSansMono-Regular",
include_bytes!("../assets/fonts/NotoSansMono-Regular.ttf"),
),
];
/// Main service handle. Thread-safe; share across threads via `Arc`.
///
/// Holds the font registry and exposes layout + PDF rendering operations.
/// All mutating operations (font registration) take `&self` and use internal
/// synchronization, so multiple readers (renders) and writers (font loads)
/// can coexist safely.
pub struct DreportService {
registry: RwLock<FontRegistry>,
}
impl DreportService {
/// Create a new service. Embedded default fonts are loaded automatically
/// when the `embedded-fonts` feature is on (default).
pub fn new() -> Self {
let mut reg = FontRegistry::new();
#[cfg(feature = "embedded-fonts")]
for (_name, bytes) in EMBEDDED_FONTS {
// Embedded fonts must parse — failure is a build-time bug.
let _ = reg.register_bytes(bytes.to_vec());
}
Self {
registry: RwLock::new(reg),
}
}
/// Create a service without the embedded defaults, regardless of feature
/// flags. Useful for tests and minimal embedders.
pub fn empty() -> Self {
Self {
registry: RwLock::new(FontRegistry::new()),
}
}
// -----------------------------------------------------------------
// Font registry operations
// -----------------------------------------------------------------
/// Register a single font from raw TTF/OTF bytes.
pub fn register_font_bytes(&self, data: Vec<u8>) -> ServiceResult<RegisteredFont> {
let mut reg = self.registry.write().expect("font registry poisoned");
reg.register_bytes(data)
}
/// Register every `.ttf` / `.otf` file in `dir` (non-recursive).
/// Returns the number of fonts successfully registered.
pub fn register_fonts_directory<P: AsRef<Path>>(&self, dir: P) -> ServiceResult<usize> {
let mut reg = self.registry.write().expect("font registry poisoned");
reg.register_directory(dir.as_ref())
}
/// List all currently-registered font families with their available variants.
pub fn list_font_families(&self) -> Vec<FontFamilyInfo> {
let reg = self.registry.read().expect("font registry poisoned");
reg.list_families()
}
/// Get the raw bytes for a specific font variant.
pub fn get_font_bytes(&self, family: &str, weight: u16, italic: bool) -> Option<Vec<u8>> {
let reg = self.registry.read().expect("font registry poisoned");
reg.get_font_bytes(family, weight, italic).map(<[u8]>::to_vec)
}
/// Number of distinct font families currently registered.
pub fn font_family_count(&self) -> usize {
let reg = self.registry.read().expect("font registry poisoned");
reg.family_count()
}
// -----------------------------------------------------------------
// Render pipeline
// -----------------------------------------------------------------
/// Compute layout from JSON inputs. Returns the LayoutResult serialized as JSON.
pub fn compute_layout_json(
&self,
template_json: &str,
data_json: &str,
) -> ServiceResult<String> {
let template: Template = serde_json::from_str(template_json)
.map_err(|e| ServiceError::InvalidTemplateJson(e.to_string()))?;
let data: serde_json::Value = serde_json::from_str(data_json)
.map_err(|e| ServiceError::InvalidDataJson(e.to_string()))?;
let layout = self.compute_layout(&template, &data)?;
serde_json::to_string(&layout).map_err(|e| ServiceError::SerializationFailed(e.to_string()))
}
/// Typed layout computation for Rust callers.
pub fn compute_layout(
&self,
template: &Template,
data: &serde_json::Value,
) -> ServiceResult<LayoutResult> {
let fonts = self.fonts_for_template(&template.fonts);
dreport_layout::compute_layout(template, data, &fonts)
.map_err(|e| ServiceError::LayoutFailed(e.to_string()))
}
/// Render a PDF from JSON inputs.
pub fn render_pdf_json(
&self,
template_json: &str,
data_json: &str,
) -> ServiceResult<Vec<u8>> {
let template: Template = serde_json::from_str(template_json)
.map_err(|e| ServiceError::InvalidTemplateJson(e.to_string()))?;
let data: serde_json::Value = serde_json::from_str(data_json)
.map_err(|e| ServiceError::InvalidDataJson(e.to_string()))?;
self.render_pdf(&template, &data)
}
/// Typed PDF rendering for Rust callers.
pub fn render_pdf(
&self,
template: &Template,
data: &serde_json::Value,
) -> ServiceResult<Vec<u8>> {
let fonts = self.fonts_for_template(&template.fonts);
let layout = dreport_layout::compute_layout(template, data, &fonts)
.map_err(|e| ServiceError::LayoutFailed(e.to_string()))?;
dreport_layout::pdf_render::render_pdf(&layout, &fonts)
.map_err(ServiceError::PdfFailed)
}
/// Snapshot the FontData set required for the given template families.
/// Held briefly under read lock then released — the resulting Vec is owned.
fn fonts_for_template(&self, families: &[String]) -> Vec<FontData> {
let reg = self.registry.read().expect("font registry poisoned");
reg.fonts_for_families(families)
}
}
impl Default for DreportService {
fn default() -> Self {
Self::new()
}
}
/// Allow consumers to use `&DreportService` wherever a `FontProvider` is expected.
impl FontProvider for DreportService {
fn list_families(&self) -> Vec<FontFamilyInfo> {
self.list_font_families()
}
fn load_font(&self, family: &str, weight: u16, italic: bool) -> Option<FontData> {
let reg = self.registry.read().expect("font registry poisoned");
reg.load_font(family, weight, italic)
}
}

View File

@@ -0,0 +1,297 @@
//! Integration tests for `DreportService`.
//!
//! These tests exercise the public API as it would be consumed by the Axum
//! adapter, the FFI layer, and any other host. Anything that breaks here
//! breaks behaviour for every consumer simultaneously, so failures should
//! be treated as a contract change.
use dreport_service::{DreportService, ServiceError};
use std::sync::Arc;
use std::thread;
const VALID_TEMPLATE: &str = r#"{
"id": "test",
"name": "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": "title",
"type": "static_text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 14, "fontWeight": "bold" },
"content": "Hello dreport"
}
]
}
}"#;
const VALID_DATA: &str = r#"{}"#;
const NOTO_SANS_REGULAR: &[u8] = include_bytes!("../assets/fonts/NotoSans-Regular.ttf");
// ---------------------------------------------------------------------------
// Service initialization
// ---------------------------------------------------------------------------
#[test]
fn new_loads_embedded_fonts() {
let svc = DreportService::new();
assert!(
svc.font_family_count() >= 1,
"embedded-fonts feature should provide at least one family"
);
let names: Vec<String> = svc
.list_font_families()
.into_iter()
.map(|f| f.family.to_lowercase())
.collect();
assert!(
names.iter().any(|n| n.contains("noto")),
"Noto Sans family expected, got {:?}",
names
);
}
#[test]
fn empty_starts_with_no_fonts() {
let svc = DreportService::empty();
assert_eq!(svc.font_family_count(), 0);
assert!(svc.list_font_families().is_empty());
}
// ---------------------------------------------------------------------------
// Font registration
// ---------------------------------------------------------------------------
#[test]
fn register_font_bytes_valid_ttf() {
let svc = DreportService::empty();
let registered = svc
.register_font_bytes(NOTO_SANS_REGULAR.to_vec())
.expect("valid TTF should register");
assert!(registered.family.to_lowercase().contains("noto"));
assert_eq!(svc.font_family_count(), 1);
}
#[test]
fn register_font_bytes_invalid_returns_parse_error() {
let svc = DreportService::empty();
let err = svc
.register_font_bytes(b"not a font".to_vec())
.expect_err("garbage bytes must not parse");
assert!(matches!(err, ServiceError::FontParseFailed));
assert_eq!(err.code(), 3);
}
#[test]
fn register_fonts_directory_loads_files() {
let svc = DreportService::empty();
let fonts_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/fonts");
let count = svc
.register_fonts_directory(&fonts_dir)
.expect("assets/fonts must be readable");
assert!(count >= 1, "at least one font expected in assets/fonts");
assert!(svc.font_family_count() >= 1);
}
#[test]
fn register_fonts_directory_missing_returns_error() {
let svc = DreportService::empty();
let err = svc
.register_fonts_directory("/no/such/dreport/fonts/path/zzz")
.expect_err("missing directory must error");
assert!(matches!(err, ServiceError::FontDirNotFound(_)));
}
#[test]
fn register_fonts_directory_skips_non_font_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("readme.txt"), b"hi").unwrap();
std::fs::write(dir.path().join("font.ttf"), NOTO_SANS_REGULAR).unwrap();
let svc = DreportService::empty();
let count = svc.register_fonts_directory(dir.path()).unwrap();
assert_eq!(count, 1);
}
#[test]
fn register_fonts_directory_skips_invalid_font_silently() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("broken.ttf"), b"not a font").unwrap();
std::fs::write(dir.path().join("good.ttf"), NOTO_SANS_REGULAR).unwrap();
let svc = DreportService::empty();
let count = svc.register_fonts_directory(dir.path()).unwrap();
assert_eq!(count, 1, "only the good font should register");
}
// ---------------------------------------------------------------------------
// Font lookup
// ---------------------------------------------------------------------------
#[test]
fn get_font_bytes_returns_data_for_known_variant() {
let svc = DreportService::new();
let bytes = svc
.get_font_bytes("Noto Sans", 400, false)
.expect("regular variant should exist");
assert!(!bytes.is_empty());
}
#[test]
fn get_font_bytes_case_insensitive() {
let svc = DreportService::new();
let lower = svc.get_font_bytes("noto sans", 400, false);
let mixed = svc.get_font_bytes("NoTo SaNs", 400, false);
assert!(lower.is_some());
assert!(mixed.is_some());
}
#[test]
fn get_font_bytes_unknown_returns_none() {
let svc = DreportService::new();
assert!(svc.get_font_bytes("DoesNotExist", 400, false).is_none());
assert!(svc.get_font_bytes("Noto Sans", 1234, false).is_none());
}
// ---------------------------------------------------------------------------
// Layout + render pipeline
// ---------------------------------------------------------------------------
#[test]
fn compute_layout_json_valid_template_returns_pages() {
let svc = DreportService::new();
let json = svc
.compute_layout_json(VALID_TEMPLATE, VALID_DATA)
.expect("layout should compute");
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let pages = parsed
.get("pages")
.and_then(|p| p.as_array())
.expect("LayoutResult must contain pages array");
assert!(!pages.is_empty(), "at least one page expected");
}
#[test]
fn compute_layout_json_invalid_template_returns_typed_error() {
let svc = DreportService::new();
let err = svc
.compute_layout_json("{not json", VALID_DATA)
.expect_err("malformed template must error");
assert!(matches!(err, ServiceError::InvalidTemplateJson(_)));
assert_eq!(err.code(), 1);
}
#[test]
fn compute_layout_json_invalid_data_returns_typed_error() {
let svc = DreportService::new();
let err = svc
.compute_layout_json(VALID_TEMPLATE, "{not json")
.expect_err("malformed data must error");
assert!(matches!(err, ServiceError::InvalidDataJson(_)));
assert_eq!(err.code(), 2);
}
#[test]
fn render_pdf_json_produces_pdf_with_magic_header() {
let svc = DreportService::new();
let pdf = svc
.render_pdf_json(VALID_TEMPLATE, VALID_DATA)
.expect("render must succeed");
assert!(
pdf.starts_with(b"%PDF-"),
"PDF magic header missing; got {:?}",
&pdf[..pdf.len().min(8)]
);
assert!(pdf.len() > 100, "PDF unexpectedly small");
}
#[test]
fn render_pdf_typed_matches_render_pdf_json() {
let svc = DreportService::new();
let from_json = svc
.render_pdf_json(VALID_TEMPLATE, VALID_DATA)
.expect("json render");
let template = serde_json::from_str(VALID_TEMPLATE).unwrap();
let data = serde_json::from_str(VALID_DATA).unwrap();
let from_typed = svc.render_pdf(&template, &data).expect("typed render");
// Producer headers vary on time; magic header + non-trivial size sufficient.
assert!(from_json.starts_with(b"%PDF-"));
assert!(from_typed.starts_with(b"%PDF-"));
assert_eq!(from_json.len(), from_typed.len());
}
// ---------------------------------------------------------------------------
// Concurrency
// ---------------------------------------------------------------------------
#[test]
fn concurrent_renders_share_service_safely() {
let svc = Arc::new(DreportService::new());
let mut handles = Vec::new();
for _ in 0..8 {
let s = Arc::clone(&svc);
handles.push(thread::spawn(move || {
let pdf = s.render_pdf_json(VALID_TEMPLATE, VALID_DATA).unwrap();
assert!(pdf.starts_with(b"%PDF-"));
}));
}
for h in handles {
h.join().expect("worker panic");
}
}
#[test]
fn concurrent_register_and_render() {
let svc = Arc::new(DreportService::new());
let mut handles = Vec::new();
let writer_svc = Arc::clone(&svc);
handles.push(thread::spawn(move || {
for _ in 0..4 {
let _ = writer_svc.register_font_bytes(NOTO_SANS_REGULAR.to_vec());
}
}));
for _ in 0..4 {
let s = Arc::clone(&svc);
handles.push(thread::spawn(move || {
let pdf = s.render_pdf_json(VALID_TEMPLATE, VALID_DATA).unwrap();
assert!(pdf.starts_with(b"%PDF-"));
}));
}
for h in handles {
h.join().expect("worker panic");
}
}
// ---------------------------------------------------------------------------
// Error display
// ---------------------------------------------------------------------------
#[test]
fn service_error_codes_are_stable() {
// FFI consumers depend on these — changing them is a breaking change.
assert_eq!(ServiceError::InvalidTemplateJson("x".into()).code(), 1);
assert_eq!(ServiceError::InvalidDataJson("x".into()).code(), 2);
assert_eq!(ServiceError::FontParseFailed.code(), 3);
assert_eq!(ServiceError::FontDirNotFound("x".into()).code(), 4);
assert_eq!(ServiceError::FontDirRead("x".into()).code(), 5);
assert_eq!(ServiceError::LayoutFailed("x".into()).code(), 6);
assert_eq!(ServiceError::PdfFailed("x".into()).code(), 7);
assert_eq!(ServiceError::SerializationFailed("x".into()).code(), 8);
}

View File

@@ -129,45 +129,24 @@ const sampleData: Record<string, unknown> = {
telefon: '+90 216 444 0018', telefon: '+90 216 444 0018',
}, },
kalemler: [ kalemler: [
{ { siraNo: 1, adi: 'Web Uygulama Gelistirme', miktar: 1, birim: 'Adet', birimFiyat: 45000, tutar: 45000 },
siraNo: 1, { siraNo: 2, adi: 'Mobil Uygulama Gelistirme', miktar: 1, birim: 'Adet', birimFiyat: 35000, tutar: 35000 },
adi: 'Web Uygulama Gelistirme', { siraNo: 3, adi: 'UI/UX Tasarim Hizmeti', miktar: 40, birim: 'Saat', birimFiyat: 750, tutar: 30000 },
miktar: 1, { siraNo: 4, adi: 'Sunucu Bakim Sozlesmesi (Yillik)', miktar: 1, birim: 'Adet', birimFiyat: 12000, tutar: 12000 },
birim: 'Adet',
birimFiyat: 45000,
tutar: 45000,
},
{
siraNo: 2,
adi: 'Mobil Uygulama Gelistirme',
miktar: 1,
birim: 'Adet',
birimFiyat: 35000,
tutar: 35000,
},
{
siraNo: 3,
adi: 'UI/UX Tasarim Hizmeti',
miktar: 40,
birim: 'Saat',
birimFiyat: 750,
tutar: 30000,
},
{
siraNo: 4,
adi: 'Sunucu Bakim Sozlesmesi (Yillik)',
miktar: 1,
birim: 'Adet',
birimFiyat: 12000,
tutar: 12000,
},
{ siraNo: 5, adi: 'SSL Sertifikasi', miktar: 3, birim: 'Adet', birimFiyat: 500, tutar: 1500 }, { siraNo: 5, adi: 'SSL Sertifikasi', miktar: 3, birim: 'Adet', birimFiyat: 500, tutar: 1500 },
{ siraNo: 6, adi: 'Veritabani Yonetimi', miktar: 12, birim: 'Ay', birimFiyat: 2000, tutar: 24000 },
{ siraNo: 7, adi: 'API Entegrasyon Hizmeti', miktar: 1, birim: 'Adet', birimFiyat: 18000, tutar: 18000 },
{ siraNo: 8, adi: 'Bulut Altyapi Kurulumu', miktar: 1, birim: 'Adet', birimFiyat: 8000, tutar: 8000 },
{ siraNo: 9, adi: 'Siber Guvenlik Danismanligi', miktar: 20, birim: 'Saat', birimFiyat: 900, tutar: 18000 },
{ siraNo: 10, adi: 'E-posta Sunucu Yapilandirmasi', miktar: 1, birim: 'Adet', birimFiyat: 3500, tutar: 3500 },
{ siraNo: 11, adi: 'Yedekleme Sistemi Kurulumu', miktar: 1, birim: 'Adet', birimFiyat: 5000, tutar: 5000 },
{ siraNo: 12, adi: 'SEO Optimizasyonu', miktar: 1, birim: 'Adet', birimFiyat: 7500, tutar: 7500 },
{ siraNo: 13, adi: 'Egitim ve Dokumantasyon', miktar: 8, birim: 'Saat', birimFiyat: 600, tutar: 4800 },
{ siraNo: 14, adi: 'Performans Testi ve Raporlama', miktar: 1, birim: 'Adet', birimFiyat: 6000, tutar: 6000 },
{ siraNo: 15, adi: 'Teknik Destek Paketi (6 Ay)', miktar: 1, birim: 'Adet', birimFiyat: 9000, tutar: 9000 },
], ],
toplamlar: { toplamlar: {
araToplam: 123500,
kdvOrani: 20, kdvOrani: 20,
kdv: 24700,
genelToplam: 148200,
}, },
} }
@@ -480,22 +459,66 @@ const defaultInvoiceTemplate: Template = {
style: { borderColor: '#e2e8f0', borderWidth: 0.5 }, style: { borderColor: '#e2e8f0', borderWidth: 0.5 },
children: [ children: [
{ {
id: 'el_ara_toplam', id: 'c_ara_toplam_row',
type: 'text', type: 'container',
position: { type: 'flow' }, position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() }, size: { width: sz.fr(), height: sz.auto() },
style: { fontSize: 10, color: '#333333', align: 'right' }, direction: 'row',
content: 'Ara Toplam: ', gap: 2,
binding: { type: 'scalar', path: 'toplamlar.araToplam' }, padding: { top: 0, right: 0, bottom: 0, left: 0 },
align: 'center',
justify: 'space-between',
style: {},
children: [
{
id: 'el_ara_toplam_label',
type: 'static_text',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 10, color: '#333333' },
content: 'Ara Toplam:',
},
{
id: 'el_ara_toplam',
type: 'calculated_text',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 10, color: '#333333', align: 'right' },
expression: 'kalemler.tutar.sum()',
format: 'currency',
},
],
}, },
{ {
id: 'el_kdv', id: 'c_kdv_row',
type: 'text', type: 'container',
position: { type: 'flow' }, position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() }, size: { width: sz.fr(), height: sz.auto() },
style: { fontSize: 10, color: '#333333', align: 'right' }, direction: 'row',
content: 'KDV (%20): ', gap: 2,
binding: { type: 'scalar', path: 'toplamlar.kdv' }, padding: { top: 0, right: 0, bottom: 0, left: 0 },
align: 'center',
justify: 'space-between',
style: {},
children: [
{
id: 'el_kdv_label',
type: 'static_text',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 10, color: '#333333' },
content: 'KDV (%20):',
},
{
id: 'el_kdv',
type: 'calculated_text',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 10, color: '#333333', align: 'right' },
expression: 'kalemler.tutar.sum() * toplamlar.kdvOrani / 100',
format: 'currency',
},
],
}, },
{ {
id: 'el_cizgi_2', id: 'el_cizgi_2',
@@ -505,13 +528,35 @@ const defaultInvoiceTemplate: Template = {
style: { strokeColor: '#1e293b', strokeWidth: 1 }, style: { strokeColor: '#1e293b', strokeWidth: 1 },
}, },
{ {
id: 'el_genel_toplam', id: 'c_genel_toplam_row',
type: 'text', type: 'container',
position: { type: 'flow' }, position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() }, size: { width: sz.fr(), height: sz.auto() },
style: { fontSize: 12, fontWeight: 'bold', color: '#1a1a1a', align: 'right' }, direction: 'row',
content: 'GENEL TOPLAM: ', gap: 2,
binding: { type: 'scalar', path: 'toplamlar.genelToplam' }, padding: { top: 0, right: 0, bottom: 0, left: 0 },
align: 'center',
justify: 'space-between',
style: {},
children: [
{
id: 'el_genel_toplam_label',
type: 'static_text',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 12, fontWeight: 'bold', color: '#1a1a1a' },
content: 'GENEL TOPLAM:',
},
{
id: 'el_genel_toplam',
type: 'calculated_text',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 12, fontWeight: 'bold', color: '#1a1a1a', align: 'right' },
expression: 'kalemler.tutar.sum() * (1 + toplamlar.kdvOrani / 100)',
format: 'currency',
},
],
}, },
], ],
}, },

View File

@@ -7,6 +7,7 @@ import { useLayoutEngine } from '../../composables/useLayoutEngine'
import LayoutRenderer from './LayoutRenderer.vue' import LayoutRenderer from './LayoutRenderer.vue'
import InteractionOverlay from './InteractionOverlay.vue' import InteractionOverlay from './InteractionOverlay.vue'
import RulerBar from './RulerBar.vue' import RulerBar from './RulerBar.vue'
import MinimapOverlay from './MinimapOverlay.vue'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -23,6 +24,7 @@ const { template, mockData, layoutVersion } = storeToRefs(templateStore)
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
const containerWidth = ref(800) const containerWidth = ref(800)
const containerHeight = ref(600)
const emit = defineEmits<{ const emit = defineEmits<{
'compile-error': [error: string | null] 'compile-error': [error: string | null]
@@ -43,9 +45,23 @@ provide('generateBarcode', generateBarcode)
watch(error, (val) => emit('compile-error', val)) watch(error, (val) => emit('compile-error', val))
// mm → px dönüşüm katsayısı // ============================================================
// Zoom gesture: CSS transform ile anlık geri bildirim,
// debounce ile gerçek scale commit
// ============================================================
// committedZoom: son commit edilen zoom seviyesi (bu değer scale'i belirler)
const committedZoom = ref(editorStore.zoom)
// Gesture sırasında hedef zoom/pan (henüz commit edilmedi)
const gestureZoom = ref(editorStore.zoom)
const gesturePanX = ref(editorStore.panX)
const gesturePanY = ref(editorStore.panY)
const isZoomGesture = ref(false)
let zoomCommitTimer: ReturnType<typeof setTimeout> | null = null
// mm → px dönüşüm katsayısı (committed zoom'a bağlı)
const scale = computed(() => { const scale = computed(() => {
return (containerWidth.value / templateStore.template.page.width) * editorStore.zoom return (containerWidth.value / templateStore.template.page.width) * committedZoom.value
}) })
// Layout sayfaları // Layout sayfaları
@@ -54,7 +70,50 @@ const layoutPages = computed(() => layout.value?.pages ?? [])
// Sayfa yüksekliği px cinsinden // Sayfa yüksekliği px cinsinden
const pageHeightPx = computed(() => templateStore.template.page.height * scale.value) const pageHeightPx = computed(() => templateStore.template.page.height * scale.value)
// Sayfalar container stili — tüm sayfaları kapsayan dış kutu // Görünür sayfa indeksleri — viewport dışındaki sayfaların DOM elemanları render edilmez
// Stabil: sadece gerçek indeksler değiştiğinde yeni Set oluştur
const _lastVisibleKey = ref('')
const _lastVisibleSet = ref(new Set<number>([0]))
const visiblePageIndices = computed(() => {
// Gesture sırasında gesture değerlerini, yoksa store değerlerini kullan
const currentPanY = isZoomGesture.value ? gesturePanY.value : editorStore.panY
const currentZoom = isZoomGesture.value ? gestureZoom.value : editorStore.zoom
const baseScale = containerWidth.value / templateStore.template.page.width
const currentScale = baseScale * currentZoom
const pageH = templateStore.template.page.height * currentScale
const gap = 24
const count = layoutPages.value.length
if (count === 0) return _lastVisibleSet.value
const pagesTop = 60 + currentPanY
const viewH = containerHeight.value
const indices: number[] = []
for (let i = 0; i < count; i++) {
const pageTop = pagesTop + i * (pageH + gap)
const pageBottom = pageTop + pageH
const buffer = pageH
if (pageBottom > -buffer && pageTop < viewH + buffer) {
indices.push(i)
}
}
const key = indices.join(',')
if (key !== _lastVisibleKey.value) {
_lastVisibleKey.value = key
_lastVisibleSet.value = new Set(indices)
}
return _lastVisibleSet.value
})
// CSS transform zoom oranı — gesture sırasında visual feedback
const zoomCssRatio = computed(() => {
if (!isZoomGesture.value) return 1
return gestureZoom.value / committedZoom.value
})
// Sayfalar container stili — committed scale'e göre
const pagesContainerStyle = computed(() => { const pagesContainerStyle = computed(() => {
const w = templateStore.template.page.width * scale.value const w = templateStore.template.page.width * scale.value
const m = templateStore.template.root.padding const m = templateStore.template.root.padding
@@ -66,6 +125,7 @@ const pagesContainerStyle = computed(() => {
height: `${totalH}px`, height: `${totalH}px`,
position: 'relative' as const, position: 'relative' as const,
flexShrink: 0, flexShrink: 0,
willChange: 'transform' as const,
'--page-margin-top': `${m.top * scale.value}px`, '--page-margin-top': `${m.top * scale.value}px`,
'--page-margin-right': `${m.right * scale.value}px`, '--page-margin-right': `${m.right * scale.value}px`,
'--page-margin-bottom': `${m.bottom * scale.value}px`, '--page-margin-bottom': `${m.bottom * scale.value}px`,
@@ -73,12 +133,94 @@ const pagesContainerStyle = computed(() => {
} }
}) })
// Pan transform — sayfa container'ına uygulanacak // Pan sınırları
const panTransform = computed(() => { function clampPan(x: number, y: number, zoomOverride?: number): [number, number] {
if (editorStore.panX === 0 && editorStore.panY === 0) return undefined const z = zoomOverride ?? committedZoom.value
return `translate(${editorStore.panX}px, ${editorStore.panY}px)` const baseScale = containerWidth.value / templateStore.template.page.width
const s = baseScale * z
const pageW = templateStore.template.page.width * s
const pageCount = Math.max(1, layoutPages.value.length)
const pageGap = 24
const phPx = templateStore.template.page.height * s
const totalH = phPx * pageCount + pageGap * (pageCount - 1)
const viewH = (containerRef.value?.clientHeight ?? 600) - 60 - 40
const clampX = pageW / 2
const maxY = viewH * 0.5
const minY = viewH * 0.5 - totalH
return [
Math.max(-clampX, Math.min(clampX, x)),
Math.max(minY, Math.min(maxY, y)),
]
}
// Pages container transform — pan + gesture zoom CSS scale
const pagesTransform = computed(() => {
const ratio = zoomCssRatio.value
const panX = isZoomGesture.value ? gesturePanX.value : editorStore.panX
const panY = isZoomGesture.value ? gesturePanY.value : editorStore.panY
if (ratio === 1) {
if (panX === 0 && panY === 0) return undefined
return `translate(${panX}px, ${panY}px)`
}
// Scale from top-left (0 0). Centering düzeltmesi:
// Flex container ortalar → naturalLeft = (containerW - w) / 2
// Scale sonrası visual width = w * ratio, visual center kayar
// Düzeltme: tx += w * (1 - ratio) / 2
const w = templateStore.template.page.width * scale.value
const centerCorrection = w * (1 - ratio) / 2
const tx = panX + centerCorrection
const ty = panY
return `translate(${tx}px, ${ty}px) scale(${ratio})`
}) })
const pagesTransformOrigin = computed(() => {
if (zoomCssRatio.value === 1) return undefined
return '0 0'
})
// Zoom commit: gesture sonunda gerçek scale'i güncelle
function commitZoom() {
const z = gestureZoom.value
const px = gesturePanX.value
let py = gesturePanY.value
const ratio = z / committedZoom.value
const pageCount = layoutPages.value.length
// Gap düzeltmesi: CSS scale sırasında 24px gap'ler de ratio ile ölçekleniyor.
// Commit sonrası gap'ler tekrar 24px'e dönüyor → dikey kayma.
// Viewport merkezindeki sayfanın üstündeki gap sayısı × 24 × (ratio - 1) kadar düzelt.
if (ratio !== 1 && pageCount > 1) {
const pageH_dom = templateStore.template.page.height * scale.value // committed scale'de
const strideVisual = (pageH_dom + 24) * ratio
// Viewport merkezinin container visual koordinatındaki Y pozisyonu
const viewCenterY = containerHeight.value / 2 - 60 - py
if (viewCenterY > 0 && strideVisual > 0) {
const gapsAbove = Math.min(pageCount - 1, Math.max(0, Math.floor(viewCenterY / strideVisual)))
py += gapsAbove * 24 * (ratio - 1)
}
}
committedZoom.value = z
editorStore.setZoom(z)
const [cx, cy] = clampPan(px, py, z)
editorStore.setPan(cx, cy)
isZoomGesture.value = false
zoomCommitTimer = null
}
function scheduleZoomCommit() {
if (zoomCommitTimer) clearTimeout(zoomCommitTimer)
zoomCommitTimer = setTimeout(commitZoom, 120)
}
// Pan: Space+drag veya orta fare tuşu // Pan: Space+drag veya orta fare tuşu
const isPanning = ref(false) const isPanning = ref(false)
const panStart = ref({ x: 0, y: 0 }) const panStart = ref({ x: 0, y: 0 })
@@ -98,7 +240,10 @@ onMounted(() => {
if (containerRef.value) { if (containerRef.value) {
resizeObserver = new ResizeObserver((entries) => { resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0] const entry = entries[0]
if (entry) containerWidth.value = entry.contentRect.width if (entry) {
containerWidth.value = entry.contentRect.width
containerHeight.value = entry.contentRect.height
}
}) })
resizeObserver.observe(containerRef.value) resizeObserver.observe(containerRef.value)
} }
@@ -109,10 +254,19 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
resizeObserver?.disconnect() resizeObserver?.disconnect()
dispose() dispose()
if (zoomCommitTimer) clearTimeout(zoomCommitTimer)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp) window.removeEventListener('keyup', onKeyUp)
}) })
// Store'daki zoom değiştiğinde (dışarıdan, ör. zoom butonları) committed'ı da güncelle
watch(() => editorStore.zoom, (z) => {
if (!isZoomGesture.value) {
committedZoom.value = z
gestureZoom.value = z
}
})
// Zoom & Pan via wheel/trackpad // Zoom & Pan via wheel/trackpad
const pageRef = ref<HTMLElement | null>(null) const pageRef = ref<HTMLElement | null>(null)
@@ -142,7 +296,17 @@ function onWheel(e: WheelEvent) {
} else { } else {
// İki parmak pan (touchpad) veya normal scroll // İki parmak pan (touchpad) veya normal scroll
e.preventDefault() e.preventDefault()
editorStore.setPan(editorStore.panX - e.deltaX, editorStore.panY - e.deltaY) const curPanX = isZoomGesture.value ? gesturePanX.value : editorStore.panX
const curPanY = isZoomGesture.value ? gesturePanY.value : editorStore.panY
const curZoom = isZoomGesture.value ? gestureZoom.value : editorStore.zoom
const [cx, cy] = clampPan(curPanX - e.deltaX, curPanY - e.deltaY, curZoom)
if (isZoomGesture.value) {
gesturePanX.value = cx
gesturePanY.value = cy
} else {
editorStore.setPan(cx, cy)
}
} }
} }
@@ -150,29 +314,40 @@ function applyZoom(delta: number, clientX: number, clientY: number) {
const pageEl = pageRef.value const pageEl = pageRef.value
if (!pageEl) return if (!pageEl) return
const oldZoom = editorStore.zoom // Gesture başlat veya devam et
if (!isZoomGesture.value) {
isZoomGesture.value = true
gestureZoom.value = editorStore.zoom
gesturePanX.value = editorStore.panX
gesturePanY.value = editorStore.panY
}
const oldZoom = gestureZoom.value
const zoomFactor = Math.pow(0.99, delta) const zoomFactor = Math.pow(0.99, delta)
const newZoom = Math.max(0.25, Math.min(4, oldZoom * zoomFactor)) const newZoom = Math.max(0.25, Math.min(4, oldZoom * zoomFactor))
if (newZoom === oldZoom) return if (newZoom === oldZoom) return
// Sayfa elemanının şu anki ekran pozisyonunu al (centering + pan dahil)
const pageRect = pageEl.getBoundingClientRect()
// Mouse'un sayfa üzerindeki pozisyonu (mm cinsinden) // Mouse'un sayfa üzerindeki pozisyonu (mm cinsinden)
// pageRef'in ekran pozisyonunu al (CSS transform dahil)
const pageRect = pageEl.getBoundingClientRect()
const baseScale = containerWidth.value / templateStore.template.page.width const baseScale = containerWidth.value / templateStore.template.page.width
const oldScale = baseScale * oldZoom const oldGestureScale = baseScale * oldZoom
const newScale = baseScale * newZoom const newGestureScale = baseScale * newZoom
const mousePageMmX = (clientX - pageRect.left) / oldScale const mousePageMmX = (clientX - pageRect.left) / oldGestureScale
const mousePageMmY = (clientY - pageRect.top) / oldScale const mousePageMmY = (clientY - pageRect.top) / oldGestureScale
const pageW = templateStore.template.page.width const pageW = templateStore.template.page.width
// Yeni pan: mouse'un gösterdiği mm noktası aynı ekran pozisyonunda kalmalı // Yeni pan: mouse'un gösterdiği mm noktası aynı ekran pozisyonunda kalmalı
const newPanX = editorStore.panX + (mousePageMmX - pageW / 2) * (oldScale - newScale) const newPanX = gesturePanX.value + (mousePageMmX - pageW / 2) * (oldGestureScale - newGestureScale)
const newPanY = editorStore.panY + mousePageMmY * (oldScale - newScale) const newPanY = gesturePanY.value + mousePageMmY * (oldGestureScale - newGestureScale)
editorStore.setZoom(newZoom) gestureZoom.value = newZoom
editorStore.setPan(newPanX, newPanY) const [cx, cy] = clampPan(newPanX, newPanY, newZoom)
gesturePanX.value = cx
gesturePanY.value = cy
scheduleZoomCommit()
} }
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
@@ -208,7 +383,8 @@ function onPointerDown(e: PointerEvent) {
function onPointerMove(e: PointerEvent) { function onPointerMove(e: PointerEvent) {
if (!isPanning.value) return if (!isPanning.value) return
editorStore.setPan(e.clientX - panStart.value.x, e.clientY - panStart.value.y) const [cx2, cy2] = clampPan(e.clientX - panStart.value.x, e.clientY - panStart.value.y)
editorStore.setPan(cx2, cy2)
} }
function onPointerUp(e: PointerEvent) { function onPointerUp(e: PointerEvent) {
@@ -217,6 +393,17 @@ function onPointerUp(e: PointerEvent) {
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId) ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
} }
} }
function onMinimapNavigate(x: number, y: number) {
const [cx, cy] = clampPan(x, y)
editorStore.setPan(cx, cy)
}
// Minimap'e gerçek scale'i geçir (gesture dahil)
const minimapScale = computed(() => {
const z = isZoomGesture.value ? gestureZoom.value : editorStore.zoom
return (containerWidth.value / templateStore.template.page.width) * z
})
</script> </script>
<template> <template>
@@ -225,9 +412,12 @@ function onPointerUp(e: PointerEvent) {
<RulerBar <RulerBar
:page-width="templateStore.template.page.width" :page-width="templateStore.template.page.width"
:page-height="templateStore.template.page.height" :page-height="templateStore.template.page.height"
:scale="scale" :scale="minimapScale"
:pan-x="editorStore.panX" :pan-x="isZoomGesture ? gesturePanX : editorStore.panX"
:pan-y="editorStore.panY" :pan-y="isZoomGesture ? gesturePanY : editorStore.panY"
:container-width="containerWidth"
:page-count="layoutPages.length"
:page-gap="24"
/> />
<!-- Scroll alanı --> <!-- Scroll alanı -->
@@ -244,9 +434,13 @@ function onPointerUp(e: PointerEvent) {
<div <div
ref="pageRef" ref="pageRef"
class="editor-canvas__pages" class="editor-canvas__pages"
:style="[pagesContainerStyle, panTransform ? { transform: panTransform } : {}]" :style="[
pagesContainerStyle,
pagesTransform ? { transform: pagesTransform } : {},
pagesTransformOrigin ? { transformOrigin: pagesTransformOrigin } : {},
]"
> >
<LayoutRenderer :layout="layout" :scale="scale" /> <LayoutRenderer :layout="layout" :scale="scale" :visible-page-indices="visiblePageIndices" />
<InteractionOverlay <InteractionOverlay
:scale="scale" :scale="scale"
:layout-map="layoutMap" :layout-map="layoutMap"
@@ -261,7 +455,24 @@ function onPointerUp(e: PointerEvent) {
{{ error }} {{ error }}
</div> </div>
<div v-if="compiling" class="editor-canvas__compiling">Derleniyor...</div> <div v-if="compiling" class="editor-canvas__compiling">Derleniyor...</div>
<div class="editor-canvas__zoom">%{{ editorStore.zoomPercent }}</div>
<!-- Minimap + zoom göstergesi -->
<div class="editor-canvas__minimap-area">
<MinimapOverlay
:layout="layout"
:page-width="templateStore.template.page.width"
:page-height="templateStore.template.page.height"
:zoom="isZoomGesture ? gestureZoom : editorStore.zoom"
:pan-x="isZoomGesture ? gesturePanX : editorStore.panX"
:pan-y="isZoomGesture ? gesturePanY : editorStore.panY"
:container-width="containerWidth"
:container-height="containerHeight"
:scale="minimapScale"
:page-gap="24"
@navigate="onMinimapNavigate"
/>
<div class="editor-canvas__zoom">%{{ Math.round((isZoomGesture ? gestureZoom : editorStore.zoom) * 100) }}</div>
</div>
</div> </div>
</template> </template>
@@ -318,15 +529,22 @@ function onPointerUp(e: PointerEvent) {
z-index: 100; z-index: 100;
} }
.editor-canvas__zoom { .editor-canvas__minimap-area {
position: absolute; position: absolute;
bottom: 12px; bottom: 12px;
right: 16px; right: 16px;
z-index: 100;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.editor-canvas__zoom {
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.6);
color: white; color: white;
border-radius: 4px; border-radius: 4px;
padding: 2px 8px; padding: 2px 8px;
font-size: 12px; font-size: 12px;
z-index: 100;
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import type { ElementLayout, PageLayout, LayoutResult } from '../../core/layout-
const props = defineProps<{ const props = defineProps<{
layout: LayoutResult | null layout: LayoutResult | null
scale: number scale: number
visiblePageIndices?: Set<number>
}>() }>()
// WASM barcode üretme fonksiyonu (EditorCanvas'tan provide edilir) // WASM barcode üretme fonksiyonu (EditorCanvas'tan provide edilir)
@@ -196,7 +197,7 @@ watch(
class="layout-page" class="layout-page"
:style="pageContainerStyle(page)" :style="pageContainerStyle(page)"
> >
<template v-for="el in page.elements" :key="el.id"> <template v-if="!visiblePageIndices || visiblePageIndices.has(pageIdx)" v-for="el in page.elements" :key="el.id">
<!-- Page break: dashed horizontal line --> <!-- Page break: dashed horizontal line -->
<div <div
v-if="el.element_type === 'page_break'" v-if="el.element_type === 'page_break'"

View File

@@ -0,0 +1,442 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import type { LayoutResult } from '../../core/layout-types'
const props = defineProps<{
layout: LayoutResult | null
pageWidth: number // mm
pageHeight: number // mm
zoom: number
panX: number
panY: number
containerWidth: number // px — editor canvas container genişliği
containerHeight: number // px — editor canvas container yüksekliği
scale: number // mm → px (zoom dahil)
pageGap: number // px — sayfalar arası boşluk
}>()
const emit = defineEmits<{
navigate: [x: number, y: number]
}>()
const MAX_MINIMAP_WIDTH = 140
const MAX_EXPANDED_HEIGHT = 300
const PADDING = 6
const canvasRef = ref<HTMLCanvasElement | null>(null)
const scrollRef = ref<HTMLElement | null>(null)
const isHovered = ref(false)
const isPointerDragging = ref(false)
// Offscreen canvas — sayfa içeriği cache'i (layout değiştiğinde yeniden çizilir)
let contentCanvas: OffscreenCanvas | null = null
let contentDirty = true
const pageCount = computed(() => Math.max(1, props.layout?.pages.length ?? 1))
// Minimap'te sayfalar arası sabit piksel boşluk
const MINIMAP_PAGE_GAP_PX = 4
// Editördeki toplam yükseklik (mm, viewport hesabı için)
const totalHeightMm = computed(() => {
const gapMm = props.pageGap / props.scale
return props.pageHeight * pageCount.value + gapMm * (pageCount.value - 1)
})
const minimapScale = computed(() => (MAX_MINIMAP_WIDTH - PADDING * 2) / props.pageWidth)
const pageHeightPx = computed(() => props.pageHeight * minimapScale.value)
const canvasWidth = computed(() => props.pageWidth * minimapScale.value + PADDING * 2)
const canvasHeight = computed(() => {
const n = pageCount.value
return pageHeightPx.value * n + MINIMAP_PAGE_GAP_PX * (n - 1) + PADDING * 2
})
const singlePageMinimapH = computed(() => pageHeightPx.value + PADDING * 2)
// Editördeki gap'in mm karşılığı (activePageIndex hesabı için)
const editorGapMm = computed(() => props.pageGap / props.scale)
const activePageIndex = computed(() => {
const viewH = props.containerHeight - 60 - 40
const viewCenterMm = (-props.panY + viewH / 2) / props.scale
const stride = props.pageHeight + editorGapMm.value
const idx = Math.floor(viewCenterMm / stride)
return Math.max(0, Math.min(pageCount.value - 1, idx))
})
const visibleHeight = computed(() => {
if (isHovered.value || isPointerDragging.value) {
return Math.min(canvasHeight.value, MAX_EXPANDED_HEIGHT)
}
return Math.min(singlePageMinimapH.value, canvasHeight.value)
})
/** Sayfanın canvas üzerindeki Y pozisyonu (px) */
function pageTopOnCanvas(pageIdx: number): number {
return PADDING + pageIdx * (pageHeightPx.value + MINIMAP_PAGE_GAP_PX)
}
const targetScrollTop = computed(() => {
if (isHovered.value || isPointerDragging.value) {
const vp = viewportRect.value
const vpCenter = vp.y + vp.h / 2
const half = visibleHeight.value / 2
const maxScroll = canvasHeight.value - visibleHeight.value
return Math.max(0, Math.min(maxScroll, vpCenter - half))
}
const top = pageTopOnCanvas(activePageIndex.value) - PADDING
const maxScroll = canvasHeight.value - visibleHeight.value
return Math.max(0, Math.min(maxScroll, top))
})
/** Editör mm koordinatını minimap canvas px'e çevir (Y ekseni, sayfa gap'leri hesaba katarak) */
function mmYToCanvasPx(mmY: number): number {
const gapMm = editorGapMm.value
const stride = props.pageHeight + gapMm
const pageIdx = Math.min(pageCount.value - 1, Math.max(0, Math.floor(mmY / stride)))
const withinPageMm = mmY - pageIdx * stride
return pageTopOnCanvas(pageIdx) + withinPageMm * minimapScale.value
}
const viewportRect = computed(() => {
const s = minimapScale.value
const pageWidthPx = props.pageWidth * props.scale
const pageLeftPx = (props.containerWidth - pageWidthPx) / 2 + props.panX
const pageTopPx = props.panY
const viewW = props.containerWidth
const viewH = props.containerHeight - 60 - 40
const visLeftMm = -pageLeftPx / props.scale
const visTopMm = -pageTopPx / props.scale
const visWidthMm = viewW / props.scale
const visHeightMm = viewH / props.scale
// Clamp to page boundaries
const clampedLeft = Math.max(0, visLeftMm)
const clampedTop = Math.max(0, visTopMm)
const clampedRight = Math.min(props.pageWidth, visLeftMm + visWidthMm)
const clampedBottom = Math.min(totalHeightMm.value, visTopMm + visHeightMm)
const y1 = mmYToCanvasPx(clampedTop)
const y2 = mmYToCanvasPx(clampedBottom)
return {
x: PADDING + clampedLeft * s,
y: y1,
w: Math.max(0, (clampedRight - clampedLeft) * s),
h: Math.max(0, y2 - y1),
}
})
function elementColor(type: string): string {
switch (type) {
case 'text':
case 'static_text':
case 'rich_text':
return '#93c5fd'
case 'container':
return '#c4b5fd'
case 'repeating_table':
return '#86efac'
case 'image':
return '#fdba74'
case 'line':
return '#9ca3af'
case 'barcode':
return '#fca5a5'
case 'chart':
return '#67e8f9'
default:
return '#d1d5db'
}
}
// --- İki aşamalı çizim: content (pahalı, cache'li) + viewport overlay (ucuz) ---
/** Sayfa içeriğini offscreen canvas'a çizer — sadece layout değiştiğinde çağrılır */
function drawContent() {
const dpr = window.devicePixelRatio || 1
const w = canvasWidth.value
const h = canvasHeight.value
if (!contentCanvas || contentCanvas.width !== Math.ceil(w * dpr) || contentCanvas.height !== Math.ceil(h * dpr)) {
contentCanvas = new OffscreenCanvas(Math.ceil(w * dpr), Math.ceil(h * dpr))
}
const ctx = contentCanvas.getContext('2d')!
ctx.resetTransform()
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, w, h)
const s = minimapScale.value
const pages = props.layout?.pages ?? []
for (let i = 0; i < Math.max(1, pages.length); i++) {
const px = PADDING
const py = pageTopOnCanvas(i)
const pw = props.pageWidth * s
const ph = props.pageHeight * s
ctx.fillStyle = '#ffffff'
ctx.fillRect(px, py, pw, ph)
ctx.strokeStyle = '#d1d5db'
ctx.lineWidth = 0.5
ctx.strokeRect(px, py, pw, ph)
const page = pages[i]
if (page) {
for (const el of page.elements) {
if (el.element_type === 'container') continue
const ex = px + el.x_mm * s
const ey = py + el.y_mm * s
const ew = Math.max(1, el.width_mm * s)
const eh = Math.max(1, el.height_mm * s)
ctx.fillStyle = elementColor(el.element_type)
ctx.globalAlpha = 0.7
ctx.fillRect(ex, ey, ew, eh)
ctx.globalAlpha = 1
}
}
}
contentDirty = false
}
/** Ana canvas'a composite: cached content + viewport dikdörtgeni */
function compose() {
const canvas = canvasRef.value
if (!canvas) return
if (contentDirty || !contentCanvas) {
drawContent()
}
const dpr = window.devicePixelRatio || 1
const w = canvasWidth.value
const h = canvasHeight.value
canvas.width = Math.ceil(w * dpr)
canvas.height = Math.ceil(h * dpr)
canvas.style.width = `${w}px`
canvas.style.height = `${h}px`
const ctx = canvas.getContext('2d')!
ctx.resetTransform()
// Offscreen content'i kopyala (1:1 pixel, zaten dpr ölçekli)
ctx.drawImage(contentCanvas!, 0, 0)
// Viewport dikdörtgenini çiz (dpr ölçekli)
ctx.scale(dpr, dpr)
const v = viewportRect.value
ctx.strokeStyle = '#2563eb'
ctx.lineWidth = 1.5
ctx.strokeRect(v.x, v.y, v.w, v.h)
ctx.fillStyle = 'rgba(37, 99, 235, 0.08)'
ctx.fillRect(v.x, v.y, v.w, v.h)
}
// rAF throttle — aynı frame'de birden fazla compose çağrısını engelle
let composeRAF: number | null = null
function scheduleCompose() {
if (composeRAF !== null) return
composeRAF = requestAnimationFrame(() => {
composeRAF = null
compose()
})
}
// --- Scroll yönetimi ---
function smoothScrollTo(target: number) {
scrollRef.value?.scrollTo({ top: target, behavior: 'smooth' })
}
function jumpScrollTo(target: number) {
if (scrollRef.value) scrollRef.value.scrollTop = target
}
// --- Pointer etkileşimi ---
/** Canvas px → editör mm (sayfa gap dönüşümü dahil) */
function canvasToMm(clientX: number, clientY: number): { mmX: number; mmY: number } {
const canvas = canvasRef.value!
const rect = canvas.getBoundingClientRect()
const mx = clientX - rect.left - PADDING
const my = clientY - rect.top - PADDING
const s = minimapScale.value
// Y: canvas px'ten hangi sayfadayız bul, editör mm'e çevir
const pageStridePx = pageHeightPx.value + MINIMAP_PAGE_GAP_PX
const pageIdx = Math.min(pageCount.value - 1, Math.max(0, Math.floor(my / pageStridePx)))
const withinPagePx = my - pageIdx * pageStridePx
const withinPageMm = withinPagePx / s
const editorStride = props.pageHeight + editorGapMm.value
const mmY = pageIdx * editorStride + withinPageMm
return { mmX: mx / s, mmY }
}
function navigateTo(clientX: number, clientY: number) {
const { mmX, mmY } = canvasToMm(clientX, clientY)
const viewW = props.containerWidth
const viewH = props.containerHeight - 60 - 40
const pageWidthPx = props.pageWidth * props.scale
const newPanX = -(mmX * props.scale) + viewW / 2 - (viewW - pageWidthPx) / 2
const newPanY = -(mmY * props.scale) + viewH / 2
emit('navigate', newPanX, newPanY)
}
function onPointerDown(e: PointerEvent) {
e.preventDefault()
e.stopPropagation()
isPointerDragging.value = true
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
navigateTo(e.clientX, e.clientY)
}
function onPointerMove(e: PointerEvent) {
if (!isPointerDragging.value) return
navigateTo(e.clientX, e.clientY)
}
function onPointerUp(e: PointerEvent) {
if (isPointerDragging.value) {
isPointerDragging.value = false
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
}
}
function onMouseEnter() {
isHovered.value = true
}
function onMouseLeave() {
if (!isPointerDragging.value) {
isHovered.value = false
}
}
watch(isPointerDragging, (dragging) => {
if (!dragging) {
nextTick(() => {
const el = scrollRef.value
if (el && !el.matches(':hover')) {
isHovered.value = false
}
})
}
})
// Layout değiştiğinde content'i dirty işaretle + tam redraw
watch(() => props.layout, () => {
contentDirty = true
scheduleCompose()
}, { deep: true })
// Scale değiştiğinde (zoom) content'i de yeniden çizmek gerekir (gapMm değişir)
watch(() => props.scale, () => {
contentDirty = true
scheduleCompose()
})
// Pan değiştiğinde sadece viewport overlay'i yeniden çiz (ucuz)
// Minimap drag sırasında scroll yapma — kullanıcı zaten sürükleyerek kontrol ediyor
watch([() => props.panX, () => props.panY], () => {
scheduleCompose()
if (!isPointerDragging.value) {
smoothScrollTo(targetScrollTop.value)
}
})
// Zoom değiştiğinde scroll da güncelle
watch(() => props.zoom, () => {
smoothScrollTo(targetScrollTop.value)
})
// Container boyutu değiştiğinde
watch([() => props.containerWidth, () => props.containerHeight], () => {
scheduleCompose()
})
// Hover/collapse durumu değiştiğinde
watch([isHovered, isPointerDragging], () => {
nextTick(() => {
if (isHovered.value || isPointerDragging.value) {
smoothScrollTo(targetScrollTop.value)
} else {
jumpScrollTo(targetScrollTop.value)
}
})
})
onMounted(() => {
drawContent()
compose()
jumpScrollTo(targetScrollTop.value)
})
</script>
<template>
<div
class="minimap"
:class="{
'minimap--expanded': isHovered || isPointerDragging,
'minimap--dragging': isPointerDragging,
}"
:style="{ width: `${canvasWidth}px`, height: `${visibleHeight}px` }"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
>
<div
ref="scrollRef"
class="minimap__scroll"
:style="{ height: `${visibleHeight}px` }"
>
<canvas
ref="canvasRef"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
/>
</div>
</div>
</template>
<style scoped>
.minimap {
background: rgba(255, 255, 255, 0.92);
border: 1px solid #d1d5db;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
cursor: crosshair;
user-select: none;
backdrop-filter: blur(4px);
overflow: hidden;
transition: height 0.25s ease;
}
.minimap--expanded {
border-color: #93c5fd;
box-shadow: 0 2px 12px rgba(37, 99, 235, 0.15);
}
.minimap--dragging {
cursor: grabbing;
}
.minimap__scroll {
overflow: hidden;
scroll-behavior: auto;
}
.minimap__scroll canvas {
display: block;
}
</style>

View File

@@ -12,6 +12,12 @@ const props = defineProps<{
panX: number panX: number
/** Pan offset Y (px) */ /** Pan offset Y (px) */
panY: number panY: number
/** editor-canvas content width (px) — ResizeObserver'dan */
containerWidth: number
/** Sayfa sayısı */
pageCount: number
/** Sayfalar arası boşluk (px) */
pageGap?: number
/** Cetvel kalınlığı px */ /** Cetvel kalınlığı px */
rulerSize?: number rulerSize?: number
}>() }>()
@@ -69,19 +75,8 @@ function drawTicks(
size: number, size: number,
) { ) {
const s = props.scale const s = props.scale
const pageMm = direction === 'horizontal' ? props.pageWidth : props.pageHeight const rulerSz = RULER_SIZE.value
const pan = direction === 'horizontal' ? props.panX : props.panY const gap = props.pageGap ?? 24
// Sayfa başlangıcı: ortaya hizalı + pan
// EditorCanvas sayfayı ortalar, ruler da buna uymalı
// Yatay: canvas ortası - sayfa genişliği/2
// Sayfanın canvas üzerindeki orijin px'i
const canvasCenter =
direction === 'horizontal'
? length / 2 // flex centering approximation
: 40 // EditorCanvas padding-top: 40px
const pageStartPx = canvasCenter - (pageMm * s) / 2 + pan
// Tick aralığı belirleme (zoom'a göre) // Tick aralığı belirleme (zoom'a göre)
const mmPerPx = 1 / s const mmPerPx = 1 / s
@@ -98,11 +93,41 @@ function drawTicks(
ctx.font = '9px system-ui, sans-serif' ctx.font = '9px system-ui, sans-serif'
ctx.textBaseline = 'top' ctx.textBaseline = 'top'
// Sayfanın mm aralığını çiz if (direction === 'horizontal') {
const startMm = 0 // Yatay cetvel: tek sayfa genişliği, flex-center ile hizalı
const endMm = pageMm // editor-canvas padding: left=60, right=40; ruler canvas left=rulerSize
// pageLeft_in_wrapper = 60 + (containerWidth - pageWidthPx) / 2
// pageLeft_in_ruler = pageLeft_in_wrapper - rulerSz + panX
const pageWidthPx = props.pageWidth * s
const pageStartPx = (60 - rulerSz) + (props.containerWidth - pageWidthPx) / 2 + props.panX
for (let mm = startMm; mm <= endMm; mm += tickMm) { drawPageTicks(ctx, direction, length, size, pageStartPx, props.pageWidth, tickMm)
} else {
// Dikey cetvel: her sayfa için ayrı tick çiz
// editor-canvas padding-top=60; ruler canvas top=rulerSize
// pageTop for page i = (60 - rulerSz) + panY + i * (pageHeightPx + gap)
const pageHeightPx = props.pageHeight * s
const pageCount = Math.max(1, props.pageCount)
for (let i = 0; i < pageCount; i++) {
const pageStartPx = (60 - rulerSz) + props.panY + i * (pageHeightPx + gap)
drawPageTicks(ctx, direction, length, size, pageStartPx, props.pageHeight, tickMm)
}
}
}
function drawPageTicks(
ctx: CanvasRenderingContext2D,
direction: 'horizontal' | 'vertical',
length: number,
size: number,
pageStartPx: number,
pageMm: number,
tickMm: number,
) {
const s = props.scale
for (let mm = 0; mm <= pageMm; mm += tickMm) {
const px = pageStartPx + mm * s const px = pageStartPx + mm * s
if (px < -10 || px > length + 10) continue if (px < -10 || px > length + 10) continue
@@ -141,7 +166,7 @@ function drawTicks(
} }
} }
// Sayfa kenar çizgileri (margin göstergesi) // Sayfa kenar çizgileri
ctx.strokeStyle = 'rgba(59, 130, 246, 0.3)' ctx.strokeStyle = 'rgba(59, 130, 246, 0.3)'
ctx.lineWidth = 1 ctx.lineWidth = 1
const startPx = pageStartPx const startPx = pageStartPx
@@ -159,6 +184,11 @@ function drawTicks(
ctx.lineTo(size, endPx) ctx.lineTo(size, endPx)
} }
ctx.stroke() ctx.stroke()
// Renkleri geri al (sonraki sayfa için)
ctx.fillStyle = '#94a3b8'
ctx.strokeStyle = '#94a3b8'
ctx.lineWidth = 0.5
} }
function redraw() { function redraw() {
@@ -166,7 +196,7 @@ function redraw() {
drawRuler(vCanvas.value, 'vertical') drawRuler(vCanvas.value, 'vertical')
} }
watch(() => [props.scale, props.panX, props.panY, props.pageWidth, props.pageHeight], redraw) watch(() => [props.scale, props.panX, props.panY, props.pageWidth, props.pageHeight, props.containerWidth, props.pageCount], redraw)
let resizeObserver: ResizeObserver | null = null let resizeObserver: ResizeObserver | null = null
@@ -205,7 +235,7 @@ onBeforeUnmount(() => {
position: absolute; position: absolute;
top: 0; top: 0;
left: 20px; left: 20px;
right: 0; width: calc(100% - 20px);
z-index: 50; z-index: 50;
pointer-events: none; pointer-events: none;
} }
@@ -214,7 +244,7 @@ onBeforeUnmount(() => {
position: absolute; position: absolute;
top: 20px; top: 20px;
left: 0; left: 0;
bottom: 0; height: calc(100% - 20px);
z-index: 50; z-index: 50;
pointer-events: none; pointer-events: none;
} }

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { ChartElement, ChartType } from '../../../core/types'
defineProps<{ chart: ChartElement }>()
const emit = defineEmits<{
update: [updates: Record<string, unknown>]
updateStyle: [key: string, value: unknown]
}>()
</script>
<template>
<!-- Chart type -->
<div class="et__group">
<button class="et__btn" :class="{ 'et__btn--active': chart.chartType === 'bar' }" data-tip="Cubuk" @click="emit('update', { chartType: 'bar' as ChartType })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="6" width="3" height="6" rx="0.5" fill="currentColor" /><rect x="5.5" y="3" width="3" height="9" rx="0.5" fill="currentColor" /><rect x="9" y="5" width="3" height="7" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': chart.chartType === 'line' }" data-tip="Cizgi" @click="emit('update', { chartType: 'line' as ChartType })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><polyline points="2,10 5,5 8,7 12,3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none" /><circle cx="2" cy="10" r="1.2" fill="currentColor" /><circle cx="5" cy="5" r="1.2" fill="currentColor" /><circle cx="8" cy="7" r="1.2" fill="currentColor" /><circle cx="12" cy="3" r="1.2" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': chart.chartType === 'pie' }" data-tip="Pasta" @click="emit('update', { chartType: 'pie' as ChartType })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 2a5 5 0 1 1-3.54 1.46" stroke="currentColor" stroke-width="1.3" fill="none" /><path d="M7 7V2A5 5 0 0 0 3.46 3.46Z" fill="currentColor" /></svg>
</button>
</div>
<div class="et__sep" />
<!-- Show labels -->
<div class="et__group">
<button class="et__btn" :class="{ 'et__btn--active': chart.labels?.show !== false }" data-tip="Etiketler" @click="emit('update', { labels: { ...chart.labels, show: chart.labels?.show === false ? true : false } })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="8" width="3" height="4" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="5.5" y="5" width="3" height="7" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="9" y="6" width="3" height="6" rx="0.5" fill="currentColor" opacity="0.4" /><text x="3.5" y="7" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">3</text><text x="7" y="4" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">7</text><text x="10.5" y="5" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">5</text></svg>
</button>
</div>
<div class="et__sep" />
<!-- Show grid -->
<div class="et__group">
<button class="et__btn" :class="{ 'et__btn--active': chart.axis?.showGrid !== false }" data-tip="Izgara" @click="emit('update', { axis: { ...chart.axis, showGrid: chart.axis?.showGrid === false ? true : false } })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5" /><line x1="2" y1="7" x2="12" y2="7" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5" /><line x1="2" y1="11" x2="12" y2="11" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5" /></svg>
</button>
</div>
<div class="et__sep" />
<!-- Background color -->
<div class="et__group">
<label class="et__color-wrap" data-tip="Arka Plan">
<input type="color" class="et__color" :value="chart.style.backgroundColor ?? '#ffffff'" @input="(e) => emit('updateStyle', 'backgroundColor', (e.target as HTMLInputElement).value)" />
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="2" width="10" height="10" rx="1.5" :fill="chart.style.backgroundColor ?? '#ffffff'" stroke="#94a3b8" stroke-width="0.8" /></svg>
</label>
</div>
</template>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import type { ContainerElement } from '../../../core/types'
const props = defineProps<{ container: ContainerElement }>()
const emit = defineEmits<{
update: [updates: Record<string, unknown>]
}>()
</script>
<template>
<!-- Direction -->
<div class="et__group">
<button class="et__btn" :class="{ 'et__btn--active': container.direction === 'column' }" data-tip="Dikey" @click="emit('update', { direction: 'column' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="3" rx="0.5" fill="currentColor" /><rect x="2" y="5.5" width="10" height="3" rx="0.5" fill="currentColor" /><rect x="2" y="10" width="10" height="3" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.direction === 'row' }" data-tip="Yatay" @click="emit('update', { direction: 'row' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="3" height="10" rx="0.5" fill="currentColor" /><rect x="5.5" y="2" width="3" height="10" rx="0.5" fill="currentColor" /><rect x="10" y="2" width="3" height="10" rx="0.5" fill="currentColor" /></svg>
</button>
</div>
<div class="et__sep" />
<!-- Align -->
<div class="et__group">
<template v-if="container.direction === 'column'">
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'start' }" data-tip="Sol" @click="emit('update', { align: 'start' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3.5" y="3" width="8" height="2.5" rx="0.5" fill="currentColor" /><rect x="3.5" y="8" width="5" height="2.5" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'center' }" data-tip="Orta" @click="emit('update', { align: 'center' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="6.25" y="1" width="1.5" height="12" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="3" width="8" height="2.5" rx="0.5" fill="currentColor" /><rect x="4.5" y="8" width="5" height="2.5" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'end' }" data-tip="Sag" @click="emit('update', { align: 'end' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="11.5" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="2.5" y="3" width="8" height="2.5" rx="0.5" fill="currentColor" /><rect x="5.5" y="8" width="5" height="2.5" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'stretch' }" data-tip="Esnet" @click="emit('update', { align: 'stretch' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="11.5" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3.5" y="3" width="7" height="2.5" rx="0.5" fill="currentColor" /><rect x="3.5" y="8" width="7" height="2.5" rx="0.5" fill="currentColor" /></svg>
</button>
</template>
<template v-else>
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'start' }" data-tip="Ust" @click="emit('update', { align: 'start' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="3.5" width="2.5" height="8" rx="0.5" fill="currentColor" /><rect x="8" y="3.5" width="2.5" height="5" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'center' }" data-tip="Orta" @click="emit('update', { align: 'center' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="6.25" width="12" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="2" width="2.5" height="10" rx="0.5" fill="currentColor" /><rect x="8" y="3.5" width="2.5" height="7" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'end' }" data-tip="Alt" @click="emit('update', { align: 'end' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="11.5" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="2.5" width="2.5" height="8" rx="0.5" fill="currentColor" /><rect x="8" y="5.5" width="2.5" height="5" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'stretch' }" data-tip="Esnet" @click="emit('update', { align: 'stretch' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="2" y="11.5" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="3.5" width="2.5" height="7" rx="0.5" fill="currentColor" /><rect x="8" y="3.5" width="2.5" height="7" rx="0.5" fill="currentColor" /></svg>
</button>
</template>
</div>
<div class="et__sep" />
<!-- Justify -->
<div class="et__group">
<template v-if="container.direction === 'column'">
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'start' }" data-tip="Ust" @click="emit('update', { justify: 'start' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="3.5" width="8" height="2" rx="0.5" fill="currentColor" /><rect x="3" y="6.5" width="8" height="2" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'center' }" data-tip="Orta" @click="emit('update', { justify: 'center' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="6.25" width="12" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="3" width="8" height="2" rx="0.5" fill="currentColor" /><rect x="3" y="9" width="8" height="2" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'end' }" data-tip="Alt" @click="emit('update', { justify: 'end' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="11.5" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="5.5" width="8" height="2" rx="0.5" fill="currentColor" /><rect x="3" y="8.5" width="8" height="2" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'space-between' }" data-tip="Esit Aralik" @click="emit('update', { justify: 'space-between' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="2" y="11.5" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="3.5" width="8" height="2" rx="0.5" fill="currentColor" /><rect x="3" y="8.5" width="8" height="2" rx="0.5" fill="currentColor" /></svg>
</button>
</template>
<template v-else>
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'start' }" data-tip="Sol" @click="emit('update', { justify: 'start' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" /><rect x="7.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'center' }" data-tip="Orta" @click="emit('update', { justify: 'center' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="6.25" y="1" width="1.5" height="12" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="2" y="3" width="3" height="8" rx="0.5" fill="currentColor" /><rect x="9" y="3" width="3" height="8" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'end' }" data-tip="Sag" @click="emit('update', { justify: 'end' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="11.5" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" /><rect x="7.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'space-between' }" data-tip="Esit Aralik" @click="emit('update', { justify: 'space-between' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="11.5" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" /><rect x="7.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" /></svg>
</button>
</template>
</div>
<div class="et__sep" />
<!-- Gap -->
<div class="et__group et__group--gap" data-tip="Bosluk (mm)">
<svg class="et__gap-icon" width="12" height="12" viewBox="0 0 12 12" fill="none"><rect x="1" y="1" width="3.5" height="10" rx="0.5" stroke="currentColor" stroke-width="1" fill="none" /><rect x="7.5" y="1" width="3.5" height="10" rx="0.5" stroke="currentColor" stroke-width="1" fill="none" /><line x1="6" y1="3" x2="6" y2="9" stroke="currentColor" stroke-width="1" stroke-dasharray="1.5 1" /></svg>
<input type="number" class="et__num" step="1" min="0" :value="container.gap" @input="(e) => emit('update', { gap: parseFloat((e.target as HTMLInputElement).value) || 0 })" />
</div>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { TableStyle } from '../../../core/types'
defineProps<{ tableStyle: TableStyle }>()
const emit = defineEmits<{
updateStyle: [key: string, value: unknown]
}>()
</script>
<template>
<!-- Font size -->
<div class="et__group et__group--gap" data-tip="Yazi Boyutu">
<svg class="et__gap-icon" width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 10L6 2l4 8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" /><line x1="3.5" y1="7" x2="8.5" y2="7" stroke="currentColor" stroke-width="1" stroke-linecap="round" /></svg>
<input type="number" class="et__num" step="1" min="6" :value="tableStyle.fontSize ?? 10" @input="(e) => emit('updateStyle', 'fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)" />
</div>
<div class="et__sep" />
<!-- Header bg color -->
<div class="et__group">
<label class="et__color-wrap" data-tip="Header Rengi">
<input type="color" class="et__color" :value="tableStyle.headerBg ?? '#f0f0f0'" @input="(e) => emit('updateStyle', 'headerBg', (e.target as HTMLInputElement).value)" />
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="2" width="10" height="4" rx="1" :fill="tableStyle.headerBg ?? '#f0f0f0'" stroke="#94a3b8" stroke-width="0.5" /><rect x="2" y="7" width="10" height="2" rx="0.5" fill="none" stroke="#94a3b8" stroke-width="0.5" /><rect x="2" y="10" width="10" height="2" rx="0.5" fill="none" stroke="#94a3b8" stroke-width="0.5" /></svg>
</label>
</div>
<!-- Zebra color -->
<div class="et__group">
<label class="et__color-wrap" data-tip="Zebra Rengi">
<input type="color" class="et__color" :value="tableStyle.zebraOdd ?? '#fafafa'" @input="(e) => emit('updateStyle', 'zebraOdd', (e.target as HTMLInputElement).value)" />
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="2" width="10" height="2.5" rx="0.5" fill="none" stroke="#94a3b8" stroke-width="0.5" /><rect x="2" y="5.5" width="10" height="2.5" rx="0.5" :fill="tableStyle.zebraOdd ?? '#fafafa'" stroke="#94a3b8" stroke-width="0.5" /><rect x="2" y="9" width="10" height="2.5" rx="0.5" fill="none" stroke="#94a3b8" stroke-width="0.5" /></svg>
</label>
</div>
<div class="et__sep" />
<!-- Border color -->
<div class="et__group">
<label class="et__color-wrap" data-tip="Kenarlik Rengi">
<input type="color" class="et__color" :value="tableStyle.borderColor ?? '#cccccc'" @input="(e) => emit('updateStyle', 'borderColor', (e.target as HTMLInputElement).value)" />
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="2" width="10" height="10" rx="1" fill="none" :stroke="tableStyle.borderColor ?? '#cccccc'" stroke-width="1.5" /><line x1="2" y1="6" x2="12" y2="6" :stroke="tableStyle.borderColor ?? '#cccccc'" stroke-width="0.8" /><line x1="7" y1="2" x2="7" y2="12" :stroke="tableStyle.borderColor ?? '#cccccc'" stroke-width="0.8" /></svg>
</label>
</div>
<!-- Border width -->
<div class="et__group et__group--gap" data-tip="Kenarlik (mm)">
<svg class="et__gap-icon" width="12" height="12" viewBox="0 0 12 12" fill="none"><rect x="1" y="1" width="10" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" /></svg>
<input type="number" class="et__num" step="0.1" min="0" :value="tableStyle.borderWidth ?? 0.5" @input="(e) => emit('updateStyle', 'borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
</div>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import type { TextStyle, TemplateElement } from '../../../core/types'
const props = defineProps<{ element: TemplateElement }>()
const style = () => props.element.style as TextStyle
const emit = defineEmits<{
updateStyle: [key: string, value: unknown]
}>()
</script>
<template>
<!-- Bold -->
<div class="et__group">
<button class="et__btn" :class="{ 'et__btn--active': style().fontWeight === 'bold' }" data-tip="Kalin" @click="emit('updateStyle', 'fontWeight', style().fontWeight === 'bold' ? 'normal' : 'bold')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M4 2.5h3.5a2.5 2.5 0 0 1 0 5H4V2.5z" stroke="currentColor" stroke-width="1.5" fill="none" /><path d="M4 7.5h4a2.5 2.5 0 0 1 0 5H4V7.5z" stroke="currentColor" stroke-width="1.5" fill="none" /></svg>
</button>
</div>
<div class="et__sep" />
<!-- Align -->
<div class="et__group">
<button class="et__btn" :class="{ 'et__btn--active': (style().align ?? 'left') === 'left' }" data-tip="Sola Hizala" @click="emit('updateStyle', 'align', 'left')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><line x1="2" y1="7" x2="9" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><line x1="2" y1="11" x2="11" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': style().align === 'center' }" data-tip="Ortala" @click="emit('updateStyle', 'align', 'center')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><line x1="3.5" y1="7" x2="10.5" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><line x1="2.5" y1="11" x2="11.5" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': style().align === 'right' }" data-tip="Saga Hizala" @click="emit('updateStyle', 'align', 'right')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><line x1="5" y1="7" x2="12" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><line x1="3" y1="11" x2="12" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg>
</button>
</div>
<div class="et__sep" />
<!-- Font size -->
<div class="et__group et__group--gap">
<svg class="et__gap-icon" width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 10L6 2l4 8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" /><line x1="3.5" y1="7" x2="8.5" y2="7" stroke="currentColor" stroke-width="1" stroke-linecap="round" /></svg>
<input type="number" class="et__num" step="1" min="1" :value="style().fontSize ?? 11" @input="(e) => emit('updateStyle', 'fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)" data-tip="Yazi Boyutu (pt)" />
</div>
<div class="et__sep" />
<!-- Color -->
<div class="et__group">
<label class="et__color-wrap" data-tip="Renk">
<input type="color" class="et__color" :value="style().color ?? '#000000'" @input="(e) => emit('updateStyle', 'color', (e.target as HTMLInputElement).value)" />
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="11" width="10" height="2" rx="0.5" :fill="style().color ?? '#000000'" /><path d="M5 9L7 3l2 6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" /><line x1="5.5" y1="7.5" x2="8.5" y2="7.5" stroke="currentColor" stroke-width="1" stroke-linecap="round" /></svg>
</label>
</div>
</template>

View File

@@ -32,6 +32,7 @@ import RichTextProperties from '../properties/RichTextProperties.vue'
import ContainerProperties from '../properties/ContainerProperties.vue' import ContainerProperties from '../properties/ContainerProperties.vue'
import RepeatingTableProperties from '../properties/RepeatingTableProperties.vue' import RepeatingTableProperties from '../properties/RepeatingTableProperties.vue'
import ChartProperties from '../properties/ChartProperties.vue' import ChartProperties from '../properties/ChartProperties.vue'
import PropCondition from '../properties/shared/PropCondition.vue'
import '../../styles/properties.css' import '../../styles/properties.css'
const templateStore = useTemplateStore() const templateStore = useTemplateStore()
@@ -233,6 +234,13 @@ function deleteSelected() {
</div> </div>
</div> </div>
<!-- Condition -->
<PropCondition
v-if="selectedElement.id !== 'root'"
:condition="selectedElement.condition"
@update:condition="(v) => templateStore.updateElement(selectedElement!.id, { condition: v } as any)"
/>
<!-- Delete --> <!-- Delete -->
<div v-if="selectedElement.id !== 'root'" class="prop-section"> <div v-if="selectedElement.id !== 'root'" class="prop-section">
<button class="prop-delete-btn" @click="deleteElement">Sil</button> <button class="prop-delete-btn" @click="deleteElement">Sil</button>

View File

@@ -3,6 +3,7 @@ import { useEditorStore } from '../../stores/editor'
import { useSchemaStore } from '../../stores/schema' import { useSchemaStore } from '../../stores/schema'
import type { import type {
TemplateElement, TemplateElement,
TextElement,
RepeatingTableElement, RepeatingTableElement,
TableColumn, TableColumn,
ImageElement, ImageElement,
@@ -46,6 +47,18 @@ const tools: ToolItem[] = [
content: 'Yeni metin', content: 'Yeni metin',
}), }),
}, },
{
label: 'Veri Metni',
icon: 'D',
create: (): TextElement => ({
id: nextId('dtxt'),
type: 'text',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 11, color: '#000000' },
binding: { type: 'scalar', path: '' },
}),
},
{ {
label: 'Zengin Metin', label: 'Zengin Metin',
icon: 'R', icon: 'R',

View File

@@ -1,25 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { useTemplateStore } from '../../stores/template' import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useEditorStore } from '../../stores/editor'
import { useSchemaStore } from '../../stores/schema' import { useSchemaStore } from '../../stores/schema'
import type { BarcodeElement, BarcodeFormat, TemplateElement } from '../../core/types' import PropSection from './shared/PropSection.vue'
import PropSelect from './shared/PropSelect.vue'
import PropColorInput from './shared/PropColorInput.vue'
import PropCheckbox from './shared/PropCheckbox.vue'
import PropFieldSelect from './shared/PropFieldSelect.vue'
import type { BarcodeElement, BarcodeFormat } from '../../core/types'
import '../../styles/properties.css' import '../../styles/properties.css'
const props = defineProps<{ element: BarcodeElement }>() const props = defineProps<{ element: BarcodeElement }>()
const templateStore = useTemplateStore() const { update, updateStyle } = usePropertyUpdate(() => props.element)
const editorStore = useEditorStore()
const schemaStore = useSchemaStore() const schemaStore = useSchemaStore()
function update(updates: Partial<TemplateElement>) { const formatOptions = [
const id = editorStore.selectedElementId { value: 'qr', label: 'QR Kod' },
if (!id) return { value: 'ean13', label: 'EAN-13' },
templateStore.updateElement(id, updates) { value: 'ean8', label: 'EAN-8' },
} { value: 'code128', label: 'Code 128' },
{ value: 'code39', label: 'Code 39' },
function updateStyle(key: string, value: unknown) { ]
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
const barcodeDefaults: Record<BarcodeFormat, string> = { const barcodeDefaults: Record<BarcodeFormat, string> = {
qr: 'https://example.com', qr: 'https://example.com',
@@ -73,7 +74,6 @@ watch(
function onBarcodeValueInput(e: Event) { function onBarcodeValueInput(e: Event) {
const val = (e.target as HTMLInputElement).value const val = (e.target as HTMLInputElement).value
barcodeInputValue.value = val barcodeInputValue.value = val
if (validateBarcode(props.element.format, val)) { if (validateBarcode(props.element.format, val)) {
barcodeInputInvalid.value = false barcodeInputInvalid.value = false
update({ value: val } as any) update({ value: val } as any)
@@ -82,38 +82,29 @@ function onBarcodeValueInput(e: Event) {
} }
} }
function onBarcodeFormatChange(newFormat: BarcodeFormat) { function onBarcodeFormatChange(newFormat: string) {
const fmt = newFormat as BarcodeFormat
const currentValue = props.element.value ?? '' const currentValue = props.element.value ?? ''
if (validateBarcode(newFormat, currentValue)) { if (validateBarcode(fmt, currentValue)) {
update({ format: newFormat } as any) update({ format: fmt } as any)
} else { } else {
const defaultVal = barcodeDefaults[newFormat] const defaultVal = barcodeDefaults[fmt]
barcodeInputValue.value = defaultVal barcodeInputValue.value = defaultVal
barcodeInputInvalid.value = false barcodeInputInvalid.value = false
update({ format: newFormat, value: defaultVal } as any) update({ format: fmt, value: defaultVal } as any)
} }
} }
</script> </script>
<template> <template>
<div class="prop-section"> <PropSection title="Barkod Ayarlari">
<div class="prop-section__title">Barkod Ayarlari</div> <PropSelect
<div class="prop-row" data-tip="Barkod formati"> label="Format"
<label class="prop-label">Format</label> :model-value="element.format"
<select :options="formatOptions"
class="prop-input prop-select" data-tip="Barkod formati"
:value="element.format" @update:model-value="onBarcodeFormatChange"
@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" data-tip="Barkod icerigi formata uygun olmali"> <div class="prop-row" data-tip="Barkod icerigi formata uygun olmali">
<label class="prop-label">Deger</label> <label class="prop-label">Deger</label>
<input <input
@@ -124,63 +115,36 @@ function onBarcodeFormatChange(newFormat: BarcodeFormat) {
@input="onBarcodeValueInput" @input="onBarcodeValueInput"
/> />
</div> </div>
<div class="prop-row" data-tip="Barkod cizgi/modül rengi"> <PropColorInput
<label class="prop-label">Renk</label> label="Renk"
<div class="prop-row-inline"> :model-value="element.style.color ?? '#000000'"
<input :clearable="true"
class="prop-input prop-color" data-tip="Barkod cizgi/modul rengi"
type="color" @update:model-value="(v) => updateStyle('color', v)"
:value="element.style.color ?? '#000000'" />
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" <PropCheckbox
/>
<button
v-if="element.style.color"
class="prop-clear"
@click="updateStyle('color', undefined)"
>
x
</button>
</div>
</div>
<div
v-if="element.format !== 'qr'" v-if="element.format !== 'qr'"
class="prop-row" label="Metin Goster"
:model-value="
element.style.includeText ?? (element.format === 'ean13' || element.format === 'ean8')
"
data-tip="Barkod altinda degeri metin olarak goster" data-tip="Barkod altinda degeri metin olarak goster"
> @update:model-value="(v) => updateStyle('includeText', v)"
<label class="prop-label">Metin Goster</label> />
<input <PropFieldSelect
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" v-if="schemaStore.scalarFields.length > 0"
class="prop-row" label="Veri Baglama"
:model-value="element.binding?.path ?? ''"
:fields="schemaStore.scalarFields"
:allow-empty="true"
empty-label="Yok (statik deger)"
data-tip="Schema'dan dinamik veri baglama" data-tip="Schema'dan dinamik veri baglama"
> @update:model-value="
<label class="prop-label">Veri Baglama</label> (v) => {
<select if (v) update({ binding: { type: 'scalar', path: v } } as any)
class="prop-input prop-select" else update({ binding: undefined } as any)
:value="element.binding?.path ?? ''" }
@change=" "
(e) => { />
const val = (e.target as HTMLSelectElement).value </PropSection>
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> </template>

View File

@@ -1,32 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateStore } from '../../stores/template' import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useEditorStore } from '../../stores/editor' import PropSection from './shared/PropSection.vue'
import type { CalculatedTextElement, TextStyle, TemplateElement } from '../../core/types' import PropSelect from './shared/PropSelect.vue'
import PropTextStyleGroup from './shared/PropTextStyleGroup.vue'
import DexprEditor from '../common/DexprEditor.vue' import DexprEditor from '../common/DexprEditor.vue'
import type { CalculatedTextElement, TextStyle } from '../../core/types'
import '../../styles/properties.css' import '../../styles/properties.css'
const props = defineProps<{ element: CalculatedTextElement }>() const props = defineProps<{ element: CalculatedTextElement }>()
const templateStore = useTemplateStore() const { update, updateStyle } = usePropertyUpdate(() => props.element)
const editorStore = useEditorStore() const style = () => props.element.style as TextStyle
function update(updates: Partial<TemplateElement>) { const formatOptions = [
const id = editorStore.selectedElementId { value: '', label: 'Yok' },
if (!id) return { value: 'currency', label: 'Para Birimi' },
templateStore.updateElement(id, updates) { value: 'number', label: 'Sayi' },
} { value: 'percentage', label: 'Yuzde' },
]
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
function onExpressionChange(value: string) {
update({ expression: value } as any)
}
</script> </script>
<template> <template>
<div class="prop-section"> <PropSection title="Hesaplanan Metin">
<div class="prop-section__title">Hesaplanan Metin</div>
<div <div
class="prop-row-stack" class="prop-row-stack"
data-tip="Hesaplama ifadesi (orn: toplamlar.kdv + toplamlar.araToplam)" data-tip="Hesaplama ifadesi (orn: toplamlar.kdv + toplamlar.araToplam)"
@@ -34,69 +28,28 @@ function onExpressionChange(value: string) {
<label class="prop-label">Ifade</label> <label class="prop-label">Ifade</label>
<DexprEditor <DexprEditor
:model-value="element.expression" :model-value="element.expression"
@update:model-value="onExpressionChange" @update:model-value="(v) => update({ expression: v } as any)"
placeholder="toplamlar.kdv + toplamlar.araToplam" placeholder="toplamlar.kdv + toplamlar.araToplam"
/> />
</div> </div>
<div class="prop-row" data-tip="Sonucun gosterim formati"> <PropSelect
<label class="prop-label">Format</label> label="Format"
<select :model-value="element.format ?? ''"
class="prop-input prop-select" :options="formatOptions"
:value="element.format ?? ''" data-tip="Sonucun gosterim formati"
@change=" @update:model-value="(v) => update({ format: v || undefined } as any)"
(e) => update({ format: (e.target as HTMLSelectElement).value || undefined } as any) />
" <PropTextStyleGroup
> :font-size="style().fontSize ?? 11"
<option value="">Yok</option> :font-weight="style().fontWeight ?? 'normal'"
<option value="currency">Para Birimi</option> :font-family="style().fontFamily"
<option value="number">Sayi</option> :color="style().color ?? '#000000'"
<option value="percentage">Yuzde</option> :align="style().align ?? 'left'"
</select> @update:font-size="(v) => updateStyle('fontSize', v)"
</div> @update:font-weight="(v) => updateStyle('fontWeight', v)"
<div class="prop-row" data-tip="Yazi tipi boyutu (point)"> @update:font-family="(v) => updateStyle('fontFamily', v)"
<label class="prop-label">Boyut (pt)</label> @update:color="(v) => updateStyle('color', v)"
<input @update:align="(v) => updateStyle('align', v)"
class="prop-input" />
type="number" </PropSection>
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" data-tip="Metin rengi">
<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" data-tip="Yazi tipi kalinligi">
<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" data-tip="Metnin yatay hizalamasi">
<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> </template>

View File

@@ -1,32 +1,43 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useTemplateStore } from '../../stores/template' import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useEditorStore } from '../../stores/editor'
import { useSchemaStore } from '../../stores/schema' import { useSchemaStore } from '../../stores/schema'
import type { ChartElement, ChartType, GroupMode, TemplateElement } from '../../core/types' import PropSection from './shared/PropSection.vue'
import PropSelect from './shared/PropSelect.vue'
import PropNumberInput from './shared/PropNumberInput.vue'
import PropColorInput from './shared/PropColorInput.vue'
import PropCheckbox from './shared/PropCheckbox.vue'
import PropFieldSelect from './shared/PropFieldSelect.vue'
import type { ChartElement, ChartType, GroupMode } from '../../core/types'
import '../../styles/properties.css' import '../../styles/properties.css'
const props = defineProps<{ element: ChartElement }>() const props = defineProps<{ element: ChartElement }>()
const templateStore = useTemplateStore() const { update, updateStyle, updateNested } = usePropertyUpdate(() => props.element)
const editorStore = useEditorStore()
const schemaStore = useSchemaStore() const schemaStore = useSchemaStore()
function update(updates: Partial<ChartElement>) { const chartTypeOptions = [
const id = editorStore.selectedElementId { value: 'bar', label: 'Bar' },
if (!id) return { value: 'line', label: 'Line' },
templateStore.updateElement(id, updates as Partial<TemplateElement>) { value: 'pie', label: 'Pie' },
} ]
function updateStyle(key: string, value: unknown) { const groupModeOptions = [
const newStyle = { ...props.element.style, [key]: value } { value: 'grouped', label: 'Yan Yana' },
if (value === undefined || value === '') delete (newStyle as Record<string, unknown>)[key] { value: 'stacked', label: 'Yigin' },
update({ style: newStyle }) ]
}
// Schema'daki array alanlari const alignOptions = [
const arrayFields = computed(() => schemaStore.arrayFields) { value: 'left', label: 'Sol' },
{ value: 'center', label: 'Orta' },
{ value: 'right', label: 'Sag' },
]
const legendPositionOptions = [
{ value: 'top', label: 'Ust' },
{ value: 'bottom', label: 'Alt' },
{ value: 'right', label: 'Sag' },
]
// Secili array'in item alanlari
const itemFields = computed(() => { const itemFields = computed(() => {
const path = props.element.dataSource?.path const path = props.element.dataSource?.path
if (!path) return [] if (!path) return []
@@ -38,6 +49,15 @@ const numberFields = computed(() =>
itemFields.value.filter((f) => f.type === 'number' || f.type === 'integer'), itemFields.value.filter((f) => f.type === 'number' || f.type === 'integer'),
) )
const isPie = computed(() => props.element.chartType === 'pie')
const hasGroup = computed(() => !!props.element.groupField)
const colorList = computed(() => {
return (
props.element.style.colors ?? ['#4F46E5', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899']
)
})
function updateDataSource(path: string) { function updateDataSource(path: string) {
const fields = schemaStore.getArrayItemFields(path) const fields = schemaStore.getArrayItemFields(path)
const strField = fields.find((f) => f.type === 'string') const strField = fields.find((f) => f.type === 'string')
@@ -47,39 +67,9 @@ function updateDataSource(path: string) {
categoryField: strField?.key ?? fields[0]?.key ?? '', categoryField: strField?.key ?? fields[0]?.key ?? '',
valueField: numField?.key ?? fields[1]?.key ?? '', valueField: numField?.key ?? fields[1]?.key ?? '',
groupField: undefined, groupField: undefined,
}) } as any)
} }
function updateTitle(key: string, value: unknown) {
const current = props.element.title ?? { text: '' }
update({ title: { ...current, [key]: value } })
}
function updateLegend(key: string, value: unknown) {
const current = props.element.legend ?? { show: false }
update({ legend: { ...current, [key]: value } })
}
function updateLabels(key: string, value: unknown) {
const current = props.element.labels ?? { show: false }
update({ labels: { ...current, [key]: value } })
}
function updateAxis(key: string, value: unknown) {
const current = props.element.axis ?? {}
update({ axis: { ...current, [key]: value } })
}
const isPie = computed(() => props.element.chartType === 'pie')
const hasGroup = computed(() => !!props.element.groupField)
// Renk paleti (default 6 renk)
const colorList = computed(() => {
return (
props.element.style.colors ?? ['#4F46E5', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899']
)
})
function updateColor(index: number, value: string) { function updateColor(index: number, value: string) {
const colors = [...colorList.value] const colors = [...colorList.value]
colors[index] = value colors[index] = value
@@ -87,8 +77,7 @@ function updateColor(index: number, value: string) {
} }
function addColor() { function addColor() {
const colors = [...colorList.value, '#6B7280'] updateStyle('colors', [...colorList.value, '#6B7280'])
updateStyle('colors', colors)
} }
function removeColor(index: number) { function removeColor(index: number) {
@@ -100,218 +89,140 @@ function removeColor(index: number) {
<template> <template>
<div class="chart-properties"> <div class="chart-properties">
<!-- Grafik Tipi --> <!-- Grafik Tipi -->
<div class="prop-section"> <PropSection title="Grafik Tipi">
<div class="prop-section__title">Grafik Tipi</div> <PropSelect
<div class="prop-row"> label=""
<select :model-value="element.chartType"
class="prop-input prop-select" :options="chartTypeOptions"
:value="element.chartType" @update:model-value="(v) => update({ chartType: v as ChartType } as any)"
@change="update({ chartType: ($event.target as HTMLSelectElement).value as ChartType })" />
> </PropSection>
<option value="bar">Bar</option>
<option value="line">Line</option>
<option value="pie">Pie</option>
</select>
</div>
</div>
<!-- Veri Kaynagi --> <!-- Veri Kaynagi -->
<div class="prop-section"> <PropSection title="Veri Kaynagi">
<div class="prop-section__title">Veri Kaynagi</div> <PropFieldSelect
<div class="prop-row"> label="Array"
<label class="prop-label">Array</label> :model-value="element.dataSource?.path ?? ''"
<select :fields="schemaStore.arrayFields"
class="prop-input prop-select" placeholder="Sec..."
:value="element.dataSource?.path ?? ''" @update:model-value="updateDataSource"
@change="updateDataSource(($event.target as HTMLSelectElement).value)" />
> <PropFieldSelect
<option value="" disabled>Sec...</option> label="Kategori"
<option v-for="arr in arrayFields" :key="arr.path" :value="arr.path"> :model-value="element.categoryField"
{{ arr.title || arr.path }} :fields="itemFields"
</option> @update:model-value="(v) => update({ categoryField: v } as any)"
</select> />
</div> <PropFieldSelect
<div class="prop-row"> label="Deger"
<label class="prop-label">Kategori</label> :model-value="element.valueField"
<select :fields="numberFields"
class="prop-input prop-select" @update:model-value="(v) => update({ valueField: v } as any)"
:value="element.categoryField" />
@change="update({ categoryField: ($event.target as HTMLSelectElement).value })" <PropFieldSelect
> label="Gruplama"
<option v-for="f in itemFields" :key="f.key" :value="f.key"> :model-value="element.groupField ?? ''"
{{ f.title || f.key }} :fields="stringFields"
</option> :allow-empty="true"
</select> empty-label="Yok"
</div> @update:model-value="(v) => update({ groupField: v || undefined } as any)"
<div class="prop-row"> />
<label class="prop-label">Deger</label> <PropSelect
<select v-if="hasGroup && !isPie"
class="prop-input prop-select" label="Grup Modu"
:value="element.valueField" :model-value="element.groupMode ?? 'grouped'"
@change="update({ valueField: ($event.target as HTMLSelectElement).value })" :options="groupModeOptions"
> @update:model-value="(v) => update({ groupMode: v as GroupMode } as any)"
<option v-for="f in numberFields" :key="f.key" :value="f.key"> />
{{ f.title || f.key }} </PropSection>
</option>
</select>
</div>
<div class="prop-row">
<label class="prop-label">Gruplama</label>
<select
class="prop-input prop-select"
:value="element.groupField ?? ''"
@change="update({ groupField: ($event.target as HTMLSelectElement).value || undefined })"
>
<option value="">Yok</option>
<option v-for="f in stringFields" :key="f.key" :value="f.key">
{{ f.title || f.key }}
</option>
</select>
</div>
<div v-if="hasGroup && !isPie" class="prop-row">
<label class="prop-label">Grup Modu</label>
<select
class="prop-input prop-select"
:value="element.groupMode ?? 'grouped'"
@change="update({ groupMode: ($event.target as HTMLSelectElement).value as GroupMode })"
>
<option value="grouped">Yan Yana</option>
<option value="stacked">Yigin</option>
</select>
</div>
</div>
<!-- Baslik --> <!-- Baslik -->
<div class="prop-section"> <PropSection title="Baslik">
<div class="prop-section__title">Baslik</div>
<div class="prop-row"> <div class="prop-row">
<label class="prop-label">Metin</label> <label class="prop-label">Metin</label>
<input <input
class="prop-input" class="prop-input"
type="text" type="text"
:value="element.title?.text ?? ''" :value="element.title?.text ?? ''"
@change="updateTitle('text', ($event.target as HTMLInputElement).value)" @change="(e) => updateNested('title', 'text', (e.target as HTMLInputElement).value, { text: '' })"
placeholder="Grafik basligi" placeholder="Grafik basligi"
/> />
</div> </div>
<div class="prop-row" v-if="element.title?.text"> <template v-if="element.title?.text">
<label class="prop-label">Boyut</label> <PropNumberInput
<input label="Boyut"
class="prop-input prop-input--sm" :model-value="element.title?.fontSize ?? 4"
type="number" :step="0.5"
:value="element.title?.fontSize ?? 4" @update:model-value="(v) => updateNested('title', 'fontSize', v, { text: '' })"
step="0.5"
@change="updateTitle('fontSize', parseFloat(($event.target as HTMLInputElement).value))"
/> />
</div> <PropColorInput
<div class="prop-row" v-if="element.title?.text"> label="Renk"
<label class="prop-label">Renk</label> :model-value="element.title?.color ?? '#333333'"
<input @update:model-value="(v) => updateNested('title', 'color', v, { text: '' })"
class="prop-color"
type="color"
:value="element.title?.color ?? '#333333'"
@input="updateTitle('color', ($event.target as HTMLInputElement).value)"
/> />
</div> <PropSelect
<div class="prop-row" v-if="element.title?.text"> label="Hiza"
<label class="prop-label">Hiza</label> :model-value="element.title?.align ?? 'center'"
<select :options="alignOptions"
class="prop-input prop-select" @update:model-value="(v) => updateNested('title', 'align', v, { text: '' })"
:value="element.title?.align ?? 'center'" />
@change="updateTitle('align', ($event.target as HTMLSelectElement).value)" </template>
> </PropSection>
<option value="left">Sol</option>
<option value="center">Orta</option>
<option value="right">Sag</option>
</select>
</div>
</div>
<!-- Gosterge (Legend) --> <!-- Gosterge (Legend) -->
<div class="prop-section"> <PropSection title="Gosterge">
<div class="prop-section__title">Gosterge</div> <PropCheckbox
<div class="prop-row"> label="Goster"
<label class="prop-label">Goster</label> :model-value="element.legend?.show ?? false"
<input @update:model-value="(v) => updateNested('legend', 'show', v, { show: false })"
type="checkbox" />
:checked="element.legend?.show ?? false"
@change="updateLegend('show', ($event.target as HTMLInputElement).checked)"
/>
</div>
<template v-if="element.legend?.show"> <template v-if="element.legend?.show">
<div class="prop-row"> <PropSelect
<label class="prop-label">Konum</label> label="Konum"
<select :model-value="element.legend?.position ?? 'bottom'"
class="prop-input prop-select" :options="legendPositionOptions"
:value="element.legend?.position ?? 'bottom'" @update:model-value="(v) => updateNested('legend', 'position', v)"
@change="updateLegend('position', ($event.target as HTMLSelectElement).value)" />
> <PropNumberInput
<option value="top">Ust</option> label="Boyut"
<option value="bottom">Alt</option> :model-value="element.legend?.fontSize ?? 2.8"
<option value="right">Sag</option> :step="0.2"
</select> @update:model-value="(v) => updateNested('legend', 'fontSize', v)"
</div> />
<div class="prop-row">
<label class="prop-label">Boyut</label>
<input
class="prop-input prop-input--sm"
type="number"
:value="element.legend?.fontSize ?? 2.8"
step="0.2"
@change="
updateLegend('fontSize', parseFloat(($event.target as HTMLInputElement).value))
"
/>
</div>
</template> </template>
</div> </PropSection>
<!-- Etiketler --> <!-- Etiketler -->
<div class="prop-section"> <PropSection title="Etiketler">
<div class="prop-section__title">Etiketler</div> <PropCheckbox
<div class="prop-row"> label="Goster"
<label class="prop-label">Goster</label> :model-value="element.labels?.show ?? false"
<input @update:model-value="(v) => updateNested('labels', 'show', v, { show: false })"
type="checkbox" />
:checked="element.labels?.show ?? false"
@change="updateLabels('show', ($event.target as HTMLInputElement).checked)"
/>
</div>
<template v-if="element.labels?.show"> <template v-if="element.labels?.show">
<div class="prop-row"> <PropNumberInput
<label class="prop-label">Boyut</label> label="Boyut"
<input :model-value="element.labels?.fontSize ?? 2.2"
class="prop-input prop-input--sm" :step="0.2"
type="number" @update:model-value="(v) => updateNested('labels', 'fontSize', v)"
:value="element.labels?.fontSize ?? 2.2" />
step="0.2" <PropColorInput
@change=" label="Renk"
updateLabels('fontSize', parseFloat(($event.target as HTMLInputElement).value)) :model-value="element.labels?.color ?? '#333333'"
" @update:model-value="(v) => updateNested('labels', 'color', v)"
/> />
</div>
<div class="prop-row">
<label class="prop-label">Renk</label>
<input
class="prop-color"
type="color"
:value="element.labels?.color ?? '#333333'"
@input="updateLabels('color', ($event.target as HTMLInputElement).value)"
/>
</div>
</template> </template>
</div> </PropSection>
<!-- Eksenler (pie haric) --> <!-- Eksenler (pie haric) -->
<div class="prop-section" v-if="!isPie"> <PropSection v-if="!isPie" title="Eksenler">
<div class="prop-section__title">Eksenler</div>
<div class="prop-row"> <div class="prop-row">
<label class="prop-label">X Etiketi</label> <label class="prop-label">X Etiketi</label>
<input <input
class="prop-input" class="prop-input"
type="text" type="text"
:value="element.axis?.xLabel ?? ''" :value="element.axis?.xLabel ?? ''"
@change="updateAxis('xLabel', ($event.target as HTMLInputElement).value || undefined)" @change="(e) => updateNested('axis', 'xLabel', (e.target as HTMLInputElement).value || undefined, {})"
placeholder="X ekseni" placeholder="X ekseni"
/> />
</div> </div>
@@ -321,116 +232,103 @@ function removeColor(index: number) {
class="prop-input" class="prop-input"
type="text" type="text"
:value="element.axis?.yLabel ?? ''" :value="element.axis?.yLabel ?? ''"
@change="updateAxis('yLabel', ($event.target as HTMLInputElement).value || undefined)" @change="(e) => updateNested('axis', 'yLabel', (e.target as HTMLInputElement).value || undefined, {})"
placeholder="Y ekseni" placeholder="Y ekseni"
/> />
</div> </div>
<div class="prop-row"> <PropCheckbox
<label class="prop-label">Izgara</label> label="Izgara"
<input :model-value="element.axis?.showGrid ?? true"
type="checkbox" @update:model-value="(v) => updateNested('axis', 'showGrid', v, {})"
:checked="element.axis?.showGrid ?? true" />
@change="updateAxis('showGrid', ($event.target as HTMLInputElement).checked)" <PropColorInput
v-if="element.axis?.showGrid !== false"
label="Izgara Renk"
:model-value="element.axis?.gridColor ?? '#E5E7EB'"
@update:model-value="(v) => updateNested('axis', 'gridColor', v, {})"
/>
<template v-if="element.chartType === 'line'">
<PropCheckbox
label="Dikey Izgara"
:model-value="element.axis?.showVerticalGrid ?? true"
@update:model-value="(v) => updateNested('axis', 'showVerticalGrid', v, {})"
/> />
</div> <PropColorInput
<div class="prop-row" v-if="element.axis?.showGrid !== false"> v-if="element.axis?.showVerticalGrid !== false"
<label class="prop-label">Izgara Renk</label> label="Dikey Izgara Renk"
<input :model-value="element.axis?.verticalGridColor ?? '#E5E7EB'"
class="prop-color" @update:model-value="(v) => updateNested('axis', 'verticalGridColor', v, {})"
type="color"
:value="element.axis?.gridColor ?? '#E5E7EB'"
@input="updateAxis('gridColor', ($event.target as HTMLInputElement).value)"
/> />
</div> </template>
</div> </PropSection>
<!-- Stil --> <!-- Stil -->
<div class="prop-section"> <PropSection title="Stil">
<div class="prop-section__title">Stil</div> <PropColorInput
<div class="prop-row"> label="Arka Plan"
<label class="prop-label">Arka Plan</label> :model-value="element.style.backgroundColor ?? '#FFFFFF'"
<input @update:model-value="(v) => updateStyle('backgroundColor', v)"
class="prop-color" />
type="color"
:value="element.style.backgroundColor ?? '#FFFFFF'"
@input="updateStyle('backgroundColor', ($event.target as HTMLInputElement).value)"
/>
</div>
<!-- Renk Paleti -->
<div class="prop-section__subtitle">Renk Paleti</div> <div class="prop-section__subtitle">Renk Paleti</div>
<div v-for="(color, i) in colorList" :key="i" class="prop-row"> <div v-for="(color, i) in colorList" :key="i" class="prop-row">
<input <input
class="prop-color" class="prop-color"
type="color" type="color"
:value="color" :value="color"
@input="updateColor(i, ($event.target as HTMLInputElement).value)" @input="(e) => updateColor(i, (e.target as HTMLInputElement).value)"
/> />
<button class="prop-btn-sm prop-btn-sm--danger" @click="removeColor(i)" title="Kaldir"> <button class="prop-btn-sm prop-btn-sm--danger" @click="removeColor(i)" title="Kaldir">
× ×
</button> </button>
</div> </div>
<button class="prop-btn-sm" @click="addColor">+ Renk Ekle</button> <button class="prop-btn-sm" @click="addColor">+ Renk Ekle</button>
</div> </PropSection>
<!-- Tipe Ozel --> <!-- Tipe Ozel -->
<div class="prop-section" v-if="element.chartType === 'bar'"> <PropSection v-if="element.chartType === 'bar'" title="Bar Ayarlari">
<div class="prop-section__title">Bar Ayarlari</div> <PropNumberInput
<div class="prop-row"> label="Bar Boslugu"
<label class="prop-label">Bar Boslugu</label> :model-value="element.style.barGap ?? 0.2"
<input :step="0.05"
class="prop-input prop-input--sm" :min="0"
type="number" :max="0.8"
:value="element.style.barGap ?? 0.2" @update:model-value="(v) => updateStyle('barGap', v)"
step="0.05" />
min="0" </PropSection>
max="0.8"
@change="updateStyle('barGap', parseFloat(($event.target as HTMLInputElement).value))"
/>
</div>
</div>
<div class="prop-section" v-if="element.chartType === 'line'"> <PropSection v-if="element.chartType === 'line'" title="Line Ayarlari">
<div class="prop-section__title">Line Ayarlari</div> <PropNumberInput
<div class="prop-row"> label="Cizgi Kalinligi"
<label class="prop-label">Cizgi Kalinligi</label> :model-value="element.style.lineWidth ?? 0.5"
<input :step="0.1"
class="prop-input prop-input--sm" :min="0.1"
type="number" @update:model-value="(v) => updateStyle('lineWidth', v)"
:value="element.style.lineWidth ?? 0.5" />
step="0.1" <PropSelect
min="0.1" label="Egri Tipi"
@change="updateStyle('lineWidth', parseFloat(($event.target as HTMLInputElement).value))" :model-value="element.style.curveType ?? 'linear'"
/> :options="[{ value: 'linear', label: 'Duz' }, { value: 'smooth', label: 'Yumusak' }]"
</div> @update:model-value="(v) => updateStyle('curveType', v)"
<div class="prop-row"> />
<label class="prop-label">Noktalar</label> <PropCheckbox
<input label="Noktalar"
type="checkbox" :model-value="element.style.showPoints ?? true"
:checked="element.style.showPoints ?? true" @update:model-value="(v) => updateStyle('showPoints', v)"
@change="updateStyle('showPoints', ($event.target as HTMLInputElement).checked)" />
/> </PropSection>
</div>
</div>
<div class="prop-section" v-if="element.chartType === 'pie'"> <PropSection v-if="element.chartType === 'pie'" title="Pie Ayarlari">
<div class="prop-section__title">Pie Ayarlari</div> <PropNumberInput
<div class="prop-row"> label="Ic Yaricap"
<label class="prop-label">Ic Yaricap</label> :model-value="element.style.innerRadius ?? 0"
<input :step="0.05"
class="prop-input prop-input--sm" :min="0"
type="number" :max="0.9"
:value="element.style.innerRadius ?? 0" @update:model-value="(v) => updateStyle('innerRadius', v)"
step="0.05" />
min="0"
max="0.9"
@change="
updateStyle('innerRadius', parseFloat(($event.target as HTMLInputElement).value))
"
/>
</div>
<div class="prop-row" style="font-size: 11px; color: #94a3b8">0 = Pie, &gt;0 = Donut</div> <div class="prop-row" style="font-size: 11px; color: #94a3b8">0 = Pie, &gt;0 = Donut</div>
</div> </PropSection>
</div> </div>
</template> </template>

View File

@@ -1,63 +1,75 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateStore } from '../../stores/template' import { computed } from 'vue'
import { useEditorStore } from '../../stores/editor' import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import type { CheckboxElement, TemplateElement } from '../../core/types' import { useSchemaStore } from '../../stores/schema'
import PropSection from './shared/PropSection.vue'
import PropNumberInput from './shared/PropNumberInput.vue'
import PropColorInput from './shared/PropColorInput.vue'
import PropCheckbox from './shared/PropCheckbox.vue'
import PropFieldSelect from './shared/PropFieldSelect.vue'
import type { CheckboxElement } from '../../core/types'
import '../../styles/properties.css' import '../../styles/properties.css'
const props = defineProps<{ element: CheckboxElement }>() const props = defineProps<{ element: CheckboxElement }>()
const templateStore = useTemplateStore() const { update, updateStyle } = usePropertyUpdate(() => props.element)
const editorStore = useEditorStore() const schemaStore = useSchemaStore()
function update(updates: Partial<TemplateElement>) { const booleanFields = computed(() =>
const id = editorStore.selectedElementId schemaStore.scalarFields.filter((f) => f.type === 'boolean' || f.type === 'string'),
if (!id) return )
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
</script> </script>
<template> <template>
<div class="prop-section"> <PropSection title="Onay Kutusu">
<div class="prop-section__title">Onay Kutusu</div> <PropFieldSelect
<div v-if="!element.binding" class="prop-row" data-tip="Onay kutusunun varsayilan durumu"> label="Veri Alani"
<label class="prop-label">Isaretli</label> :model-value="element.binding?.path ?? ''"
<input :fields="booleanFields"
type="checkbox" :allow-empty="true"
:checked="element.checked ?? false" empty-label="Yok (statik)"
@change="(e) => update({ checked: (e.target as HTMLInputElement).checked } as any)" data-tip="Onay durumunun gelecegi veri alani"
/> @update:model-value="
</div> (v) =>
<div class="prop-row" data-tip="Onay kutusu boyutu (mm)"> update({
<label class="prop-label">Boyut (mm)</label> binding: v ? { type: 'scalar', path: v } : undefined,
<input checked: v ? undefined : element.checked ?? false,
class="prop-input" } as any)
type="number" "
step="0.5" />
min="1" <PropCheckbox
:value="element.style.size ?? 4" v-if="!element.binding"
@input="(e) => updateStyle('size', parseFloat((e.target as HTMLInputElement).value) || 4)" label="Isaretli"
/> :model-value="element.checked ?? false"
</div> data-tip="Onay kutusunun varsayilan durumu"
<div class="prop-row" data-tip="Isaret (tik) rengi"> @update:model-value="(v) => update({ checked: v } as any)"
<label class="prop-label">Isaret Rengi</label> />
<input <PropNumberInput
class="prop-input prop-color" label="Boyut (mm)"
type="color" :model-value="element.style.size ?? 4"
:value="element.style.checkColor ?? '#000000'" :step="0.5"
@input="(e) => updateStyle('checkColor', (e.target as HTMLInputElement).value)" :min="1"
/> data-tip="Onay kutusu boyutu (mm)"
</div> @update:model-value="(v) => updateStyle('size', v)"
<div class="prop-row" data-tip="Kutu kenarlik rengi"> />
<label class="prop-label">Kenar Rengi</label> <PropColorInput
<input label="Isaret Rengi"
class="prop-input prop-color" :model-value="element.style.checkColor ?? '#000000'"
type="color" data-tip="Isaret (tik) rengi"
:value="element.style.borderColor ?? '#333333'" @update:model-value="(v) => updateStyle('checkColor', v)"
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)" />
/> <PropColorInput
</div> label="Kenar Rengi"
</div> :model-value="element.style.borderColor ?? '#333333'"
data-tip="Kutu kenarlik rengi"
@update:model-value="(v) => updateStyle('borderColor', v)"
/>
<PropNumberInput
label="Kenar Kalinligi"
:model-value="element.style.borderWidth ?? 0.3"
:step="0.1"
:min="0"
data-tip="Kutu kenarlik kalinligi (mm)"
@update:model-value="(v) => updateStyle('borderWidth', v)"
/>
</PropSection>
</template> </template>

View File

@@ -1,52 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateStore } from '../../stores/template' import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useEditorStore } from '../../stores/editor' import PropSection from './shared/PropSection.vue'
import PropSelect from './shared/PropSelect.vue'
import PropNumberInput from './shared/PropNumberInput.vue'
import PropColorInput from './shared/PropColorInput.vue'
import PaddingBox from './PaddingBox.vue' import PaddingBox from './PaddingBox.vue'
import type { ContainerElement, TemplateElement } from '../../core/types' import type { ContainerElement } from '../../core/types'
import '../../styles/properties.css' import '../../styles/properties.css'
const props = defineProps<{ element: ContainerElement }>() const props = defineProps<{ element: ContainerElement }>()
const templateStore = useTemplateStore() const { update, updateStyle } = usePropertyUpdate(() => props.element)
const editorStore = useEditorStore()
function update(updates: Partial<TemplateElement>) { const directionOptions = [
const id = editorStore.selectedElementId { value: 'column', label: 'Dikey' },
if (!id) return { value: 'row', label: 'Yatay' },
templateStore.updateElement(id, updates) ]
}
function updateStyle(key: string, value: unknown) { const breakOptions = [
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>) { value: 'auto', label: 'Izin Ver' },
} { value: 'avoid', label: 'Bolme' },
]
const borderStyleOptions = [
{ value: 'solid', label: 'Duz' },
{ value: 'dashed', label: 'Kesikli' },
{ value: 'dotted', label: 'Noktali' },
]
</script> </script>
<template> <template>
<div class="prop-section"> <PropSection title="Container Ayarlari">
<div class="prop-section__title">Container Ayarlari</div> <PropSelect
<div class="prop-row" data-tip="Cocuk elemanlarin dizilim yonu"> label="Yon"
<label class="prop-label">Yon</label> :model-value="element.direction"
<select :options="directionOptions"
class="prop-input prop-select" data-tip="Cocuk elemanlarin dizilim yonu"
:value="element.direction" @update:model-value="(v) => update({ direction: v } as any)"
@change="(e) => update({ direction: (e.target as HTMLSelectElement).value } as any)" />
> <PropNumberInput
<option value="column">Dikey</option> label="Bosluk (mm)"
<option value="row">Yatay</option> :model-value="element.gap"
</select> :step="1"
</div> :min="0"
<div class="prop-row" data-tip="Cocuk elemanlar arasi bosluk (mm)"> data-tip="Cocuk elemanlar arasi bosluk (mm)"
<label class="prop-label">Bosluk (mm)</label> @update:model-value="(v) => update({ gap: v } as any)"
<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" data-tip="Cocuklarin cross-axis hizalamasi"> <div class="prop-row" data-tip="Cocuklarin cross-axis hizalamasi">
<label class="prop-label">{{ <label class="prop-label">{{
element.direction === 'column' ? 'Yatay Hizalama' : 'Dikey Hizalama' element.direction === 'column' ? 'Yatay Hizalama' : 'Dikey Hizalama'
@@ -87,92 +86,53 @@ function updateStyle(key: string, value: unknown) {
@update="(side, value) => update({ padding: { ...element.padding, [side]: value } } as any)" @update="(side, value) => update({ padding: { ...element.padding, [side]: value } } as any)"
/> />
<div class="prop-row" data-tip="Sayfa sonunda bolunmeyi kontrol eder"> <PropSelect
<label class="prop-label">Sayfa Bolme</label> label="Sayfa Bolme"
<select :model-value="element.breakInside ?? 'auto'"
class="prop-input prop-select" :options="breakOptions"
:value="element.breakInside ?? 'auto'" data-tip="Sayfa sonunda bolunmeyi kontrol eder"
@change="(e) => update({ breakInside: (e.target as HTMLSelectElement).value } as any)" @update:model-value="(v) => update({ breakInside: v } as any)"
> />
<option value="auto">Izin Ver</option> </PropSection>
<option value="avoid">Bolme</option>
</select>
</div>
<div class="prop-section__subtitle">Stil</div> <PropSection title="Stil">
<div class="prop-row" data-tip="Container arka plan rengi"> <PropColorInput
<label class="prop-label">Arka plan</label> label="Arka plan"
<div class="prop-row-inline"> :model-value="element.style.backgroundColor"
<input default-color="#ffffff"
class="prop-input prop-color" :clearable="true"
type="color" data-tip="Container arka plan rengi"
:value="element.style.backgroundColor ?? '#ffffff'" @update:model-value="(v) => updateStyle('backgroundColor', v)"
@input="(e) => updateStyle('backgroundColor', (e.target as HTMLInputElement).value)" />
/> <PropNumberInput
<button label="Kenarlik (mm)"
v-if="element.style.backgroundColor" :model-value="element.style.borderWidth ?? 0"
class="prop-clear" :step="0.1"
@click="updateStyle('backgroundColor', undefined)" :min="0"
> data-tip="Kenarlik kalinligi (mm)"
x @update:model-value="(v) => updateStyle('borderWidth', v)"
</button> />
</div> <PropColorInput
</div> label="Kenarlik rengi"
<div class="prop-row" data-tip="Kenarlik kalinligi (mm)"> :model-value="element.style.borderColor"
<label class="prop-label">Kenarlik (mm)</label> :clearable="true"
<input data-tip="Kenarlik cizgisi rengi"
class="prop-input" @update:model-value="(v) => updateStyle('borderColor', v)"
type="number" />
step="0.1" <PropSelect
min="0" label="Kenarlik stili"
:value="element.style.borderWidth ?? 0" :model-value="element.style.borderStyle ?? 'solid'"
@input=" :options="borderStyleOptions"
(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0) data-tip="Kenarlik cizgi stili"
" @update:model-value="(v) => updateStyle('borderStyle', v)"
/> />
</div> <PropNumberInput
<div class="prop-row" data-tip="Kenarlik cizgisi rengi"> label="Radius (mm)"
<label class="prop-label">Kenarlik rengi</label> :model-value="element.style.borderRadius ?? 0"
<div class="prop-row-inline"> :step="0.5"
<input :min="0"
class="prop-input prop-color" data-tip="Kose yuvarlakligi (mm)"
type="color" @update:model-value="(v) => updateStyle('borderRadius', v)"
:value="element.style.borderColor ?? '#000000'" />
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)" </PropSection>
/>
<button
v-if="element.style.borderColor"
class="prop-clear"
@click="updateStyle('borderColor', undefined)"
>
x
</button>
</div>
</div>
<div class="prop-row" data-tip="Kenarlik cizgi stili">
<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" data-tip="Kose yuvarlakligi (mm)">
<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> </template>

View File

@@ -1,73 +1,43 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateStore } from '../../stores/template' import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useEditorStore } from '../../stores/editor' import PropSection from './shared/PropSection.vue'
import type { CurrentDateElement, TextStyle, TemplateElement } from '../../core/types' import PropSelect from './shared/PropSelect.vue'
import PropTextStyleGroup from './shared/PropTextStyleGroup.vue'
import type { CurrentDateElement, TextStyle } from '../../core/types'
import '../../styles/properties.css' import '../../styles/properties.css'
const props = defineProps<{ element: CurrentDateElement }>() const props = defineProps<{ element: CurrentDateElement }>()
const templateStore = useTemplateStore() const { update, updateStyle } = usePropertyUpdate(() => props.element)
const editorStore = useEditorStore() const style = () => props.element.style as TextStyle
function update(updates: Partial<TemplateElement>) { const formatOptions = [
const id = editorStore.selectedElementId { value: 'DD.MM.YYYY', label: '30.03.2026' },
if (!id) return { value: 'DD/MM/YYYY', label: '30/03/2026' },
templateStore.updateElement(id, updates) { value: 'YYYY-MM-DD', label: '2026-03-30' },
} { value: 'DD.MM.YYYY HH:mm', label: '30.03.2026 14:30' },
]
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
</script> </script>
<template> <template>
<div class="prop-section"> <PropSection title="Tarih">
<div class="prop-section__title">Tarih</div> <PropSelect
<div class="prop-row" data-tip="Tarih gosterim formati"> label="Format"
<label class="prop-label">Format</label> :model-value="element.format ?? 'DD.MM.YYYY'"
<select :options="formatOptions"
class="prop-input prop-select" data-tip="Tarih gosterim formati"
:value="element.format ?? 'DD.MM.YYYY'" @update:model-value="(v) => update({ format: v } as any)"
@change="(e) => update({ format: (e.target as HTMLSelectElement).value } as any)" />
> <PropTextStyleGroup
<option value="DD.MM.YYYY">30.03.2026</option> :font-size="style().fontSize ?? 10"
<option value="DD/MM/YYYY">30/03/2026</option> :font-weight="style().fontWeight ?? 'normal'"
<option value="YYYY-MM-DD">2026-03-30</option> :font-family="style().fontFamily"
<option value="DD.MM.YYYY HH:mm">30.03.2026 14:30</option> :color="style().color ?? '#666666'"
</select> :align="style().align ?? 'left'"
</div> @update:font-size="(v) => updateStyle('fontSize', v)"
<div class="prop-row" data-tip="Yazi tipi boyutu (point)"> @update:font-weight="(v) => updateStyle('fontWeight', v)"
<label class="prop-label">Boyut (pt)</label> @update:font-family="(v) => updateStyle('fontFamily', v)"
<input @update:color="(v) => updateStyle('color', v)"
class="prop-input" @update:align="(v) => updateStyle('align', v)"
type="number" />
step="1" </PropSection>
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" data-tip="Metin rengi">
<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" data-tip="Metnin yatay hizalamasi">
<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> </template>

View File

@@ -1,28 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useTemplateStore } from '../../stores/template' import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useEditorStore } from '../../stores/editor'
import { useSchemaStore } from '../../stores/schema' import { useSchemaStore } from '../../stores/schema'
import type { ImageElement, TemplateElement } from '../../core/types' import PropSection from './shared/PropSection.vue'
import PropSelect from './shared/PropSelect.vue'
import PropFieldSelect from './shared/PropFieldSelect.vue'
import type { ImageElement } from '../../core/types'
import '../../styles/properties.css' import '../../styles/properties.css'
const props = defineProps<{ element: ImageElement }>() const props = defineProps<{ element: ImageElement }>()
const templateStore = useTemplateStore() const { update, updateStyle } = usePropertyUpdate(() => props.element)
const editorStore = useEditorStore()
const schemaStore = useSchemaStore() const schemaStore = useSchemaStore()
/** Statik mi dinamik mi? */
const isDynamic = computed(() => !!props.element.binding) const isDynamic = computed(() => !!props.element.binding)
function update(updates: Partial<TemplateElement>) { const imageScalarFields = computed(() =>
const id = editorStore.selectedElementId schemaStore.scalarFields.filter((f) => f.format === 'image' || f.type === 'string'),
if (!id) return )
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) { const fitOptions = [
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>) { value: 'contain', label: 'Sigdir' },
} { value: 'cover', label: 'Kap' },
{ value: 'stretch', label: 'Esnet' },
]
function onImageFileSelect(e: Event) { function onImageFileSelect(e: Event) {
const input = e.target as HTMLInputElement const input = e.target as HTMLInputElement
@@ -30,40 +30,24 @@ function onImageFileSelect(e: Event) {
if (!file) return if (!file) return
const reader = new FileReader() const reader = new FileReader()
reader.onload = () => { reader.onload = () => {
update({ src: reader.result as string, binding: undefined } as Partial<TemplateElement>) update({ src: reader.result as string, binding: undefined } as any)
} }
reader.readAsDataURL(file) reader.readAsDataURL(file)
} }
function setMode(mode: 'static' | 'dynamic') { function setMode(mode: 'static' | 'dynamic') {
if (mode === 'static') { if (mode === 'static') {
update({ binding: undefined } as Partial<TemplateElement>) update({ binding: undefined } as any)
} else { } else {
// Dinamik moda geç — ilk uygun alanı seç veya boş bırak const path = imageScalarFields.value.length > 0 ? imageScalarFields.value[0].path : ''
const imageFields = schemaStore.scalarFields.filter( update({ src: undefined, binding: { type: 'scalar', path } } as any)
(f) => f.format === 'image' || f.type === 'string',
)
const path = imageFields.length > 0 ? imageFields[0].path : ''
update({ src: undefined, binding: { type: 'scalar', path } } as Partial<TemplateElement>)
} }
} }
function setBindingPath(path: string) {
update({ binding: { type: 'scalar', path } } as Partial<TemplateElement>)
}
/** Schema'dan görsel olabilecek alanlar (format: image veya string) */
const imageScalarFields = computed(() => {
return schemaStore.scalarFields.filter((f) => f.format === 'image' || f.type === 'string')
})
</script> </script>
<template> <template>
<div class="prop-section"> <PropSection title="Gorsel">
<div class="prop-section__title">Gorsel</div> <div class="prop-row" data-tip="Gorsel kaynagi: dosya veya veri alanindan">
<!-- Statik / Dinamik toggle -->
<div class="prop-row" data-tip="Gorsel kaynagi: dosya veya veri alanından">
<label class="prop-label">Mod</label> <label class="prop-label">Mod</label>
<div class="prop-toggle-group"> <div class="prop-toggle-group">
<button <button
@@ -83,7 +67,6 @@ const imageScalarFields = computed(() => {
</div> </div>
</div> </div>
<!-- Statik: dosya seçimi -->
<template v-if="!isDynamic"> <template v-if="!isDynamic">
<div class="prop-row" data-tip="Gorsel dosyasi secin (PNG, JPG, SVG)"> <div class="prop-row" data-tip="Gorsel dosyasi secin (PNG, JPG, SVG)">
<label class="prop-label">Kaynak</label> <label class="prop-label">Kaynak</label>
@@ -104,41 +87,28 @@ const imageScalarFields = computed(() => {
</div> </div>
</template> </template>
<!-- Dinamik: schema alan seçimi -->
<template v-else> <template v-else>
<div class="prop-row" data-tip="Gorsel URL'sinin gelecegi veri alani"> <PropFieldSelect
<label class="prop-label">Veri Alani</label> label="Veri Alani"
<select :model-value="element.binding?.path ?? ''"
class="prop-input prop-select" :fields="imageScalarFields"
:value="element.binding?.path ?? ''" data-tip="Gorsel URL'sinin gelecegi veri alani"
@change="(e) => setBindingPath((e.target as HTMLSelectElement).value)" @update:model-value="(v) => update({ binding: { type: 'scalar', path: v } } as any)"
> />
<option value="" disabled>Secin...</option>
<option v-for="field in imageScalarFields" :key="field.path" :value="field.path">
{{ field.title }} ({{ field.path }})
</option>
</select>
</div>
<div v-if="element.binding?.path" class="prop-row"> <div v-if="element.binding?.path" class="prop-row">
<label class="prop-label">Path</label> <label class="prop-label">Path</label>
<span class="prop-info">{{ element.binding.path }}</span> <span class="prop-info">{{ element.binding.path }}</span>
</div> </div>
</template> </template>
<!-- Sığdırma modu (ortak) --> <PropSelect
<div class="prop-row" data-tip="Gorselin alana sigdirma modu"> label="Sigdirma"
<label class="prop-label">Sigdirma</label> :model-value="element.style.objectFit ?? 'contain'"
<select :options="fitOptions"
class="prop-input prop-select" data-tip="Gorselin alana sigdirma modu"
:value="element.style.objectFit ?? 'contain'" @update:model-value="(v) => updateStyle('objectFit', v)"
@change="(e) => updateStyle('objectFit', (e.target as HTMLSelectElement).value)" />
> </PropSection>
<option value="contain">Sigdir</option>
<option value="cover">Kap</option>
<option value="stretch">Esnet</option>
</select>
</div>
</div>
</template> </template>
<style scoped> <style scoped>

View File

@@ -1,46 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateStore } from '../../stores/template' import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useEditorStore } from '../../stores/editor' import PropSection from './shared/PropSection.vue'
import type { LineElement, TemplateElement } from '../../core/types' import PropNumberInput from './shared/PropNumberInput.vue'
import PropColorInput from './shared/PropColorInput.vue'
import type { LineElement } from '../../core/types'
import '../../styles/properties.css' import '../../styles/properties.css'
const props = defineProps<{ element: LineElement }>() const props = defineProps<{ element: LineElement }>()
const templateStore = useTemplateStore() const { updateStyle } = usePropertyUpdate(() => props.element)
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> </script>
<template> <template>
<div class="prop-section"> <PropSection title="Cizgi Stili">
<div class="prop-section__title">Cizgi Stili</div> <PropNumberInput
<div class="prop-row" data-tip="Cizgi kalinligi (mm)"> label="Kalinlik (mm)"
<label class="prop-label">Kalinlik (mm)</label> :model-value="element.style.strokeWidth ?? 0.5"
<input :step="0.1"
class="prop-input" :min="0.1"
type="number" data-tip="Cizgi kalinligi (mm)"
step="0.1" @update:model-value="(v) => updateStyle('strokeWidth', v)"
min="0.1" />
:value="element.style.strokeWidth ?? 0.5" <PropColorInput
@input=" label="Renk"
(e) => updateStyle('strokeWidth', parseFloat((e.target as HTMLInputElement).value) || 0.5) :model-value="element.style.strokeColor ?? '#000000'"
" data-tip="Cizgi rengi"
/> @update:model-value="(v) => updateStyle('strokeColor', v)"
</div> />
<div class="prop-row" data-tip="Cizgi rengi"> </PropSection>
<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> </template>

View File

@@ -1,73 +1,43 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateStore } from '../../stores/template' import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useEditorStore } from '../../stores/editor' import PropSection from './shared/PropSection.vue'
import type { PageNumberElement, TextStyle, TemplateElement } from '../../core/types' import PropSelect from './shared/PropSelect.vue'
import PropTextStyleGroup from './shared/PropTextStyleGroup.vue'
import type { PageNumberElement, TextStyle } from '../../core/types'
import '../../styles/properties.css' import '../../styles/properties.css'
const props = defineProps<{ element: PageNumberElement }>() const props = defineProps<{ element: PageNumberElement }>()
const templateStore = useTemplateStore() const { update, updateStyle } = usePropertyUpdate(() => props.element)
const editorStore = useEditorStore() const style = () => props.element.style as TextStyle
function update(updates: Partial<TemplateElement>) { const formatOptions = [
const id = editorStore.selectedElementId { value: '{current} / {total}', label: '1 / 5' },
if (!id) return { value: '{current}', label: '1' },
templateStore.updateElement(id, updates) { value: 'Sayfa {current}', label: 'Sayfa 1' },
} { value: 'Sayfa {current} / {total}', label: 'Sayfa 1 / 5' },
]
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
</script> </script>
<template> <template>
<div class="prop-section"> <PropSection title="Sayfa Numarasi">
<div class="prop-section__title">Sayfa Numarasi</div> <PropSelect
<div class="prop-row" data-tip="Sayfa numarasi gosterim formati"> label="Format"
<label class="prop-label">Format</label> :model-value="element.format ?? '{current} / {total}'"
<select :options="formatOptions"
class="prop-input prop-select" data-tip="Sayfa numarasi gosterim formati"
:value="element.format ?? '{current} / {total}'" @update:model-value="(v) => update({ format: v } as any)"
@change="(e) => update({ format: (e.target as HTMLSelectElement).value } as any)" />
> <PropTextStyleGroup
<option value="{current} / {total}">1 / 5</option> :font-size="style().fontSize ?? 10"
<option value="{current}">1</option> :font-weight="style().fontWeight ?? 'normal'"
<option value="Sayfa {current}">Sayfa 1</option> :font-family="style().fontFamily"
<option value="Sayfa {current} / {total}">Sayfa 1 / 5</option> :color="style().color ?? '#666666'"
</select> :align="style().align ?? 'center'"
</div> @update:font-size="(v) => updateStyle('fontSize', v)"
<div class="prop-row" data-tip="Yazi tipi boyutu (point)"> @update:font-weight="(v) => updateStyle('fontWeight', v)"
<label class="prop-label">Boyut (pt)</label> @update:font-family="(v) => updateStyle('fontFamily', v)"
<input @update:color="(v) => updateStyle('color', v)"
class="prop-input" @update:align="(v) => updateStyle('align', v)"
type="number" />
step="1" </PropSection>
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" data-tip="Metin rengi">
<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" data-tip="Metnin yatay hizalamasi">
<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> </template>

View File

@@ -1,13 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateStore } from '../../stores/template' import { useTemplateStore } from '../../stores/template'
import PropSection from './shared/PropSection.vue'
import PropSelect from './shared/PropSelect.vue'
import PropNumberInput from './shared/PropNumberInput.vue'
import type { TemplateElement } from '../../core/types' import type { TemplateElement } from '../../core/types'
import '../../styles/properties.css' import '../../styles/properties.css'
const props = defineProps<{ element: TemplateElement }>() const props = defineProps<{ element: TemplateElement }>()
const templateStore = useTemplateStore() const templateStore = useTemplateStore()
function togglePositioning() { const positionOptions = [
if (props.element.position.type === 'flow') { { value: 'flow', label: 'Flow' },
{ value: 'absolute', label: 'Absolute' },
]
function togglePositioning(value: string) {
if (value === 'absolute') {
templateStore.updateElementPosition(props.element.id, { type: 'absolute', x: 0, y: 0 }) templateStore.updateElementPosition(props.element.id, { type: 'absolute', x: 0, y: 0 })
} else { } else {
templateStore.updateElementPosition(props.element.id, { type: 'flow' }) templateStore.updateElementPosition(props.element.id, { type: 'flow' })
@@ -16,54 +24,43 @@ function togglePositioning() {
</script> </script>
<template> <template>
<div class="prop-section"> <PropSection title="Pozisyon">
<div class="prop-section__title">Pozisyon</div> <PropSelect
<div class="prop-row" data-tip="Flow: otomatik dizilim, Absolute: sabit konum"> label="Mod"
<label class="prop-label">Mod</label> :model-value="element.position.type"
<select :options="positionOptions"
class="prop-input prop-select" data-tip="Flow: otomatik dizilim, Absolute: sabit konum"
:value="element.position.type" @update:model-value="togglePositioning"
@change="togglePositioning" />
>
<option value="flow">Flow</option>
<option value="absolute">Absolute</option>
</select>
</div>
<template v-if="element.position.type === 'absolute'"> <template v-if="element.position.type === 'absolute'">
<div class="prop-row" data-tip="Yatay pozisyon parent sol kenardan uzaklik (mm)"> <PropNumberInput
<label class="prop-label">X (mm)</label> label="X (mm)"
<input :model-value="(element.position as any).x ?? 0"
class="prop-input" :step="0.5"
type="number" data-tip="Yatay pozisyon parent sol kenardan uzaklik (mm)"
step="0.5" @update:model-value="
:value="element.position.x" (v) =>
@input=" templateStore.updateElementPosition(element.id, {
(e) => type: 'absolute',
templateStore.updateElementPosition(element.id, { x: v,
type: 'absolute', y: (element.position as any).y ?? 0,
x: parseFloat((e.target as HTMLInputElement).value) || 0, })
y: (element.position as any).y ?? 0, "
}) />
" <PropNumberInput
/> label="Y (mm)"
</div> :model-value="(element.position as any).y ?? 0"
<div class="prop-row" data-tip="Dikey pozisyon parent ust kenardan uzaklik (mm)"> :step="0.5"
<label class="prop-label">Y (mm)</label> data-tip="Dikey pozisyon parent ust kenardan uzaklik (mm)"
<input @update:model-value="
class="prop-input" (v) =>
type="number" templateStore.updateElementPosition(element.id, {
step="0.5" type: 'absolute',
:value="element.position.y" x: (element.position as any).x ?? 0,
@input=" y: v,
(e) => })
templateStore.updateElementPosition(element.id, { "
type: 'absolute', />
x: (element.position as any).x ?? 0,
y: parseFloat((e.target as HTMLInputElement).value) || 0,
})
"
/>
</div>
</template> </template>
</div> </PropSection>
</template> </template>

View File

@@ -1,29 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useTemplateStore } from '../../stores/template' import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useEditorStore } from '../../stores/editor'
import { useSchemaStore } from '../../stores/schema' import { useSchemaStore } from '../../stores/schema'
import { sz } from '../../core/types' import { sz } from '../../core/types'
import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser' import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser'
import type { import PropSection from './shared/PropSection.vue'
RepeatingTableElement, import PropFieldSelect from './shared/PropFieldSelect.vue'
TableColumn, import TableColumnEditor from './table/TableColumnEditor.vue'
FormatType, import TableStyleEditor from './table/TableStyleEditor.vue'
TemplateElement, import type { RepeatingTableElement, TableColumn, TableStyle } from '../../core/types'
} from '../../core/types'
import '../../styles/properties.css' import '../../styles/properties.css'
const props = defineProps<{ element: RepeatingTableElement }>() const props = defineProps<{ element: RepeatingTableElement }>()
const templateStore = useTemplateStore() const { update } = usePropertyUpdate(() => props.element)
const editorStore = useEditorStore()
const schemaStore = useSchemaStore() const schemaStore = useSchemaStore()
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
let colIdCounter = Date.now() let colIdCounter = Date.now()
function nextColId() { function nextColId() {
return `col_${(++colIdCounter).toString(36)}` return `col_${(++colIdCounter).toString(36)}`
@@ -40,24 +31,21 @@ function updateTableDataSource(path: string) {
align: defaultAlignForSchema(field), align: defaultAlignForSchema(field),
format: schemaFormatToFormatType(field.format), format: schemaFormatToFormatType(field.format),
})) }))
update({ update({ dataSource: { type: 'array', path }, columns } as any)
dataSource: { type: 'array', path },
columns,
} as Partial<TemplateElement>)
} else { } else {
update({ dataSource: { type: 'array', path } } as Partial<TemplateElement>) update({ dataSource: { type: 'array', path } } as any)
} }
} }
function updateTableStyle(key: string, value: unknown) { function updateTableStyle(key: string, value: unknown) {
const newStyle = { ...props.element.style, [key]: value } const newStyle = { ...props.element.style, [key]: value }
if (value === undefined || value === '') delete (newStyle as Record<string, unknown>)[key] if (value === undefined || value === '') delete (newStyle as Record<string, unknown>)[key]
update({ style: newStyle } as Partial<TemplateElement>) update({ style: newStyle } as any)
} }
function updateColumn(colId: string, updates: Partial<TableColumn>) { function updateColumn(colId: string, updates: Partial<TableColumn>) {
const columns = props.element.columns.map((c) => (c.id === colId ? { ...c, ...updates } : c)) const columns = props.element.columns.map((c) => (c.id === colId ? { ...c, ...updates } : c))
update({ columns } as Partial<TemplateElement>) update({ columns } as any)
} }
function addColumn() { function addColumn() {
@@ -68,13 +56,11 @@ function addColumn() {
width: sz.auto(), width: sz.auto(),
align: 'left', align: 'left',
} }
update({ columns: [...props.element.columns, newCol] } as Partial<TemplateElement>) update({ columns: [...props.element.columns, newCol] } as any)
} }
function removeColumn(colId: string) { function removeColumn(colId: string) {
update({ update({ columns: props.element.columns.filter((c) => c.id !== colId) } as any)
columns: props.element.columns.filter((c) => c.id !== colId),
} as Partial<TemplateElement>)
} }
function moveColumn(colId: string, direction: -1 | 1) { function moveColumn(colId: string, direction: -1 | 1) {
@@ -83,7 +69,7 @@ function moveColumn(colId: string, direction: -1 | 1) {
const newIdx = idx + direction const newIdx = idx + direction
if (newIdx < 0 || newIdx >= cols.length) return if (newIdx < 0 || newIdx >= cols.length) return
;[cols[idx], cols[newIdx]] = [cols[newIdx], cols[idx]] ;[cols[idx], cols[newIdx]] = [cols[newIdx], cols[idx]]
update({ columns: cols } as Partial<TemplateElement>) update({ columns: cols } as any)
} }
const tableItemFields = computed(() => { const tableItemFields = computed(() => {
@@ -93,864 +79,39 @@ const tableItemFields = computed(() => {
<template> <template>
<!-- Data source --> <!-- Data source -->
<div class="prop-section"> <PropSection title="Veri Kaynagi">
<div class="prop-section__title">Veri Kaynagi</div> <PropFieldSelect
<div class="prop-row" data-tip="Tablonun baglanacagi array veri kaynagi"> label="Kaynak"
<label class="prop-label">Kaynak</label> :model-value="element.dataSource.path"
<select :fields="schemaStore.arrayFields"
class="prop-input prop-select" data-tip="Tablonun baglanacagi array veri kaynagi"
:value="element.dataSource.path" @update:model-value="updateTableDataSource"
@change="(e) => updateTableDataSource((e.target as HTMLSelectElement).value)" />
> </PropSection>
<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 --> <!-- Columns -->
<div class="prop-section"> <PropSection title="Sutunlar">
<div class="prop-section__title"> <template #actions>
Sutunlar
<button class="prop-add-btn" @click="addColumn">+</button> <button class="prop-add-btn" @click="addColumn">+</button>
</div> </template>
<div v-for="col in element.columns" :key="col.id" class="tbl-col"> <TableColumnEditor
<!-- Row 1: title + actions --> v-for="col in element.columns"
<div class="tbl-col__head"> :key="col.id"
<input :column="col"
class="tbl-col__title" :item-fields="tableItemFields"
type="text" @update="updateColumn"
:value="col.title" @remove="removeColumn"
@change="(e) => updateColumn(col.id, { title: (e.target as HTMLInputElement).value })" @move="moveColumn"
:placeholder="col.field" />
data-tip="Sutun basligi" </PropSection>
/>
<div class="tbl-col__actions">
<button class="tbl-col__act" @click="moveColumn(col.id, -1)" data-tip="Yukari tasi">
<svg width="10" height="10" viewBox="0 0 10 10">
<path d="M5 2L2 6h6L5 2z" fill="currentColor" />
</svg>
</button>
<button class="tbl-col__act" @click="moveColumn(col.id, 1)" data-tip="Asagi tasi">
<svg width="10" height="10" viewBox="0 0 10 10">
<path d="M5 8L2 4h6L5 8z" fill="currentColor" />
</svg>
</button>
<button
class="tbl-col__act tbl-col__act--del"
@click="removeColumn(col.id)"
data-tip="Sutunu sil"
>
<svg width="10" height="10" viewBox="0 0 10 10">
<path
d="M2 2l6 6M8 2l-6 6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
</button>
</div>
</div>
<!-- Row 2: field + align + format + width compact -->
<div class="tbl-col__controls">
<!-- Field -->
<select
v-if="tableItemFields.length > 0"
class="tbl-col__field"
:value="col.field"
data-tip="Veri alani"
@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.key }}</option>
</select>
<input
v-else
class="tbl-col__field"
type="text"
:value="col.field"
@change="(e) => updateColumn(col.id, { field: (e.target as HTMLInputElement).value })"
data-tip="Veri alani"
/>
<!-- Alignment icons -->
<div class="tbl-col__align">
<button
class="tbl-col__align-btn"
:class="{ 'tbl-col__align-btn--on': col.align === 'left' }"
@click="updateColumn(col.id, { align: 'left' })"
data-tip="Sola hizala"
>
<svg width="12" height="12" viewBox="0 0 12 12">
<line
x1="1"
y1="3"
x2="11"
y2="3"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
<line
x1="1"
y1="6"
x2="8"
y2="6"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
<line
x1="1"
y1="9"
x2="10"
y2="9"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
</svg>
</button>
<button
class="tbl-col__align-btn"
:class="{ 'tbl-col__align-btn--on': col.align === 'center' }"
@click="updateColumn(col.id, { align: 'center' })"
data-tip="Ortala"
>
<svg width="12" height="12" viewBox="0 0 12 12">
<line
x1="1"
y1="3"
x2="11"
y2="3"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
<line
x1="2.5"
y1="6"
x2="9.5"
y2="6"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
<line
x1="1.5"
y1="9"
x2="10.5"
y2="9"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
</svg>
</button>
<button
class="tbl-col__align-btn"
:class="{ 'tbl-col__align-btn--on': col.align === 'right' }"
@click="updateColumn(col.id, { align: 'right' })"
data-tip="Saga hizala"
>
<svg width="12" height="12" viewBox="0 0 12 12">
<line
x1="1"
y1="3"
x2="11"
y2="3"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
<line
x1="4"
y1="6"
x2="11"
y2="6"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
<line
x1="2"
y1="9"
x2="11"
y2="9"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
</svg>
</button>
</div>
</div>
<!-- Row 3: format + width -->
<div class="tbl-col__extra" data-tip="Veri gosterim formati">
<label class="tbl-col__elabel">Format</label>
<select
class="tbl-col__fmt"
: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="tbl-col__extra" data-tip="Sutun genislik modu">
<label class="tbl-col__elabel">Genislik</label>
<select
class="tbl-col__wtype"
: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>
<span
v-if="col.width.type === 'fixed' || col.width.type === 'fr'"
class="ts-tip-wrap"
:data-tip="col.width.type === 'fixed' ? 'Sabit genislik (mm)' : 'Oran degeri (fr)'"
>
<input
class="tbl-col__wval"
type="number"
step="1"
:min="col.width.type === 'fixed' ? 5 : 1"
:value="(col.width as any).value"
@change="
(e) =>
updateColumn(col.id, {
width: {
type: col.width.type,
value:
parseFloat((e.target as HTMLInputElement).value) ||
(col.width.type === 'fixed' ? 30 : 1),
} as any,
})
"
/>
</span>
</div>
</div>
</div>
<!-- Table style --> <!-- Table style -->
<div class="prop-section"> <PropSection title="Tablo Stili">
<div class="prop-section__title">Tablo Stili</div> <TableStyleEditor
:style="element.style as TableStyle"
<div class="ts-form"> :repeat-header="element.repeatHeader !== false"
<!-- Font sizes --> @update:style="updateTableStyle"
<label class="ts-lbl" data-tip="Icerik ve header yazi boyutu (pt)">Yazi boyutu</label> @update:repeat-header="(v) => update({ repeatHeader: v } as any)"
<div class="ts-val ts-val--pair"> />
<span class="ts-sep">Icerik</span> </PropSection>
<span class="ts-tip-wrap" data-tip="Icerik yazi boyutu (pt)">
<input
class="ts-num"
type="number"
step="1"
min="6"
max="99"
:value="element.style.fontSize ?? 10"
@input="
(e) =>
updateTableStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)
"
/>
</span>
<span class="ts-sep">Header</span>
<span class="ts-tip-wrap" data-tip="Header yazi boyutu (pt)">
<input
class="ts-num"
type="number"
step="1"
min="6"
max="99"
:value="element.style.headerFontSize ?? element.style.fontSize ?? 10"
@input="
(e) =>
updateTableStyle(
'headerFontSize',
parseFloat((e.target as HTMLInputElement).value) || 10,
)
"
/>
</span>
</div>
<!-- Colors -->
<label class="ts-lbl" data-tip="Header, metin ve zebra satirlari renkleri">Renkler</label>
<div class="ts-val ts-val--colors">
<div class="ts-color-item" data-tip="Header arkaplan rengi">
<input
class="ts-swatch"
type="color"
:value="element.style.headerBg ?? '#f0f0f0'"
@input="(e) => updateTableStyle('headerBg', (e.target as HTMLInputElement).value)"
/>
<span class="ts-clbl">Arkaplan</span>
</div>
<div class="ts-color-item" data-tip="Header metin rengi">
<input
class="ts-swatch"
type="color"
:value="element.style.headerColor ?? '#000000'"
@input="(e) => updateTableStyle('headerColor', (e.target as HTMLInputElement).value)"
/>
<span class="ts-clbl">Metin</span>
</div>
<div class="ts-color-item" data-tip="Zebra satir rengi tek satirlar">
<div class="ts-swatch-wrap">
<input
class="ts-swatch"
type="color"
:value="element.style.zebraOdd ?? '#fafafa'"
@input="(e) => updateTableStyle('zebraOdd', (e.target as HTMLInputElement).value)"
/>
<button
v-if="element.style.zebraOdd"
class="ts-swatch-clr"
@click="updateTableStyle('zebraOdd', undefined)"
>
&times;
</button>
</div>
<span class="ts-clbl">Zebra</span>
</div>
</div>
<!-- Border -->
<label class="ts-lbl" data-tip="Tablo kenarlik rengi ve kalinligi">Kenarlik</label>
<div class="ts-val ts-val--pair">
<div class="ts-swatch-wrap" data-tip="Kenarlik rengi">
<input
class="ts-swatch"
type="color"
:value="element.style.borderColor ?? '#cccccc'"
@input="(e) => updateTableStyle('borderColor', (e.target as HTMLInputElement).value)"
/>
<button
v-if="element.style.borderColor"
class="ts-swatch-clr"
@click="updateTableStyle('borderColor', undefined)"
>
&times;
</button>
</div>
<span class="ts-tip-wrap" data-tip="Kenarlik kalinligi (mm)">
<input
class="ts-num"
type="number"
step="0.1"
min="0"
max="99"
:value="element.style.borderWidth ?? 0.5"
@input="
(e) =>
updateTableStyle(
'borderWidth',
parseFloat((e.target as HTMLInputElement).value) || 0,
)
"
/>
</span>
<span class="ts-unit">mm</span>
</div>
<!-- Cell padding -->
<label class="ts-lbl" data-tip="Hucre ic bosluklari yatay ve dikey (mm)">Ic bosluk</label>
<div class="ts-val ts-val--pair">
<span class="ts-pad-icon" data-tip="Yatay bosluk (mm)">&#8596;</span>
<span class="ts-tip-wrap" data-tip="Yatay ic bosluk (mm)">
<input
class="ts-num"
type="number"
step="0.5"
min="0"
max="99"
:value="element.style.cellPaddingH ?? 2"
@input="
(e) =>
updateTableStyle(
'cellPaddingH',
parseFloat((e.target as HTMLInputElement).value) || 0,
)
"
/>
</span>
<span class="ts-pad-icon" data-tip="Dikey bosluk (mm)">&#8597;</span>
<span class="ts-tip-wrap" data-tip="Dikey ic bosluk (mm)">
<input
class="ts-num"
type="number"
step="0.5"
min="0"
max="99"
:value="element.style.cellPaddingV ?? 1"
@input="
(e) =>
updateTableStyle(
'cellPaddingV',
parseFloat((e.target as HTMLInputElement).value) || 0,
)
"
/>
</span>
</div>
<!-- Header padding -->
<label class="ts-lbl" data-tip="Header hucre bosluklari yatay ve dikey (mm)"
>Header bosluk</label
>
<div class="ts-val ts-val--pair">
<span class="ts-pad-icon" data-tip="Yatay bosluk (mm)">&#8596;</span>
<span class="ts-tip-wrap" data-tip="Header yatay bosluk (mm)">
<input
class="ts-num"
type="number"
step="0.5"
min="0"
max="99"
:value="element.style.headerPaddingH ?? element.style.cellPaddingH ?? 2"
@input="
(e) =>
updateTableStyle(
'headerPaddingH',
parseFloat((e.target as HTMLInputElement).value) || 0,
)
"
/>
</span>
<span class="ts-pad-icon" data-tip="Dikey bosluk (mm)">&#8597;</span>
<span class="ts-tip-wrap" data-tip="Header dikey bosluk (mm)">
<input
class="ts-num"
type="number"
step="0.5"
min="0"
max="99"
:value="element.style.headerPaddingV ?? element.style.cellPaddingV ?? 1"
@input="
(e) =>
updateTableStyle(
'headerPaddingV',
parseFloat((e.target as HTMLInputElement).value) || 0,
)
"
/>
</span>
</div>
<!-- Repeat header -->
<label class="ts-lbl" data-tip="Cok sayfali tablolarda header'i her sayfada tekrarla"
>Header tekrarla</label
>
<div class="ts-val">
<label class="ts-toggle">
<input
type="checkbox"
:checked="element.repeatHeader !== false"
@change="(e) => update({ repeatHeader: (e.target as HTMLInputElement).checked } as any)"
/>
<span class="ts-toggle__track"></span>
</label>
</div>
</div>
</div>
</template> </template>
<style scoped>
/* Column card - compact */
.tbl-col {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 5px;
padding: 5px 6px;
margin-bottom: 5px;
}
.tbl-col__head {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.tbl-col__title {
flex: 1;
min-width: 0;
border: none;
background: transparent;
font-size: 12px;
font-weight: 500;
color: #334155;
padding: 1px 0;
outline: none;
}
.tbl-col__title:focus {
border-bottom: 1px solid #93c5fd;
}
.tbl-col__actions {
display: flex;
gap: 1px;
flex-shrink: 0;
}
.tbl-col__act {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: none;
border-radius: 3px;
background: transparent;
color: #94a3b8;
cursor: pointer;
padding: 0;
}
.tbl-col__act:hover {
background: #e2e8f0;
color: #475569;
}
.tbl-col__act--del:hover {
background: #fef2f2;
color: #dc2626;
}
.tbl-col__controls {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 3px;
}
.tbl-col__field {
flex: 1;
min-width: 0;
padding: 2px 4px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
}
.tbl-col__field:focus {
outline: none;
border-color: #93c5fd;
}
.tbl-col__align {
display: flex;
gap: 0;
flex-shrink: 0;
}
.tbl-col__align-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 1px solid #e2e8f0;
background: white;
color: #94a3b8;
cursor: pointer;
padding: 0;
}
.tbl-col__align-btn:first-child {
border-radius: 3px 0 0 3px;
}
.tbl-col__align-btn:last-child {
border-radius: 0 3px 3px 0;
}
.tbl-col__align-btn:not(:first-child) {
border-left: none;
}
.tbl-col__align-btn--on {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.tbl-col__extra {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 3px;
}
.tbl-col__elabel {
font-size: 11px;
color: #64748b;
flex-shrink: 0;
}
.tbl-col__fmt {
flex: 1;
min-width: 0;
padding: 2px 4px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
cursor: pointer;
}
.tbl-col__wtype {
width: 80px;
padding: 2px 4px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
cursor: pointer;
}
.tbl-col__wval {
width: 36px;
padding: 2px 3px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
text-align: center;
-moz-appearance: textfield;
}
.tbl-col__wval::-webkit-inner-spin-button,
.tbl-col__wval::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.tbl-col__wval:focus {
outline: none;
border-color: #93c5fd;
}
/* Table style — aligned 2-column form */
.ts-form {
display: grid;
grid-template-columns: auto 1fr;
gap: 5px 8px;
align-items: center;
}
.ts-lbl {
font-size: 11px;
color: #64748b;
white-space: nowrap;
}
.ts-val {
display: flex;
align-items: center;
justify-content: flex-end;
}
.ts-val--pair {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.ts-val--colors {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 6px;
}
.ts-sep {
font-size: 10px;
color: #94a3b8;
}
.ts-num {
width: 32px;
padding: 2px 3px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
text-align: center;
-moz-appearance: textfield;
}
.ts-num::-webkit-inner-spin-button,
.ts-num::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.ts-num:focus {
outline: none;
border-color: #93c5fd;
}
.ts-unit {
font-size: 10px;
color: #94a3b8;
}
/* Color swatches */
.ts-color-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.ts-clbl {
font-size: 9px;
color: #94a3b8;
white-space: nowrap;
}
.ts-swatch {
width: 22px;
height: 22px;
padding: 0;
cursor: pointer;
border: 1px solid #e2e8f0;
border-radius: 3px;
}
.ts-swatch-wrap {
position: relative;
display: inline-flex;
}
.ts-swatch-clr {
position: absolute;
top: -4px;
right: -4px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #f1f5f9;
border: 1px solid #e2e8f0;
font-size: 9px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #94a3b8;
padding: 0;
}
.ts-swatch-clr:hover {
background: #fef2f2;
color: #dc2626;
border-color: #fecaca;
}
.ts-pad-icon {
font-size: 11px;
color: #94a3b8;
line-height: 1;
}
.ts-tip-wrap {
position: relative;
display: inline-flex;
}
/* Toggle switch */
.ts-toggle {
position: relative;
display: inline-block;
cursor: pointer;
}
.ts-toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.ts-toggle__track {
display: block;
width: 28px;
height: 16px;
background: #e2e8f0;
border-radius: 8px;
transition: background 0.15s;
position: relative;
}
.ts-toggle__track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
background: white;
border-radius: 50%;
transition: transform 0.15s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.ts-toggle input:checked + .ts-toggle__track {
background: #3b82f6;
}
.ts-toggle input:checked + .ts-toggle__track::after {
transform: translateX(12px);
}
</style>

View File

@@ -1,27 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateStore } from '../../stores/template' import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useEditorStore } from '../../stores/editor' import { useSchemaStore } from '../../stores/schema'
import PropSection from './shared/PropSection.vue'
import PropColorInput from './shared/PropColorInput.vue'
import PropSelect from './shared/PropSelect.vue'
import PropFieldSelect from './shared/PropFieldSelect.vue'
import PropTextStyleGroup from './shared/PropTextStyleGroup.vue'
import type { RichTextElement, RichTextSpan, TextStyle } from '../../core/types' import type { RichTextElement, RichTextSpan, TextStyle } from '../../core/types'
import '../../styles/properties.css' import '../../styles/properties.css'
const props = defineProps<{ element: RichTextElement }>() const props = defineProps<{ element: RichTextElement }>()
const templateStore = useTemplateStore() const { update, updateStyle } = usePropertyUpdate(() => props.element)
const editorStore = useEditorStore() const schemaStore = useSchemaStore()
function update(updates: Partial<RichTextElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates as any)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<RichTextElement>)
}
function updateSpan(index: number, updates: Partial<RichTextSpan>) { function updateSpan(index: number, updates: Partial<RichTextSpan>) {
const content = [...props.element.content] const content = [...props.element.content]
content[index] = { ...content[index], ...updates } content[index] = { ...content[index], ...updates }
update({ content }) update({ content } as any)
} }
function updateSpanStyle(index: number, key: string, value: unknown) { function updateSpanStyle(index: number, key: string, value: unknown) {
@@ -31,60 +26,42 @@ function updateSpanStyle(index: number, key: string, value: unknown) {
function addSpan() { function addSpan() {
const content = [...props.element.content, { text: 'yeni', style: {} }] const content = [...props.element.content, { text: 'yeni', style: {} }]
update({ content }) update({ content } as any)
} }
function removeSpan(index: number) { function removeSpan(index: number) {
if (props.element.content.length <= 1) return if (props.element.content.length <= 1) return
const content = props.element.content.filter((_, i) => i !== index) const content = props.element.content.filter((_, i) => i !== index)
update({ content }) update({ content } as any)
} }
const weightOptions = [
{ value: '', label: 'Varsayilan' },
{ value: 'normal', label: 'Normal' },
{ value: 'bold', label: 'Kalin' },
]
</script> </script>
<template> <template>
<div class="prop-section"> <PropSection title="Varsayilan Stil">
<div class="prop-section__title">Varsayilan Stil</div> <PropTextStyleGroup
<div class="prop-row" data-tip="Varsayilan yazi tipi boyutu (point)"> :font-size="element.style.fontSize ?? 11"
<label class="prop-label">Boyut (pt)</label> :font-weight="element.style.fontWeight ?? 'normal'"
<input :font-family="element.style.fontFamily"
class="prop-input" :color="element.style.color ?? '#000000'"
type="number" :align="element.style.align ?? 'left'"
step="1" @update:font-size="(v) => updateStyle('fontSize', v)"
min="1" @update:font-weight="(v) => updateStyle('fontWeight', v)"
:value="element.style.fontSize ?? 11" @update:font-family="(v) => updateStyle('fontFamily', v)"
@input=" @update:color="(v) => updateStyle('color', v)"
(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11) @update:align="(v) => updateStyle('align', v)"
" />
/> </PropSection>
</div>
<div class="prop-row" data-tip="Varsayilan metin rengi">
<label class="prop-label">Renk</label>
<input
class="prop-input prop-color"
type="color"
:value="element.style.color ?? '#000000'"
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)"
/>
</div>
<div class="prop-row" data-tip="Metnin yatay hizalamasi">
<label class="prop-label">Hizalama</label>
<select
class="prop-input prop-select"
:value="element.style.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>
<div class="prop-section"> <PropSection title="Span'lar">
<div class="prop-section__title"> <template #actions>
Span'lar
<button class="prop-add-btn" @click="addSpan" title="Span ekle">+</button> <button class="prop-add-btn" @click="addSpan" title="Span ekle">+</button>
</div> </template>
<div v-for="(span, idx) in element.content" :key="idx" class="prop-span-card"> <div v-for="(span, idx) in element.content" :key="idx" class="prop-span-card">
<div class="prop-span-card__header"> <div class="prop-span-card__header">
@@ -108,6 +85,15 @@ function removeSpan(index: number) {
@input="(e) => updateSpan(idx, { text: (e.target as HTMLInputElement).value })" @input="(e) => updateSpan(idx, { text: (e.target as HTMLInputElement).value })"
/> />
</div> </div>
<PropFieldSelect
label="Binding"
:model-value="span.binding?.path ?? ''"
:fields="schemaStore.scalarFields"
:allow-empty="true"
empty-label="Yok (statik)"
data-tip="Span'in baglanacagi veri alani"
@update:model-value="(v) => updateSpan(idx, { binding: v ? { type: 'scalar', path: v } : undefined })"
/>
<div class="prop-row" data-tip="Span yazi boyutu bos birakilirsa varsayilan kullanilir"> <div class="prop-row" data-tip="Span yazi boyutu bos birakilirsa varsayilan kullanilir">
<label class="prop-label">Boyut</label> <label class="prop-label">Boyut</label>
<input <input
@@ -125,57 +111,31 @@ function removeSpan(index: number) {
" "
/> />
</div> </div>
<div class="prop-row" data-tip="Span yazi kalinligi"> <PropSelect
<label class="prop-label">Kalinlik</label> label="Kalinlik"
<select :model-value="(span.style as TextStyle).fontWeight ?? ''"
class="prop-input prop-select" :options="weightOptions"
:value="(span.style as TextStyle).fontWeight ?? ''" data-tip="Span yazi kalinligi"
@change=" @update:model-value="(v) => updateSpanStyle(idx, 'fontWeight', v || undefined)"
(e) => { />
const v = (e.target as HTMLSelectElement).value <PropColorInput
updateSpanStyle(idx, 'fontWeight', v || undefined) label="Renk"
} :model-value="(span.style as TextStyle).color ?? element.style.color ?? '#000000'"
" data-tip="Span metin rengi"
> @update:model-value="(v) => updateSpanStyle(idx, 'color', v)"
<option value="">Varsayilan</option> />
<option value="normal">Normal</option> <PropSelect
<option value="bold">Kalin</option> label="Hizalama"
</select> :model-value="(span.style as TextStyle).align ?? ''"
</div> :options="[{ value: '', label: 'Varsayilan' }, { value: 'left', label: 'Sol' }, { value: 'center', label: 'Orta' }, { value: 'right', label: 'Sag' }]"
<div class="prop-row" data-tip="Span metin rengi"> data-tip="Span hizalamasi"
<label class="prop-label">Renk</label> @update:model-value="(v) => updateSpanStyle(idx, 'align', v || undefined)"
<input />
class="prop-input prop-color"
type="color"
:value="(span.style as TextStyle).color ?? element.style.color ?? '#000000'"
@input="(e) => updateSpanStyle(idx, 'color', (e.target as HTMLInputElement).value)"
/>
</div>
</div> </div>
</div> </PropSection>
</template> </template>
<style scoped> <style scoped>
.prop-add-btn {
float: right;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
width: 22px;
height: 22px;
font-size: 14px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.prop-add-btn:hover {
background: #2563eb;
}
.prop-span-card { .prop-span-card {
background: #f8fafc; background: #f8fafc;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;

View File

@@ -1,86 +1,72 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateStore } from '../../stores/template' import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useEditorStore } from '../../stores/editor' import PropSection from './shared/PropSection.vue'
import type { ShapeElement, TemplateElement } from '../../core/types' import PropSelect from './shared/PropSelect.vue'
import PropNumberInput from './shared/PropNumberInput.vue'
import PropColorInput from './shared/PropColorInput.vue'
import type { ShapeElement } from '../../core/types'
import '../../styles/properties.css' import '../../styles/properties.css'
const props = defineProps<{ element: ShapeElement }>() const props = defineProps<{ element: ShapeElement }>()
const templateStore = useTemplateStore() const { update, updateStyle } = usePropertyUpdate(() => props.element)
const editorStore = useEditorStore()
function update(updates: Partial<TemplateElement>) { const shapeOptions = [
const id = editorStore.selectedElementId { value: 'rectangle', label: 'Dikdortgen' },
if (!id) return { value: 'rounded_rectangle', label: 'Yuvarlak Dikdortgen' },
templateStore.updateElement(id, updates) { value: 'ellipse', label: 'Elips' },
} ]
function updateStyle(key: string, value: unknown) { const borderStyleOptions = [
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>) { value: 'solid', label: 'Duz' },
} { value: 'dashed', label: 'Kesikli' },
{ value: 'dotted', label: 'Noktali' },
]
</script> </script>
<template> <template>
<div class="prop-section"> <PropSection title="Sekil">
<div class="prop-section__title">Sekil</div> <PropSelect
<div class="prop-row" data-tip="Sekil tipi"> label="Tip"
<label class="prop-label">Tip</label> :model-value="element.shapeType"
<select :options="shapeOptions"
class="prop-input prop-select" data-tip="Sekil tipi"
:value="element.shapeType" @update:model-value="(v) => update({ shapeType: v } as any)"
@change="(e) => update({ shapeType: (e.target as HTMLSelectElement).value } as any)" />
> <PropColorInput
<option value="rectangle">Dikdortgen</option> label="Arka Plan"
<option value="rounded_rectangle">Yuvarlak Dikdortgen</option> :model-value="element.style.backgroundColor ?? '#f0f0f0'"
<option value="ellipse">Elips</option> data-tip="Sekil arka plan rengi"
</select> @update:model-value="(v) => updateStyle('backgroundColor', v)"
</div> />
<div class="prop-row" data-tip="Sekil arka plan rengi"> <PropColorInput
<label class="prop-label">Arka Plan</label> label="Kenar Rengi"
<input :model-value="element.style.borderColor ?? '#333333'"
class="prop-input prop-color" data-tip="Kenarlik cizgisi rengi"
type="color" @update:model-value="(v) => updateStyle('borderColor', v)"
:value="element.style.backgroundColor ?? '#f0f0f0'" />
@input="(e) => updateStyle('backgroundColor', (e.target as HTMLInputElement).value)" <PropNumberInput
/> label="Kenar Kalinligi"
</div> :model-value="element.style.borderWidth ?? 0.5"
<div class="prop-row" data-tip="Kenarlik cizgisi rengi"> :step="0.25"
<label class="prop-label">Kenar Rengi</label> :min="0"
<input data-tip="Kenarlik cizgi kalinligi (mm)"
class="prop-input prop-color" @update:model-value="(v) => updateStyle('borderWidth', v)"
type="color" />
:value="element.style.borderColor ?? '#333333'" <PropSelect
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)" label="Kenar Stili"
/> :model-value="element.style.borderStyle ?? 'solid'"
</div> :options="borderStyleOptions"
<div class="prop-row" data-tip="Kenarlik cizgi kalinligi (mm)"> data-tip="Kenarlik cizgi stili"
<label class="prop-label">Kenar Kalinligi</label> @update:model-value="(v) => updateStyle('borderStyle', v)"
<input />
class="prop-input" <PropNumberInput
type="number"
step="0.25"
min="0"
:value="element.style.borderWidth ?? 0.5"
@input="
(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)
"
/>
</div>
<div
v-if="element.shapeType === 'rounded_rectangle'" v-if="element.shapeType === 'rounded_rectangle'"
class="prop-row" label="Kose Yuvarlakligi"
:model-value="element.style.borderRadius ?? 2"
:step="0.5"
:min="0"
data-tip="Kose yuvarlakligi (mm)" data-tip="Kose yuvarlakligi (mm)"
> @update:model-value="(v) => updateStyle('borderRadius', v)"
<label class="prop-label">Kose Yuvarlakligi</label> />
<input </PropSection>
class="prop-input"
type="number"
step="0.5"
min="0"
:value="element.style.borderRadius ?? 2"
@input="
(e) => updateStyle('borderRadius', parseFloat((e.target as HTMLInputElement).value) || 0)
"
/>
</div>
</div>
</template> </template>

View File

@@ -1,120 +1,131 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateStore } from '../../stores/template' import { useTemplateStore } from '../../stores/template'
import PropSection from './shared/PropSection.vue'
import PropNumberInput from './shared/PropNumberInput.vue'
import type { TemplateElement, SizeValue } from '../../core/types' import type { TemplateElement, SizeValue } from '../../core/types'
import '../../styles/properties.css' import '../../styles/properties.css'
const props = defineProps<{ element: TemplateElement }>() const props = defineProps<{ element: TemplateElement }>()
const templateStore = useTemplateStore() const templateStore = useTemplateStore()
const sizeOptions = [
{ value: 'auto', label: 'Otomatik' },
{ value: 'fixed', label: 'Sabit (mm)' },
{ value: 'fr', label: 'Oran (fr)' },
]
function updateSize(axis: 'width' | 'height', sv: SizeValue) { function updateSize(axis: 'width' | 'height', sv: SizeValue) {
templateStore.updateElementSize(props.element.id, { [axis]: sv }) templateStore.updateElementSize(props.element.id, { [axis]: sv })
} }
function updateSizeConstraint(key: string, value: number | undefined) {
templateStore.updateElementSize(props.element.id, { [key]: value })
}
function onTypeChange(axis: 'width' | 'height', type: string) {
if (type === 'auto') updateSize(axis, { type: 'auto' })
else if (type === 'fr') updateSize(axis, { type: 'fr', value: 1 })
else updateSize(axis, { type: 'fixed', value: axis === 'width' ? 50 : 20 })
}
</script> </script>
<template> <template>
<div class="prop-section"> <PropSection title="Boyut">
<div class="prop-section__title">Boyut</div>
<div class="prop-row" data-tip="Genislik boyutlandirma modu"> <div class="prop-row" data-tip="Genislik boyutlandirma modu">
<label class="prop-label">Genislik</label> <label class="prop-label">Genislik</label>
<select <select
class="prop-input prop-select" class="prop-input prop-select"
:value="element.size.width.type" :value="element.size.width.type"
@change=" @change="(e) => onTypeChange('width', (e.target as HTMLSelectElement).value)"
(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 v-for="opt in sizeOptions" :key="opt.value" :value="opt.value">
<option value="fixed">Sabit (mm)</option> {{ opt.label }}
<option value="fr">Oran (fr)</option> </option>
</select> </select>
</div> </div>
<div <PropNumberInput
v-if="element.size.width.type === 'fixed'" v-if="element.size.width.type === 'fixed'"
class="prop-row" label="mm"
:model-value="(element.size.width as any).value"
:step="1"
:min="1"
data-tip="Sabit genislik degeri (mm)" data-tip="Sabit genislik degeri (mm)"
> @update:model-value="(v) => updateSize('width', { type: 'fixed', value: v })"
<label class="prop-label">mm</label> />
<input <PropNumberInput
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'" v-if="element.size.width.type === 'fr'"
class="prop-row" label="fr"
:model-value="(element.size.width as any).value"
:step="1"
:min="1"
data-tip="Kalan alani oransal doldurma degeri" data-tip="Kalan alani oransal doldurma degeri"
> @update:model-value="(v) => updateSize('width', { type: 'fr', value: v })"
<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" data-tip="Yukseklik boyutlandirma modu"> <div class="prop-row" data-tip="Yukseklik boyutlandirma modu">
<label class="prop-label">Yukseklik</label> <label class="prop-label">Yukseklik</label>
<select <select
class="prop-input prop-select" class="prop-input prop-select"
:value="element.size.height.type" :value="element.size.height.type"
@change=" @change="(e) => onTypeChange('height', (e.target as HTMLSelectElement).value)"
(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 v-for="opt in sizeOptions" :key="opt.value" :value="opt.value">
<option value="fixed">Sabit (mm)</option> {{ opt.label }}
<option value="fr">Oran (fr)</option> </option>
</select> </select>
</div> </div>
<div <PropNumberInput
v-if="element.size.height.type === 'fixed'" v-if="element.size.height.type === 'fixed'"
class="prop-row" label="mm"
:model-value="(element.size.height as any).value"
:step="1"
:min="1"
data-tip="Sabit yukseklik degeri (mm)" data-tip="Sabit yukseklik degeri (mm)"
> @update:model-value="(v) => updateSize('height', { type: 'fixed', value: v })"
<label class="prop-label">mm</label> />
<input <PropNumberInput
class="prop-input" v-if="element.size.height.type === 'fr'"
type="number" label="fr"
step="1" :model-value="(element.size.height as any).value"
min="1" :step="1"
:value="(element.size.height as any).value" :min="1"
@input=" data-tip="Kalan alani oransal doldurma degeri"
(e) => @update:model-value="(v) => updateSize('height', { type: 'fr', value: v })"
updateSize('height', { />
type: 'fixed', </PropSection>
value: parseFloat((e.target as HTMLInputElement).value) || 10,
}) <PropSection title="Min / Max">
" <PropNumberInput
/> label="Min Gen."
</div> :model-value="element.size.minWidth ?? 0"
</div> :step="1"
:min="0"
data-tip="Minimum genislik (mm) 0 = sinir yok"
@update:model-value="(v) => updateSizeConstraint('minWidth', v > 0 ? v : undefined)"
/>
<PropNumberInput
label="Max Gen."
:model-value="element.size.maxWidth ?? 0"
:step="1"
:min="0"
data-tip="Maksimum genislik (mm) 0 = sinir yok"
@update:model-value="(v) => updateSizeConstraint('maxWidth', v > 0 ? v : undefined)"
/>
<PropNumberInput
label="Min Yuk."
:model-value="element.size.minHeight ?? 0"
:step="1"
:min="0"
data-tip="Minimum yukseklik (mm) 0 = sinir yok"
@update:model-value="(v) => updateSizeConstraint('minHeight', v > 0 ? v : undefined)"
/>
<PropNumberInput
label="Max Yuk."
:model-value="element.size.maxHeight ?? 0"
:step="1"
:min="0"
data-tip="Maksimum yukseklik (mm) 0 = sinir yok"
@update:model-value="(v) => updateSizeConstraint('maxHeight', v > 0 ? v : undefined)"
/>
</PropSection>
</template> </template>

View File

@@ -1,28 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateStore } from '../../stores/template' import { computed } from 'vue'
import { useEditorStore } from '../../stores/editor' import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import type { StaticTextElement, TextStyle, TemplateElement } from '../../core/types' import { useSchemaStore } from '../../stores/schema'
import PropSection from './shared/PropSection.vue'
import PropFieldSelect from './shared/PropFieldSelect.vue'
import PropTextStyleGroup from './shared/PropTextStyleGroup.vue'
import type { StaticTextElement, TextElement, TextStyle, TemplateElement } from '../../core/types'
import '../../styles/properties.css' import '../../styles/properties.css'
const props = defineProps<{ element: TemplateElement }>() const props = defineProps<{ element: TemplateElement }>()
const templateStore = useTemplateStore() const { update, updateStyle } = usePropertyUpdate(() => props.element)
const editorStore = useEditorStore() const schemaStore = useSchemaStore()
const style = () => props.element.style as TextStyle
function update(updates: Partial<TemplateElement>) { const isText = computed(() => props.element.type === 'text')
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> </script>
<template> <template>
<div class="prop-section"> <PropSection title="Metin">
<div class="prop-section__title">Metin Stili</div>
<div v-if="element.type === 'static_text'" class="prop-row" data-tip="Sabit metin icerigi"> <div v-if="element.type === 'static_text'" class="prop-row" data-tip="Sabit metin icerigi">
<label class="prop-label">Metin</label> <label class="prop-label">Metin</label>
<input <input
@@ -33,50 +28,39 @@ function updateStyle(key: string, value: unknown) {
/> />
</div> </div>
<div class="prop-row" data-tip="Yazi tipi boyutu (point)"> <template v-if="isText">
<label class="prop-label">Boyut (pt)</label> <PropFieldSelect
<input label="Veri Alani"
class="prop-input" :model-value="(element as TextElement).binding?.path ?? ''"
type="number" :fields="schemaStore.scalarFields"
step="1" data-tip="Metnin baglanacagi veri alani"
min="1" @update:model-value="(v) => update({ binding: { type: 'scalar', path: v } } as any)"
:value="(element.style as TextStyle).fontSize ?? 11"
@input="
(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)
"
/> />
</div> <div class="prop-row" data-tip="Veri alaninin onune eklenecek sabit metin">
<div class="prop-row" data-tip="Yazi tipi kalinligi"> <label class="prop-label">Ön Ek</label>
<label class="prop-label">Kalinlik</label> <input
<select class="prop-input"
class="prop-input prop-select" type="text"
:value="(element.style as TextStyle).fontWeight ?? 'normal'" :value="(element as TextElement).content ?? ''"
@change="(e) => updateStyle('fontWeight', (e.target as HTMLSelectElement).value)" placeholder="ör: Fatura No: "
> @input="(e) => update({ content: (e.target as HTMLInputElement).value || undefined } as any)"
<option value="normal">Normal</option> />
<option value="bold">Kalin</option> </div>
</select> </template>
</div> </PropSection>
<div class="prop-row" data-tip="Metin rengi">
<label class="prop-label">Renk</label> <PropSection title="Metin Stili">
<input <PropTextStyleGroup
class="prop-input prop-color" :font-size="style().fontSize ?? 11"
type="color" :font-weight="style().fontWeight ?? 'normal'"
:value="(element.style as TextStyle).color ?? '#000000'" :font-family="style().fontFamily"
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" :color="style().color ?? '#000000'"
/> :align="style().align ?? 'left'"
</div> @update:font-size="(v) => updateStyle('fontSize', v)"
<div class="prop-row" data-tip="Metnin yatay hizalamasi"> @update:font-weight="(v) => updateStyle('fontWeight', v)"
<label class="prop-label">Hizalama</label> @update:font-family="(v) => updateStyle('fontFamily', v)"
<select @update:color="(v) => updateStyle('color', v)"
class="prop-input prop-select" @update:align="(v) => updateStyle('align', v)"
:value="(element.style as TextStyle).align ?? 'left'" />
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)" </PropSection>
>
<option value="left">Sol</option>
<option value="center">Orta</option>
<option value="right">Sag</option>
</select>
</div>
</div>
</template> </template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
defineProps<{
label: string
modelValue: boolean
dataTip?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
</script>
<template>
<div class="prop-row" :data-tip="dataTip">
<label class="prop-label">{{ label }}</label>
<input
type="checkbox"
:checked="modelValue"
@change="(e) => emit('update:modelValue', (e.target as HTMLInputElement).checked)"
/>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label: string
modelValue: string | undefined
defaultColor?: string
clearable?: boolean
dataTip?: string
}>(),
{ defaultColor: '#000000', clearable: false },
)
const emit = defineEmits<{ 'update:modelValue': [value: string | undefined] }>()
</script>
<template>
<div class="prop-row" :data-tip="dataTip">
<label class="prop-label">{{ label }}</label>
<div v-if="clearable" class="prop-row-inline">
<input
class="prop-input prop-color"
type="color"
:value="modelValue ?? defaultColor"
@input="(e) => emit('update:modelValue', (e.target as HTMLInputElement).value)"
/>
<button v-if="modelValue" class="prop-clear" @click="emit('update:modelValue', undefined)">
x
</button>
</div>
<input
v-else
class="prop-input prop-color"
type="color"
:value="modelValue ?? defaultColor"
@input="(e) => emit('update:modelValue', (e.target as HTMLInputElement).value)"
/>
</div>
</template>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useSchemaStore } from '../../../stores/schema'
import PropFieldSelect from './PropFieldSelect.vue'
import PropSelect from './PropSelect.vue'
import PropSection from './PropSection.vue'
import type { Condition } from '../../../core/types'
import '../../../styles/properties.css'
const props = defineProps<{
condition?: Condition
}>()
const emit = defineEmits<{
'update:condition': [value: Condition | undefined]
}>()
const schemaStore = useSchemaStore()
const enabled = computed(() => !!props.condition)
const operatorOptions = [
{ value: 'eq', label: '= Esit' },
{ value: 'neq', label: '≠ Esit Degil' },
{ value: 'gt', label: '> Buyuk' },
{ value: 'gte', label: '>= Buyuk Esit' },
{ value: 'lt', label: '< Kucuk' },
{ value: 'lte', label: '<= Kucuk Esit' },
{ value: 'truthy', label: 'Dolu (truthy)' },
{ value: 'falsy', label: 'Bos (falsy)' },
]
const needsValue = computed(() => {
const op = props.condition?.operator
return op && op !== 'truthy' && op !== 'falsy'
})
function toggle(on: boolean) {
if (on) {
emit('update:condition', { path: '', operator: 'truthy' })
} else {
emit('update:condition', undefined)
}
}
function updateField(key: keyof Condition, value: unknown) {
emit('update:condition', { ...props.condition!, [key]: value })
}
</script>
<template>
<PropSection title="Kosullu Gosterim">
<div class="prop-row" data-tip="Elemani belirli bir kosulla goster/gizle">
<label class="prop-label">Aktif</label>
<input type="checkbox" :checked="enabled" @change="toggle(($event.target as HTMLInputElement).checked)" />
</div>
<template v-if="enabled">
<PropFieldSelect
label="Alan"
:model-value="condition!.path"
:fields="schemaStore.scalarFields"
data-tip="Kosulun degerlendirilecegi veri alani"
@update:model-value="(v) => updateField('path', v)"
/>
<PropSelect
label="Operator"
:model-value="condition!.operator"
:options="operatorOptions"
data-tip="Karsilastirma operatoru"
@update:model-value="(v) => updateField('operator', v)"
/>
<div v-if="needsValue" class="prop-row" data-tip="Karsilastirilacak deger">
<label class="prop-label">Deger</label>
<input
class="prop-input"
type="text"
:value="condition!.value ?? ''"
@input="(e) => updateField('value', (e.target as HTMLInputElement).value)"
/>
</div>
</template>
</PropSection>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label: string
modelValue: string
fields: Array<{ path?: string; key?: string; title?: string; type?: string }>
placeholder?: string
allowEmpty?: boolean
emptyLabel?: string
dataTip?: string
}>(),
{ placeholder: 'Secin...', allowEmpty: false, emptyLabel: 'Yok' },
)
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<div class="prop-row" :data-tip="dataTip">
<label class="prop-label">{{ label }}</label>
<select
class="prop-input prop-select"
:value="modelValue"
@change="(e) => emit('update:modelValue', (e.target as HTMLSelectElement).value)"
>
<option v-if="allowEmpty" value="">{{ emptyLabel }}</option>
<option v-else value="" disabled>{{ placeholder }}</option>
<option
v-for="field in fields"
:key="field.path ?? field.key"
:value="field.path ?? field.key"
>
{{ field.title ?? field.path ?? field.key }}
<template v-if="field.path">({{ field.path }})</template>
</option>
</select>
</div>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label: string
modelValue: number
step?: number
min?: number
max?: number
dataTip?: string
}>(),
{ step: 1, min: 0 },
)
const emit = defineEmits<{ 'update:modelValue': [value: number] }>()
function onInput(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value)
if (!isNaN(val)) emit('update:modelValue', val)
}
</script>
<template>
<div class="prop-row" :data-tip="dataTip">
<label class="prop-label">{{ label }}</label>
<input
class="prop-input"
type="number"
:step="step"
:min="min"
:max="max"
:value="modelValue"
@input="onInput"
/>
</div>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { ref } from 'vue'
const props = withDefaults(defineProps<{ title: string; defaultOpen?: boolean }>(), {
defaultOpen: true,
})
const open = ref(props.defaultOpen)
</script>
<template>
<div class="prop-section">
<div class="prop-section__title prop-section__title--collapsible" @click="open = !open">
<span class="prop-section__chevron" :class="{ 'prop-section__chevron--closed': !open }"
>&#9662;</span
>
{{ title }}
<span class="prop-section__actions" @click.stop><slot name="actions" /></span>
</div>
<template v-if="open"><slot /></template>
</div>
</template>
<style scoped>
.prop-section__title--collapsible {
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 4px;
}
.prop-section__chevron {
font-size: 8px;
transition: transform 0.15s;
display: inline-block;
}
.prop-section__chevron--closed {
transform: rotate(-90deg);
}
.prop-section__actions {
margin-left: auto;
}
</style>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
defineProps<{
label: string
modelValue: string
options: Array<{ value: string; label: string }>
dataTip?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<div class="prop-row" :data-tip="dataTip">
<label class="prop-label">{{ label }}</label>
<select
class="prop-input prop-select"
:value="modelValue"
@change="(e) => emit('update:modelValue', (e.target as HTMLSelectElement).value)"
>
<option v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
</template>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTemplateStore } from '../../../stores/template'
import PropNumberInput from './PropNumberInput.vue'
import PropColorInput from './PropColorInput.vue'
import PropSelect from './PropSelect.vue'
const props = withDefaults(
defineProps<{
fontSize: number
fontWeight?: string
fontFamily?: string
color: string
align: string
showWeight?: boolean
}>(),
{ fontWeight: 'normal', fontFamily: undefined, showWeight: true },
)
defineEmits<{
'update:fontSize': [value: number]
'update:fontWeight': [value: string]
'update:fontFamily': [value: string | undefined]
'update:color': [value: string]
'update:align': [value: string]
}>()
const templateStore = useTemplateStore()
const fontOptions = computed(() =>
templateStore.template.fonts.map((f) => ({ value: f, label: f })),
)
const weightOptions = [
{ value: 'normal', label: 'Normal' },
{ value: 'bold', label: 'Kalin' },
]
const alignOptions = [
{ value: 'left', label: 'Sol' },
{ value: 'center', label: 'Orta' },
{ value: 'right', label: 'Sag' },
]
</script>
<template>
<PropSelect
v-if="fontOptions.length > 1"
label="Font"
:model-value="fontFamily ?? fontOptions[0]?.value ?? ''"
:options="fontOptions"
data-tip="Yazi tipi ailesi"
@update:model-value="$emit('update:fontFamily', $event)"
/>
<PropNumberInput
label="Boyut (pt)"
:model-value="fontSize"
:step="1"
:min="1"
data-tip="Yazi tipi boyutu (point)"
@update:model-value="$emit('update:fontSize', $event)"
/>
<PropSelect
v-if="showWeight"
label="Kalinlik"
:model-value="fontWeight!"
:options="weightOptions"
data-tip="Yazi tipi kalinligi"
@update:model-value="$emit('update:fontWeight', $event)"
/>
<PropColorInput
label="Renk"
:model-value="color"
data-tip="Metin rengi"
@update:model-value="$emit('update:color', $event!)"
/>
<PropSelect
label="Hizalama"
:model-value="align"
:options="alignOptions"
data-tip="Metnin yatay hizalamasi"
@update:model-value="$emit('update:align', $event)"
/>
</template>

View File

@@ -0,0 +1,387 @@
<script setup lang="ts">
import { defaultAlignForSchema, schemaFormatToFormatType } from '../../../core/schema-parser'
import type { TableColumn, FormatType } from '../../../core/types'
type ItemField = { key: string; title: string; type?: string; format?: string }
import '../../../styles/properties.css'
defineProps<{
column: TableColumn
itemFields: ItemField[]
}>()
const emit = defineEmits<{
update: [colId: string, updates: Partial<TableColumn>]
remove: [colId: string]
move: [colId: string, direction: -1 | 1]
}>()
</script>
<template>
<div class="tbl-col">
<!-- Row 1: title + actions -->
<div class="tbl-col__head">
<input
class="tbl-col__title"
type="text"
:value="column.title"
@change="(e) => emit('update', column.id, { title: (e.target as HTMLInputElement).value })"
:placeholder="column.field"
data-tip="Sutun basligi"
/>
<div class="tbl-col__actions">
<button class="tbl-col__act" @click="emit('move', column.id, -1)" data-tip="Yukari tasi">
<svg width="10" height="10" viewBox="0 0 10 10">
<path d="M5 2L2 6h6L5 2z" fill="currentColor" />
</svg>
</button>
<button class="tbl-col__act" @click="emit('move', column.id, 1)" data-tip="Asagi tasi">
<svg width="10" height="10" viewBox="0 0 10 10">
<path d="M5 8L2 4h6L5 8z" fill="currentColor" />
</svg>
</button>
<button
class="tbl-col__act tbl-col__act--del"
@click="emit('remove', column.id)"
data-tip="Sutunu sil"
>
<svg width="10" height="10" viewBox="0 0 10 10">
<path
d="M2 2l6 6M8 2l-6 6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
</button>
</div>
</div>
<!-- Row 2: field + align -->
<div class="tbl-col__controls">
<select
v-if="itemFields.length > 0"
class="tbl-col__field"
:value="column.field"
data-tip="Veri alani"
@change="
(e) => {
const field = (e.target as HTMLSelectElement).value
const node = itemFields.find((f) => f.key === field)
if (node) {
emit('update', column.id, {
field,
title: node.title,
align: defaultAlignForSchema(node as any),
format: schemaFormatToFormatType(node.format),
})
} else {
emit('update', column.id, { field })
}
}
"
>
<option v-for="f in itemFields" :key="f.key" :value="f.key">{{ f.key }}</option>
</select>
<input
v-else
class="tbl-col__field"
type="text"
:value="column.field"
@change="(e) => emit('update', column.id, { field: (e.target as HTMLInputElement).value })"
data-tip="Veri alani"
/>
<!-- Alignment icons -->
<div class="tbl-col__align">
<button
class="tbl-col__align-btn"
:class="{ 'tbl-col__align-btn--on': column.align === 'left' }"
@click="emit('update', column.id, { align: 'left' })"
data-tip="Sola hizala"
>
<svg width="12" height="12" viewBox="0 0 12 12">
<line x1="1" y1="3" x2="11" y2="3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<line x1="1" y1="6" x2="8" y2="6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<line x1="1" y1="9" x2="10" y2="9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
</svg>
</button>
<button
class="tbl-col__align-btn"
:class="{ 'tbl-col__align-btn--on': column.align === 'center' }"
@click="emit('update', column.id, { align: 'center' })"
data-tip="Ortala"
>
<svg width="12" height="12" viewBox="0 0 12 12">
<line x1="1" y1="3" x2="11" y2="3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<line x1="2.5" y1="6" x2="9.5" y2="6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<line x1="1.5" y1="9" x2="10.5" y2="9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
</svg>
</button>
<button
class="tbl-col__align-btn"
:class="{ 'tbl-col__align-btn--on': column.align === 'right' }"
@click="emit('update', column.id, { align: 'right' })"
data-tip="Saga hizala"
>
<svg width="12" height="12" viewBox="0 0 12 12">
<line x1="1" y1="3" x2="11" y2="3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<line x1="4" y1="6" x2="11" y2="6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<line x1="2" y1="9" x2="11" y2="9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
</svg>
</button>
</div>
</div>
<!-- Row 3: format + width -->
<div class="tbl-col__extra" data-tip="Veri gosterim formati">
<label class="tbl-col__elabel">Format</label>
<select
class="tbl-col__fmt"
:value="column.format ?? ''"
@change="
(e) =>
emit('update', column.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="tbl-col__extra" data-tip="Sutun genislik modu">
<label class="tbl-col__elabel">Genislik</label>
<select
class="tbl-col__wtype"
:value="column.width.type"
@change="
(e) => {
const t = (e.target as HTMLSelectElement).value
if (t === 'auto') emit('update', column.id, { width: { type: 'auto' } })
else if (t === 'fr') emit('update', column.id, { width: { type: 'fr', value: 1 } })
else emit('update', column.id, { width: { type: 'fixed', value: 30 } })
}
"
>
<option value="auto">Otomatik</option>
<option value="fixed">Sabit (mm)</option>
<option value="fr">Oran (fr)</option>
</select>
<span
v-if="column.width.type === 'fixed' || column.width.type === 'fr'"
class="ts-tip-wrap"
:data-tip="column.width.type === 'fixed' ? 'Sabit genislik (mm)' : 'Oran degeri (fr)'"
>
<input
class="tbl-col__wval"
type="number"
step="1"
:min="column.width.type === 'fixed' ? 5 : 1"
:value="(column.width as any).value"
@change="
(e) =>
emit('update', column.id, {
width: {
type: column.width.type,
value:
parseFloat((e.target as HTMLInputElement).value) ||
(column.width.type === 'fixed' ? 30 : 1),
} as any,
})
"
/>
</span>
</div>
</div>
</template>
<style scoped>
.tbl-col {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 5px;
padding: 5px 6px;
margin-bottom: 5px;
}
.tbl-col__head {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.tbl-col__title {
flex: 1;
min-width: 0;
border: none;
background: transparent;
font-size: 12px;
font-weight: 500;
color: #334155;
padding: 1px 0;
outline: none;
}
.tbl-col__title:focus {
border-bottom: 1px solid #93c5fd;
}
.tbl-col__actions {
display: flex;
gap: 1px;
flex-shrink: 0;
}
.tbl-col__act {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: none;
border-radius: 3px;
background: transparent;
color: #94a3b8;
cursor: pointer;
padding: 0;
}
.tbl-col__act:hover {
background: #e2e8f0;
color: #475569;
}
.tbl-col__act--del:hover {
background: #fef2f2;
color: #dc2626;
}
.tbl-col__controls {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 3px;
}
.tbl-col__field {
flex: 1;
min-width: 0;
padding: 2px 4px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
}
.tbl-col__field:focus {
outline: none;
border-color: #93c5fd;
}
.tbl-col__align {
display: flex;
gap: 0;
flex-shrink: 0;
}
.tbl-col__align-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 1px solid #e2e8f0;
background: white;
color: #94a3b8;
cursor: pointer;
padding: 0;
}
.tbl-col__align-btn:first-child {
border-radius: 3px 0 0 3px;
}
.tbl-col__align-btn:last-child {
border-radius: 0 3px 3px 0;
}
.tbl-col__align-btn:not(:first-child) {
border-left: none;
}
.tbl-col__align-btn--on {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.tbl-col__extra {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 3px;
}
.tbl-col__elabel {
font-size: 11px;
color: #64748b;
flex-shrink: 0;
}
.tbl-col__fmt {
flex: 1;
min-width: 0;
padding: 2px 4px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
cursor: pointer;
}
.tbl-col__wtype {
width: 80px;
padding: 2px 4px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
cursor: pointer;
}
.tbl-col__wval {
width: 36px;
padding: 2px 3px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
text-align: center;
-moz-appearance: textfield;
}
.tbl-col__wval::-webkit-inner-spin-button,
.tbl-col__wval::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.tbl-col__wval:focus {
outline: none;
border-color: #93c5fd;
}
.ts-tip-wrap {
position: relative;
display: inline-flex;
}
</style>

View File

@@ -0,0 +1,384 @@
<script setup lang="ts">
import type { TableStyle } from '../../../core/types'
import '../../../styles/properties.css'
const props = defineProps<{
style: TableStyle
repeatHeader: boolean
}>()
const emit = defineEmits<{
'update:style': [key: string, value: unknown]
'update:repeatHeader': [value: boolean]
}>()
</script>
<template>
<div class="ts-form">
<!-- Font sizes -->
<label class="ts-lbl" data-tip="Icerik ve header yazi boyutu (pt)">Yazi boyutu</label>
<div class="ts-val ts-val--pair">
<span class="ts-sep">Icerik</span>
<span class="ts-tip-wrap" data-tip="Icerik yazi boyutu (pt)">
<input
class="ts-num"
type="number"
step="1"
min="6"
max="99"
:value="style.fontSize ?? 10"
@input="(e) => emit('update:style', 'fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)"
/>
</span>
<span class="ts-sep">Header</span>
<span class="ts-tip-wrap" data-tip="Header yazi boyutu (pt)">
<input
class="ts-num"
type="number"
step="1"
min="6"
max="99"
:value="style.headerFontSize ?? style.fontSize ?? 10"
@input="(e) => emit('update:style', 'headerFontSize', parseFloat((e.target as HTMLInputElement).value) || 10)"
/>
</span>
</div>
<!-- Colors -->
<label class="ts-lbl" data-tip="Header, metin ve zebra satirlari renkleri">Renkler</label>
<div class="ts-val ts-val--colors">
<div class="ts-color-item" data-tip="Header arkaplan rengi">
<input
class="ts-swatch"
type="color"
:value="style.headerBg ?? '#f0f0f0'"
@input="(e) => emit('update:style', 'headerBg', (e.target as HTMLInputElement).value)"
/>
<span class="ts-clbl">Arkaplan</span>
</div>
<div class="ts-color-item" data-tip="Header metin rengi">
<input
class="ts-swatch"
type="color"
:value="style.headerColor ?? '#000000'"
@input="(e) => emit('update:style', 'headerColor', (e.target as HTMLInputElement).value)"
/>
<span class="ts-clbl">Metin</span>
</div>
<div class="ts-color-item" data-tip="Zebra satir rengi tek satirlar">
<div class="ts-swatch-wrap">
<input
class="ts-swatch"
type="color"
:value="style.zebraOdd ?? '#fafafa'"
@input="(e) => emit('update:style', 'zebraOdd', (e.target as HTMLInputElement).value)"
/>
<button
v-if="style.zebraOdd"
class="ts-swatch-clr"
@click="emit('update:style', 'zebraOdd', undefined)"
>
&times;
</button>
</div>
<span class="ts-clbl">Tek</span>
</div>
<div class="ts-color-item" data-tip="Zebra satir rengi cift satirlar">
<div class="ts-swatch-wrap">
<input
class="ts-swatch"
type="color"
:value="style.zebraEven ?? '#ffffff'"
@input="(e) => emit('update:style', 'zebraEven', (e.target as HTMLInputElement).value)"
/>
<button
v-if="style.zebraEven"
class="ts-swatch-clr"
@click="emit('update:style', 'zebraEven', undefined)"
>
&times;
</button>
</div>
<span class="ts-clbl">Cift</span>
</div>
</div>
<!-- Border -->
<label class="ts-lbl" data-tip="Tablo kenarlik rengi ve kalinligi">Kenarlik</label>
<div class="ts-val ts-val--pair">
<div class="ts-swatch-wrap" data-tip="Kenarlik rengi">
<input
class="ts-swatch"
type="color"
:value="style.borderColor ?? '#cccccc'"
@input="(e) => emit('update:style', 'borderColor', (e.target as HTMLInputElement).value)"
/>
<button
v-if="style.borderColor"
class="ts-swatch-clr"
@click="emit('update:style', 'borderColor', undefined)"
>
&times;
</button>
</div>
<span class="ts-tip-wrap" data-tip="Kenarlik kalinligi (mm)">
<input
class="ts-num"
type="number"
step="0.1"
min="0"
max="99"
:value="style.borderWidth ?? 0.5"
@input="(e) => emit('update:style', 'borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)"
/>
</span>
<span class="ts-unit">mm</span>
</div>
<!-- Cell padding -->
<label class="ts-lbl" data-tip="Hucre ic bosluklari yatay ve dikey (mm)">Ic bosluk</label>
<div class="ts-val ts-val--pair">
<span class="ts-pad-icon" data-tip="Yatay bosluk (mm)">&#8596;</span>
<span class="ts-tip-wrap" data-tip="Yatay ic bosluk (mm)">
<input
class="ts-num"
type="number"
step="0.5"
min="0"
max="99"
:value="style.cellPaddingH ?? 2"
@input="(e) => emit('update:style', 'cellPaddingH', parseFloat((e.target as HTMLInputElement).value) || 0)"
/>
</span>
<span class="ts-pad-icon" data-tip="Dikey bosluk (mm)">&#8597;</span>
<span class="ts-tip-wrap" data-tip="Dikey ic bosluk (mm)">
<input
class="ts-num"
type="number"
step="0.5"
min="0"
max="99"
:value="style.cellPaddingV ?? 1"
@input="(e) => emit('update:style', 'cellPaddingV', parseFloat((e.target as HTMLInputElement).value) || 0)"
/>
</span>
</div>
<!-- Header padding -->
<label class="ts-lbl" data-tip="Header hucre bosluklari yatay ve dikey (mm)">Header bosluk</label>
<div class="ts-val ts-val--pair">
<span class="ts-pad-icon" data-tip="Yatay bosluk (mm)">&#8596;</span>
<span class="ts-tip-wrap" data-tip="Header yatay bosluk (mm)">
<input
class="ts-num"
type="number"
step="0.5"
min="0"
max="99"
:value="style.headerPaddingH ?? style.cellPaddingH ?? 2"
@input="(e) => emit('update:style', 'headerPaddingH', parseFloat((e.target as HTMLInputElement).value) || 0)"
/>
</span>
<span class="ts-pad-icon" data-tip="Dikey bosluk (mm)">&#8597;</span>
<span class="ts-tip-wrap" data-tip="Header dikey bosluk (mm)">
<input
class="ts-num"
type="number"
step="0.5"
min="0"
max="99"
:value="style.headerPaddingV ?? style.cellPaddingV ?? 1"
@input="(e) => emit('update:style', 'headerPaddingV', parseFloat((e.target as HTMLInputElement).value) || 0)"
/>
</span>
</div>
<!-- Repeat header -->
<label class="ts-lbl" data-tip="Cok sayfali tablolarda header'i her sayfada tekrarla">Header tekrarla</label>
<div class="ts-val">
<label class="ts-toggle">
<input
type="checkbox"
:checked="repeatHeader"
@change="(e) => emit('update:repeatHeader', (e.target as HTMLInputElement).checked)"
/>
<span class="ts-toggle__track"></span>
</label>
</div>
</div>
</template>
<style scoped>
.ts-form {
display: grid;
grid-template-columns: auto 1fr;
gap: 5px 8px;
align-items: center;
}
.ts-lbl {
font-size: 11px;
color: #64748b;
white-space: nowrap;
}
.ts-val {
display: flex;
align-items: center;
justify-content: flex-end;
}
.ts-val--pair {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.ts-val--colors {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 6px;
}
.ts-sep {
font-size: 10px;
color: #94a3b8;
}
.ts-num {
width: 32px;
padding: 2px 3px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
text-align: center;
-moz-appearance: textfield;
}
.ts-num::-webkit-inner-spin-button,
.ts-num::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.ts-num:focus {
outline: none;
border-color: #93c5fd;
}
.ts-unit {
font-size: 10px;
color: #94a3b8;
}
.ts-color-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.ts-clbl {
font-size: 9px;
color: #94a3b8;
white-space: nowrap;
}
.ts-swatch {
width: 22px;
height: 22px;
padding: 0;
cursor: pointer;
border: 1px solid #e2e8f0;
border-radius: 3px;
}
.ts-swatch-wrap {
position: relative;
display: inline-flex;
}
.ts-swatch-clr {
position: absolute;
top: -4px;
right: -4px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #f1f5f9;
border: 1px solid #e2e8f0;
font-size: 9px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #94a3b8;
padding: 0;
}
.ts-swatch-clr:hover {
background: #fef2f2;
color: #dc2626;
border-color: #fecaca;
}
.ts-pad-icon {
font-size: 11px;
color: #94a3b8;
line-height: 1;
}
.ts-tip-wrap {
position: relative;
display: inline-flex;
}
.ts-toggle {
position: relative;
display: inline-block;
cursor: pointer;
}
.ts-toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.ts-toggle__track {
display: block;
width: 28px;
height: 16px;
background: #e2e8f0;
border-radius: 8px;
transition: background 0.15s;
position: relative;
}
.ts-toggle__track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
background: white;
border-radius: 50%;
transition: transform 0.15s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.ts-toggle input:checked + .ts-toggle__track {
background: #3b82f6;
}
.ts-toggle input:checked + .ts-toggle__track::after {
transform: translateX(12px);
}
</style>

View File

@@ -0,0 +1,189 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useSnapGuides } from '../useSnapGuides'
import type { ElementLayout } from '../../core/layout-types'
function makeLayout(
id: string,
x: number,
y: number,
w: number,
h: number,
): ElementLayout {
return {
id,
x_mm: x,
y_mm: y,
width_mm: w,
height_mm: h,
element_type: 'static_text',
style: {},
} as ElementLayout
}
describe('useSnapGuides', () => {
let guides: ReturnType<typeof useSnapGuides>
beforeEach(() => {
guides = useSnapGuides()
})
describe('collectEdges', () => {
it('collects page edges and element edges', () => {
const layoutMap: Record<string, ElementLayout> = {
el1: makeLayout('el1', 10, 20, 50, 30),
}
guides.collectEdges(layoutMap, 'excluded', 210, 297)
// After collecting, calculateSnap should work
const result = guides.calculateSnap(0, 0, 10, 10)
expect(result).toBeDefined()
})
it('excludes the dragged element', () => {
const layoutMap: Record<string, ElementLayout> = {
dragged: makeLayout('dragged', 50, 50, 20, 20),
other: makeLayout('other', 100, 100, 30, 30),
}
guides.collectEdges(layoutMap, 'dragged', 210, 297)
// Snap to "other" element's left edge (100mm)
const result = guides.calculateSnap(99.5, 50, 20, 20)
expect(result.snappedX_mm).toBe(100) // snaps to other's left edge
})
})
describe('calculateSnap', () => {
it('returns proposed position when no edges cached', () => {
const result = guides.calculateSnap(42, 73, 10, 10)
expect(result.snappedX_mm).toBe(42)
expect(result.snappedY_mm).toBe(73)
expect(result.guides).toHaveLength(0)
})
it('snaps left edge to page left (0)', () => {
guides.collectEdges({}, 'none', 210, 297)
// Proposed x=0.5 → should snap to 0 (within 1.5mm threshold)
const result = guides.calculateSnap(0.5, 50, 20, 20)
expect(result.snappedX_mm).toBe(0)
expect(result.guides).toContainEqual({ type: 'vertical', position_mm: 0 })
})
it('snaps right edge to page right', () => {
guides.collectEdges({}, 'none', 210, 297)
// Element 20mm wide, proposed x=189 → right edge = 209, should snap to 210
const result = guides.calculateSnap(189, 50, 20, 20)
expect(result.snappedX_mm).toBe(190) // 210 - 20 = 190
expect(result.guides).toContainEqual({ type: 'vertical', position_mm: 210 })
})
it('snaps center to page center', () => {
guides.collectEdges({}, 'none', 210, 297)
// Element 20mm wide, center at 105mm → x = 95
// Proposed x=94.5 → center = 104.5, should snap to 105 → x = 95
const result = guides.calculateSnap(94.5, 50, 20, 20)
expect(result.snappedX_mm).toBe(95) // 105 - 10 = 95
})
it('snaps top edge to page top', () => {
guides.collectEdges({}, 'none', 210, 297)
const result = guides.calculateSnap(50, 1.0, 20, 20)
expect(result.snappedY_mm).toBe(0)
expect(result.guides).toContainEqual({ type: 'horizontal', position_mm: 0 })
})
it('does not snap when outside threshold', () => {
guides.collectEdges({}, 'none', 210, 297)
// Proposed x=50, far from any edge → no snap
const result = guides.calculateSnap(50, 50, 20, 20)
expect(result.snappedX_mm).toBe(50)
expect(result.snappedY_mm).toBe(50)
})
it('snaps to other element edges', () => {
const layoutMap: Record<string, ElementLayout> = {
ref: makeLayout('ref', 30, 40, 50, 20),
}
guides.collectEdges(layoutMap, 'dragged', 210, 297)
// Snap dragged element's left to ref's right (30+50=80)
const result = guides.calculateSnap(79.5, 50, 20, 20)
expect(result.snappedX_mm).toBe(80)
})
it('snaps both axes simultaneously', () => {
guides.collectEdges({}, 'none', 210, 297)
// Near page origin
const result = guides.calculateSnap(0.5, 0.5, 20, 20)
expect(result.snappedX_mm).toBe(0)
expect(result.snappedY_mm).toBe(0)
expect(result.guides).toHaveLength(2)
})
it('updates activeGuides ref', () => {
guides.collectEdges({}, 'none', 210, 297)
guides.calculateSnap(0.5, 0.5, 20, 20)
expect(guides.activeGuides.value.length).toBeGreaterThan(0)
})
})
describe('calculateResizeSnap', () => {
it('returns proposed value when no edges', () => {
const result = guides.calculateResizeSnap('right', 42)
expect(result).toBe(42)
})
it('snaps right edge to nearest vertical', () => {
const layoutMap: Record<string, ElementLayout> = {
ref: makeLayout('ref', 100, 50, 40, 20),
}
guides.collectEdges(layoutMap, 'resizing', 210, 297)
// Snap to ref's left edge (100mm)
const result = guides.calculateResizeSnap('right', 99.5)
expect(result).toBe(100)
})
it('snaps bottom edge to nearest horizontal', () => {
const layoutMap: Record<string, ElementLayout> = {
ref: makeLayout('ref', 50, 80, 40, 20),
}
guides.collectEdges(layoutMap, 'resizing', 210, 297)
// Snap to ref's top edge (80mm)
const result = guides.calculateResizeSnap('bottom', 79.5)
expect(result).toBe(80)
})
it('does not snap when outside threshold', () => {
guides.collectEdges({}, 'none', 210, 297)
const result = guides.calculateResizeSnap('right', 50)
expect(result).toBe(50) // no edge near 50mm
})
})
describe('clearGuides', () => {
it('clears active guides and cached edges', () => {
guides.collectEdges({}, 'none', 210, 297)
guides.calculateSnap(0.5, 0.5, 10, 10)
expect(guides.activeGuides.value.length).toBeGreaterThan(0)
guides.clearGuides()
expect(guides.activeGuides.value).toHaveLength(0)
// After clear, calculateSnap should return unsnapped
const result = guides.calculateSnap(0.5, 0.5, 10, 10)
expect(result.snappedX_mm).toBe(0.5)
})
})
})

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { ref } from 'vue'
import { useUndoRedo } from '../useUndoRedo'
describe('useUndoRedo', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('starts with initial snapshot', () => {
const source = ref({ value: 1 })
const { canUndo, canRedo } = useUndoRedo(source)
expect(canUndo()).toBe(false) // only 1 snapshot (initial)
expect(canRedo()).toBe(false)
})
it('records snapshot after debounce', async () => {
const source = ref({ value: 1 })
const { canUndo } = useUndoRedo(source)
source.value = { value: 2 }
await vi.advanceTimersByTimeAsync(350) // debounce = 300ms
expect(canUndo()).toBe(true)
})
it('undo restores previous state', async () => {
const source = ref({ count: 0 })
const { undo, canUndo } = useUndoRedo(source)
source.value = { count: 1 }
await vi.advanceTimersByTimeAsync(350)
source.value = { count: 2 }
await vi.advanceTimersByTimeAsync(350)
expect(source.value.count).toBe(2)
undo()
expect(source.value.count).toBe(1)
undo()
expect(source.value.count).toBe(0)
})
it('redo restores undone state', async () => {
const source = ref({ count: 0 })
const { undo, redo, canRedo } = useUndoRedo(source)
source.value = { count: 1 }
await vi.advanceTimersByTimeAsync(350)
undo()
expect(source.value.count).toBe(0)
expect(canRedo()).toBe(true)
redo()
expect(source.value.count).toBe(1)
expect(canRedo()).toBe(false)
})
it('new mutation clears redo stack', async () => {
const source = ref({ v: 'a' })
const { undo, redo, canRedo } = useUndoRedo(source)
source.value = { v: 'b' }
await vi.advanceTimersByTimeAsync(350)
undo()
expect(canRedo()).toBe(true)
// New mutation after undo → clears redo
source.value = { v: 'c' }
await vi.advanceTimersByTimeAsync(350)
expect(canRedo()).toBe(false)
})
it('respects maxHistory limit', async () => {
const source = ref({ n: 0 })
const { canUndo, undo } = useUndoRedo(source, 3) // max 3 snapshots
source.value = { n: 1 }
await vi.advanceTimersByTimeAsync(350)
source.value = { n: 2 }
await vi.advanceTimersByTimeAsync(350)
source.value = { n: 3 }
await vi.advanceTimersByTimeAsync(350)
// Stack: [1, 2, 3] (initial 0 was shifted out)
// 3 snapshots, can undo twice (back to 1)
undo()
expect(source.value.n).toBe(2)
undo()
expect(source.value.n).toBe(1)
// Can't undo further (stack has only 1 left)
expect(canUndo()).toBe(false)
})
it('skips duplicate snapshots', async () => {
const source = ref({ x: 1 })
const { canUndo } = useUndoRedo(source)
// Set same value
source.value = { x: 1 }
await vi.advanceTimersByTimeAsync(350)
expect(canUndo()).toBe(false) // no new snapshot since value same
})
it('debounces rapid changes into one snapshot', async () => {
const source = ref({ n: 0 })
const { undo } = useUndoRedo(source)
// Rapid changes within debounce window
source.value = { n: 1 }
await vi.advanceTimersByTimeAsync(100)
source.value = { n: 2 }
await vi.advanceTimersByTimeAsync(100)
source.value = { n: 3 }
await vi.advanceTimersByTimeAsync(350) // trigger debounce
// Only one snapshot recorded (n=3), so one undo goes to initial
undo()
expect(source.value.n).toBe(0)
})
it('undo with only initial snapshot does nothing', () => {
const source = ref({ v: 'init' })
const { undo } = useUndoRedo(source)
undo() // should not crash
expect(source.value.v).toBe('init')
})
it('redo with empty redo stack does nothing', () => {
const source = ref({ v: 'init' })
const { redo } = useUndoRedo(source)
redo() // should not crash
expect(source.value.v).toBe('init')
})
})

View File

@@ -0,0 +1,30 @@
import { useTemplateStore } from '../stores/template'
import { useEditorStore } from '../stores/editor'
import type { TemplateElement } from '../core/types'
export function usePropertyUpdate(elementRef: () => 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: { ...elementRef().style, [key]: value } } as Partial<TemplateElement>)
}
function updateNested(
field: string,
key: string,
value: unknown,
defaults: Record<string, unknown> = {},
) {
const current = (elementRef() as any)[field] ?? defaults
update({ [field]: { ...current, [key]: value } } as any)
}
return { update, updateStyle, updateNested }
}

View File

@@ -116,10 +116,19 @@ export interface BarcodeStyle {
includeText?: boolean // barkod altına değer yazılsın mı (QR hariç) includeText?: boolean // barkod altına değer yazılsın mı (QR hariç)
} }
// --- Condition (koşullu gösterim) ---
export interface Condition {
path: string
operator: string
value?: unknown
}
// --- Element tipleri --- // --- Element tipleri ---
interface BaseElement { interface BaseElement {
id: string id: string
condition?: Condition
position: PositionMode position: PositionMode
size: SizeConstraint size: SizeConstraint
} }
@@ -241,11 +250,22 @@ export interface ChartLabels {
color?: string color?: string
} }
export interface ChartReferenceLine {
categoryIndex: number
color?: string
width?: number
label?: string
dash?: boolean
}
export interface ChartAxis { export interface ChartAxis {
xLabel?: string xLabel?: string
yLabel?: string yLabel?: string
showGrid?: boolean showGrid?: boolean
gridColor?: string gridColor?: string
showVerticalGrid?: boolean
verticalGridColor?: string
referenceLines?: ChartReferenceLine[]
} }
export interface ChartStyle { export interface ChartStyle {

View File

@@ -0,0 +1,119 @@
.et {
display: flex;
align-items: center;
gap: 2px;
background: #1e293b;
border-radius: 6px;
padding: 3px 4px;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.06);
pointer-events: auto;
white-space: nowrap;
}
.et__group {
display: flex;
align-items: center;
gap: 1px;
}
.et__sep {
width: 1px;
height: 16px;
background: #334155;
margin: 0 2px;
flex-shrink: 0;
}
.et__btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: transparent;
color: #94a3b8;
cursor: pointer;
padding: 0;
transition:
background 0.1s,
color 0.1s;
}
.et__btn:hover {
background: #334155;
color: #e2e8f0;
}
.et__btn--active {
background: #3b82f6;
color: white;
}
.et__btn--active:hover {
background: #2563eb;
}
.et__group--gap {
gap: 3px;
}
.et__gap-icon {
color: #64748b;
flex-shrink: 0;
}
.et__num {
width: 32px;
height: 22px;
border: 1px solid #334155;
border-radius: 4px;
background: #0f172a;
color: #e2e8f0;
text-align: center;
font-size: 11px;
font-family: inherit;
padding: 0;
outline: none;
-moz-appearance: textfield;
}
.et__num::-webkit-inner-spin-button,
.et__num::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.et__num:focus {
border-color: #3b82f6;
}
.et__color-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
position: relative;
color: #94a3b8;
transition: background 0.1s;
}
.et__color-wrap:hover {
background: #334155;
color: #e2e8f0;
}
.et__color {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
height: 100%;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

175
justfile
View File

@@ -55,6 +55,10 @@ test-visual-editor:
test-visual: visual-refs test-visual: visual-refs
cd frontend && bun run test:visual cd frontend && bun run test:visual
# Visual snapshot'lari guncelle (UI degisikliklerinden sonra)
update-snapshots: visual-refs
cd frontend && bun run test:visual -- --update-snapshots
# Tum testler (Rust + frontend unit + visual) # Tum testler (Rust + frontend unit + visual)
test-all: test-rust test-front test-visual test-all: test-rust test-front test-visual
@@ -109,3 +113,174 @@ publish-layout:
publish-all: publish-all:
just publish-core just publish-core
just publish-layout just publish-layout
# --- NuGet (Dreport.Service) ---
# Gitea NuGet feed (override env var DREPORT_NUGET_TOKEN if rotated).
NUGET_REGISTRY_URL := "https://gitea.duhanbalci.com/api/packages/duhanbalci/nuget/index.json"
NUGET_TOKEN := env_var_or_default("DREPORT_NUGET_TOKEN", "56b178d79d9cf9dea1c4b90d836d55e41ddff897")
NUGET_VERSION := "0.2.0"
# Build dreport-ffi for the host RID and copy the dylib into runtimes/.
nuget-build-native-host:
#!/usr/bin/env bash
set -euo pipefail
cargo build --release -p dreport-ffi
case "$(uname -s)-$(uname -m)" in
Darwin-arm64) RID=osx-arm64; PREFIX=lib; EXT=dylib ;;
Darwin-x86_64) RID=osx-x64; PREFIX=lib; EXT=dylib ;;
Linux-x86_64) RID=linux-x64; PREFIX=lib; EXT=so ;;
Linux-aarch64) RID=linux-arm64; PREFIX=lib; EXT=so ;;
*) echo "unsupported host: $(uname -s)-$(uname -m)" >&2; exit 1 ;;
esac
DEST="bindings/dotnet/src/Dreport.Service/runtimes/$RID/native"
mkdir -p "$DEST"
cp "target/release/${PREFIX}dreport_ffi.$EXT" "$DEST/${PREFIX}dreport_ffi.$EXT"
# Cross-compile dreport-ffi for all supported RIDs into runtimes/.
# Requires: rustup targets installed + cargo-zigbuild (`cargo install cargo-zigbuild` and `brew install zig`).
nuget-build-native-all:
#!/usr/bin/env bash
set -euo pipefail
if ! command -v cargo-zigbuild >/dev/null; then
echo "cargo-zigbuild not found. install with: cargo install cargo-zigbuild && brew install zig" >&2
exit 1
fi
BASE="bindings/dotnet/src/Dreport.Service/runtimes"
build_target() {
local TARGET=$1 RID=$2 PREFIX=$3 EXT=$4
rustup target add "$TARGET" >/dev/null
cargo zigbuild --release -p dreport-ffi --target "$TARGET"
mkdir -p "$BASE/$RID/native"
cp "target/$TARGET/release/${PREFIX}dreport_ffi.$EXT" \
"$BASE/$RID/native/${PREFIX}dreport_ffi.$EXT"
echo "$RID"
}
build_target aarch64-apple-darwin osx-arm64 lib dylib
build_target x86_64-apple-darwin osx-x64 lib dylib
build_target x86_64-unknown-linux-gnu linux-x64 lib so
build_target aarch64-unknown-linux-gnu linux-arm64 lib so
build_target x86_64-pc-windows-gnu win-x64 "" dll
# Generate a nuspec for whatever RIDs currently sit in runtimes/, then pack
# the Dreport.Service NuGet package.
nuget-pack:
#!/usr/bin/env bash
set -euo pipefail
PROJ_DIR="bindings/dotnet/src/Dreport.Service"
NUSPEC_NAME=".generated.nuspec"
NUSPEC="$PROJ_DIR/$NUSPEC_NAME"
OUT_DIR="$(pwd)/target/nuget"
mkdir -p "$OUT_DIR"
dotnet build "$PROJ_DIR/Dreport.Service.csproj" -c Release --nologo
# <files> entries for every native binary that exists on disk.
FILES=""
while IFS= read -r path; do
rel="${path#${PROJ_DIR}/}"
FILES+=" <file src=\"$rel\" target=\"$rel\" />"$'\n'
done < <(find "$PROJ_DIR/runtimes" -type f \( -name '*.dylib' -o -name '*.so' -o -name '*.dll' \) 2>/dev/null | sort)
{
echo '<?xml version="1.0" encoding="utf-8"?>'
echo '<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">'
echo ' <metadata>'
echo ' <id>Dreport.Service</id>'
echo " <version>{{NUGET_VERSION}}</version>"
echo ' <authors>dreport</authors>'
echo ' <description>Native layout engine + PDF renderer for dreport templates. Wraps the dreport-ffi C ABI.</description>'
echo ' <tags>pdf layout template rendering</tags>'
echo ' <dependencies><group targetFramework="net8.0" /></dependencies>'
echo ' </metadata>'
echo ' <files>'
echo ' <file src="bin/Release/net8.0/Dreport.Service.dll" target="lib/net8.0/Dreport.Service.dll" />'
printf '%s' "$FILES"
echo ' </files>'
echo '</package>'
} > "$NUSPEC"
rm -f "$OUT_DIR/Dreport.Service."*.nupkg
dotnet pack "$PROJ_DIR/Dreport.Service.csproj" \
-c Release --no-build --nologo \
-p:NuspecFile="$NUSPEC_NAME" \
-p:NuspecBasePath="." \
-p:IsPackable=true \
-o "$OUT_DIR"
echo "package -> $OUT_DIR/Dreport.Service.{{NUGET_VERSION}}.nupkg"
unzip -l "$OUT_DIR/Dreport.Service.{{NUGET_VERSION}}.nupkg"
# Push the packed nupkg to Gitea.
nuget-push:
dotnet nuget push \
"target/nuget/Dreport.Service.{{NUGET_VERSION}}.nupkg" \
--source "{{NUGET_REGISTRY_URL}}" \
--api-key "{{NUGET_TOKEN}}" \
--skip-duplicate
# Pack Dreport.AspNetCore (depends on Dreport.Service via NuGet dependency).
nuget-pack-aspnetcore:
#!/usr/bin/env bash
set -euo pipefail
PROJ_DIR="bindings/dotnet/src/Dreport.AspNetCore"
NUSPEC_NAME=".generated.nuspec"
NUSPEC="$PROJ_DIR/$NUSPEC_NAME"
OUT_DIR="$(pwd)/target/nuget"
mkdir -p "$OUT_DIR"
dotnet build "$PROJ_DIR/Dreport.AspNetCore.csproj" -c Release --nologo
{
echo '<?xml version="1.0" encoding="utf-8"?>'
echo '<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">'
echo ' <metadata>'
echo ' <id>Dreport.AspNetCore</id>'
echo " <version>{{NUGET_VERSION}}</version>"
echo ' <authors>dreport</authors>'
echo ' <description>ASP.NET Core integration for Dreport.Service: DI registration plus optional /api endpoint mapping.</description>'
echo ' <tags>pdf layout aspnetcore dreport</tags>'
echo ' <dependencies>'
echo ' <group targetFramework="net8.0">'
echo " <dependency id=\"Dreport.Service\" version=\"{{NUGET_VERSION}}\" />"
echo ' </group>'
echo ' </dependencies>'
echo ' <frameworkReferences>'
echo ' <group targetFramework="net8.0">'
echo ' <frameworkReference name="Microsoft.AspNetCore.App" />'
echo ' </group>'
echo ' </frameworkReferences>'
echo ' </metadata>'
echo ' <files>'
echo ' <file src="bin/Release/net8.0/Dreport.AspNetCore.dll" target="lib/net8.0/Dreport.AspNetCore.dll" />'
echo ' </files>'
echo '</package>'
} > "$NUSPEC"
rm -f "$OUT_DIR/Dreport.AspNetCore."*.nupkg
dotnet pack "$PROJ_DIR/Dreport.AspNetCore.csproj" \
-c Release --no-build --nologo \
-p:NuspecFile="$NUSPEC_NAME" \
-p:NuspecBasePath="." \
-p:IsPackable=true \
-o "$OUT_DIR"
echo "package -> $OUT_DIR/Dreport.AspNetCore.{{NUGET_VERSION}}.nupkg"
unzip -l "$OUT_DIR/Dreport.AspNetCore.{{NUGET_VERSION}}.nupkg"
# Push Dreport.AspNetCore to Gitea.
nuget-push-aspnetcore:
dotnet nuget push \
"target/nuget/Dreport.AspNetCore.{{NUGET_VERSION}}.nupkg" \
--source "{{NUGET_REGISTRY_URL}}" \
--api-key "{{NUGET_TOKEN}}" \
--skip-duplicate
# Single-host publish (host RID only — fastest, good for dev iterations).
nuget-publish: nuget-build-native-host nuget-pack nuget-push nuget-pack-aspnetcore nuget-push-aspnetcore
# Multi-RID publish (osx-arm64 + osx-x64 + linux-x64 + linux-arm64 + win-x64).
# Requires cargo-zigbuild. Single command, all platforms + AspNetCore, push to Gitea.
nuget-publish-all: nuget-build-native-all nuget-pack nuget-push nuget-pack-aspnetcore nuget-push-aspnetcore

View File

@@ -12,7 +12,7 @@ crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
dreport-core = { version = "0.2.0", path = "../core", registry = "gitea" } dreport-core = { version = "0.2.0", path = "../core", registry = "gitea" }
dexpr = { version = "0.1.0", registry = "gitea" } dexpr = { version = "0.3.0", registry = "gitea" }
rust_decimal = "1.41" rust_decimal = "1.41"
taffy = "0.9" taffy = "0.9"
cosmic-text = { version = "0.18", default-features = false, features = ["std", "swash"] } cosmic-text = { version = "0.18", default-features = false, features = ["std", "swash"] }

View File

@@ -64,7 +64,9 @@ pub struct YTick {
pub struct XLabelLayout { pub struct XLabelLayout {
pub labels: Vec<XLabel>, pub labels: Vec<XLabel>,
pub needs_rotate: bool, /// Rotation angle in degrees (0 = horizontal, 90 = fully vertical).
/// Dynamically computed based on available space vs label length.
pub rotate_angle: f64,
} }
pub struct XLabel { pub struct XLabel {
@@ -127,10 +129,23 @@ pub struct LineChartLayout {
pub show_labels: bool, pub show_labels: bool,
pub label_font: f64, pub label_font: f64,
pub label_color: String, pub label_color: String,
pub smooth: bool,
/// X axis line endpoints /// X axis line endpoints
pub x_axis_y: f64, pub x_axis_y: f64,
pub x_axis_x1: f64, pub x_axis_x1: f64,
pub x_axis_x2: f64, pub x_axis_x2: f64,
/// Vertical reference lines
pub ref_lines: Vec<RefLineLayout>,
}
pub struct RefLineLayout {
pub x: f64,
pub y1: f64,
pub y2: f64,
pub color: String,
pub width: f64,
pub dash: bool,
pub label: Option<String>,
} }
pub struct PieSlice { pub struct PieSlice {
@@ -162,6 +177,8 @@ pub struct PieChartLayout {
pub inner_radius: f64, pub inner_radius: f64,
pub slices: Vec<PieSlice>, pub slices: Vec<PieSlice>,
pub show_labels: bool, pub show_labels: bool,
/// Category name labels + leader lines outside slices
pub show_cat_labels: bool,
pub label_font: f64, pub label_font: f64,
pub label_color: String, pub label_color: String,
} }
@@ -219,6 +236,10 @@ pub trait ChartDataSource {
fn inner_radius(&self) -> Option<f64>; fn inner_radius(&self) -> Option<f64>;
fn show_points(&self) -> Option<bool>; fn show_points(&self) -> Option<bool>;
fn line_width(&self) -> Option<f64>; fn line_width(&self) -> Option<f64>;
fn curve_type(&self) -> Option<&str>;
fn reference_lines(&self) -> &[dreport_core::models::ChartReferenceLine];
fn show_vertical_grid(&self) -> bool;
fn vertical_grid_color(&self) -> Option<&str>;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -310,6 +331,18 @@ impl ChartDataSource for crate::data_resolve::ResolvedChartData {
fn line_width(&self) -> Option<f64> { fn line_width(&self) -> Option<f64> {
self.style.line_width self.style.line_width
} }
fn curve_type(&self) -> Option<&str> {
self.style.curve_type.as_deref()
}
fn reference_lines(&self) -> &[dreport_core::models::ChartReferenceLine] {
self.axis.as_ref().map_or(&[], |a| &a.reference_lines)
}
fn show_vertical_grid(&self) -> bool {
self.axis.as_ref().and_then(|a| a.show_vertical_grid).unwrap_or(true)
}
fn vertical_grid_color(&self) -> Option<&str> {
self.axis.as_ref().and_then(|a| a.vertical_grid_color.as_deref())
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -399,6 +432,18 @@ impl ChartDataSource for crate::ChartRenderData {
fn line_width(&self) -> Option<f64> { fn line_width(&self) -> Option<f64> {
self.line_width self.line_width
} }
fn curve_type(&self) -> Option<&str> {
self.curve_type.as_deref()
}
fn reference_lines(&self) -> &[dreport_core::models::ChartReferenceLine] {
&self.reference_lines
}
fn show_vertical_grid(&self) -> bool {
self.show_vertical_grid
}
fn vertical_grid_color(&self) -> Option<&str> {
self.vertical_grid_color.as_deref()
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -543,14 +588,14 @@ pub fn compute_chart_layout(
} else { } else {
available_w available_w
}; };
let max_chars_fit = (cat_width / 1.25).max(1.0) as usize; let rotate_angle = compute_label_rotation(max_label_len, cat_width);
let will_rotate = max_label_len > max_chars_fit; if rotate_angle > 0.0 {
if will_rotate { let char_w_mm = 2.5 * 0.6;
let char_w_mm = 1.1;
let max_text_w = max_label_len as f64 * char_w_mm; let max_text_w = max_label_len as f64 * char_w_mm;
let label_v = max_text_w * 0.707; let angle_rad = rotate_angle.to_radians();
let label_v = max_text_w * angle_rad.sin();
margin_bottom += label_v.clamp(6.0, 25.0); margin_bottom += label_v.clamp(6.0, 25.0);
let label_h = max_text_w * 0.707; let label_h = max_text_w * angle_rad.cos();
let extra_left = (label_h - cat_width / 2.0).max(0.0); let extra_left = (label_h - cat_width / 2.0).max(0.0);
margin_left += extra_left.min(10.0); margin_left += extra_left.min(10.0);
} else { } else {
@@ -620,6 +665,29 @@ pub fn compute_y_axis(
} }
} }
/// Compute dynamic label rotation angle (degrees) based on available space.
/// Uses Chart.js-style algorithm: rotate only when labels overflow their slot,
/// and use the minimum angle that prevents overlap.
fn compute_label_rotation(max_label_len: usize, slot_width: f64) -> f64 {
let label_font_size = 2.5_f64;
let char_w_mm = label_font_size * 0.6;
let max_label_w = max_label_len as f64 * char_w_mm;
let padding = label_font_size * 0.5;
// Labels fit horizontally — no rotation needed
if (max_label_w + padding) <= slot_width {
return 0.0;
}
// Chart.js Constraint A: sin(angle) = (label_height + padding) / slot_width
// This finds the minimum angle where the rotated label's projected height
// fits within the tick slot width, preventing horizontal overlap.
let label_h = label_font_size;
let sin_val = ((label_h + padding) / slot_width).clamp(0.0, 1.0);
let angle_deg = sin_val.asin().to_degrees();
angle_deg.clamp(0.0, 50.0)
}
/// Compute X label positions for bar chart (slot-based spacing). /// Compute X label positions for bar chart (slot-based spacing).
pub fn compute_x_labels_bar( pub fn compute_x_labels_bar(
categories: &[String], categories: &[String],
@@ -631,12 +699,12 @@ pub fn compute_x_labels_bar(
if n_cats == 0 { if n_cats == 0 {
return XLabelLayout { return XLabelLayout {
labels: vec![], labels: vec![],
needs_rotate: false, rotate_angle: 0.0,
}; };
} }
let cat_width = pw / n_cats as f64; let cat_width = pw / n_cats as f64;
let max_chars = (cat_width / 1.25).max(1.0) as usize; let max_label_len = categories.iter().map(|c| c.len()).max().unwrap_or(0);
let needs_rotate = categories.iter().any(|c| c.len() > max_chars); let rotate_angle = compute_label_rotation(max_label_len, cat_width);
let labels = categories let labels = categories
.iter() .iter()
.enumerate() .enumerate()
@@ -648,7 +716,7 @@ pub fn compute_x_labels_bar(
.collect(); .collect();
XLabelLayout { XLabelLayout {
labels, labels,
needs_rotate, rotate_angle,
} }
} }
@@ -663,25 +731,17 @@ pub fn compute_x_labels_line(
if n_cats == 0 { if n_cats == 0 {
return XLabelLayout { return XLabelLayout {
labels: vec![], labels: vec![],
needs_rotate: false, rotate_angle: 0.0,
}; };
} }
let spacing = if n_cats == 1 { let step = pw / n_cats as f64;
pw let max_label_len = categories.iter().map(|c| c.len()).max().unwrap_or(0);
} else { let rotate_angle = compute_label_rotation(max_label_len, step);
pw / (n_cats - 1) as f64
};
let max_chars = (spacing / 1.25).max(1.0) as usize;
let needs_rotate = categories.iter().any(|c| c.len() > max_chars);
let labels = categories let labels = categories
.iter() .iter()
.enumerate() .enumerate()
.map(|(ci, cat)| { .map(|(ci, cat)| {
let x = if n_cats == 1 { let x = px + step / 2.0 + ci as f64 * step;
px + pw / 2.0
} else {
px + ci as f64 * pw / (n_cats - 1) as f64
};
XLabel { XLabel {
text: cat.clone(), text: cat.clone(),
x, x,
@@ -691,7 +751,7 @@ pub fn compute_x_labels_line(
.collect(); .collect();
XLabelLayout { XLabelLayout {
labels, labels,
needs_rotate, rotate_angle,
} }
} }
@@ -806,6 +866,11 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
let show_labels = data.show_labels(); let show_labels = data.show_labels();
let label_font = data.label_font_size().unwrap_or(2.2); let label_font = data.label_font_size().unwrap_or(2.2);
let label_color = data.label_color().unwrap_or("#333").to_string(); let label_color = data.label_color().unwrap_or("#333").to_string();
let smooth = data.curve_type() == Some("smooth");
// Slot-based positioning: each category gets a slot, point centered in slot
// This adds padding on left/right so first/last points don't touch axes
let step = if n_cats > 0 { pw / n_cats as f64 } else { pw };
let series = (0..data.series_count()) let series = (0..data.series_count())
.map(|si| { .map(|si| {
@@ -814,11 +879,7 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
.iter() .iter()
.enumerate() .enumerate()
.map(|(ci, val)| { .map(|(ci, val)| {
let x = if n_cats == 1 { let x = px + step / 2.0 + ci as f64 * step;
px + pw / 2.0
} else {
px + ci as f64 * pw / (n_cats - 1) as f64
};
let y = py + ph - ((val - min_val) / range) * ph; let y = py + ph - ((val - min_val) / range) * ph;
LinePoint { x, y, value: *val } LinePoint { x, y, value: *val }
}) })
@@ -832,6 +893,42 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
let x_labels = compute_x_labels_line(data.categories(), px, py + ph, pw); let x_labels = compute_x_labels_line(data.categories(), px, py + ph, pw);
// Vertical grid lines at each category
let vgrid_color = data.vertical_grid_color().unwrap_or("#E5E7EB").to_string();
let mut ref_lines: Vec<RefLineLayout> = if data.show_vertical_grid() {
(0..n_cats).map(|ci| {
let x = px + step / 2.0 + ci as f64 * step;
RefLineLayout {
x,
y1: py,
y2: py + ph,
color: vgrid_color.clone(),
width: 0.15,
dash: false,
label: None,
}
}).collect()
} else {
vec![]
};
// Explicit reference lines (overlay on top of grid)
for rl in data.reference_lines() {
if rl.category_index >= n_cats {
continue;
}
let x = px + step / 2.0 + rl.category_index as f64 * step;
ref_lines.push(RefLineLayout {
x,
y1: py,
y2: py + ph,
color: rl.color.clone().unwrap_or_else(|| "#9CA3AF".to_string()),
width: rl.width.unwrap_or(0.3),
dash: rl.dash.unwrap_or(true),
label: rl.label.clone(),
});
}
LineChartLayout { LineChartLayout {
min_val, min_val,
max_val, max_val,
@@ -843,9 +940,11 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
show_labels, show_labels,
label_font, label_font,
label_color, label_color,
smooth,
x_axis_y: py + ph, x_axis_y: py + ph,
x_axis_x1: px, x_axis_x1: px,
x_axis_x2: px + pw, x_axis_x2: px + pw,
ref_lines,
} }
} }
@@ -955,6 +1054,7 @@ pub fn compute_pie_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> PieCh
inner_radius: inner_r, inner_radius: inner_r,
slices, slices,
show_labels, show_labels,
show_cat_labels: show_labels,
label_font, label_font,
label_color, label_color,
} }

View File

@@ -29,14 +29,14 @@ pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> St
// Title // Title
if let Some(ref title) = cl.title { if let Some(ref title) = cl.title {
let anchor = match title.align.as_str() { let anchor = match title.align.as_str() {
"left" => "start", "left" => SvgAnchor::Start,
"right" => "end", "right" => SvgAnchor::End,
_ => "middle", _ => SvgAnchor::Middle,
}; };
write!( write!(
svg, svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="{}" font-weight="bold">{}</text>"##, r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="{}" font-weight="bold">{}</text>"##,
title.x, title.y, title.font_size, title.color, anchor, escape_xml(&title.text) title.x, title.y, title.font_size, title.color, anchor.as_str(), escape_xml(&title.text)
) )
.unwrap(); .unwrap();
} }
@@ -48,7 +48,7 @@ pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> St
} }
// Legend render // Legend render
if cl.legend_show && data.series.len() > 1 { if cl.legend_show {
render_legend(&mut svg, data, &cl, width_mm, height_mm); render_legend(&mut svg, data, &cl, width_mm, height_mm);
} }
@@ -56,14 +56,7 @@ pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> St
let has_axis = !matches!(data.chart_type, dreport_core::models::ChartType::Pie); let has_axis = !matches!(data.chart_type, dreport_core::models::ChartType::Pie);
if has_axis && let Some(ref axis) = data.axis { if has_axis && let Some(ref axis) = data.axis {
if let Some(ref x_label) = axis.x_label { if let Some(ref x_label) = axis.x_label {
let x = cl.plot_x + cl.plot_w / 2.0; svg_text(&mut svg, cl.plot_x + cl.plot_w / 2.0, height_mm - 2.0, 2.8, "#666", SvgAnchor::Middle, x_label);
let y = height_mm - 2.0;
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.8" fill="#666" text-anchor="middle">{}</text>"##,
x, y, escape_xml(x_label)
)
.unwrap();
} }
if let Some(ref y_label) = axis.y_label { if let Some(ref y_label) = axis.y_label {
let x = 3.0; let x = 3.0;
@@ -101,24 +94,8 @@ fn render_bar(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
) )
.unwrap(); .unwrap();
if bl.show_labels { if bl.show_labels && (!bl.stacked || bar.value > 0.0) {
if bl.stacked { svg_text(svg, bar.label_x, bar.label_y, bl.label_font, &bl.label_color, SvgAnchor::Middle, &format_value(bar.value));
if bar.value > 0.0 {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle">{}</text>"##,
bar.label_x, bar.label_y, bl.label_font, bl.label_color, format_value(bar.value)
)
.unwrap();
}
} else {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle">{}</text>"##,
bar.label_x, bar.label_y, bl.label_font, bl.label_color, format_value(bar.value)
)
.unwrap();
}
} }
} }
@@ -144,14 +121,13 @@ fn render_line(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
// Y axis // Y axis
render_y_axis_svg(svg, &ll.y_axis); render_y_axis_svg(svg, &ll.y_axis);
let mut label_texts = String::new();
for series_layout in &ll.series { for series_layout in &ll.series {
let color = color_at(&cl.palette, series_layout.color_idx); let color = color_at(&cl.palette, series_layout.color_idx);
let mut points = String::new();
let mut point_circles = String::new(); let mut point_circles = String::new();
for pt in &series_layout.points { for pt in &series_layout.points {
write!(points, "{:.2},{:.2} ", pt.x, pt.y).unwrap();
if ll.show_points { if ll.show_points {
write!( write!(
point_circles, point_circles,
@@ -162,24 +138,75 @@ fn render_line(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
} }
if ll.show_labels { if ll.show_labels {
write!( svg_text(&mut label_texts, pt.x, pt.y - 1.5, ll.label_font, &ll.label_color, SvgAnchor::Middle, &format_value(pt.value));
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle">{}</text>"##,
pt.x, pt.y - 1.5, ll.label_font, ll.label_color, format_value(pt.value)
)
.unwrap();
} }
} }
write!( if ll.smooth && series_layout.points.len() >= 2 {
svg, // Catmull-Rom → cubic bezier smooth curve
r##"<polyline points="{}" fill="none" stroke="{}" stroke-width="{:.2}" stroke-linejoin="round" stroke-linecap="round"/>"##, let pts = &series_layout.points;
points.trim(), color, ll.line_width let mut d = format!("M{:.2},{:.2}", pts[0].x, pts[0].y);
) for i in 0..pts.len() - 1 {
.unwrap(); let p0 = if i > 0 { &pts[i - 1] } else { &pts[i] };
let p1 = &pts[i];
let p2 = &pts[i + 1];
let p3 = if i + 2 < pts.len() { &pts[i + 2] } else { &pts[i + 1] };
let cp1x = p1.x + (p2.x - p0.x) / 6.0;
let cp1y = p1.y + (p2.y - p0.y) / 6.0;
let cp2x = p2.x - (p3.x - p1.x) / 6.0;
let cp2y = p2.y - (p3.y - p1.y) / 6.0;
write!(d, " C{:.2},{:.2} {:.2},{:.2} {:.2},{:.2}",
cp1x, cp1y, cp2x, cp2y, p2.x, p2.y
).unwrap();
}
write!(
svg,
r##"<path d="{}" fill="none" stroke="{}" stroke-width="{:.2}" stroke-linejoin="round" stroke-linecap="round"/>"##,
d, color, ll.line_width
)
.unwrap();
} else {
let mut points = String::new();
for pt in &series_layout.points {
write!(points, "{:.2},{:.2} ", pt.x, pt.y).unwrap();
}
write!(
svg,
r##"<polyline points="{}" fill="none" stroke="{}" stroke-width="{:.2}" stroke-linejoin="round" stroke-linecap="round"/>"##,
points.trim(), color, ll.line_width
)
.unwrap();
}
svg.push_str(&point_circles); svg.push_str(&point_circles);
} }
// Data labels (rendered after lines/points so they appear on top)
svg.push_str(&label_texts);
// Reference lines (vertical)
for rl in &ll.ref_lines {
if rl.dash {
write!(
svg,
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="{}" stroke-width="{:.2}" stroke-dasharray="1.5,1"/>"##,
rl.x, rl.y1, rl.x, rl.y2, rl.color, rl.width
)
.unwrap();
} else {
write!(
svg,
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="{}" stroke-width="{:.2}"/>"##,
rl.x, rl.y1, rl.x, rl.y2, rl.color, rl.width
)
.unwrap();
}
if let Some(ref label) = rl.label {
svg_text(svg, rl.x, rl.y1 - 1.0, 2.0, &rl.color, SvgAnchor::Middle, label);
}
}
// X axis labels // X axis labels
render_x_labels_svg(svg, &ll.x_labels); render_x_labels_svg(svg, &ll.x_labels);
@@ -239,19 +266,12 @@ fn render_pie(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
.unwrap(); .unwrap();
} }
// Percentage label inside slice
if pl.show_labels { if pl.show_labels {
write!( let pct = format!("{}%", (slice.fraction * 100.0).round());
svg, svg_text_central(svg, slice.label_x, slice.label_y, pl.label_font, &pl.label_color, SvgAnchor::Middle, &pct);
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle" dominant-baseline="central">{}%</text>"##,
slice.label_x, slice.label_y, pl.label_font, pl.label_color,
(slice.fraction * 100.0).round()
)
.unwrap();
} }
// Category name label outside slice with leader line if pl.show_cat_labels && !slice.cat_label_text.is_empty() {
if !slice.cat_label_text.is_empty() {
write!( write!(
svg, svg,
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="#999" stroke-width="0.2"/>"##, r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="#999" stroke-width="0.2"/>"##,
@@ -259,18 +279,8 @@ fn render_pie(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
slice.leader_end_x, slice.leader_end_y slice.leader_end_x, slice.leader_end_y
) )
.unwrap(); .unwrap();
let anchor = if slice.cat_label_anchor_end { SvgAnchor::End } else { SvgAnchor::Start };
let anchor = if slice.cat_label_anchor_end { svg_text_central(svg, slice.cat_label_x, slice.cat_label_y, 2.5, "#555", anchor, &slice.cat_label_text);
"end"
} else {
"start"
};
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.5" fill="#555" text-anchor="{}" dominant-baseline="central">{}</text>"##,
slice.cat_label_x, slice.cat_label_y, anchor, escape_xml(&slice.cat_label_text)
)
.unwrap();
} }
} }
} }
@@ -292,15 +302,7 @@ fn render_legend(
item.swatch_x, item.swatch_y, color item.swatch_x, item.swatch_y, color
) )
.unwrap(); .unwrap();
write!( svg_text(svg, item.text_x, item.text_y, legend.font_size, "#666", SvgAnchor::Start, &item.name);
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="#666">{}</text>"##,
item.text_x,
item.text_y,
legend.font_size,
escape_xml(&item.name)
)
.unwrap();
} }
} }
@@ -310,12 +312,7 @@ fn render_legend(
fn render_y_axis_svg(svg: &mut String, y_axis: &chart_layout::YAxisLayout) { fn render_y_axis_svg(svg: &mut String, y_axis: &chart_layout::YAxisLayout) {
for tick in &y_axis.ticks { for tick in &y_axis.ticks {
write!( svg_text(svg, y_axis.axis_x - 1.5, tick.y + 0.8, 2.3, "#666", SvgAnchor::End, &tick.label);
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.3" fill="#666" text-anchor="end">{}</text>"##,
y_axis.axis_x - 1.5, tick.y + 0.8, tick.label
)
.unwrap();
if y_axis.show_grid { if y_axis.show_grid {
write!( write!(
@@ -337,21 +334,18 @@ fn render_y_axis_svg(svg: &mut String, y_axis: &chart_layout::YAxisLayout) {
} }
fn render_x_labels_svg(svg: &mut String, x_labels: &chart_layout::XLabelLayout) { fn render_x_labels_svg(svg: &mut String, x_labels: &chart_layout::XLabelLayout) {
let angle = x_labels.rotate_angle;
for label in &x_labels.labels { for label in &x_labels.labels {
if x_labels.needs_rotate { if angle > 0.0 {
// Döndürülmüş etiket — transform gerektiğinden helper kullanamıyoruz
write!( write!(
svg, svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.2" fill="#666" text-anchor="end" transform="rotate(-45,{:.2},{:.2})">{}</text>"##, r##"<text x="{:.2}" y="{:.2}" font-size="2.2" fill="#666" text-anchor="end" transform="rotate(-{:.1},{:.2},{:.2})">{}</text>"##,
label.x, label.y, label.x, label.y, escape_xml(&label.text) label.x, label.y, angle, label.x, label.y, escape_xml(&label.text)
) )
.unwrap(); .unwrap();
} else { } else {
write!( svg_text(svg, label.x, label.y, 2.5, "#666", SvgAnchor::Middle, &label.text);
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.5" fill="#666" text-anchor="middle">{}</text>"##,
label.x, label.y, escape_xml(&label.text)
)
.unwrap();
} }
} }
} }
@@ -362,3 +356,311 @@ fn escape_xml(s: &str) -> String {
.replace('>', "&gt;") .replace('>', "&gt;")
.replace('"', "&quot;") .replace('"', "&quot;")
} }
/// SVG text hizalama modu
enum SvgAnchor {
Start,
Middle,
End,
}
impl SvgAnchor {
fn as_str(&self) -> &str {
match self {
SvgAnchor::Start => "start",
SvgAnchor::Middle => "middle",
SvgAnchor::End => "end",
}
}
}
/// Tekrarlayan SVG text element yazımını soyutlar.
fn svg_text(
svg: &mut String,
x: f64,
y: f64,
font_size: f64,
fill: &str,
anchor: SvgAnchor,
text: &str,
) {
write!(
svg,
r##"<text x="{x:.2}" y="{y:.2}" font-size="{font_size:.1}" fill="{fill}" text-anchor="{anchor}">{text}</text>"##,
anchor = anchor.as_str(),
text = escape_xml(text),
)
.unwrap();
}
/// SVG text with dominant-baseline="central" (pie labels vb.)
fn svg_text_central(
svg: &mut String,
x: f64,
y: f64,
font_size: f64,
fill: &str,
anchor: SvgAnchor,
text: &str,
) {
write!(
svg,
r##"<text x="{x:.2}" y="{y:.2}" font-size="{font_size:.1}" fill="{fill}" text-anchor="{anchor}" dominant-baseline="central">{text}</text>"##,
anchor = anchor.as_str(),
text = escape_xml(text),
)
.unwrap();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data_resolve::{ChartSeries, ResolvedChartData};
use dreport_core::models::{ChartAxis, ChartLabels, ChartLegend, ChartStyle, ChartTitle, ChartType};
fn make_bar_data(categories: Vec<&str>, series: Vec<(&str, Vec<f64>)>) -> ResolvedChartData {
ResolvedChartData {
chart_type: ChartType::Bar,
categories: categories.into_iter().map(|s| s.to_string()).collect(),
series: series
.into_iter()
.map(|(name, values)| ChartSeries {
name: name.to_string(),
values,
})
.collect(),
title: None,
legend: None,
labels: None,
axis: None,
style: ChartStyle::default(),
group_mode: None,
}
}
fn make_line_data(categories: Vec<&str>, series: Vec<(&str, Vec<f64>)>) -> ResolvedChartData {
let mut data = make_bar_data(categories, series);
data.chart_type = ChartType::Line;
data
}
fn make_pie_data(categories: Vec<&str>, values: Vec<f64>) -> ResolvedChartData {
ResolvedChartData {
chart_type: ChartType::Pie,
categories: categories.into_iter().map(|s| s.to_string()).collect(),
series: vec![ChartSeries {
name: "data".to_string(),
values,
}],
title: None,
legend: None,
labels: None,
axis: None,
style: ChartStyle::default(),
group_mode: None,
}
}
#[test]
fn test_bar_chart_svg_structure() {
let data = make_bar_data(vec!["A", "B", "C"], vec![("Sales", vec![10.0, 20.0, 30.0])]);
let svg = render_svg(&data, 100.0, 60.0);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
// 3 categories × 1 series = 3 bars (each with rx="0.5")
let bar_count = svg.matches(r#"rx="0.5""#).count();
assert_eq!(bar_count, 3, "expected 3 bars for 3 categories, got {}", bar_count);
}
#[test]
fn test_bar_chart_with_labels() {
let mut data = make_bar_data(vec!["A", "B"], vec![("S1", vec![10.0, 20.0])]);
data.labels = Some(ChartLabels {
show: true,
font_size: None,
color: None,
});
let svg = render_svg(&data, 100.0, 60.0);
// Labels shown → should contain text elements with formatted values
assert!(svg.contains("<text"), "labels enabled but no text elements found");
}
#[test]
fn test_line_chart_svg_structure() {
let data = make_line_data(vec!["Jan", "Feb", "Mar"], vec![("Revenue", vec![5.0, 15.0, 10.0])]);
let svg = render_svg(&data, 100.0, 60.0);
assert!(svg.starts_with("<svg"));
// Should contain polyline for the series
assert!(svg.contains("<polyline"), "line chart should contain polyline");
}
#[test]
fn test_line_chart_with_points() {
let mut data = make_line_data(vec!["A", "B", "C"], vec![("S1", vec![1.0, 2.0, 3.0])]);
data.style.show_points = Some(true);
let svg = render_svg(&data, 100.0, 60.0);
// 3 data points → 3 circles
let circle_count = svg.matches("<circle").count();
assert_eq!(circle_count, 3, "expected 3 circles for 3 data points, got {}", circle_count);
}
#[test]
fn test_pie_chart_svg_structure() {
let data = make_pie_data(vec!["A", "B", "C"], vec![50.0, 30.0, 20.0]);
let svg = render_svg(&data, 80.0, 80.0);
assert!(svg.starts_with("<svg"));
// 3 slices → 3 path elements
let path_count = svg.matches("<path d=").count();
assert_eq!(path_count, 3, "expected 3 pie slices, got {}", path_count);
}
#[test]
fn test_pie_chart_percentage_labels() {
let mut data = make_pie_data(vec!["A", "B"], vec![75.0, 25.0]);
data.labels = Some(ChartLabels {
show: true,
font_size: None,
color: None,
});
let svg = render_svg(&data, 80.0, 80.0);
assert!(svg.contains("75%"), "should show 75% label");
assert!(svg.contains("25%"), "should show 25% label");
}
#[test]
fn test_legend_renders_for_multi_series() {
let mut data = make_bar_data(
vec!["A", "B"],
vec![("Series 1", vec![10.0, 20.0]), ("Series 2", vec![15.0, 25.0])],
);
data.legend = Some(ChartLegend {
show: true,
position: None,
font_size: None,
});
let svg = render_svg(&data, 100.0, 60.0);
// Multi-series + legend.show → legend should render
assert!(svg.contains("Series 1"), "legend should show series name");
assert!(svg.contains("Series 2"), "legend should show second series name");
}
#[test]
fn test_legend_hidden_for_single_series() {
let data = make_bar_data(vec!["A", "B"], vec![("Only", vec![10.0, 20.0])]);
let svg = render_svg(&data, 100.0, 60.0);
// legend: None → legend_show=false → legend not rendered
// The text "Only" might appear in x-axis labels, so check for legend swatch rect pattern
// Legend renders swatch rects with width="2.5" height="2.5"
let legend_swatch = svg.contains(r#"width="2.5" height="2.5""#);
assert!(!legend_swatch, "single series should not render legend swatches");
}
#[test]
fn test_empty_categories_bar_chart() {
let data = make_bar_data(vec![], vec![("S", vec![])]);
let svg = render_svg(&data, 100.0, 60.0);
// Should still produce valid SVG (bg rect + no bars)
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
}
#[test]
fn test_empty_series_bar_chart() {
let data = make_bar_data(vec!["A", "B"], vec![]);
let svg = render_svg(&data, 100.0, 60.0);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
}
#[test]
fn test_empty_pie_chart() {
let data = make_pie_data(vec![], vec![]);
let svg = render_svg(&data, 80.0, 80.0);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
// No slices
assert!(!svg.contains("<path d="), "empty pie should have no slices");
}
#[test]
fn test_title_rendered() {
let mut data = make_bar_data(vec!["A"], vec![("S", vec![10.0])]);
data.title = Some(ChartTitle {
text: "My Chart Title".to_string(),
font_size: Some(4.0),
color: Some("#333".to_string()),
align: None,
});
let svg = render_svg(&data, 100.0, 60.0);
assert!(svg.contains("My Chart Title"), "title should be rendered");
}
#[test]
fn test_axis_labels_rendered() {
let mut data = make_bar_data(vec!["Q1", "Q2"], vec![("Sales", vec![100.0, 200.0])]);
data.axis = Some(ChartAxis {
x_label: Some("Quarter".to_string()),
y_label: Some("Revenue".to_string()),
show_grid: None,
grid_color: None,
show_vertical_grid: None,
vertical_grid_color: None,
reference_lines: vec![],
});
let svg = render_svg(&data, 100.0, 60.0);
assert!(svg.contains("Quarter"), "x axis label should be rendered");
assert!(svg.contains("Revenue"), "y axis label should be rendered");
}
#[test]
fn test_axis_labels_not_on_pie() {
let mut data = make_pie_data(vec!["A", "B"], vec![50.0, 50.0]);
data.axis = Some(ChartAxis {
x_label: Some("X Label".to_string()),
y_label: Some("Y Label".to_string()),
show_grid: None,
grid_color: None,
show_vertical_grid: None,
vertical_grid_color: None,
reference_lines: vec![],
});
let svg = render_svg(&data, 80.0, 80.0);
// Pie charts should not render axis labels
assert!(!svg.contains("X Label"), "pie chart should not have x axis label");
assert!(!svg.contains("Y Label"), "pie chart should not have y axis label");
}
#[test]
fn test_escape_xml_special_chars() {
assert_eq!(escape_xml("a & b"), "a &amp; b");
assert_eq!(escape_xml("<script>"), "&lt;script&gt;");
assert_eq!(escape_xml(r#"say "hi""#), "say &quot;hi&quot;");
assert_eq!(escape_xml("normal"), "normal");
}
#[test]
fn test_donut_chart_inner_radius() {
let mut data = make_pie_data(vec!["A", "B"], vec![60.0, 40.0]);
data.style.inner_radius = Some(0.5);
let svg = render_svg(&data, 80.0, 80.0);
// Donut chart uses arc paths with inner radius → the path should contain "A" commands
// for both outer and inner arcs
let path_count = svg.matches("<path d=").count();
assert_eq!(path_count, 2, "donut chart should have 2 slices");
}
}

View File

@@ -2,6 +2,9 @@ use dreport_core::models::*;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
// Re-export HasOptionalBinding for convenience
pub use dreport_core::models::HasOptionalBinding;
/// Şu anki tarihi verilen format string'ine göre formatla. /// Şu anki tarihi verilen format string'ine göre formatla.
/// Desteklenen tokenlar: YYYY, MM, DD, HH, mm, ss /// Desteklenen tokenlar: YYYY, MM, DD, HH, mm, ss
/// WASM'da js_sys::Date, native'de SystemTime kullanır. /// WASM'da js_sys::Date, native'de SystemTime kullanır.
@@ -106,6 +109,8 @@ pub struct ResolvedData {
pub rich_texts: HashMap<String, Vec<ResolvedRichSpan>>, pub rich_texts: HashMap<String, Vec<ResolvedRichSpan>>,
/// element_id → çözümlenmiş chart verisi /// element_id → çözümlenmiş chart verisi
pub charts: HashMap<String, ResolvedChartData>, pub charts: HashMap<String, ResolvedChartData>,
/// Koşulu sağlamayan (gizlenmesi gereken) element ID'leri
pub hidden_elements: std::collections::HashSet<String>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -146,33 +151,103 @@ pub fn resolve_template(template: &Template, data: &Value) -> ResolvedData {
page_number_formats: HashMap::new(), page_number_formats: HashMap::new(),
rich_texts: HashMap::new(), rich_texts: HashMap::new(),
charts: HashMap::new(), charts: HashMap::new(),
hidden_elements: std::collections::HashSet::new(),
}; };
let fc = template.effective_format_config();
if let Some(ref header) = template.header { if let Some(ref header) = template.header {
resolve_element( resolve_element(
&TemplateElement::Container(header.clone()), &TemplateElement::Container(header.clone()),
data, data,
&mut resolved, &mut resolved,
&fc,
); );
} }
resolve_element( resolve_element(
&TemplateElement::Container(template.root.clone()), &TemplateElement::Container(template.root.clone()),
data, data,
&mut resolved, &mut resolved,
&fc,
); );
if let Some(ref footer) = template.footer { if let Some(ref footer) = template.footer {
resolve_element( resolve_element(
&TemplateElement::Container(footer.clone()), &TemplateElement::Container(footer.clone()),
data, data,
&mut resolved, &mut resolved,
&fc,
); );
} }
resolved resolved
} }
fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedData) { /// Koşul değerlendirme: Condition struct'ındaki path, operator, value ile data'yı karşılaştır.
fn evaluate_condition(condition: &dreport_core::models::Condition, data: &Value) -> bool {
let actual = resolve_path(data, &condition.path);
match condition.operator.as_str() {
"empty" => matches!(actual, Value::Null) || actual.as_str().is_some_and(|s| s.is_empty()),
"not_empty" => !matches!(actual, Value::Null) && !actual.as_str().is_some_and(|s| s.is_empty()),
"eq" => {
if let Some(ref expected) = condition.value {
json_values_eq(actual, expected)
} else {
actual.is_null()
}
}
"neq" => {
if let Some(ref expected) = condition.value {
!json_values_eq(actual, expected)
} else {
!actual.is_null()
}
}
op @ ("gt" | "gte" | "lt" | "lte") => {
let a = actual.as_f64().unwrap_or(0.0);
let b = condition.value.as_ref().and_then(|v| v.as_f64()).unwrap_or(0.0);
match op {
"gt" => a > b,
"gte" => a >= b,
"lt" => a < b,
"lte" => a <= b,
_ => unreachable!(),
}
}
_ => true, // bilinmeyen operator → göster
}
}
/// İki JSON değerini karşılaştır (tip dönüşümlü).
fn json_values_eq(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Number(a), Value::Number(b)) => a.as_f64() == b.as_f64(),
(Value::String(a), Value::String(b)) => a == b,
(Value::Bool(a), Value::Bool(b)) => a == b,
(Value::Null, Value::Null) => true,
// Çapraz tip karşılaştırma: sayı string vs sayı
(Value::String(s), Value::Number(n)) | (Value::Number(n), Value::String(s)) => {
s.parse::<f64>().ok() == n.as_f64()
}
_ => a == b,
}
}
/// Çözümle optional binding: binding varsa data'dan, yoksa static value'dan
fn resolve_optional_binding(el: &impl HasOptionalBinding, data: &Value) -> String {
if let Some(binding) = el.binding() {
value_to_string(resolve_path(data, &binding.path))
} else {
el.static_value().unwrap_or_default().to_string()
}
}
fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedData, format_config: &dreport_core::models::FormatConfig) {
// Koşul kontrolü: condition varsa ve sağlanmıyorsa, hidden olarak işaretle ve çık
if let Some(condition) = el.condition() && !evaluate_condition(condition, data) {
resolved.hidden_elements.insert(el.id().to_string());
return;
}
match el { match el {
TemplateElement::StaticText(e) => { TemplateElement::StaticText(e) => {
resolved.texts.insert(e.id.clone(), e.content.clone()); resolved.texts.insert(e.base.id.clone(), e.content.clone());
} }
TemplateElement::Text(e) => { TemplateElement::Text(e) => {
let bound_value = value_to_string(resolve_path(data, &e.binding.path)); let bound_value = value_to_string(resolve_path(data, &e.binding.path));
@@ -180,7 +255,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
Some(prefix) if !prefix.is_empty() => format!("{}{}", prefix, bound_value), Some(prefix) if !prefix.is_empty() => format!("{}{}", prefix, bound_value),
_ => bound_value, _ => bound_value,
}; };
resolved.texts.insert(e.id.clone(), text); resolved.texts.insert(e.base.id.clone(), text);
} }
TemplateElement::PageNumber(e) => { TemplateElement::PageNumber(e) => {
// Format string'i sakla — sayfa bölme sonrası gerçek değerlerle çözülecek // Format string'i sakla — sayfa bölme sonrası gerçek değerlerle çözülecek
@@ -191,28 +266,18 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
.to_string(); .to_string();
resolved resolved
.page_number_formats .page_number_formats
.insert(e.id.clone(), fmt.clone()); .insert(e.base.id.clone(), fmt.clone());
// Placeholder koy (tek sayfalık fallback) // Placeholder koy (tek sayfalık fallback)
resolved.texts.insert( resolved.texts.insert(
e.id.clone(), e.base.id.clone(),
fmt.replace("{current}", "1").replace("{total}", "1"), fmt.replace("{current}", "1").replace("{total}", "1"),
); );
} }
TemplateElement::Barcode(e) => { TemplateElement::Barcode(e) => {
let value = if let Some(binding) = &e.binding { resolved.barcodes.insert(e.base.id.clone(), resolve_optional_binding(e, data));
value_to_string(resolve_path(data, &binding.path))
} else {
e.value.clone().unwrap_or_default()
};
resolved.barcodes.insert(e.id.clone(), value);
} }
TemplateElement::Image(e) => { TemplateElement::Image(e) => {
let src = if let Some(binding) = &e.binding { resolved.images.insert(e.base.id.clone(), resolve_optional_binding(e, data));
value_to_string(resolve_path(data, &binding.path))
} else {
e.src.clone().unwrap_or_default()
};
resolved.images.insert(e.id.clone(), src);
} }
TemplateElement::RepeatingTable(e) => { TemplateElement::RepeatingTable(e) => {
let array = resolve_path(data, &e.data_source.path); let array = resolve_path(data, &e.data_source.path);
@@ -228,7 +293,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
let raw = value_to_string(v); let raw = value_to_string(v);
// Sütun formatı varsa uygula (currency, percentage, number, date) // Sütun formatı varsa uygula (currency, percentage, number, date)
if let Some(ref fmt) = col.format { if let Some(ref fmt) = col.format {
crate::expr_eval::apply_format(&raw, Some(fmt.as_str())) crate::expr_eval::apply_format_with_config(&raw, Some(fmt.as_str()), format_config)
} else { } else {
raw raw
} }
@@ -239,17 +304,17 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
} }
_ => vec![], _ => vec![],
}; };
resolved.tables.insert(e.id.clone(), ResolvedTable { rows }); resolved.tables.insert(e.base.id.clone(), ResolvedTable { rows });
} }
TemplateElement::Container(e) => { TemplateElement::Container(e) => {
for child in &e.children { for child in &e.children {
resolve_element(child, data, resolved); resolve_element(child, data, resolved, format_config);
} }
} }
TemplateElement::CurrentDate(e) => { TemplateElement::CurrentDate(e) => {
let fmt = e.format.as_deref().unwrap_or("DD.MM.YYYY"); let fmt = e.format.as_deref().unwrap_or("DD.MM.YYYY");
let text = format_current_date(fmt); let text = format_current_date(fmt);
resolved.texts.insert(e.id.clone(), text); resolved.texts.insert(e.base.id.clone(), text);
} }
TemplateElement::Checkbox(e) => { TemplateElement::Checkbox(e) => {
let checked = if let Some(binding) = &e.binding { let checked = if let Some(binding) = &e.binding {
@@ -264,18 +329,18 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
e.checked.unwrap_or(false) e.checked.unwrap_or(false)
}; };
// Store as "true"/"false" string in texts map // Store as "true"/"false" string in texts map
resolved.texts.insert(e.id.clone(), checked.to_string()); resolved.texts.insert(e.base.id.clone(), checked.to_string());
} }
TemplateElement::CalculatedText(e) => { TemplateElement::CalculatedText(e) => {
let result = crate::expr_eval::evaluate_expression(&e.expression, data); let result = crate::expr_eval::evaluate_expression(&e.expression, data);
let formatted = crate::expr_eval::apply_format(&result, e.format.as_deref()); let formatted = crate::expr_eval::apply_format_with_config(&result, e.format.as_deref(), format_config);
// Bos ifade veya hata durumunda placeholder goster — element 0 yukseklige dusmesin // Bos ifade veya hata durumunda placeholder goster — element 0 yukseklige dusmesin
let text = if formatted.is_empty() { let text = if formatted.is_empty() {
" ".to_string() " ".to_string()
} else { } else {
formatted formatted
}; };
resolved.texts.insert(e.id.clone(), text); resolved.texts.insert(e.base.id.clone(), text);
} }
TemplateElement::RichText(e) => { TemplateElement::RichText(e) => {
let spans: Vec<ResolvedRichSpan> = e let spans: Vec<ResolvedRichSpan> = e
@@ -308,7 +373,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
} }
}) })
.collect(); .collect();
resolved.rich_texts.insert(e.id.clone(), spans); resolved.rich_texts.insert(e.base.id.clone(), spans);
} }
TemplateElement::Chart(e) => { TemplateElement::Chart(e) => {
let array = resolve_path(data, &e.data_source.path); let array = resolve_path(data, &e.data_source.path);
@@ -326,7 +391,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
group_mode: e.group_mode.clone(), group_mode: e.group_mode.clone(),
}, },
}; };
resolved.charts.insert(e.id.clone(), chart_data); resolved.charts.insert(e.base.id.clone(), chart_data);
} }
TemplateElement::Line(_) => {} TemplateElement::Line(_) => {}
TemplateElement::Shape(_) => {} TemplateElement::Shape(_) => {}
@@ -477,10 +542,9 @@ mod tests {
header: None, header: None,
footer: None, footer: None,
format_config: None, format_config: None,
locale: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(), direction: "column".to_string(),
gap: 0.0, gap: 0.0,
padding: Padding::default(), padding: Padding::default(),
@@ -489,9 +553,7 @@ mod tests {
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(), break_inside: "auto".to_string(),
children: vec![TemplateElement::Text(TextElement { children: vec![TemplateElement::Text(TextElement {
id: "el_name".to_string(), base: ElementBase::flow("el_name".to_string(), SizeConstraint::default()),
position: PositionMode::Flow,
size: SizeConstraint::default(),
style: TextStyle::default(), style: TextStyle::default(),
content: None, content: None,
binding: ScalarBinding { binding: ScalarBinding {
@@ -525,10 +587,9 @@ mod tests {
header: None, header: None,
footer: None, footer: None,
format_config: None, format_config: None,
locale: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(), direction: "column".to_string(),
gap: 0.0, gap: 0.0,
padding: Padding::default(), padding: Padding::default(),
@@ -537,9 +598,7 @@ mod tests {
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(), break_inside: "auto".to_string(),
children: vec![TemplateElement::Text(TextElement { children: vec![TemplateElement::Text(TextElement {
id: "el_no".to_string(), base: ElementBase::flow("el_no".to_string(), SizeConstraint::default()),
position: PositionMode::Flow,
size: SizeConstraint::default(),
style: TextStyle::default(), style: TextStyle::default(),
content: Some("Fatura No: ".to_string()), content: Some("Fatura No: ".to_string()),
binding: ScalarBinding { binding: ScalarBinding {
@@ -570,10 +629,9 @@ mod tests {
header: None, header: None,
footer: None, footer: None,
format_config: None, format_config: None,
locale: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(), direction: "column".to_string(),
gap: 0.0, gap: 0.0,
padding: Padding::default(), padding: Padding::default(),
@@ -582,9 +640,7 @@ mod tests {
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(), break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement { children: vec![TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(), base: ElementBase::flow("title".to_string(), SizeConstraint::default()),
position: PositionMode::Flow,
size: SizeConstraint::default(),
style: TextStyle::default(), style: TextStyle::default(),
content: "FATURA".to_string(), content: "FATURA".to_string(),
})], })],
@@ -608,10 +664,9 @@ mod tests {
header: None, header: None,
footer: None, footer: None,
format_config: None, format_config: None,
locale: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(), direction: "column".to_string(),
gap: 0.0, gap: 0.0,
padding: Padding::default(), padding: Padding::default(),
@@ -620,9 +675,7 @@ mod tests {
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(), break_inside: "auto".to_string(),
children: vec![TemplateElement::RepeatingTable(RepeatingTableElement { children: vec![TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl".to_string(), base: ElementBase::flow("tbl".to_string(), SizeConstraint::default()),
position: PositionMode::Flow,
size: SizeConstraint::default(),
data_source: ArrayBinding { data_source: ArrayBinding {
path: "kalemler".to_string(), path: "kalemler".to_string(),
}, },
@@ -677,10 +730,9 @@ mod tests {
header: None, header: None,
footer: None, footer: None,
format_config: None, format_config: None,
locale: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(), direction: "column".to_string(),
gap: 0.0, gap: 0.0,
padding: Padding::default(), padding: Padding::default(),
@@ -689,9 +741,7 @@ mod tests {
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(), break_inside: "auto".to_string(),
children: vec![TemplateElement::RepeatingTable(RepeatingTableElement { children: vec![TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl".to_string(), base: ElementBase::flow("tbl".to_string(), SizeConstraint::default()),
position: PositionMode::Flow,
size: SizeConstraint::default(),
data_source: ArrayBinding { data_source: ArrayBinding {
path: "items".to_string(), path: "items".to_string(),
}, },
@@ -728,10 +778,9 @@ mod tests {
header: None, header: None,
footer: None, footer: None,
format_config: None, format_config: None,
locale: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(), direction: "column".to_string(),
gap: 0.0, gap: 0.0,
padding: Padding::default(), padding: Padding::default(),
@@ -740,9 +789,7 @@ mod tests {
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(), break_inside: "auto".to_string(),
children: vec![TemplateElement::Text(TextElement { children: vec![TemplateElement::Text(TextElement {
id: "el_missing".to_string(), base: ElementBase::flow("el_missing".to_string(), SizeConstraint::default()),
position: PositionMode::Flow,
size: SizeConstraint::default(),
style: TextStyle::default(), style: TextStyle::default(),
content: None, content: None,
binding: ScalarBinding { binding: ScalarBinding {

View File

@@ -65,6 +65,10 @@ fn dexpr_value_to_string(val: &DexprValue) -> String {
.collect(); .collect();
format!("{{{}}}", items.join(", ")) format!("{{{}}}", items.join(", "))
} }
DexprValue::List(list) => {
let items: Vec<String> = list.iter().map(|v| dexpr_value_to_string(v)).collect();
format!("[{}]", items.join(", "))
}
} }
} }
@@ -358,4 +362,31 @@ mod tests {
}; };
assert_eq!(format_currency("1500.25", &config), "$1,500.25"); assert_eq!(format_currency("1500.25", &config), "$1,500.25");
} }
#[test]
fn test_array_field_sum() {
let data = json!({
"kalemler": [
{"adi": "A", "tutar": 100},
{"adi": "B", "tutar": 200},
{"adi": "C", "tutar": 50}
]
});
assert_eq!(evaluate_expression("kalemler.tutar.sum()", &data), "350");
}
#[test]
fn test_array_field_sum_in_arithmetic() {
let data = json!({
"kalemler": [
{"tutar": 1000},
{"tutar": 2000}
],
"toplamlar": {"kdvOrani": 20}
});
assert_eq!(
evaluate_expression("kalemler.tutar.sum() * toplamlar.kdvOrani / 100", &data),
"600"
);
}
} }

View File

@@ -161,8 +161,21 @@ pub struct ChartRenderData {
// Title align // Title align
#[serde(default)] #[serde(default)]
pub title_align: Option<String>, pub title_align: Option<String>,
// Curve type for line charts
#[serde(default)]
pub curve_type: Option<String>,
// Vertical reference lines
#[serde(default)]
pub reference_lines: Vec<dreport_core::models::ChartReferenceLine>,
// Vertical grid
#[serde(default = "default_true")]
pub show_vertical_grid: bool,
#[serde(default)]
pub vertical_grid_color: Option<String>,
} }
fn default_true() -> bool { true }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChartSeriesData { pub struct ChartSeriesData {
pub name: String, pub name: String,
@@ -223,6 +236,131 @@ pub struct ResolvedStyle {
pub barcode_include_text: Option<bool>, pub barcode_include_text: Option<bool>,
} }
// --- From<&XStyle> for ResolvedStyle ---
impl From<&dreport_core::models::TextStyle> for ResolvedStyle {
fn from(s: &dreport_core::models::TextStyle) -> Self {
Self {
font_size: s.font_size,
font_weight: s.font_weight.clone(),
font_style: s.font_style.clone(),
font_family: s.font_family.clone(),
color: s.color.clone(),
text_align: s.align.clone(),
..Default::default()
}
}
}
impl From<&dreport_core::models::ContainerStyle> for ResolvedStyle {
fn from(s: &dreport_core::models::ContainerStyle) -> Self {
Self {
background_color: s.background_color.clone(),
border_color: s.border_color.clone(),
border_width: s.border_width,
border_radius: s.border_radius,
border_style: s.border_style.clone(),
..Default::default()
}
}
}
impl From<&dreport_core::models::LineStyle> for ResolvedStyle {
fn from(s: &dreport_core::models::LineStyle) -> Self {
Self {
stroke_color: s.stroke_color.clone(),
stroke_width: s.stroke_width,
..Default::default()
}
}
}
impl From<&dreport_core::models::ImageStyle> for ResolvedStyle {
fn from(s: &dreport_core::models::ImageStyle) -> Self {
Self {
object_fit: s.object_fit.clone(),
..Default::default()
}
}
}
impl From<&dreport_core::models::BarcodeStyle> for ResolvedStyle {
fn from(s: &dreport_core::models::BarcodeStyle) -> Self {
Self {
barcode_color: s.color.clone(),
barcode_include_text: s.include_text,
..Default::default()
}
}
}
impl From<&dreport_core::models::CheckboxStyle> for ResolvedStyle {
fn from(s: &dreport_core::models::CheckboxStyle) -> Self {
Self {
color: s.check_color.clone(),
border_color: s.border_color.clone(),
border_width: s.border_width,
..Default::default()
}
}
}
impl From<&data_resolve::ResolvedChartData> for ChartRenderData {
fn from(cd: &data_resolve::ResolvedChartData) -> Self {
let n_colors = cd.categories.len().max(cd.series.len()).max(1);
let colors: Vec<String> = (0..n_colors)
.map(|i| {
cd.style
.colors
.as_ref()
.and_then(|c| c.get(i).cloned())
.unwrap_or_else(|| {
chart_layout::DEFAULT_COLORS[i % chart_layout::DEFAULT_COLORS.len()]
.to_string()
})
})
.collect();
Self {
chart_type: cd.chart_type.clone(),
categories: cd.categories.clone(),
series: cd
.series
.iter()
.map(|s| ChartSeriesData {
name: s.name.clone(),
values: s.values.clone(),
})
.collect(),
title_text: cd.title.as_ref().map(|t| t.text.clone()),
title_font_size: cd.title.as_ref().and_then(|t| t.font_size),
title_color: cd.title.as_ref().and_then(|t| t.color.clone()),
title_align: cd.title.as_ref().and_then(|t| t.align.clone()),
colors,
show_labels: cd.labels.as_ref().is_some_and(|l| l.show),
label_font_size: cd.labels.as_ref().and_then(|l| l.font_size),
label_color: cd.labels.as_ref().and_then(|l| l.color.clone()),
show_grid: cd.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true),
grid_color: cd.axis.as_ref().and_then(|a| a.grid_color.clone()),
bar_gap: cd.style.bar_gap,
stacked: matches!(cd.group_mode, Some(dreport_core::models::GroupMode::Stacked)),
inner_radius: cd.style.inner_radius,
show_points: cd.style.show_points,
line_width: cd.style.line_width,
background_color: cd.style.background_color.clone(),
legend_show: cd.legend.as_ref().is_some_and(|l| l.show),
legend_position: cd.legend.as_ref().and_then(|l| l.position.clone()),
legend_font_size: cd.legend.as_ref().and_then(|l| l.font_size),
x_label: cd.axis.as_ref().and_then(|a| a.x_label.clone()),
y_label: cd.axis.as_ref().and_then(|a| a.y_label.clone()),
curve_type: cd.style.curve_type.clone(),
reference_lines: cd.axis.as_ref().map_or_else(Vec::new, |a| a.reference_lines.clone()),
show_vertical_grid: cd.axis.as_ref().and_then(|a| a.show_vertical_grid).unwrap_or(true),
vertical_grid_color: cd.axis.as_ref().and_then(|a| a.vertical_grid_color.clone()),
}
}
}
/// Ana layout hesaplama fonksiyonu. /// Ana layout hesaplama fonksiyonu.
/// Template + data + font verileri alır, her element için pozisyon döner. /// Template + data + font verileri alır, her element için pozisyon döner.
pub fn compute_layout( pub fn compute_layout(

View File

@@ -896,6 +896,261 @@ mod tests {
); );
} }
#[test]
fn test_break_inside_avoid_group_moves_to_new_page() {
// break_inside: avoid olan container grubunun mevcut sayfaya sığmadığında
// komple yeni sayfaya taşınması gerekir.
let mut break_modes = HashMap::new();
break_modes.insert("group".to_string(), "avoid".to_string());
// group container: y=250, h=80 → bottom=330 > 297 (sayfa yüksekliği)
// ama 80mm tek sayfaya sığar → yeni sayfaya geçmeli
let mut group = make_element("group", 250.0, 80.0, "container");
group.children = vec!["g_child1".to_string(), "g_child2".to_string()];
let input = PageSplitInput {
body_elements: vec![
make_element("el1", 0.0, 250.0, "text"),
group,
make_element("g_child1", 250.0, 40.0, "text"),
make_element("g_child2", 290.0, 40.0, "text"),
],
page_height_mm: 297.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes,
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 2, "avoid group should cause a new page");
// el1 sayfada 1, group + children sayfada 2
assert!(pages[0].elements.iter().any(|e| e.id == "el1"));
assert!(pages[1].elements.iter().any(|e| e.id == "group"));
assert!(pages[1].elements.iter().any(|e| e.id == "g_child1"));
assert!(pages[1].elements.iter().any(|e| e.id == "g_child2"));
}
#[test]
fn test_avoid_group_larger_than_page_stays_in_flow() {
// break_inside: avoid olan container sayfadan büyükse, normal akışa devam etmeli.
// Yeni sayfaya atlamamalı çünkü zaten sığmıyor.
let mut break_modes = HashMap::new();
break_modes.insert("big_group".to_string(), "avoid".to_string());
let mut big = make_element("big_group", 0.0, 400.0, "container");
big.children = vec!["bg_child".to_string()];
let input = PageSplitInput {
body_elements: vec![
big,
make_element("bg_child", 0.0, 400.0, "text"),
],
page_height_mm: 297.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes,
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
// Sayfa 1'de grup başlamalı (sığmasa da mecbur)
assert!(pages[0].elements.iter().any(|e| e.id == "big_group"));
}
#[test]
fn test_element_exactly_at_page_boundary() {
// Eleman tam sayfa sınırına denk geldiğinde doğru sayfalanmalı.
// İki eleman: 148.5mm + 148.5mm = 297mm → tam sığar, tek sayfa.
let input = PageSplitInput {
body_elements: vec![
make_element("el1", 0.0, 148.5, "text"),
make_element("el2", 148.5, 148.5, "text"),
],
page_height_mm: 297.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 1, "elements exactly filling page should fit in 1 page");
assert_eq!(pages[0].elements.len(), 2);
}
#[test]
fn test_element_one_mm_over_page_boundary() {
// Eleman sayfa sınırını 1mm aşıyorsa yeni sayfaya geçmeli.
let input = PageSplitInput {
body_elements: vec![
make_element("el1", 0.0, 148.5, "text"),
make_element("el2", 148.5, 149.5, "text"), // bottom = 298 > 297
],
page_height_mm: 297.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 2, "element exceeding page by 1mm should go to page 2");
assert!(pages[0].elements.iter().any(|e| e.id == "el1"));
assert!(pages[1].elements.iter().any(|e| e.id == "el2"));
}
#[test]
fn test_single_element_larger_than_page() {
// Sayfadan büyük tek eleman — mecburen sayfa 1'de kalmalı.
let input = PageSplitInput {
body_elements: vec![
make_element("huge", 0.0, 500.0, "text"),
],
page_height_mm: 297.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 1, "single oversized element should stay on page 1");
assert_eq!(pages[0].elements[0].id, "huge");
}
#[test]
fn test_no_repeat_header_tables_suppresses_header() {
// no_repeat_header_tables'a eklenen tablonun header'ı tekrarlanmamalı.
let mut tbl_wrapper = make_element("tbl", 0.0, 200.0, "container");
tbl_wrapper.children = vec![
"tbl_header".to_string(),
"tbl_row_0".to_string(),
"tbl_row_1".to_string(),
"tbl_row_2".to_string(),
"tbl_row_3".to_string(),
"tbl_row_4".to_string(),
];
let tbl_header = {
let mut el = make_element("tbl_header", 0.0, 20.0, "container");
el.children = vec!["tbl_hdr_0".to_string()];
el
};
let tbl_hdr_0 = make_element("tbl_hdr_0", 0.0, 20.0, "static_text");
let rows: Vec<ElementLayout> = (0..5)
.flat_map(|i| {
let y = 20.0 + (i as f64) * 30.0;
let mut row = make_element(&format!("tbl_row_{}", i), y, 30.0, "container");
row.children = vec![format!("tbl_r{}c0", i)];
let cell = make_element(&format!("tbl_r{}c0", i), y, 30.0, "static_text");
vec![row, cell]
})
.collect();
let mut body_elements = vec![tbl_wrapper, tbl_header, tbl_hdr_0];
body_elements.extend(rows);
let mut no_repeat = HashSet::new();
no_repeat.insert("tbl".to_string());
let input = PageSplitInput {
body_elements,
page_height_mm: 120.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: no_repeat,
};
let pages = split_into_pages(input);
assert!(pages.len() >= 2, "table should split across pages");
// Sayfa 2'de tekrarlanan header OLMAMALI
let page2_has_repeated_header = pages[1]
.elements
.iter()
.any(|e| e.id.starts_with("tbl_header") && e.id != "tbl_header");
assert!(
!page2_has_repeated_header,
"no_repeat_header_tables should suppress header repetition on page 2"
);
}
#[test]
fn test_empty_body_produces_single_page() {
let input = PageSplitInput {
body_elements: vec![],
page_height_mm: 297.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 1, "empty body should produce 1 page");
}
#[test]
fn test_content_height_zero_returns_single_page() {
// Header + footer sayfayı dolduruyor → content_height <= 0
let input = PageSplitInput {
body_elements: vec![
make_element("el1", 0.0, 50.0, "text"),
],
page_height_mm: 100.0,
header_height_mm: 60.0,
footer_height_mm: 50.0,
header_elements: vec![make_element("hdr", 0.0, 60.0, "text")],
footer_elements: vec![make_element("ftr", 0.0, 50.0, "text")],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 1, "zero content height should produce 1 page");
}
#[test] #[test]
fn test_repeated_header_no_gap_with_rows() { fn test_repeated_header_no_gap_with_rows() {
// Tekrarlanan header ile ilk satır arasında boşluk olmamalı. // Tekrarlanan header ile ilk satır arasında boşluk olmamalı.

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More