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-diffs/
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"
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]]
name = "cc"
version = "1.2.58"
@@ -397,9 +415,9 @@ dependencies = [
[[package]]
name = "dexpr"
version = "0.1.0"
version = "0.3.0"
source = "sparse+https://gitea.duhanbalci.com/api/packages/duhanbalci/cargo/"
checksum = "37e0a98f2810bb770c76ef1e99d07066a15997086f9ead93917a82711274af25"
checksum = "e65e74adffaab8b52681e3e3e5006365f0f8c5e3e07870cbd58ca74769eb150a"
dependencies = [
"bumpalo",
"indexmap",
@@ -421,12 +439,12 @@ version = "0.2.0"
dependencies = [
"anyhow",
"axum",
"dreport-core",
"dreport-layout",
"dreport-service",
"http-body-util",
"serde",
"serde_json",
"thiserror",
"tokio",
"tower",
"tower-http",
]
@@ -439,6 +457,15 @@ dependencies = [
"serde_json",
]
[[package]]
name = "dreport-ffi"
version = "0.2.0"
dependencies = [
"cbindgen",
"dreport-service",
"serde_json",
]
[[package]]
name = "dreport-layout"
version = "0.2.0"
@@ -460,6 +487,18 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "dreport-service"
version = "0.2.0"
dependencies = [
"dreport-core",
"dreport-layout",
"serde",
"serde_json",
"tempfile",
"thiserror",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -505,6 +544,12 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -725,6 +770,12 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "heck"
version = "0.5.0"
@@ -971,6 +1022,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -1287,7 +1344,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
"toml_edit 0.25.10+spec-1.1.0",
]
[[package]]
@@ -1541,6 +1598,19 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "rustversion"
version = "1.0.22"
@@ -1681,6 +1751,15 @@ dependencies = [
"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]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -1889,6 +1968,19 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "thiserror"
version = "2.0.18"
@@ -1963,6 +2055,27 @@ dependencies = [
"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]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
@@ -1972,6 +2085,20 @@ dependencies = [
"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]]
name = "toml_edit"
version = "0.25.10+spec-1.1.0"
@@ -1979,9 +2106,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b"
dependencies = [
"indexmap",
"toml_datetime",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow",
"winnow 1.0.1",
]
[[package]]
@@ -1990,9 +2117,15 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
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]]
name = "tower"
version = "0.5.3"
@@ -2328,6 +2461,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "1.0.1"
@@ -2353,7 +2495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"heck 0.5.0",
"wit-parser",
]
@@ -2364,7 +2506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"heck 0.5.0",
"indexmap",
"prettyplease",
"syn 2.0.117",

View File

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

View File

@@ -657,7 +657,7 @@ pub fn load_test_fonts() -> Vec<FontData> { ... }
## 7. Yeni Ozellik Onerileri
### 7.1 Conditional Rendering `[IMPLEMENTE EDILMEDI]`
### 7.1 Conditional Rendering `[IMPLEMENTE EDILDI]`
**Aciklama:**
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:**
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:**
`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.1 Chart Legend Tek Seri Durumu `[IMPLEMENTE EDILMEDI]`
### 8.1 Chart Legend Tek Seri Durumu `[IMPLEMENTE EDILDI]`
**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)

View File

@@ -5,12 +5,14 @@ edition = "2024"
publish = false
[dependencies]
dreport-core = { path = "../core" }
dreport-layout = { path = "../layout-engine" }
dreport-service = { path = "../dreport-service" }
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["cors"] }
thiserror = "2"
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 tokio::net::TcpListener;
use tower_http::cors::{Any, CorsLayer};
mod font_registry;
mod models;
mod routes;
use font_registry::FontRegistry;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
println!("Font registry başlatılıyor...");
let registry = Arc::new(FontRegistry::new());
let 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 service = Arc::new(app::build_service()?);
println!(
"dreport-service hazır ({} font ailesi)",
service.font_family_count()
);
let app = build_router(service);
let listener = TcpListener::bind("0.0.0.0:3001").await?;
println!("dreport backend listening on http://localhost:3001");
serve(listener, app).await?;
axum::serve(listener, app).await?;
Ok(())
}

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
use axum::{Json, Router, routing::get};
use serde::Serialize;
use std::sync::Arc;
use crate::font_registry::FontRegistry;
use super::AppState;
#[derive(Serialize)]
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))
}

View File

@@ -3,11 +3,12 @@ mod health;
mod render;
use axum::Router;
use dreport_service::DreportService;
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()
.merge(health::router())
.merge(render::router())

View File

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

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>,
}
// --- 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 ---
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -216,6 +230,26 @@ pub struct ChartAxis {
pub y_label: Option<String>,
pub show_grid: Option<bool>,
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)]
@@ -233,9 +267,8 @@ pub struct ChartStyle {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChartElement {
pub id: String,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub chart_type: ChartType,
pub data_source: ArrayBinding,
pub category_field: String,
@@ -256,6 +289,138 @@ pub struct ChartElement {
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 ---
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -300,71 +465,59 @@ pub enum TemplateElement {
}
impl TemplateElement {
pub fn id(&self) -> &str {
fn inner_base(&self) -> &ElementBase {
match self {
Self::Container(e) => &e.id,
Self::StaticText(e) => &e.id,
Self::Text(e) => &e.id,
Self::Line(e) => &e.id,
Self::RepeatingTable(e) => &e.id,
Self::Image(e) => &e.id,
Self::PageNumber(e) => &e.id,
Self::Barcode(e) => &e.id,
Self::PageBreak(e) => &e.id,
Self::CurrentDate(e) => &e.id,
Self::Shape(e) => &e.id,
Self::Checkbox(e) => &e.id,
Self::CalculatedText(e) => &e.id,
Self::RichText(e) => &e.id,
Self::Chart(e) => &e.id,
Self::Container(e) => e.base(),
Self::StaticText(e) => e.base(),
Self::Text(e) => e.base(),
Self::Line(e) => e.base(),
Self::RepeatingTable(e) => e.base(),
Self::Image(e) => e.base(),
Self::PageNumber(e) => e.base(),
Self::Barcode(e) => e.base(),
Self::PageBreak(e) => e.base(),
Self::CurrentDate(e) => e.base(),
Self::Shape(e) => e.base(),
Self::Checkbox(e) => e.base(),
Self::CalculatedText(e) => e.base(),
Self::RichText(e) => e.base(),
Self::Chart(e) => e.base(),
}
}
pub fn id(&self) -> &str {
&self.inner_base().id
}
pub fn position(&self) -> &PositionMode {
match self {
Self::Container(e) => &e.position,
Self::StaticText(e) => &e.position,
Self::Text(e) => &e.position,
Self::Line(e) => &e.position,
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,
}
&self.inner_base().position
}
pub fn condition(&self) -> Option<&Condition> {
self.inner_base().condition.as_ref()
}
pub fn size(&self) -> &SizeConstraint {
static DEFAULT_SIZE: SizeConstraint = SizeConstraint {
width: SizeValue::Auto,
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
};
&self.inner_base().size
}
pub fn type_str(&self) -> &'static str {
match self {
Self::Container(e) => &e.size,
Self::StaticText(e) => &e.size,
Self::Text(e) => &e.size,
Self::Line(e) => &e.size,
Self::RepeatingTable(e) => &e.size,
Self::Image(e) => &e.size,
Self::PageNumber(e) => &e.size,
Self::Barcode(e) => &e.size,
Self::PageBreak(_) => &DEFAULT_SIZE,
Self::CurrentDate(e) => &e.size,
Self::Shape(e) => &e.size,
Self::Checkbox(e) => &e.size,
Self::CalculatedText(e) => &e.size,
Self::RichText(e) => &e.size,
Self::Chart(e) => &e.size,
Self::Container(e) => e.type_str(),
Self::StaticText(e) => e.type_str(),
Self::Text(e) => e.type_str(),
Self::Line(e) => e.type_str(),
Self::RepeatingTable(e) => e.type_str(),
Self::Image(e) => e.type_str(),
Self::PageNumber(e) => e.type_str(),
Self::Barcode(e) => e.type_str(),
Self::PageBreak(e) => e.type_str(),
Self::CurrentDate(e) => e.type_str(),
Self::Shape(e) => e.type_str(),
Self::Checkbox(e) => e.type_str(),
Self::CalculatedText(e) => e.type_str(),
Self::RichText(e) => e.type_str(),
Self::Chart(e) => e.type_str(),
}
}
}
@@ -372,9 +525,8 @@ impl TemplateElement {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RichTextElement {
pub id: String,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
#[serde(default)]
pub style: TextStyle, // varsayilan stil (span'lar override edebilir)
pub content: Vec<RichTextSpan>,
@@ -383,11 +535,8 @@ pub struct RichTextElement {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContainerElement {
pub id: String,
#[serde(default)]
pub position: PositionMode,
#[serde(default)]
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
#[serde(default = "default_column")]
pub direction: String,
#[serde(default)]
@@ -423,9 +572,8 @@ fn default_start() -> String {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StaticTextElement {
pub id: String,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub style: TextStyle,
pub content: String,
}
@@ -433,9 +581,8 @@ pub struct StaticTextElement {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextElement {
pub id: String,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub style: TextStyle,
pub content: Option<String>,
pub binding: ScalarBinding,
@@ -444,18 +591,16 @@ pub struct TextElement {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LineElement {
pub id: String,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub style: LineStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageElement {
pub id: String,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub src: Option<String>,
pub binding: Option<ScalarBinding>,
pub style: ImageStyle,
@@ -464,9 +609,8 @@ pub struct ImageElement {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageNumberElement {
pub id: String,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub style: TextStyle,
pub format: Option<String>,
}
@@ -474,9 +618,8 @@ pub struct PageNumberElement {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BarcodeElement {
pub id: String,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub format: String, // qr, ean13, ean8, code128, code39
pub value: Option<String>,
pub binding: Option<ScalarBinding>,
@@ -486,9 +629,8 @@ pub struct BarcodeElement {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RepeatingTableElement {
pub id: String,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub data_source: ArrayBinding,
pub columns: Vec<TableColumn>,
pub style: TableStyle,
@@ -503,15 +645,15 @@ fn default_true() -> Option<bool> {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageBreakElement {
pub id: String,
#[serde(flatten)]
pub base: ElementBase,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurrentDateElement {
pub id: String,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub style: TextStyle,
pub format: Option<String>,
}
@@ -519,9 +661,8 @@ pub struct CurrentDateElement {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShapeElement {
pub id: String,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub shape_type: String, // rectangle, ellipse, rounded_rectangle
pub style: ContainerStyle,
}
@@ -538,9 +679,8 @@ pub struct CheckboxStyle {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckboxElement {
pub id: String,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub checked: Option<bool>, // statik değer
pub binding: Option<ScalarBinding>, // dinamik boolean binding
pub style: CheckboxStyle,
@@ -549,9 +689,8 @@ pub struct CheckboxElement {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CalculatedTextElement {
pub id: String,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub style: TextStyle,
pub expression: String,
pub format: Option<String>,
@@ -572,6 +711,10 @@ pub struct Template {
pub root: ContainerElement,
#[serde(default)]
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ı.
@@ -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',
},
kalemler: [
{
siraNo: 1,
adi: 'Web Uygulama Gelistirme',
miktar: 1,
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: 1, adi: 'Web Uygulama Gelistirme', miktar: 1, 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: 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: {
araToplam: 123500,
kdvOrani: 20,
kdv: 24700,
genelToplam: 148200,
},
}
@@ -480,22 +459,66 @@ const defaultInvoiceTemplate: Template = {
style: { borderColor: '#e2e8f0', borderWidth: 0.5 },
children: [
{
id: 'el_ara_toplam',
type: 'text',
id: 'c_ara_toplam_row',
type: 'container',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 10, color: '#333333', align: 'right' },
content: 'Ara Toplam: ',
binding: { type: 'scalar', path: 'toplamlar.araToplam' },
size: { width: sz.fr(), height: sz.auto() },
direction: 'row',
gap: 2,
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',
type: 'text',
id: 'c_kdv_row',
type: 'container',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 10, color: '#333333', align: 'right' },
content: 'KDV (%20): ',
binding: { type: 'scalar', path: 'toplamlar.kdv' },
size: { width: sz.fr(), height: sz.auto() },
direction: 'row',
gap: 2,
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',
@@ -505,13 +528,35 @@ const defaultInvoiceTemplate: Template = {
style: { strokeColor: '#1e293b', strokeWidth: 1 },
},
{
id: 'el_genel_toplam',
type: 'text',
id: 'c_genel_toplam_row',
type: 'container',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 12, fontWeight: 'bold', color: '#1a1a1a', align: 'right' },
content: 'GENEL TOPLAM: ',
binding: { type: 'scalar', path: 'toplamlar.genelToplam' },
size: { width: sz.fr(), height: sz.auto() },
direction: 'row',
gap: 2,
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 InteractionOverlay from './InteractionOverlay.vue'
import RulerBar from './RulerBar.vue'
import MinimapOverlay from './MinimapOverlay.vue'
const props = withDefaults(
defineProps<{
@@ -23,6 +24,7 @@ const { template, mockData, layoutVersion } = storeToRefs(templateStore)
const containerRef = ref<HTMLElement | null>(null)
const containerWidth = ref(800)
const containerHeight = ref(600)
const emit = defineEmits<{
'compile-error': [error: string | null]
@@ -43,9 +45,23 @@ provide('generateBarcode', generateBarcode)
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(() => {
return (containerWidth.value / templateStore.template.page.width) * editorStore.zoom
return (containerWidth.value / templateStore.template.page.width) * committedZoom.value
})
// Layout sayfaları
@@ -54,7 +70,50 @@ const layoutPages = computed(() => layout.value?.pages ?? [])
// Sayfa yüksekliği px cinsinden
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 w = templateStore.template.page.width * scale.value
const m = templateStore.template.root.padding
@@ -66,6 +125,7 @@ const pagesContainerStyle = computed(() => {
height: `${totalH}px`,
position: 'relative' as const,
flexShrink: 0,
willChange: 'transform' as const,
'--page-margin-top': `${m.top * scale.value}px`,
'--page-margin-right': `${m.right * scale.value}px`,
'--page-margin-bottom': `${m.bottom * scale.value}px`,
@@ -73,12 +133,94 @@ const pagesContainerStyle = computed(() => {
}
})
// Pan transform — sayfa container'ına uygulanacak
const panTransform = computed(() => {
if (editorStore.panX === 0 && editorStore.panY === 0) return undefined
return `translate(${editorStore.panX}px, ${editorStore.panY}px)`
// Pan sınırları
function clampPan(x: number, y: number, zoomOverride?: number): [number, number] {
const z = zoomOverride ?? committedZoom.value
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
const isPanning = ref(false)
const panStart = ref({ x: 0, y: 0 })
@@ -98,7 +240,10 @@ onMounted(() => {
if (containerRef.value) {
resizeObserver = new ResizeObserver((entries) => {
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)
}
@@ -109,10 +254,19 @@ onMounted(() => {
onBeforeUnmount(() => {
resizeObserver?.disconnect()
dispose()
if (zoomCommitTimer) clearTimeout(zoomCommitTimer)
window.removeEventListener('keydown', onKeyDown)
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
const pageRef = ref<HTMLElement | null>(null)
@@ -142,7 +296,17 @@ function onWheel(e: WheelEvent) {
} else {
// İki parmak pan (touchpad) veya normal scroll
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
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 newZoom = Math.max(0.25, Math.min(4, oldZoom * zoomFactor))
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)
// pageRef'in ekran pozisyonunu al (CSS transform dahil)
const pageRect = pageEl.getBoundingClientRect()
const baseScale = containerWidth.value / templateStore.template.page.width
const oldScale = baseScale * oldZoom
const newScale = baseScale * newZoom
const mousePageMmX = (clientX - pageRect.left) / oldScale
const mousePageMmY = (clientY - pageRect.top) / oldScale
const oldGestureScale = baseScale * oldZoom
const newGestureScale = baseScale * newZoom
const mousePageMmX = (clientX - pageRect.left) / oldGestureScale
const mousePageMmY = (clientY - pageRect.top) / oldGestureScale
const pageW = templateStore.template.page.width
// Yeni pan: mouse'un gösterdiği mm noktası aynı ekran pozisyonunda kalmalı
const newPanX = editorStore.panX + (mousePageMmX - pageW / 2) * (oldScale - newScale)
const newPanY = editorStore.panY + mousePageMmY * (oldScale - newScale)
const newPanX = gesturePanX.value + (mousePageMmX - pageW / 2) * (oldGestureScale - newGestureScale)
const newPanY = gesturePanY.value + mousePageMmY * (oldGestureScale - newGestureScale)
editorStore.setZoom(newZoom)
editorStore.setPan(newPanX, newPanY)
gestureZoom.value = newZoom
const [cx, cy] = clampPan(newPanX, newPanY, newZoom)
gesturePanX.value = cx
gesturePanY.value = cy
scheduleZoomCommit()
}
function onKeyDown(e: KeyboardEvent) {
@@ -208,7 +383,8 @@ function onPointerDown(e: PointerEvent) {
function onPointerMove(e: PointerEvent) {
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) {
@@ -217,6 +393,17 @@ function onPointerUp(e: PointerEvent) {
;(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>
<template>
@@ -225,9 +412,12 @@ function onPointerUp(e: PointerEvent) {
<RulerBar
:page-width="templateStore.template.page.width"
:page-height="templateStore.template.page.height"
:scale="scale"
:pan-x="editorStore.panX"
:pan-y="editorStore.panY"
:scale="minimapScale"
:pan-x="isZoomGesture ? gesturePanX : editorStore.panX"
:pan-y="isZoomGesture ? gesturePanY : editorStore.panY"
:container-width="containerWidth"
:page-count="layoutPages.length"
:page-gap="24"
/>
<!-- Scroll alanı -->
@@ -244,9 +434,13 @@ function onPointerUp(e: PointerEvent) {
<div
ref="pageRef"
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
:scale="scale"
:layout-map="layoutMap"
@@ -261,7 +455,24 @@ function onPointerUp(e: PointerEvent) {
{{ error }}
</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>
</template>
@@ -318,15 +529,22 @@ function onPointerUp(e: PointerEvent) {
z-index: 100;
}
.editor-canvas__zoom {
.editor-canvas__minimap-area {
position: absolute;
bottom: 12px;
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);
color: white;
border-radius: 4px;
padding: 2px 8px;
font-size: 12px;
z-index: 100;
}
</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<{
layout: LayoutResult | null
scale: number
visiblePageIndices?: Set<number>
}>()
// WASM barcode üretme fonksiyonu (EditorCanvas'tan provide edilir)
@@ -196,7 +197,7 @@ watch(
class="layout-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 -->
<div
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
/** Pan offset Y (px) */
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 */
rulerSize?: number
}>()
@@ -69,19 +75,8 @@ function drawTicks(
size: number,
) {
const s = props.scale
const pageMm = direction === 'horizontal' ? props.pageWidth : props.pageHeight
const pan = direction === 'horizontal' ? props.panX : props.panY
// 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
const rulerSz = RULER_SIZE.value
const gap = props.pageGap ?? 24
// Tick aralığı belirleme (zoom'a göre)
const mmPerPx = 1 / s
@@ -98,11 +93,41 @@ function drawTicks(
ctx.font = '9px system-ui, sans-serif'
ctx.textBaseline = 'top'
// Sayfanın mm aralığını çiz
const startMm = 0
const endMm = pageMm
if (direction === 'horizontal') {
// Yatay cetvel: tek sayfa genişliği, flex-center ile hizalı
// 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
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.lineWidth = 1
const startPx = pageStartPx
@@ -159,6 +184,11 @@ function drawTicks(
ctx.lineTo(size, endPx)
}
ctx.stroke()
// Renkleri geri al (sonraki sayfa için)
ctx.fillStyle = '#94a3b8'
ctx.strokeStyle = '#94a3b8'
ctx.lineWidth = 0.5
}
function redraw() {
@@ -166,7 +196,7 @@ function redraw() {
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
@@ -205,7 +235,7 @@ onBeforeUnmount(() => {
position: absolute;
top: 0;
left: 20px;
right: 0;
width: calc(100% - 20px);
z-index: 50;
pointer-events: none;
}
@@ -214,7 +244,7 @@ onBeforeUnmount(() => {
position: absolute;
top: 20px;
left: 0;
bottom: 0;
height: calc(100% - 20px);
z-index: 50;
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 RepeatingTableProperties from '../properties/RepeatingTableProperties.vue'
import ChartProperties from '../properties/ChartProperties.vue'
import PropCondition from '../properties/shared/PropCondition.vue'
import '../../styles/properties.css'
const templateStore = useTemplateStore()
@@ -233,6 +234,13 @@ function deleteSelected() {
</div>
</div>
<!-- Condition -->
<PropCondition
v-if="selectedElement.id !== 'root'"
:condition="selectedElement.condition"
@update:condition="(v) => templateStore.updateElement(selectedElement!.id, { condition: v } as any)"
/>
<!-- Delete -->
<div v-if="selectedElement.id !== 'root'" class="prop-section">
<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 type {
TemplateElement,
TextElement,
RepeatingTableElement,
TableColumn,
ImageElement,
@@ -46,6 +47,18 @@ const tools: ToolItem[] = [
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',
icon: 'R',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,29 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useSchemaStore } from '../../stores/schema'
import { sz } from '../../core/types'
import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser'
import type {
RepeatingTableElement,
TableColumn,
FormatType,
TemplateElement,
} from '../../core/types'
import PropSection from './shared/PropSection.vue'
import PropFieldSelect from './shared/PropFieldSelect.vue'
import TableColumnEditor from './table/TableColumnEditor.vue'
import TableStyleEditor from './table/TableStyleEditor.vue'
import type { RepeatingTableElement, TableColumn, TableStyle } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: RepeatingTableElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const { update } = usePropertyUpdate(() => props.element)
const schemaStore = useSchemaStore()
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
let colIdCounter = Date.now()
function nextColId() {
return `col_${(++colIdCounter).toString(36)}`
@@ -40,24 +31,21 @@ function updateTableDataSource(path: string) {
align: defaultAlignForSchema(field),
format: schemaFormatToFormatType(field.format),
}))
update({
dataSource: { type: 'array', path },
columns,
} as Partial<TemplateElement>)
update({ dataSource: { type: 'array', path }, columns } as any)
} else {
update({ dataSource: { type: 'array', path } } as Partial<TemplateElement>)
update({ dataSource: { type: 'array', path } } as any)
}
}
function updateTableStyle(key: string, value: unknown) {
const newStyle = { ...props.element.style, [key]: value }
if (value === undefined || value === '') delete (newStyle as Record<string, unknown>)[key]
update({ style: newStyle } as Partial<TemplateElement>)
update({ style: newStyle } as any)
}
function updateColumn(colId: string, updates: Partial<TableColumn>) {
const columns = props.element.columns.map((c) => (c.id === colId ? { ...c, ...updates } : c))
update({ columns } as Partial<TemplateElement>)
update({ columns } as any)
}
function addColumn() {
@@ -68,13 +56,11 @@ function addColumn() {
width: sz.auto(),
align: 'left',
}
update({ columns: [...props.element.columns, newCol] } as Partial<TemplateElement>)
update({ columns: [...props.element.columns, newCol] } as any)
}
function removeColumn(colId: string) {
update({
columns: props.element.columns.filter((c) => c.id !== colId),
} as Partial<TemplateElement>)
update({ columns: props.element.columns.filter((c) => c.id !== colId) } as any)
}
function moveColumn(colId: string, direction: -1 | 1) {
@@ -83,7 +69,7 @@ function moveColumn(colId: string, direction: -1 | 1) {
const newIdx = idx + direction
if (newIdx < 0 || newIdx >= cols.length) return
;[cols[idx], cols[newIdx]] = [cols[newIdx], cols[idx]]
update({ columns: cols } as Partial<TemplateElement>)
update({ columns: cols } as any)
}
const tableItemFields = computed(() => {
@@ -93,864 +79,39 @@ const tableItemFields = computed(() => {
<template>
<!-- Data source -->
<div class="prop-section">
<div class="prop-section__title">Veri Kaynagi</div>
<div class="prop-row" data-tip="Tablonun baglanacagi array veri kaynagi">
<label class="prop-label">Kaynak</label>
<select
class="prop-input prop-select"
:value="element.dataSource.path"
@change="(e) => updateTableDataSource((e.target as HTMLSelectElement).value)"
>
<option value="" disabled>Secin...</option>
<option v-for="arr in schemaStore.arrayFields" :key="arr.path" :value="arr.path">
{{ arr.title }} ({{ arr.path }})
</option>
</select>
</div>
</div>
<PropSection title="Veri Kaynagi">
<PropFieldSelect
label="Kaynak"
:model-value="element.dataSource.path"
:fields="schemaStore.arrayFields"
data-tip="Tablonun baglanacagi array veri kaynagi"
@update:model-value="updateTableDataSource"
/>
</PropSection>
<!-- Columns -->
<div class="prop-section">
<div class="prop-section__title">
Sutunlar
<PropSection title="Sutunlar">
<template #actions>
<button class="prop-add-btn" @click="addColumn">+</button>
</div>
<div v-for="col in element.columns" :key="col.id" class="tbl-col">
<!-- Row 1: title + actions -->
<div class="tbl-col__head">
<input
class="tbl-col__title"
type="text"
:value="col.title"
@change="(e) => updateColumn(col.id, { title: (e.target as HTMLInputElement).value })"
:placeholder="col.field"
data-tip="Sutun basligi"
/>
<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>
</template>
<TableColumnEditor
v-for="col in element.columns"
:key="col.id"
:column="col"
:item-fields="tableItemFields"
@update="updateColumn"
@remove="removeColumn"
@move="moveColumn"
/>
</PropSection>
<!-- Table style -->
<div class="prop-section">
<div class="prop-section__title">Tablo Stili</div>
<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="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>
<PropSection title="Tablo Stili">
<TableStyleEditor
:style="element.style as TableStyle"
:repeat-header="element.repeatHeader !== false"
@update:style="updateTableStyle"
@update:repeat-header="(v) => update({ repeatHeader: v } as any)"
/>
</PropSection>
</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">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
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 '../../styles/properties.css'
const props = defineProps<{ element: RichTextElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
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>)
}
const { update, updateStyle } = usePropertyUpdate(() => props.element)
const schemaStore = useSchemaStore()
function updateSpan(index: number, updates: Partial<RichTextSpan>) {
const content = [...props.element.content]
content[index] = { ...content[index], ...updates }
update({ content })
update({ content } as any)
}
function updateSpanStyle(index: number, key: string, value: unknown) {
@@ -31,60 +26,42 @@ function updateSpanStyle(index: number, key: string, value: unknown) {
function addSpan() {
const content = [...props.element.content, { text: 'yeni', style: {} }]
update({ content })
update({ content } as any)
}
function removeSpan(index: number) {
if (props.element.content.length <= 1) return
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>
<template>
<div class="prop-section">
<div class="prop-section__title">Varsayilan Stil</div>
<div class="prop-row" data-tip="Varsayilan yazi tipi boyutu (point)">
<label class="prop-label">Boyut (pt)</label>
<input
class="prop-input"
type="number"
step="1"
min="1"
:value="element.style.fontSize ?? 11"
@input="
(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)
"
/>
</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>
<PropSection title="Varsayilan Stil">
<PropTextStyleGroup
:font-size="element.style.fontSize ?? 11"
:font-weight="element.style.fontWeight ?? 'normal'"
:font-family="element.style.fontFamily"
:color="element.style.color ?? '#000000'"
:align="element.style.align ?? 'left'"
@update:font-size="(v) => updateStyle('fontSize', v)"
@update:font-weight="(v) => updateStyle('fontWeight', v)"
@update:font-family="(v) => updateStyle('fontFamily', v)"
@update:color="(v) => updateStyle('color', v)"
@update:align="(v) => updateStyle('align', v)"
/>
</PropSection>
<div class="prop-section">
<div class="prop-section__title">
Span'lar
<PropSection title="Span'lar">
<template #actions>
<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 class="prop-span-card__header">
@@ -108,6 +85,15 @@ function removeSpan(index: number) {
@input="(e) => updateSpan(idx, { text: (e.target as HTMLInputElement).value })"
/>
</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">
<label class="prop-label">Boyut</label>
<input
@@ -125,57 +111,31 @@ function removeSpan(index: number) {
"
/>
</div>
<div class="prop-row" data-tip="Span yazi kalinligi">
<label class="prop-label">Kalinlik</label>
<select
class="prop-input prop-select"
:value="(span.style as TextStyle).fontWeight ?? ''"
@change="
(e) => {
const v = (e.target as HTMLSelectElement).value
updateSpanStyle(idx, 'fontWeight', v || undefined)
}
"
>
<option value="">Varsayilan</option>
<option value="normal">Normal</option>
<option value="bold">Kalin</option>
</select>
</div>
<div class="prop-row" data-tip="Span metin rengi">
<label class="prop-label">Renk</label>
<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>
<PropSelect
label="Kalinlik"
:model-value="(span.style as TextStyle).fontWeight ?? ''"
:options="weightOptions"
data-tip="Span yazi kalinligi"
@update:model-value="(v) => updateSpanStyle(idx, 'fontWeight', v || undefined)"
/>
<PropColorInput
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)"
/>
<PropSelect
label="Hizalama"
:model-value="(span.style as TextStyle).align ?? ''"
:options="[{ value: '', label: 'Varsayilan' }, { value: 'left', label: 'Sol' }, { value: 'center', label: 'Orta' }, { value: 'right', label: 'Sag' }]"
data-tip="Span hizalamasi"
@update:model-value="(v) => updateSpanStyle(idx, 'align', v || undefined)"
/>
</div>
</div>
</PropSection>
</template>
<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 {
background: #f8fafc;
border: 1px solid #e2e8f0;

View File

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

View File

@@ -1,120 +1,131 @@
<script setup lang="ts">
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 '../../styles/properties.css'
const props = defineProps<{ element: TemplateElement }>()
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) {
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>
<template>
<div class="prop-section">
<div class="prop-section__title">Boyut</div>
<PropSection title="Boyut">
<div class="prop-row" data-tip="Genislik boyutlandirma modu">
<label class="prop-label">Genislik</label>
<select
class="prop-input prop-select"
:value="element.size.width.type"
@change="
(e) => {
const t = (e.target as HTMLSelectElement).value
if (t === 'auto') updateSize('width', { type: 'auto' })
else if (t === 'fr') updateSize('width', { type: 'fr', value: 1 })
else updateSize('width', { type: 'fixed', value: 50 })
}
"
@change="(e) => onTypeChange('width', (e.target as HTMLSelectElement).value)"
>
<option value="auto">Otomatik</option>
<option value="fixed">Sabit (mm)</option>
<option value="fr">Oran (fr)</option>
<option v-for="opt in sizeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div
<PropNumberInput
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)"
>
<label class="prop-label">mm</label>
<input
class="prop-input"
type="number"
step="1"
min="1"
:value="(element.size.width as any).value"
@input="
(e) =>
updateSize('width', {
type: 'fixed',
value: parseFloat((e.target as HTMLInputElement).value) || 10,
})
"
/>
</div>
<div
@update:model-value="(v) => updateSize('width', { type: 'fixed', value: v })"
/>
<PropNumberInput
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"
>
<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>
@update:model-value="(v) => updateSize('width', { type: 'fr', value: v })"
/>
<div class="prop-row" data-tip="Yukseklik boyutlandirma modu">
<label class="prop-label">Yukseklik</label>
<select
class="prop-input prop-select"
:value="element.size.height.type"
@change="
(e) => {
const t = (e.target as HTMLSelectElement).value
if (t === 'auto') updateSize('height', { type: 'auto' })
else if (t === 'fr') updateSize('height', { type: 'fr', value: 1 })
else updateSize('height', { type: 'fixed', value: 20 })
}
"
@change="(e) => onTypeChange('height', (e.target as HTMLSelectElement).value)"
>
<option value="auto">Otomatik</option>
<option value="fixed">Sabit (mm)</option>
<option value="fr">Oran (fr)</option>
<option v-for="opt in sizeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div
<PropNumberInput
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)"
>
<label class="prop-label">mm</label>
<input
class="prop-input"
type="number"
step="1"
min="1"
:value="(element.size.height as any).value"
@input="
(e) =>
updateSize('height', {
type: 'fixed',
value: parseFloat((e.target as HTMLInputElement).value) || 10,
})
"
/>
</div>
</div>
@update:model-value="(v) => updateSize('height', { type: 'fixed', value: v })"
/>
<PropNumberInput
v-if="element.size.height.type === 'fr'"
label="fr"
:model-value="(element.size.height as any).value"
:step="1"
:min="1"
data-tip="Kalan alani oransal doldurma degeri"
@update:model-value="(v) => updateSize('height', { type: 'fr', value: v })"
/>
</PropSection>
<PropSection title="Min / Max">
<PropNumberInput
label="Min Gen."
:model-value="element.size.minWidth ?? 0"
: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>

View File

@@ -1,28 +1,23 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { StaticTextElement, TextStyle, TemplateElement } from '../../core/types'
import { computed } from 'vue'
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
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'
const props = defineProps<{ element: TemplateElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const { update, updateStyle } = usePropertyUpdate(() => props.element)
const schemaStore = useSchemaStore()
const style = () => props.element.style as TextStyle
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
const isText = computed(() => props.element.type === 'text')
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Metin Stili</div>
<PropSection title="Metin">
<div v-if="element.type === 'static_text'" class="prop-row" data-tip="Sabit metin icerigi">
<label class="prop-label">Metin</label>
<input
@@ -33,50 +28,39 @@ function updateStyle(key: string, value: unknown) {
/>
</div>
<div class="prop-row" data-tip="Yazi tipi boyutu (point)">
<label class="prop-label">Boyut (pt)</label>
<input
class="prop-input"
type="number"
step="1"
min="1"
:value="(element.style as TextStyle).fontSize ?? 11"
@input="
(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)
"
<template v-if="isText">
<PropFieldSelect
label="Veri Alani"
:model-value="(element as TextElement).binding?.path ?? ''"
:fields="schemaStore.scalarFields"
data-tip="Metnin baglanacagi veri alani"
@update:model-value="(v) => update({ binding: { type: 'scalar', path: v } } as any)"
/>
</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="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="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>
<div class="prop-row" data-tip="Veri alaninin onune eklenecek sabit metin">
<label class="prop-label">Ön Ek</label>
<input
class="prop-input"
type="text"
:value="(element as TextElement).content ?? ''"
placeholder="ör: Fatura No: "
@input="(e) => update({ content: (e.target as HTMLInputElement).value || undefined } as any)"
/>
</div>
</template>
</PropSection>
<PropSection title="Metin Stili">
<PropTextStyleGroup
:font-size="style().fontSize ?? 11"
:font-weight="style().fontWeight ?? 'normal'"
:font-family="style().fontFamily"
:color="style().color ?? '#000000'"
:align="style().align ?? 'left'"
@update:font-size="(v) => updateStyle('fontSize', v)"
@update:font-weight="(v) => updateStyle('fontWeight', v)"
@update:font-family="(v) => updateStyle('fontFamily', v)"
@update:color="(v) => updateStyle('color', v)"
@update:align="(v) => updateStyle('align', v)"
/>
</PropSection>
</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ç)
}
// --- Condition (koşullu gösterim) ---
export interface Condition {
path: string
operator: string
value?: unknown
}
// --- Element tipleri ---
interface BaseElement {
id: string
condition?: Condition
position: PositionMode
size: SizeConstraint
}
@@ -241,11 +250,22 @@ export interface ChartLabels {
color?: string
}
export interface ChartReferenceLine {
categoryIndex: number
color?: string
width?: number
label?: string
dash?: boolean
}
export interface ChartAxis {
xLabel?: string
yLabel?: string
showGrid?: boolean
gridColor?: string
showVerticalGrid?: boolean
verticalGridColor?: string
referenceLines?: ChartReferenceLine[]
}
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
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)
test-all: test-rust test-front test-visual
@@ -109,3 +113,174 @@ publish-layout:
publish-all:
just publish-core
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]
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"
taffy = "0.9"
cosmic-text = { version = "0.18", default-features = false, features = ["std", "swash"] }

View File

@@ -64,7 +64,9 @@ pub struct YTick {
pub struct XLabelLayout {
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 {
@@ -127,10 +129,23 @@ pub struct LineChartLayout {
pub show_labels: bool,
pub label_font: f64,
pub label_color: String,
pub smooth: bool,
/// X axis line endpoints
pub x_axis_y: f64,
pub x_axis_x1: 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 {
@@ -162,6 +177,8 @@ pub struct PieChartLayout {
pub inner_radius: f64,
pub slices: Vec<PieSlice>,
pub show_labels: bool,
/// Category name labels + leader lines outside slices
pub show_cat_labels: bool,
pub label_font: f64,
pub label_color: String,
}
@@ -219,6 +236,10 @@ pub trait ChartDataSource {
fn inner_radius(&self) -> Option<f64>;
fn show_points(&self) -> Option<bool>;
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> {
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> {
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 {
available_w
};
let max_chars_fit = (cat_width / 1.25).max(1.0) as usize;
let will_rotate = max_label_len > max_chars_fit;
if will_rotate {
let char_w_mm = 1.1;
let rotate_angle = compute_label_rotation(max_label_len, cat_width);
if rotate_angle > 0.0 {
let char_w_mm = 2.5 * 0.6;
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);
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);
margin_left += extra_left.min(10.0);
} 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).
pub fn compute_x_labels_bar(
categories: &[String],
@@ -631,12 +699,12 @@ pub fn compute_x_labels_bar(
if n_cats == 0 {
return XLabelLayout {
labels: vec![],
needs_rotate: false,
rotate_angle: 0.0,
};
}
let cat_width = pw / n_cats as f64;
let max_chars = (cat_width / 1.25).max(1.0) as usize;
let needs_rotate = categories.iter().any(|c| c.len() > max_chars);
let max_label_len = categories.iter().map(|c| c.len()).max().unwrap_or(0);
let rotate_angle = compute_label_rotation(max_label_len, cat_width);
let labels = categories
.iter()
.enumerate()
@@ -648,7 +716,7 @@ pub fn compute_x_labels_bar(
.collect();
XLabelLayout {
labels,
needs_rotate,
rotate_angle,
}
}
@@ -663,25 +731,17 @@ pub fn compute_x_labels_line(
if n_cats == 0 {
return XLabelLayout {
labels: vec![],
needs_rotate: false,
rotate_angle: 0.0,
};
}
let spacing = if n_cats == 1 {
pw
} else {
pw / (n_cats - 1) as f64
};
let max_chars = (spacing / 1.25).max(1.0) as usize;
let needs_rotate = categories.iter().any(|c| c.len() > max_chars);
let step = pw / n_cats as f64;
let max_label_len = categories.iter().map(|c| c.len()).max().unwrap_or(0);
let rotate_angle = compute_label_rotation(max_label_len, step);
let labels = categories
.iter()
.enumerate()
.map(|(ci, cat)| {
let x = if n_cats == 1 {
px + pw / 2.0
} else {
px + ci as f64 * pw / (n_cats - 1) as f64
};
let x = px + step / 2.0 + ci as f64 * step;
XLabel {
text: cat.clone(),
x,
@@ -691,7 +751,7 @@ pub fn compute_x_labels_line(
.collect();
XLabelLayout {
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 label_font = data.label_font_size().unwrap_or(2.2);
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())
.map(|si| {
@@ -814,11 +879,7 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
.iter()
.enumerate()
.map(|(ci, val)| {
let x = if n_cats == 1 {
px + pw / 2.0
} else {
px + ci as f64 * pw / (n_cats - 1) as f64
};
let x = px + step / 2.0 + ci as f64 * step;
let y = py + ph - ((val - min_val) / range) * ph;
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);
// 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 {
min_val,
max_val,
@@ -843,9 +940,11 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
show_labels,
label_font,
label_color,
smooth,
x_axis_y: py + ph,
x_axis_x1: px,
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,
slices,
show_labels,
show_cat_labels: show_labels,
label_font,
label_color,
}

View File

@@ -29,14 +29,14 @@ pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> St
// Title
if let Some(ref title) = cl.title {
let anchor = match title.align.as_str() {
"left" => "start",
"right" => "end",
_ => "middle",
"left" => SvgAnchor::Start,
"right" => SvgAnchor::End,
_ => SvgAnchor::Middle,
};
write!(
svg,
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();
}
@@ -48,7 +48,7 @@ pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> St
}
// Legend render
if cl.legend_show && data.series.len() > 1 {
if cl.legend_show {
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);
if has_axis && let Some(ref axis) = data.axis {
if let Some(ref x_label) = axis.x_label {
let x = cl.plot_x + cl.plot_w / 2.0;
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();
svg_text(&mut svg, cl.plot_x + cl.plot_w / 2.0, height_mm - 2.0, 2.8, "#666", SvgAnchor::Middle, x_label);
}
if let Some(ref y_label) = axis.y_label {
let x = 3.0;
@@ -101,24 +94,8 @@ fn render_bar(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
)
.unwrap();
if bl.show_labels {
if bl.stacked {
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();
}
if bl.show_labels && (!bl.stacked || bar.value > 0.0) {
svg_text(svg, bar.label_x, bar.label_y, bl.label_font, &bl.label_color, SvgAnchor::Middle, &format_value(bar.value));
}
}
@@ -144,14 +121,13 @@ fn render_line(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
// Y axis
render_y_axis_svg(svg, &ll.y_axis);
let mut label_texts = String::new();
for series_layout in &ll.series {
let color = color_at(&cl.palette, series_layout.color_idx);
let mut points = String::new();
let mut point_circles = String::new();
for pt in &series_layout.points {
write!(points, "{:.2},{:.2} ", pt.x, pt.y).unwrap();
if ll.show_points {
write!(
point_circles,
@@ -162,24 +138,75 @@ fn render_line(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
}
if ll.show_labels {
write!(
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();
svg_text(&mut label_texts, pt.x, pt.y - 1.5, ll.label_font, &ll.label_color, SvgAnchor::Middle, &format_value(pt.value));
}
}
write!(
svg,
r##"<polyline points="{}" fill="none" stroke="{}" stroke-width="{:.2}" stroke-linejoin="round" stroke-linecap="round"/>"##,
points.trim(), color, ll.line_width
)
.unwrap();
if ll.smooth && series_layout.points.len() >= 2 {
// Catmull-Rom → cubic bezier smooth curve
let pts = &series_layout.points;
let mut d = format!("M{:.2},{:.2}", pts[0].x, pts[0].y);
for i in 0..pts.len() - 1 {
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);
}
// 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
render_x_labels_svg(svg, &ll.x_labels);
@@ -239,19 +266,12 @@ fn render_pie(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
.unwrap();
}
// Percentage label inside slice
if pl.show_labels {
write!(
svg,
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();
let pct = format!("{}%", (slice.fraction * 100.0).round());
svg_text_central(svg, slice.label_x, slice.label_y, pl.label_font, &pl.label_color, SvgAnchor::Middle, &pct);
}
// Category name label outside slice with leader line
if !slice.cat_label_text.is_empty() {
if pl.show_cat_labels && !slice.cat_label_text.is_empty() {
write!(
svg,
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
)
.unwrap();
let anchor = if slice.cat_label_anchor_end {
"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();
let anchor = if slice.cat_label_anchor_end { SvgAnchor::End } else { SvgAnchor::Start };
svg_text_central(svg, slice.cat_label_x, slice.cat_label_y, 2.5, "#555", anchor, &slice.cat_label_text);
}
}
}
@@ -292,15 +302,7 @@ fn render_legend(
item.swatch_x, item.swatch_y, color
)
.unwrap();
write!(
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();
svg_text(svg, item.text_x, item.text_y, legend.font_size, "#666", SvgAnchor::Start, &item.name);
}
}
@@ -310,12 +312,7 @@ fn render_legend(
fn render_y_axis_svg(svg: &mut String, y_axis: &chart_layout::YAxisLayout) {
for tick in &y_axis.ticks {
write!(
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();
svg_text(svg, y_axis.axis_x - 1.5, tick.y + 0.8, 2.3, "#666", SvgAnchor::End, &tick.label);
if y_axis.show_grid {
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) {
let angle = x_labels.rotate_angle;
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!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.2" fill="#666" text-anchor="end" transform="rotate(-45,{:.2},{:.2})">{}</text>"##,
label.x, label.y, label.x, label.y, escape_xml(&label.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, angle, label.x, label.y, escape_xml(&label.text)
)
.unwrap();
} else {
write!(
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();
svg_text(svg, label.x, label.y, 2.5, "#666", SvgAnchor::Middle, &label.text);
}
}
}
@@ -362,3 +356,311 @@ fn escape_xml(s: &str) -> String {
.replace('>', "&gt;")
.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 std::collections::HashMap;
// Re-export HasOptionalBinding for convenience
pub use dreport_core::models::HasOptionalBinding;
/// Şu anki tarihi verilen format string'ine göre formatla.
/// Desteklenen tokenlar: YYYY, MM, DD, HH, mm, ss
/// WASM'da js_sys::Date, native'de SystemTime kullanır.
@@ -106,6 +109,8 @@ pub struct ResolvedData {
pub rich_texts: HashMap<String, Vec<ResolvedRichSpan>>,
/// element_id → çözümlenmiş chart verisi
pub charts: HashMap<String, ResolvedChartData>,
/// Koşulu sağlamayan (gizlenmesi gereken) element ID'leri
pub hidden_elements: std::collections::HashSet<String>,
}
#[derive(Debug, Clone)]
@@ -146,33 +151,103 @@ pub fn resolve_template(template: &Template, data: &Value) -> ResolvedData {
page_number_formats: HashMap::new(),
rich_texts: HashMap::new(),
charts: HashMap::new(),
hidden_elements: std::collections::HashSet::new(),
};
let fc = template.effective_format_config();
if let Some(ref header) = template.header {
resolve_element(
&TemplateElement::Container(header.clone()),
data,
&mut resolved,
&fc,
);
}
resolve_element(
&TemplateElement::Container(template.root.clone()),
data,
&mut resolved,
&fc,
);
if let Some(ref footer) = template.footer {
resolve_element(
&TemplateElement::Container(footer.clone()),
data,
&mut resolved,
&fc,
);
}
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 {
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) => {
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),
_ => bound_value,
};
resolved.texts.insert(e.id.clone(), text);
resolved.texts.insert(e.base.id.clone(), text);
}
TemplateElement::PageNumber(e) => {
// 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();
resolved
.page_number_formats
.insert(e.id.clone(), fmt.clone());
.insert(e.base.id.clone(), fmt.clone());
// Placeholder koy (tek sayfalık fallback)
resolved.texts.insert(
e.id.clone(),
e.base.id.clone(),
fmt.replace("{current}", "1").replace("{total}", "1"),
);
}
TemplateElement::Barcode(e) => {
let value = if let Some(binding) = &e.binding {
value_to_string(resolve_path(data, &binding.path))
} else {
e.value.clone().unwrap_or_default()
};
resolved.barcodes.insert(e.id.clone(), value);
resolved.barcodes.insert(e.base.id.clone(), resolve_optional_binding(e, data));
}
TemplateElement::Image(e) => {
let src = if let Some(binding) = &e.binding {
value_to_string(resolve_path(data, &binding.path))
} else {
e.src.clone().unwrap_or_default()
};
resolved.images.insert(e.id.clone(), src);
resolved.images.insert(e.base.id.clone(), resolve_optional_binding(e, data));
}
TemplateElement::RepeatingTable(e) => {
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);
// Sütun formatı varsa uygula (currency, percentage, number, date)
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 {
raw
}
@@ -239,17 +304,17 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
}
_ => vec![],
};
resolved.tables.insert(e.id.clone(), ResolvedTable { rows });
resolved.tables.insert(e.base.id.clone(), ResolvedTable { rows });
}
TemplateElement::Container(e) => {
for child in &e.children {
resolve_element(child, data, resolved);
resolve_element(child, data, resolved, format_config);
}
}
TemplateElement::CurrentDate(e) => {
let fmt = e.format.as_deref().unwrap_or("DD.MM.YYYY");
let text = format_current_date(fmt);
resolved.texts.insert(e.id.clone(), text);
resolved.texts.insert(e.base.id.clone(), text);
}
TemplateElement::Checkbox(e) => {
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)
};
// 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) => {
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
let text = if formatted.is_empty() {
" ".to_string()
} else {
formatted
};
resolved.texts.insert(e.id.clone(), text);
resolved.texts.insert(e.base.id.clone(), text);
}
TemplateElement::RichText(e) => {
let spans: Vec<ResolvedRichSpan> = e
@@ -308,7 +373,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
}
})
.collect();
resolved.rich_texts.insert(e.id.clone(), spans);
resolved.rich_texts.insert(e.base.id.clone(), spans);
}
TemplateElement::Chart(e) => {
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(),
},
};
resolved.charts.insert(e.id.clone(), chart_data);
resolved.charts.insert(e.base.id.clone(), chart_data);
}
TemplateElement::Line(_) => {}
TemplateElement::Shape(_) => {}
@@ -477,10 +542,9 @@ mod tests {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),
@@ -489,9 +553,7 @@ mod tests {
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::Text(TextElement {
id: "el_name".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("el_name".to_string(), SizeConstraint::default()),
style: TextStyle::default(),
content: None,
binding: ScalarBinding {
@@ -525,10 +587,9 @@ mod tests {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),
@@ -537,9 +598,7 @@ mod tests {
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::Text(TextElement {
id: "el_no".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("el_no".to_string(), SizeConstraint::default()),
style: TextStyle::default(),
content: Some("Fatura No: ".to_string()),
binding: ScalarBinding {
@@ -570,10 +629,9 @@ mod tests {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),
@@ -582,9 +640,7 @@ mod tests {
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("title".to_string(), SizeConstraint::default()),
style: TextStyle::default(),
content: "FATURA".to_string(),
})],
@@ -608,10 +664,9 @@ mod tests {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),
@@ -620,9 +675,7 @@ mod tests {
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("tbl".to_string(), SizeConstraint::default()),
data_source: ArrayBinding {
path: "kalemler".to_string(),
},
@@ -677,10 +730,9 @@ mod tests {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),
@@ -689,9 +741,7 @@ mod tests {
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("tbl".to_string(), SizeConstraint::default()),
data_source: ArrayBinding {
path: "items".to_string(),
},
@@ -728,10 +778,9 @@ mod tests {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),
@@ -740,9 +789,7 @@ mod tests {
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::Text(TextElement {
id: "el_missing".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("el_missing".to_string(), SizeConstraint::default()),
style: TextStyle::default(),
content: None,
binding: ScalarBinding {

View File

@@ -65,6 +65,10 @@ fn dexpr_value_to_string(val: &DexprValue) -> String {
.collect();
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");
}
#[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
#[serde(default)]
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)]
pub struct ChartSeriesData {
pub name: String,
@@ -223,6 +236,131 @@ pub struct ResolvedStyle {
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.
/// Template + data + font verileri alır, her element için pozisyon döner.
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]
fn test_repeated_header_no_gap_with_rows() {
// 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