Compare commits
5 Commits
e574889e5d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2db5929e39 | |||
| 92583141c9 | |||
| 58a59f2609 | |||
| aa27228d08 | |||
| 4fda0e7d98 |
14
.gitignore
vendored
@@ -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/
|
||||
|
||||
160
Cargo.lock
generated
@@ -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"
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
[workspace]
|
||||
members = ["core", "backend", "layout-engine"]
|
||||
members = ["core", "backend", "layout-engine", "dreport-service", "dreport-ffi"]
|
||||
resolver = "2"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub use dreport_core::models::*;
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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()
|
||||
);
|
||||
}
|
||||
10
bindings/dotnet/Dreport.Service.slnx
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
20
bindings/dotnet/src/Dreport.AspNetCore/DreportOptions.cs
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
38
bindings/dotnet/src/Dreport.Service/Dreport.Service.csproj
Normal 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>
|
||||
67
bindings/dotnet/src/Dreport.Service/Exceptions.cs
Normal 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) { }
|
||||
}
|
||||
13
bindings/dotnet/src/Dreport.Service/FontFamily.cs
Normal 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);
|
||||
220
bindings/dotnet/src/Dreport.Service/LayoutEngine.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
145
bindings/dotnet/src/Dreport.Service/Native.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
173
bindings/dotnet/tests/Dreport.AspNetCore.Tests/EndpointTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
236
bindings/dotnet/tests/Dreport.Service.Tests/LayoutEngineTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -230,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)]
|
||||
@@ -247,11 +267,8 @@ pub struct ChartStyle {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChartElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
#[serde(flatten)]
|
||||
pub base: ElementBase,
|
||||
pub chart_type: ChartType,
|
||||
pub data_source: ArrayBinding,
|
||||
pub category_field: String,
|
||||
@@ -272,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)]
|
||||
@@ -316,91 +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> {
|
||||
match self {
|
||||
Self::Container(e) => e.condition.as_ref(),
|
||||
Self::StaticText(e) => e.condition.as_ref(),
|
||||
Self::Text(e) => e.condition.as_ref(),
|
||||
Self::Line(e) => e.condition.as_ref(),
|
||||
Self::RepeatingTable(e) => e.condition.as_ref(),
|
||||
Self::Image(e) => e.condition.as_ref(),
|
||||
Self::PageNumber(e) => e.condition.as_ref(),
|
||||
Self::Barcode(e) => e.condition.as_ref(),
|
||||
Self::PageBreak(e) => e.condition.as_ref(),
|
||||
Self::CurrentDate(e) => e.condition.as_ref(),
|
||||
Self::Shape(e) => e.condition.as_ref(),
|
||||
Self::Checkbox(e) => e.condition.as_ref(),
|
||||
Self::CalculatedText(e) => e.condition.as_ref(),
|
||||
Self::RichText(e) => e.condition.as_ref(),
|
||||
Self::Chart(e) => e.condition.as_ref(),
|
||||
}
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,11 +525,8 @@ impl TemplateElement {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RichTextElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
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>,
|
||||
@@ -421,13 +535,8 @@ pub struct RichTextElement {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContainerElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
#[serde(default)]
|
||||
pub position: PositionMode,
|
||||
#[serde(default)]
|
||||
pub size: SizeConstraint,
|
||||
#[serde(flatten)]
|
||||
pub base: ElementBase,
|
||||
#[serde(default = "default_column")]
|
||||
pub direction: String,
|
||||
#[serde(default)]
|
||||
@@ -463,11 +572,8 @@ fn default_start() -> String {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StaticTextElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
#[serde(flatten)]
|
||||
pub base: ElementBase,
|
||||
pub style: TextStyle,
|
||||
pub content: String,
|
||||
}
|
||||
@@ -475,11 +581,8 @@ pub struct StaticTextElement {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TextElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
#[serde(flatten)]
|
||||
pub base: ElementBase,
|
||||
pub style: TextStyle,
|
||||
pub content: Option<String>,
|
||||
pub binding: ScalarBinding,
|
||||
@@ -488,22 +591,16 @@ pub struct TextElement {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LineElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
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,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
#[serde(flatten)]
|
||||
pub base: ElementBase,
|
||||
pub src: Option<String>,
|
||||
pub binding: Option<ScalarBinding>,
|
||||
pub style: ImageStyle,
|
||||
@@ -512,11 +609,8 @@ pub struct ImageElement {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageNumberElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
#[serde(flatten)]
|
||||
pub base: ElementBase,
|
||||
pub style: TextStyle,
|
||||
pub format: Option<String>,
|
||||
}
|
||||
@@ -524,11 +618,8 @@ pub struct PageNumberElement {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarcodeElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
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>,
|
||||
@@ -538,11 +629,8 @@ pub struct BarcodeElement {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RepeatingTableElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
#[serde(flatten)]
|
||||
pub base: ElementBase,
|
||||
pub data_source: ArrayBinding,
|
||||
pub columns: Vec<TableColumn>,
|
||||
pub style: TableStyle,
|
||||
@@ -557,19 +645,15 @@ fn default_true() -> Option<bool> {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageBreakElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
#[serde(flatten)]
|
||||
pub base: ElementBase,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CurrentDateElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
#[serde(flatten)]
|
||||
pub base: ElementBase,
|
||||
pub style: TextStyle,
|
||||
pub format: Option<String>,
|
||||
}
|
||||
@@ -577,11 +661,8 @@ pub struct CurrentDateElement {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ShapeElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
#[serde(flatten)]
|
||||
pub base: ElementBase,
|
||||
pub shape_type: String, // rectangle, ellipse, rounded_rectangle
|
||||
pub style: ContainerStyle,
|
||||
}
|
||||
@@ -598,11 +679,8 @@ pub struct CheckboxStyle {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CheckboxElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
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,
|
||||
@@ -611,11 +689,8 @@ pub struct CheckboxElement {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CalculatedTextElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
#[serde(flatten)]
|
||||
pub base: ElementBase,
|
||||
pub style: TextStyle,
|
||||
pub expression: String,
|
||||
pub format: Option<String>,
|
||||
|
||||
17
dreport-ffi/Cargo.toml
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
21
dreport-service/Cargo.toml
Normal 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"
|
||||
48
dreport-service/src/error.rs
Normal 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>;
|
||||
164
dreport-service/src/font_registry.rs
Normal 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
@@ -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)
|
||||
}
|
||||
}
|
||||
297
dreport-service/tests/service.rs
Normal 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);
|
||||
}
|
||||
53
frontend/src/components/editor/toolbars/ChartToolbar.vue
Normal 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>
|
||||
97
frontend/src/components/editor/toolbars/ContainerToolbar.vue
Normal 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>
|
||||
51
frontend/src/components/editor/toolbars/TableToolbar.vue
Normal 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>
|
||||
52
frontend/src/components/editor/toolbars/TextToolbar.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, >0 = Donut</div>
|
||||
</div>
|
||||
</PropSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
×
|
||||
</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)"
|
||||
>
|
||||
×
|
||||
</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)">↔</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)">↕</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)">↔</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)">↕</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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
20
frontend/src/components/properties/shared/PropCheckbox.vue
Normal 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>
|
||||
38
frontend/src/components/properties/shared/PropColorInput.vue
Normal 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>
|
||||
84
frontend/src/components/properties/shared/PropCondition.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
46
frontend/src/components/properties/shared/PropSection.vue
Normal 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 }"
|
||||
>▾</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>
|
||||
23
frontend/src/components/properties/shared/PropSelect.vue
Normal 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>
|
||||
@@ -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>
|
||||
387
frontend/src/components/properties/table/TableColumnEditor.vue
Normal 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>
|
||||
384
frontend/src/components/properties/table/TableStyleEditor.vue
Normal 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)"
|
||||
>
|
||||
×
|
||||
</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)"
|
||||
>
|
||||
×
|
||||
</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)"
|
||||
>
|
||||
×
|
||||
</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)">↔</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)">↕</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)">↔</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)">↕</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>
|
||||
30
frontend/src/composables/usePropertyUpdate.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
119
frontend/src/styles/toolbar.css
Normal 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%;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
175
justfile
@@ -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
|
||||
|
||||
@@ -129,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 {
|
||||
@@ -223,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>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -314,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())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -403,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()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -693,22 +734,14 @@ pub fn compute_x_labels_line(
|
||||
rotate_angle: 0.0,
|
||||
};
|
||||
}
|
||||
let spacing = if n_cats == 1 {
|
||||
pw
|
||||
} else {
|
||||
pw / (n_cats - 1) as f64
|
||||
};
|
||||
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, spacing);
|
||||
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,
|
||||
@@ -833,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| {
|
||||
@@ -841,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 }
|
||||
})
|
||||
@@ -859,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,
|
||||
@@ -870,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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,18 +266,11 @@ 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 pl.show_cat_labels && !slice.cat_label_text.is_empty() {
|
||||
write!(
|
||||
svg,
|
||||
@@ -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!(
|
||||
@@ -340,6 +337,7 @@ 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 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(-{:.1},{:.2},{:.2})">{}</text>"##,
|
||||
@@ -347,12 +345,7 @@ fn render_x_labels_svg(svg: &mut String, x_labels: &chart_layout::XLabelLayout)
|
||||
)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,6 +357,61 @@ fn escape_xml(s: &str) -> String {
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
/// 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::*;
|
||||
@@ -567,6 +615,9 @@ mod tests {
|
||||
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);
|
||||
|
||||
@@ -582,6 +633,9 @@ mod tests {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -226,6 +229,15 @@ fn json_values_eq(a: &Value, b: &Value) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Çö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) {
|
||||
@@ -235,7 +247,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
|
||||
|
||||
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));
|
||||
@@ -243,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
|
||||
@@ -254,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);
|
||||
@@ -302,7 +304,7 @@ 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 {
|
||||
@@ -312,7 +314,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
|
||||
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 {
|
||||
@@ -327,7 +329,7 @@ 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);
|
||||
@@ -338,7 +340,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
|
||||
} 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
|
||||
@@ -371,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);
|
||||
@@ -389,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(_) => {}
|
||||
@@ -542,10 +544,7 @@ mod tests {
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
@@ -554,10 +553,7 @@ mod tests {
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::Text(TextElement {
|
||||
id: "el_name".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("el_name".to_string(), SizeConstraint::default()),
|
||||
style: TextStyle::default(),
|
||||
content: None,
|
||||
binding: ScalarBinding {
|
||||
@@ -593,10 +589,7 @@ mod tests {
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
@@ -605,10 +598,7 @@ mod tests {
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::Text(TextElement {
|
||||
id: "el_no".to_string(),
|
||||
condition: None,
|
||||
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 {
|
||||
@@ -641,10 +631,7 @@ mod tests {
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
@@ -653,10 +640,7 @@ mod tests {
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("title".to_string(), SizeConstraint::default()),
|
||||
style: TextStyle::default(),
|
||||
content: "FATURA".to_string(),
|
||||
})],
|
||||
@@ -682,10 +666,7 @@ mod tests {
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
@@ -694,10 +675,7 @@ mod tests {
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::RepeatingTable(RepeatingTableElement {
|
||||
id: "tbl".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("tbl".to_string(), SizeConstraint::default()),
|
||||
data_source: ArrayBinding {
|
||||
path: "kalemler".to_string(),
|
||||
},
|
||||
@@ -754,10 +732,7 @@ mod tests {
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
@@ -766,10 +741,7 @@ mod tests {
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::RepeatingTable(RepeatingTableElement {
|
||||
id: "tbl".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("tbl".to_string(), SizeConstraint::default()),
|
||||
data_source: ArrayBinding {
|
||||
path: "items".to_string(),
|
||||
},
|
||||
@@ -808,10 +780,7 @@ mod tests {
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
@@ -820,10 +789,7 @@ mod tests {
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::Text(TextElement {
|
||||
id: "el_missing".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("el_missing".to_string(), SizeConstraint::default()),
|
||||
style: TextStyle::default(),
|
||||
content: None,
|
||||
binding: ScalarBinding {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -21,11 +21,6 @@ fn mm(v: f64) -> f32 {
|
||||
v as f32 * MM_TO_PT
|
||||
}
|
||||
|
||||
/// f64 mm degerini f32 pt'ye cevir (chart render icin)
|
||||
fn pt(mm_val: f64) -> f32 {
|
||||
mm_val as f32 * MM_TO_PT
|
||||
}
|
||||
|
||||
/// Hex renk (#RRGGBB veya #RGB) → rgb::Color
|
||||
fn parse_color(hex: &str) -> rgb::Color {
|
||||
let hex = hex.trim_start_matches('#');
|
||||
@@ -46,6 +41,18 @@ fn parse_color(hex: &str) -> rgb::Color {
|
||||
rgb::Color::new(r, g, b)
|
||||
}
|
||||
|
||||
fn fill_from_color(color: rgb::Color) -> Fill {
|
||||
Fill {
|
||||
paint: color.into(),
|
||||
opacity: NormalizedF32::ONE,
|
||||
rule: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Rounded rectangle path oluştur. radius 0 ise düz dikdörtgen.
|
||||
fn build_rect_path(x: f32, y: f32, w: f32, h: f32, radius: f32) -> Option<krilla::geom::Path> {
|
||||
let mut pb = PathBuilder::new();
|
||||
@@ -92,11 +99,132 @@ fn build_ellipse_path(x: f32, y: f32, w: f32, h: f32) -> Option<krilla::geom::Pa
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
fn fill_from_color(color: rgb::Color) -> Fill {
|
||||
Fill {
|
||||
paint: color.into(),
|
||||
opacity: NormalizedF32::ONE,
|
||||
rule: Default::default(),
|
||||
/// Merkez + radius'tan daire path'i oluştur (build_ellipse_path'in kısa hali)
|
||||
fn build_circle_path(cx: f32, cy: f32, r: f32) -> Option<krilla::geom::Path> {
|
||||
build_ellipse_path(cx - r, cy - r, r * 2.0, r * 2.0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SurfaceExt — krilla surface üzerinde tekrar eden draw kalıplarını soyutlar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
trait SurfaceExt {
|
||||
fn draw_filled(&mut self, path: &krilla::geom::Path, color: rgb::Color);
|
||||
fn draw_stroked(&mut self, path: &krilla::geom::Path, color: rgb::Color, width: f32);
|
||||
fn draw_filled_stroked(
|
||||
&mut self,
|
||||
path: &krilla::geom::Path,
|
||||
fill: Option<rgb::Color>,
|
||||
stroke_color: rgb::Color,
|
||||
stroke_width: f32,
|
||||
);
|
||||
}
|
||||
|
||||
impl SurfaceExt for krilla::surface::Surface<'_> {
|
||||
fn draw_filled(&mut self, path: &krilla::geom::Path, color: rgb::Color) {
|
||||
self.set_fill(Some(fill_from_color(color)));
|
||||
self.set_stroke(None);
|
||||
self.draw_path(path);
|
||||
self.set_fill(None);
|
||||
}
|
||||
|
||||
fn draw_stroked(&mut self, path: &krilla::geom::Path, color: rgb::Color, width: f32) {
|
||||
self.set_fill(None);
|
||||
self.set_stroke(Some(Stroke {
|
||||
paint: color.into(),
|
||||
width,
|
||||
opacity: NormalizedF32::ONE,
|
||||
..Default::default()
|
||||
}));
|
||||
self.draw_path(path);
|
||||
self.set_stroke(None);
|
||||
}
|
||||
|
||||
fn draw_filled_stroked(
|
||||
&mut self,
|
||||
path: &krilla::geom::Path,
|
||||
fill: Option<rgb::Color>,
|
||||
stroke_color: rgb::Color,
|
||||
stroke_width: f32,
|
||||
) {
|
||||
self.set_fill(fill.map(fill_from_color));
|
||||
self.set_stroke(Some(Stroke {
|
||||
paint: stroke_color.into(),
|
||||
width: stroke_width,
|
||||
opacity: NormalizedF32::ONE,
|
||||
..Default::default()
|
||||
}));
|
||||
self.draw_path(path);
|
||||
self.set_fill(None);
|
||||
self.set_stroke(None);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// draw_box — container ve shape'in ortak fill+border çizim mantığı
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Kutu şekli: dikdörtgen, yuvarlatılmış dikdörtgen veya elips.
|
||||
enum BoxShape {
|
||||
Rect { radius: f32 },
|
||||
Ellipse,
|
||||
}
|
||||
|
||||
/// Arka plan + border'ı tek seferde çizer (CSS border-box modeli).
|
||||
/// Container ve shape render'larının ortak kodu.
|
||||
fn draw_box(
|
||||
surface: &mut krilla::surface::Surface<'_>,
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
bg_color: Option<&str>,
|
||||
border_color: Option<&str>,
|
||||
border_width: Option<f64>,
|
||||
shape: BoxShape,
|
||||
) {
|
||||
let has_bg = bg_color.is_some();
|
||||
let has_border = border_color.is_some() && border_width.unwrap_or(0.0) > 0.0;
|
||||
|
||||
if !has_bg && !has_border {
|
||||
return;
|
||||
}
|
||||
|
||||
let build_path = |bx: f32, by: f32, bw: f32, bh: f32, shape: &BoxShape| -> Option<krilla::geom::Path> {
|
||||
match shape {
|
||||
BoxShape::Ellipse => build_ellipse_path(bx, by, bw, bh),
|
||||
BoxShape::Rect { radius } => build_rect_path(bx, by, bw, bh, *radius),
|
||||
}
|
||||
};
|
||||
|
||||
if has_border {
|
||||
let bw = mm(border_width.unwrap_or(0.5));
|
||||
let bc = parse_color(border_color.unwrap_or("#000000"));
|
||||
let inset = bw / 2.0;
|
||||
|
||||
// Border durumunda radius'u inset kadar küçült
|
||||
let inset_shape = match shape {
|
||||
BoxShape::Ellipse => BoxShape::Ellipse,
|
||||
BoxShape::Rect { radius } => BoxShape::Rect {
|
||||
radius: (radius - inset).max(0.0),
|
||||
},
|
||||
};
|
||||
|
||||
let path = build_path(x + inset, y + inset, w - bw, h - bw, &inset_shape);
|
||||
if let Some(p) = path {
|
||||
surface.draw_filled_stroked(
|
||||
&p,
|
||||
bg_color.map(parse_color),
|
||||
bc,
|
||||
bw,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let fill = parse_color(bg_color.unwrap_or("#ffffff"));
|
||||
let path = build_path(x, y, w, h, &shape);
|
||||
if let Some(p) = path {
|
||||
surface.draw_filled(&p, fill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,80 +457,29 @@ fn render_shape(
|
||||
style: &ResolvedStyle,
|
||||
content: &Option<ResolvedContent>,
|
||||
) {
|
||||
let has_bg = style.background_color.is_some();
|
||||
let has_border = style.border_color.is_some() && style.border_width.unwrap_or(0.0) > 0.0;
|
||||
|
||||
if !has_bg && !has_border {
|
||||
return;
|
||||
}
|
||||
|
||||
let shape_type = match content {
|
||||
Some(ResolvedContent::Shape { shape_type }) => shape_type.as_str(),
|
||||
_ => "rectangle",
|
||||
};
|
||||
|
||||
let rect_radius = |s: &ResolvedStyle| -> f32 {
|
||||
if shape_type == "rounded_rectangle" {
|
||||
s.border_radius.map(mm).unwrap_or(mm(3.0))
|
||||
} else {
|
||||
s.border_radius.map(mm).unwrap_or(0.0)
|
||||
}
|
||||
let shape = match shape_type {
|
||||
"ellipse" => BoxShape::Ellipse,
|
||||
"rounded_rectangle" => BoxShape::Rect {
|
||||
radius: style.border_radius.map(mm).unwrap_or(mm(3.0)),
|
||||
},
|
||||
_ => BoxShape::Rect {
|
||||
radius: style.border_radius.map(mm).unwrap_or(0.0),
|
||||
},
|
||||
};
|
||||
|
||||
if has_border {
|
||||
let border_width = mm(style.border_width.unwrap_or(0.5));
|
||||
let border_color = parse_color(style.border_color.as_deref().unwrap_or("#000000"));
|
||||
let inset = border_width / 2.0;
|
||||
|
||||
// Fill + stroke tek path ile — anti-aliasing uyumu
|
||||
if let Some(ref bg) = style.background_color {
|
||||
surface.set_fill(Some(fill_from_color(parse_color(bg))));
|
||||
} else {
|
||||
surface.set_fill(None);
|
||||
}
|
||||
surface.set_stroke(Some(Stroke {
|
||||
paint: border_color.into(),
|
||||
width: border_width,
|
||||
opacity: NormalizedF32::ONE,
|
||||
..Default::default()
|
||||
}));
|
||||
|
||||
let path = match shape_type {
|
||||
"ellipse" => {
|
||||
build_ellipse_path(x + inset, y + inset, w - border_width, h - border_width)
|
||||
}
|
||||
_ => {
|
||||
let radius = rect_radius(style);
|
||||
build_rect_path(
|
||||
x + inset,
|
||||
y + inset,
|
||||
w - border_width,
|
||||
h - border_width,
|
||||
(radius - inset).max(0.0),
|
||||
)
|
||||
}
|
||||
};
|
||||
if let Some(p) = path {
|
||||
surface.draw_path(&p);
|
||||
}
|
||||
} else {
|
||||
// Sadece fill, border yok
|
||||
surface.set_fill(Some(fill_from_color(parse_color(
|
||||
style.background_color.as_deref().unwrap_or("#ffffff"),
|
||||
))));
|
||||
surface.set_stroke(None);
|
||||
|
||||
let path = match shape_type {
|
||||
"ellipse" => build_ellipse_path(x, y, w, h),
|
||||
_ => build_rect_path(x, y, w, h, rect_radius(style)),
|
||||
};
|
||||
if let Some(p) = path {
|
||||
surface.draw_path(&p);
|
||||
}
|
||||
}
|
||||
|
||||
surface.set_fill(None);
|
||||
surface.set_stroke(None);
|
||||
draw_box(
|
||||
surface,
|
||||
x, y, w, h,
|
||||
style.background_color.as_deref(),
|
||||
style.border_color.as_deref(),
|
||||
style.border_width,
|
||||
shape,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_checkbox(
|
||||
@@ -418,54 +495,24 @@ fn render_checkbox(
|
||||
let border_width = mm(style.border_width.unwrap_or(0.3));
|
||||
let inset = border_width / 2.0;
|
||||
|
||||
// Draw box outline (inset for CSS border-box match)
|
||||
surface.set_fill(None);
|
||||
surface.set_stroke(Some(Stroke {
|
||||
paint: border_color.into(),
|
||||
width: border_width,
|
||||
opacity: NormalizedF32::ONE,
|
||||
..Default::default()
|
||||
}));
|
||||
|
||||
if let Some(p) = build_rect_path(
|
||||
x + inset,
|
||||
y + inset,
|
||||
w - border_width,
|
||||
h - border_width,
|
||||
0.0,
|
||||
) {
|
||||
surface.draw_path(&p);
|
||||
if let Some(p) = build_rect_path(x + inset, y + inset, w - border_width, h - border_width, 0.0) {
|
||||
surface.draw_stroked(&p, border_color, border_width);
|
||||
}
|
||||
|
||||
// Draw checkmark if checked
|
||||
if checked {
|
||||
let check_color = parse_color(style.color.as_deref().unwrap_or("#000000"));
|
||||
let stroke_w = w.min(h) * 0.12;
|
||||
surface.set_fill(None);
|
||||
surface.set_stroke(Some(Stroke {
|
||||
paint: check_color.into(),
|
||||
width: stroke_w,
|
||||
opacity: NormalizedF32::ONE,
|
||||
..Default::default()
|
||||
}));
|
||||
|
||||
// Checkmark: two lines forming a "✓"
|
||||
let check_path = {
|
||||
let mut pb = PathBuilder::new();
|
||||
let mx = w * 0.2;
|
||||
let my = h * 0.5;
|
||||
pb.move_to(x + mx, y + my);
|
||||
pb.move_to(x + w * 0.2, y + h * 0.5);
|
||||
pb.line_to(x + w * 0.4, y + h * 0.75);
|
||||
pb.line_to(x + w * 0.8, y + h * 0.25);
|
||||
pb.finish()
|
||||
};
|
||||
if let Some(p) = check_path {
|
||||
surface.draw_path(&p);
|
||||
surface.draw_stroked(&p, check_color, stroke_w);
|
||||
}
|
||||
}
|
||||
|
||||
surface.set_fill(None);
|
||||
surface.set_stroke(None);
|
||||
}
|
||||
|
||||
fn render_container_bg(
|
||||
@@ -476,55 +523,16 @@ fn render_container_bg(
|
||||
h: f32,
|
||||
style: &ResolvedStyle,
|
||||
) {
|
||||
let has_bg = style.background_color.is_some();
|
||||
let has_border = style.border_color.is_some() && style.border_width.unwrap_or(0.0) > 0.0;
|
||||
|
||||
if !has_bg && !has_border {
|
||||
return;
|
||||
}
|
||||
|
||||
let radius = style.border_radius.map(mm).unwrap_or(0.0);
|
||||
|
||||
if has_border {
|
||||
let border_width = mm(style.border_width.unwrap_or(0.5));
|
||||
let border_color = parse_color(style.border_color.as_deref().unwrap_or("#000000"));
|
||||
let inset = border_width / 2.0;
|
||||
|
||||
// CSS border-box: stroke path'i border_width/2 içeri çek.
|
||||
// Tek draw_path ile hem fill hem stroke çizerek anti-aliasing uyumunu sağla.
|
||||
if let Some(ref bg) = style.background_color {
|
||||
surface.set_fill(Some(fill_from_color(parse_color(bg))));
|
||||
} else {
|
||||
surface.set_fill(None);
|
||||
}
|
||||
surface.set_stroke(Some(Stroke {
|
||||
paint: border_color.into(),
|
||||
width: border_width,
|
||||
opacity: NormalizedF32::ONE,
|
||||
..Default::default()
|
||||
}));
|
||||
if let Some(path) = build_rect_path(
|
||||
x + inset,
|
||||
y + inset,
|
||||
w - border_width,
|
||||
h - border_width,
|
||||
(radius - inset).max(0.0),
|
||||
) {
|
||||
surface.draw_path(&path);
|
||||
}
|
||||
} else {
|
||||
// Sadece background, border yok
|
||||
surface.set_fill(Some(fill_from_color(parse_color(
|
||||
style.background_color.as_deref().unwrap_or("#ffffff"),
|
||||
))));
|
||||
surface.set_stroke(None);
|
||||
if let Some(path) = build_rect_path(x, y, w, h, radius) {
|
||||
surface.draw_path(&path);
|
||||
}
|
||||
}
|
||||
|
||||
surface.set_fill(None);
|
||||
surface.set_stroke(None);
|
||||
draw_box(
|
||||
surface,
|
||||
x, y, w, h,
|
||||
style.background_color.as_deref(),
|
||||
style.border_color.as_deref(),
|
||||
style.border_width,
|
||||
BoxShape::Rect {
|
||||
radius: style.border_radius.map(mm).unwrap_or(0.0),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -707,32 +715,16 @@ fn render_line(
|
||||
h: f32,
|
||||
style: &ResolvedStyle,
|
||||
) {
|
||||
let stroke_color = style
|
||||
let color = style
|
||||
.stroke_color
|
||||
.as_deref()
|
||||
.map(parse_color)
|
||||
.unwrap_or(rgb::Color::new(0, 0, 0));
|
||||
|
||||
// Çizgiyi filled rectangle olarak çiz — CSS borderTop ile aynı davranış.
|
||||
// Stroke kullanmak sub-pixel anti-aliasing farkları yaratır.
|
||||
surface.set_fill(Some(fill_from_color(stroke_color)));
|
||||
surface.set_stroke(None);
|
||||
|
||||
let rect_path = {
|
||||
let mut pb = PathBuilder::new();
|
||||
// Eleman yüksekliği layout engine tarafından stroke_width olarak hesaplandı.
|
||||
// Tüm eleman alanını dolduran ince dikdörtgen çiz.
|
||||
if let Some(rect) = krilla::geom::Rect::from_xywh(x, y, w, h) {
|
||||
pb.push_rect(rect);
|
||||
}
|
||||
pb.finish()
|
||||
};
|
||||
|
||||
if let Some(p) = rect_path {
|
||||
surface.draw_path(&p);
|
||||
if let Some(path) = build_rect_path(x, y, w, h, 0.0) {
|
||||
surface.draw_filled(&path, color);
|
||||
}
|
||||
|
||||
surface.set_fill(None);
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
@@ -935,14 +927,14 @@ fn render_chart(
|
||||
if let Some(f) = font {
|
||||
surface.set_fill(Some(fill_from_color(color)));
|
||||
surface.set_stroke(None);
|
||||
let fs_pt = pt(title.font_size);
|
||||
let fs_pt = mm(title.font_size);
|
||||
let (tw, _) = measurer.measure(&title.text, None, fs_pt, Some("bold"), None);
|
||||
let tx = match title.align.as_str() {
|
||||
"left" => pt(title.x),
|
||||
"right" => pt(title.x) - tw,
|
||||
_ => pt(title.x) - tw / 2.0,
|
||||
"left" => mm(title.x),
|
||||
"right" => mm(title.x) - tw,
|
||||
_ => mm(title.x) - tw / 2.0,
|
||||
};
|
||||
let ty = pt(title.y);
|
||||
let ty = mm(title.y);
|
||||
surface.draw_text(
|
||||
Point::from_xy(tx, ty),
|
||||
f.clone(),
|
||||
@@ -966,26 +958,28 @@ fn render_chart(
|
||||
if bl.stacked {
|
||||
if bar.value > 0.0 {
|
||||
let label = format_value(bar.value);
|
||||
chart_text_centered(
|
||||
chart_text(
|
||||
surface,
|
||||
bar.label_x,
|
||||
bar.label_y,
|
||||
&label,
|
||||
bl.label_font,
|
||||
&bl.label_color,
|
||||
ChartTextAlign::Center,
|
||||
fonts,
|
||||
measurer,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let label = format_value(bar.value);
|
||||
chart_text_centered(
|
||||
chart_text(
|
||||
surface,
|
||||
bar.label_x,
|
||||
bar.label_y,
|
||||
&label,
|
||||
bl.label_font,
|
||||
&bl.label_color,
|
||||
ChartTextAlign::Center,
|
||||
fonts,
|
||||
measurer,
|
||||
);
|
||||
@@ -1015,17 +1009,39 @@ fn render_chart(
|
||||
surface.set_fill(None);
|
||||
surface.set_stroke(Some(Stroke {
|
||||
paint: color.into(),
|
||||
width: pt(ll.line_width),
|
||||
width: mm(ll.line_width),
|
||||
opacity: NormalizedF32::ONE,
|
||||
..Default::default()
|
||||
}));
|
||||
let path = {
|
||||
let mut pb = PathBuilder::new();
|
||||
for (i, (lx, ly)) in points.iter().enumerate() {
|
||||
if i == 0 {
|
||||
pb.move_to(pt(*lx), pt(*ly));
|
||||
} else {
|
||||
pb.line_to(pt(*lx), pt(*ly));
|
||||
if ll.smooth && points.len() >= 2 {
|
||||
let pts = &series_layout.points;
|
||||
pb.move_to(mm(pts[0].x), mm(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;
|
||||
|
||||
pb.cubic_to(
|
||||
mm(cp1x), mm(cp1y),
|
||||
mm(cp2x), mm(cp2y),
|
||||
mm(p2.x), mm(p2.y),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (i, (lx, ly)) in points.iter().enumerate() {
|
||||
if i == 0 {
|
||||
pb.move_to(mm(*lx), mm(*ly));
|
||||
} else {
|
||||
pb.line_to(mm(*lx), mm(*ly));
|
||||
}
|
||||
}
|
||||
}
|
||||
pb.finish()
|
||||
@@ -1037,24 +1053,8 @@ fn render_chart(
|
||||
// Points
|
||||
if ll.show_points {
|
||||
for (lx, ly) in &points {
|
||||
let r = pt(0.8);
|
||||
let cx = pt(*lx);
|
||||
let cy = pt(*ly);
|
||||
surface.set_fill(Some(fill_from_color(color)));
|
||||
surface.set_stroke(None);
|
||||
let circle = {
|
||||
let mut pb = PathBuilder::new();
|
||||
let k = r * 0.5522848;
|
||||
pb.move_to(cx, cy - r);
|
||||
pb.cubic_to(cx + k, cy - r, cx + r, cy - k, cx + r, cy);
|
||||
pb.cubic_to(cx + r, cy + k, cx + k, cy + r, cx, cy + r);
|
||||
pb.cubic_to(cx - k, cy + r, cx - r, cy + k, cx - r, cy);
|
||||
pb.cubic_to(cx - r, cy - k, cx - k, cy - r, cx, cy - r);
|
||||
pb.close();
|
||||
pb.finish()
|
||||
};
|
||||
if let Some(p) = circle {
|
||||
surface.draw_path(&p);
|
||||
if let Some(circle) = build_circle_path(mm(*lx), mm(*ly), mm(0.8)) {
|
||||
surface.draw_filled(&circle, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1063,19 +1063,46 @@ fn render_chart(
|
||||
if ll.show_labels {
|
||||
for lp in &series_layout.points {
|
||||
let label = format_value(lp.value);
|
||||
chart_text_centered(
|
||||
chart_text(
|
||||
surface,
|
||||
lp.x,
|
||||
lp.y - 1.5,
|
||||
&label,
|
||||
ll.label_font,
|
||||
&ll.label_color,
|
||||
ChartTextAlign::Center,
|
||||
fonts,
|
||||
measurer,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reference lines (vertical)
|
||||
for rl in &ll.ref_lines {
|
||||
let rl_color = parse_color(&rl.color);
|
||||
chart_line_seg(
|
||||
surface,
|
||||
rl.x,
|
||||
rl.y1,
|
||||
rl.x,
|
||||
rl.y2,
|
||||
rl_color,
|
||||
(rl.width * 2.5) as f32,
|
||||
);
|
||||
if let Some(ref label) = rl.label {
|
||||
chart_text(
|
||||
surface,
|
||||
rl.x,
|
||||
rl.y1 - 1.0,
|
||||
label,
|
||||
2.0,
|
||||
&rl.color,
|
||||
ChartTextAlign::Center,
|
||||
fonts,
|
||||
measurer,
|
||||
);
|
||||
}
|
||||
}
|
||||
render_chart_x_labels(surface, &ll.x_labels, fonts, measurer);
|
||||
let ac = parse_color("#9CA3AF");
|
||||
chart_line_seg(
|
||||
@@ -1114,13 +1141,14 @@ fn render_chart(
|
||||
if pl.show_labels {
|
||||
let pct = (slice.fraction * 100.0).round();
|
||||
let label = format!("{}%", pct);
|
||||
chart_text_centered(
|
||||
chart_text(
|
||||
surface,
|
||||
slice.label_x,
|
||||
slice.label_y,
|
||||
&label,
|
||||
pl.label_font,
|
||||
&pl.label_color,
|
||||
ChartTextAlign::Center,
|
||||
fonts,
|
||||
measurer,
|
||||
);
|
||||
@@ -1136,29 +1164,22 @@ fn render_chart(
|
||||
parse_color("#999999"),
|
||||
0.5,
|
||||
);
|
||||
if slice.cat_label_anchor_end {
|
||||
chart_text_end(
|
||||
surface,
|
||||
slice.cat_label_x,
|
||||
slice.cat_label_y,
|
||||
&slice.cat_label_text,
|
||||
2.5,
|
||||
"#555555",
|
||||
fonts,
|
||||
measurer,
|
||||
);
|
||||
let align = if slice.cat_label_anchor_end {
|
||||
ChartTextAlign::End
|
||||
} else {
|
||||
chart_text_start(
|
||||
surface,
|
||||
slice.cat_label_x,
|
||||
slice.cat_label_y,
|
||||
&slice.cat_label_text,
|
||||
2.5,
|
||||
"#555555",
|
||||
fonts,
|
||||
measurer,
|
||||
);
|
||||
}
|
||||
ChartTextAlign::Start
|
||||
};
|
||||
chart_text(
|
||||
surface,
|
||||
slice.cat_label_x,
|
||||
slice.cat_label_y,
|
||||
&slice.cat_label_text,
|
||||
2.5,
|
||||
"#555555",
|
||||
align,
|
||||
fonts,
|
||||
measurer,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1177,13 +1198,14 @@ fn render_chart(
|
||||
legend.swatch_size,
|
||||
color,
|
||||
);
|
||||
chart_text_start(
|
||||
chart_text(
|
||||
surface,
|
||||
item.text_x,
|
||||
item.text_y,
|
||||
&item.name,
|
||||
legend.font_size,
|
||||
"#666666",
|
||||
ChartTextAlign::Start,
|
||||
fonts,
|
||||
measurer,
|
||||
);
|
||||
@@ -1196,14 +1218,14 @@ fn render_chart(
|
||||
if let Some(ref x_label) = data.x_label {
|
||||
let lx = cl.plot_x + cl.plot_w / 2.0;
|
||||
let ly = base_y_mm + h_mm - 2.0;
|
||||
chart_text_centered(surface, lx, ly, x_label, 2.8, "#666666", fonts, measurer);
|
||||
chart_text(surface, lx, ly, x_label, 2.8, "#666666", ChartTextAlign::Center, fonts, measurer);
|
||||
}
|
||||
if let Some(ref y_label) = data.y_label {
|
||||
let lx = base_x_mm + 3.0;
|
||||
let ly = cl.plot_y + cl.plot_h / 2.0;
|
||||
surface.push_transform(&Transform::from_translate(pt(lx), pt(ly)));
|
||||
surface.push_transform(&Transform::from_translate(mm(lx), mm(ly)));
|
||||
surface.push_transform(&Transform::from_row(0.0, -1.0, 1.0, 0.0, 0.0, 0.0));
|
||||
chart_text_centered(surface, 0.0, 0.0, y_label, 2.8, "#666666", fonts, measurer);
|
||||
chart_text(surface, 0.0, 0.0, y_label, 2.8, "#666666", ChartTextAlign::Center, fonts, measurer);
|
||||
surface.pop();
|
||||
surface.pop();
|
||||
}
|
||||
@@ -1219,7 +1241,7 @@ fn chart_rect(
|
||||
rh: f64,
|
||||
color: rgb::Color,
|
||||
) {
|
||||
let (rx, ry, rw, rh) = (pt(rx), pt(ry), pt(rw), pt(rh));
|
||||
let (rx, ry, rw, rh) = (mm(rx), mm(ry), mm(rw), mm(rh));
|
||||
surface.set_fill(Some(fill_from_color(color)));
|
||||
surface.set_stroke(None);
|
||||
let path = {
|
||||
@@ -1243,7 +1265,7 @@ fn chart_line_seg(
|
||||
color: rgb::Color,
|
||||
width: f32,
|
||||
) {
|
||||
let (x1, y1, x2, y2) = (pt(x1), pt(y1), pt(x2), pt(y2));
|
||||
let (x1, y1, x2, y2) = (mm(x1), mm(y1), mm(x2), mm(y2));
|
||||
surface.set_fill(None);
|
||||
surface.set_stroke(Some(Stroke {
|
||||
paint: color.into(),
|
||||
@@ -1262,93 +1284,52 @@ fn chart_line_seg(
|
||||
}
|
||||
}
|
||||
|
||||
/// Chart icin metin ciz — tek satirlik, centered
|
||||
/// font_size_mm: SVG viewBox'taki mm cinsinden boyut, pt'ye cevrilir
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn chart_text_centered(
|
||||
surface: &mut krilla::surface::Surface<'_>,
|
||||
cx_mm: f64,
|
||||
cy_mm: f64,
|
||||
text: &str,
|
||||
font_size_mm: f64,
|
||||
color_hex: &str,
|
||||
fonts: &FontCollection,
|
||||
measurer: &mut TextMeasurer,
|
||||
) {
|
||||
let font = fonts.get(None, None, None);
|
||||
let Some(f) = font else {
|
||||
return;
|
||||
};
|
||||
let color = parse_color(color_hex);
|
||||
let fs_pt = pt(font_size_mm);
|
||||
let (tw, _) = measurer.measure(text, None, fs_pt, None, None);
|
||||
surface.set_fill(Some(fill_from_color(color)));
|
||||
surface.set_stroke(None);
|
||||
surface.draw_text(
|
||||
Point::from_xy(pt(cx_mm) - tw / 2.0, pt(cy_mm)),
|
||||
f.clone(),
|
||||
fs_pt,
|
||||
text,
|
||||
false,
|
||||
TextDirection::Auto,
|
||||
);
|
||||
/// Chart metin hizalama modu
|
||||
enum ChartTextAlign {
|
||||
Start,
|
||||
Center,
|
||||
End,
|
||||
}
|
||||
|
||||
/// Chart icin metin ciz — end-aligned (sag hizali)
|
||||
/// Chart için tek satır metin çiz (mm cinsinden koordinatlar, pt'ye çevrilir)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn chart_text_end(
|
||||
surface: &mut krilla::surface::Surface<'_>,
|
||||
right_x_mm: f64,
|
||||
cy_mm: f64,
|
||||
text: &str,
|
||||
font_size_mm: f64,
|
||||
color_hex: &str,
|
||||
fonts: &FontCollection,
|
||||
measurer: &mut TextMeasurer,
|
||||
) {
|
||||
let font = fonts.get(None, None, None);
|
||||
let Some(f) = font else {
|
||||
return;
|
||||
};
|
||||
let color = parse_color(color_hex);
|
||||
let fs_pt = pt(font_size_mm);
|
||||
let (tw, _) = measurer.measure(text, None, fs_pt, None, None);
|
||||
surface.set_fill(Some(fill_from_color(color)));
|
||||
surface.set_stroke(None);
|
||||
surface.draw_text(
|
||||
Point::from_xy(pt(right_x_mm) - tw, pt(cy_mm)),
|
||||
f.clone(),
|
||||
fs_pt,
|
||||
text,
|
||||
false,
|
||||
TextDirection::Auto,
|
||||
);
|
||||
}
|
||||
|
||||
/// Chart icin metin ciz — start-aligned (sol hizali)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn chart_text_start(
|
||||
fn chart_text(
|
||||
surface: &mut krilla::surface::Surface<'_>,
|
||||
x_mm: f64,
|
||||
cy_mm: f64,
|
||||
y_mm: f64,
|
||||
text: &str,
|
||||
font_size_mm: f64,
|
||||
color_hex: &str,
|
||||
align: ChartTextAlign,
|
||||
fonts: &FontCollection,
|
||||
_measurer: &mut TextMeasurer,
|
||||
measurer: &mut TextMeasurer,
|
||||
) {
|
||||
let font = fonts.get(None, None, None);
|
||||
let Some(f) = font else {
|
||||
let Some(font) = fonts.get(None, None, None) else {
|
||||
return;
|
||||
};
|
||||
let color = parse_color(color_hex);
|
||||
let fs_pt = pt(font_size_mm);
|
||||
let fs = mm(font_size_mm);
|
||||
let px = mm(x_mm);
|
||||
let py = mm(y_mm);
|
||||
|
||||
let draw_x = match align {
|
||||
ChartTextAlign::Start => px,
|
||||
ChartTextAlign::Center => {
|
||||
let (tw, _) = measurer.measure(text, None, fs, None, None);
|
||||
px - tw / 2.0
|
||||
}
|
||||
ChartTextAlign::End => {
|
||||
let (tw, _) = measurer.measure(text, None, fs, None, None);
|
||||
px - tw
|
||||
}
|
||||
};
|
||||
|
||||
surface.set_fill(Some(fill_from_color(color)));
|
||||
surface.set_stroke(None);
|
||||
surface.draw_text(
|
||||
Point::from_xy(pt(x_mm), pt(cy_mm)),
|
||||
f.clone(),
|
||||
fs_pt,
|
||||
Point::from_xy(draw_x, py),
|
||||
font.clone(),
|
||||
fs,
|
||||
text,
|
||||
false,
|
||||
TextDirection::Auto,
|
||||
@@ -1363,13 +1344,14 @@ fn render_chart_y_axis(
|
||||
measurer: &mut TextMeasurer,
|
||||
) {
|
||||
for tick in &y_axis.ticks {
|
||||
chart_text_end(
|
||||
chart_text(
|
||||
surface,
|
||||
y_axis.axis_x - 1.5,
|
||||
tick.y + 0.8,
|
||||
&tick.label,
|
||||
2.3,
|
||||
"#666666",
|
||||
ChartTextAlign::End,
|
||||
fonts,
|
||||
measurer,
|
||||
);
|
||||
@@ -1409,31 +1391,33 @@ fn render_chart_x_labels(
|
||||
let angle = x_labels.rotate_angle;
|
||||
for label in &x_labels.labels {
|
||||
if angle > 0.0 {
|
||||
surface.push_transform(&Transform::from_translate(pt(label.x), pt(label.y)));
|
||||
surface.push_transform(&Transform::from_translate(mm(label.x), mm(label.y)));
|
||||
let angle_rad = (angle as f32).to_radians();
|
||||
let c = angle_rad.cos();
|
||||
let s = angle_rad.sin();
|
||||
surface.push_transform(&Transform::from_row(c, -s, s, c, 0.0, 0.0));
|
||||
chart_text_end(
|
||||
chart_text(
|
||||
surface,
|
||||
0.0,
|
||||
0.0,
|
||||
&label.text,
|
||||
2.2,
|
||||
"#666666",
|
||||
ChartTextAlign::End,
|
||||
fonts,
|
||||
measurer,
|
||||
);
|
||||
surface.pop();
|
||||
surface.pop();
|
||||
} else {
|
||||
chart_text_centered(
|
||||
chart_text(
|
||||
surface,
|
||||
label.x,
|
||||
label.y,
|
||||
&label.text,
|
||||
2.5,
|
||||
"#666666",
|
||||
ChartTextAlign::Center,
|
||||
fonts,
|
||||
measurer,
|
||||
);
|
||||
@@ -1452,19 +1436,19 @@ fn build_arc_path(
|
||||
) -> Option<krilla::geom::Path> {
|
||||
let mut pb = PathBuilder::new();
|
||||
|
||||
let sx = pt(cx + radius * start.cos());
|
||||
let sy = pt(cy + radius * start.sin());
|
||||
let sx = mm(cx + radius * start.cos());
|
||||
let sy = mm(cy + radius * start.sin());
|
||||
|
||||
if inner_r > 0.0 {
|
||||
pb.move_to(sx, sy);
|
||||
approximate_arc(&mut pb, cx, cy, radius, start, end);
|
||||
let ix = pt(cx + inner_r * end.cos());
|
||||
let iy = pt(cy + inner_r * end.sin());
|
||||
let ix = mm(cx + inner_r * end.cos());
|
||||
let iy = mm(cy + inner_r * end.sin());
|
||||
pb.line_to(ix, iy);
|
||||
approximate_arc(&mut pb, cx, cy, inner_r, end, start);
|
||||
pb.close();
|
||||
} else {
|
||||
pb.move_to(pt(cx), pt(cy));
|
||||
pb.move_to(mm(cx), mm(cy));
|
||||
pb.line_to(sx, sy);
|
||||
approximate_arc(&mut pb, cx, cy, radius, start, end);
|
||||
pb.close();
|
||||
@@ -1496,7 +1480,7 @@ fn approximate_arc(pb: &mut PathBuilder, cx: f64, cy: f64, r: f64, start: f64, e
|
||||
let c2x = p2x + k * r * a2.sin();
|
||||
let c2y = p2y - k * r * a2.cos();
|
||||
|
||||
pb.cubic_to(pt(c1x), pt(c1y), pt(c2x), pt(c2y), pt(p2x), pt(p2y));
|
||||
pb.cubic_to(mm(c1x), mm(c1y), mm(c2x), mm(c2y), mm(p2x), mm(p2y));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1603,17 +1587,14 @@ mod tests {
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint {
|
||||
width: SizeValue::Auto,
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
}),
|
||||
direction: "column".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding {
|
||||
@@ -1628,17 +1609,14 @@ mod tests {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("title".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
}),
|
||||
style: TextStyle {
|
||||
font_size: Some(18.0),
|
||||
font_weight: Some("bold".to_string()),
|
||||
@@ -1647,34 +1625,28 @@ mod tests {
|
||||
content: "FATURA".to_string(),
|
||||
}),
|
||||
TemplateElement::Line(LineElement {
|
||||
id: "line1".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("line1".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
}),
|
||||
style: LineStyle {
|
||||
stroke_color: Some("#000000".to_string()),
|
||||
stroke_width: Some(0.5),
|
||||
},
|
||||
}),
|
||||
TemplateElement::Text(TextElement {
|
||||
id: "firma".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("firma".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
}),
|
||||
style: TextStyle {
|
||||
font_size: Some(11.0),
|
||||
..Default::default()
|
||||
@@ -1804,7 +1776,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_pt_conversion() {
|
||||
let result = pt(25.4);
|
||||
let result = mm(25.4);
|
||||
assert!((result - 72.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ pub fn container_to_style(el: &ContainerElement, parent_direction: Option<&str>)
|
||||
};
|
||||
|
||||
// Pozisyon moduna göre
|
||||
match &el.position {
|
||||
match &el.base.position {
|
||||
PositionMode::Absolute { x, y } => {
|
||||
style.position = Position::Absolute;
|
||||
style.inset = Rect {
|
||||
@@ -152,7 +152,7 @@ pub fn container_to_style(el: &ContainerElement, parent_direction: Option<&str>)
|
||||
}
|
||||
|
||||
// Boyut
|
||||
apply_size_to_style(&mut style, &el.size, parent_direction);
|
||||
apply_size_to_style(&mut style, &el.base.size, parent_direction);
|
||||
|
||||
// Container border
|
||||
if let Some(bw) = el.style.border_width {
|
||||
@@ -197,7 +197,7 @@ pub fn leaf_style(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dreport_core::models::{ContainerStyle, Padding};
|
||||
use dreport_core::models::{ContainerStyle, ElementBase, Padding};
|
||||
|
||||
#[test]
|
||||
fn test_mm_to_pt_conversion() {
|
||||
@@ -327,10 +327,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_container_to_style_direction() {
|
||||
let el = ContainerElement {
|
||||
id: "test".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("test".to_string(), SizeConstraint::default()),
|
||||
direction: "row".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding {
|
||||
@@ -354,10 +351,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_container_to_style_absolute() {
|
||||
let el = ContainerElement {
|
||||
id: "test".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Absolute { x: 20.0, y: 30.0 },
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase {
|
||||
id: "test".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Absolute { x: 20.0, y: 30.0 },
|
||||
size: SizeConstraint::default(),
|
||||
},
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
|
||||
@@ -186,7 +186,7 @@ pub fn expand_table_cached(
|
||||
) -> ContainerElement {
|
||||
let rows = resolved
|
||||
.tables
|
||||
.get(&table.id)
|
||||
.get(&table.base.id)
|
||||
.map(|t| t.rows.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let key = table_cache_key(table, rows, available_width_mm);
|
||||
@@ -211,7 +211,7 @@ pub fn expand_table(
|
||||
measurer: &mut TextMeasurer,
|
||||
available_width_mm: f64,
|
||||
) -> ContainerElement {
|
||||
let resolved_table = resolved.tables.get(&table.id);
|
||||
let resolved_table = resolved.tables.get(&table.base.id);
|
||||
let rows = resolved_table.map(|t| t.rows.as_slice()).unwrap_or(&[]);
|
||||
|
||||
// Auto sütunlar için içerik bazlı genişlik hesapla
|
||||
@@ -232,17 +232,14 @@ pub fn expand_table(
|
||||
.enumerate()
|
||||
.map(|(i, col)| {
|
||||
let text = TemplateElement::StaticText(StaticTextElement {
|
||||
id: format!("{}_hdr_{}", table.id, i),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
base: ElementBase::flow(
|
||||
format!("{}_hdr_{}", table.base.id, i),
|
||||
SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
style: TextStyle {
|
||||
font_size: table.style.header_font_size.or(table.style.font_size),
|
||||
font_weight: Some("bold".to_string()),
|
||||
@@ -254,25 +251,13 @@ pub fn expand_table(
|
||||
content: col.title.clone(),
|
||||
});
|
||||
TemplateElement::Container(ContainerElement {
|
||||
id: format!("{}_hdr_{}_wrap", table.id, i),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: effective_widths[i].clone(),
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
base: ElementBase::flow(
|
||||
format!("{}_hdr_{}_wrap", table.base.id, i),
|
||||
SizeConstraint { width: effective_widths[i].clone(), ..Default::default() },
|
||||
),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding {
|
||||
top: header_pad_v,
|
||||
right: header_pad_h,
|
||||
bottom: header_pad_v,
|
||||
left: header_pad_h,
|
||||
},
|
||||
padding: Padding { top: header_pad_v, right: header_pad_h, bottom: header_pad_v, left: header_pad_h },
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
@@ -283,31 +268,16 @@ pub fn expand_table(
|
||||
.collect();
|
||||
|
||||
children.push(TemplateElement::Container(ContainerElement {
|
||||
id: format!("{}_header", table.id),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
base: ElementBase::flow(
|
||||
format!("{}_header", table.base.id),
|
||||
SizeConstraint { width: SizeValue::Fr { value: 1.0 }, ..Default::default() },
|
||||
),
|
||||
direction: "row".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding {
|
||||
top: 0.0,
|
||||
right: 0.0,
|
||||
bottom: 0.0,
|
||||
left: 0.0,
|
||||
},
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle {
|
||||
background_color: table.style.header_bg.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
style: ContainerStyle { background_color: table.style.header_bg.clone(), ..Default::default() },
|
||||
children: header_cells,
|
||||
break_inside: "auto".to_string(),
|
||||
}));
|
||||
@@ -315,17 +285,10 @@ pub fn expand_table(
|
||||
// Header altına ayırıcı çizgi
|
||||
if table.style.border_color.is_some() {
|
||||
children.push(TemplateElement::Line(LineElement {
|
||||
id: format!("{}_header_line", table.id),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
base: ElementBase::flow(
|
||||
format!("{}_header_line", table.base.id),
|
||||
SizeConstraint { width: SizeValue::Fr { value: 1.0 }, ..Default::default() },
|
||||
),
|
||||
style: LineStyle {
|
||||
stroke_color: table.style.border_color.clone(),
|
||||
stroke_width: table.style.border_width,
|
||||
@@ -343,17 +306,10 @@ pub fn expand_table(
|
||||
let text_content = row_data.get(col_idx).cloned().unwrap_or_default();
|
||||
|
||||
let text = TemplateElement::StaticText(StaticTextElement {
|
||||
id: format!("{}_r{}c{}", table.id, row_idx, col_idx),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
base: ElementBase::flow(
|
||||
format!("{}_r{}c{}", table.base.id, row_idx, col_idx),
|
||||
SizeConstraint { width: SizeValue::Fr { value: 1.0 }, ..Default::default() },
|
||||
),
|
||||
style: TextStyle {
|
||||
font_size: table.style.font_size,
|
||||
font_weight: None,
|
||||
@@ -365,25 +321,13 @@ pub fn expand_table(
|
||||
content: text_content,
|
||||
});
|
||||
TemplateElement::Container(ContainerElement {
|
||||
id: format!("{}_r{}c{}_wrap", table.id, row_idx, col_idx),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: effective_widths[col_idx].clone(),
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
base: ElementBase::flow(
|
||||
format!("{}_r{}c{}_wrap", table.base.id, row_idx, col_idx),
|
||||
SizeConstraint { width: effective_widths[col_idx].clone(), ..Default::default() },
|
||||
),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding {
|
||||
top: cell_pad_v,
|
||||
right: cell_pad_h,
|
||||
bottom: cell_pad_v,
|
||||
left: cell_pad_h,
|
||||
},
|
||||
padding: Padding { top: cell_pad_v, right: cell_pad_h, bottom: cell_pad_v, left: cell_pad_h },
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
@@ -401,31 +345,16 @@ pub fn expand_table(
|
||||
};
|
||||
|
||||
children.push(TemplateElement::Container(ContainerElement {
|
||||
id: format!("{}_row_{}", table.id, row_idx),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
base: ElementBase::flow(
|
||||
format!("{}_row_{}", table.base.id, row_idx),
|
||||
SizeConstraint { width: SizeValue::Fr { value: 1.0 }, ..Default::default() },
|
||||
),
|
||||
direction: "row".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding {
|
||||
top: 0.0,
|
||||
right: 0.0,
|
||||
bottom: 0.0,
|
||||
left: 0.0,
|
||||
},
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle {
|
||||
background_color: bg,
|
||||
..Default::default()
|
||||
},
|
||||
style: ContainerStyle { background_color: bg, ..Default::default() },
|
||||
children: cells,
|
||||
break_inside: "auto".to_string(),
|
||||
}));
|
||||
@@ -433,18 +362,15 @@ pub fn expand_table(
|
||||
|
||||
// Wrapper container (column direction, tüm tablo)
|
||||
ContainerElement {
|
||||
id: table.id.clone(),
|
||||
condition: None,
|
||||
position: table.position.clone(),
|
||||
size: table.size.clone(),
|
||||
base: ElementBase {
|
||||
id: table.base.id.clone(),
|
||||
condition: None,
|
||||
position: table.base.position.clone(),
|
||||
size: table.base.size.clone(),
|
||||
},
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding {
|
||||
top: 0.0,
|
||||
right: 0.0,
|
||||
bottom: 0.0,
|
||||
left: 0.0,
|
||||
},
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle {
|
||||
@@ -478,14 +404,10 @@ mod tests {
|
||||
.collect();
|
||||
|
||||
RepeatingTableElement {
|
||||
id: "tbl".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
base: ElementBase::flow(
|
||||
"tbl".to_string(),
|
||||
SizeConstraint { width: SizeValue::Fr { value: 1.0 }, ..Default::default() },
|
||||
),
|
||||
data_source: ArrayBinding {
|
||||
path: "items".to_string(),
|
||||
},
|
||||
@@ -554,7 +476,7 @@ mod tests {
|
||||
let container = expand_table(&table, &resolved, &mut measurer, 180.0);
|
||||
|
||||
// Wrapper container properties
|
||||
assert_eq!(container.id, "tbl");
|
||||
assert_eq!(container.base.id, "tbl");
|
||||
assert_eq!(container.direction, "column");
|
||||
|
||||
// Children: header row + 2 data rows (no border_color so no separator line)
|
||||
@@ -563,7 +485,7 @@ mod tests {
|
||||
// First child is header row container
|
||||
match &container.children[0] {
|
||||
TemplateElement::Container(c) => {
|
||||
assert_eq!(c.id, "tbl_header");
|
||||
assert_eq!(c.base.id, "tbl_header");
|
||||
assert_eq!(c.direction, "row");
|
||||
assert_eq!(c.children.len(), 2); // 2 columns
|
||||
// Check header cell text (inside wrapper container)
|
||||
@@ -577,7 +499,7 @@ mod tests {
|
||||
for (row_idx, child) in container.children[1..].iter().enumerate() {
|
||||
match child {
|
||||
TemplateElement::Container(c) => {
|
||||
assert_eq!(c.id, format!("tbl_row_{}", row_idx));
|
||||
assert_eq!(c.base.id, format!("tbl_row_{}", row_idx));
|
||||
assert_eq!(c.direction, "row");
|
||||
assert_eq!(c.children.len(), 2);
|
||||
}
|
||||
@@ -666,7 +588,7 @@ mod tests {
|
||||
// Second child should be a Line
|
||||
match &container.children[1] {
|
||||
TemplateElement::Line(l) => {
|
||||
assert_eq!(l.id, "tbl_header_line");
|
||||
assert_eq!(l.base.id, "tbl_header_line");
|
||||
}
|
||||
_ => panic!("Expected Line separator after header"),
|
||||
}
|
||||
@@ -738,14 +660,10 @@ mod tests {
|
||||
];
|
||||
|
||||
let table = RepeatingTableElement {
|
||||
id: "tbl".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
base: ElementBase::flow(
|
||||
"tbl".to_string(),
|
||||
SizeConstraint { width: SizeValue::Fr { value: 1.0 }, ..Default::default() },
|
||||
),
|
||||
data_source: ArrayBinding {
|
||||
path: "items".to_string(),
|
||||
},
|
||||
@@ -769,14 +687,14 @@ mod tests {
|
||||
match &container.children[0] {
|
||||
TemplateElement::Container(c) => {
|
||||
let w0 = match &c.children[0] {
|
||||
TemplateElement::Container(wrap) => match &wrap.size.width {
|
||||
TemplateElement::Container(wrap) => match &wrap.base.size.width {
|
||||
SizeValue::Fixed { value } => *value,
|
||||
_ => panic!("Expected Fixed width for auto column wrapper"),
|
||||
},
|
||||
_ => panic!("Expected Container wrapper"),
|
||||
};
|
||||
let w1 = match &c.children[1] {
|
||||
TemplateElement::Container(wrap) => match &wrap.size.width {
|
||||
TemplateElement::Container(wrap) => match &wrap.base.size.width {
|
||||
SizeValue::Fixed { value } => *value,
|
||||
_ => panic!("Expected Fixed width for auto column wrapper"),
|
||||
},
|
||||
@@ -811,7 +729,7 @@ mod tests {
|
||||
// Second call — same inputs — cache hit
|
||||
let result2 = expand_table_cached(&table, &resolved, &mut measurer, 180.0, &mut cache);
|
||||
assert_eq!(cache.len(), 1); // no new entry
|
||||
assert_eq!(result1.id, result2.id);
|
||||
assert_eq!(result1.base.id, result2.base.id);
|
||||
assert_eq!(result1.children.len(), result2.children.len());
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ fn collect_break_modes(root: &ContainerElement) -> HashMap<String, String> {
|
||||
|
||||
fn collect_break_modes_recursive(el: &TemplateElement, modes: &mut HashMap<String, String>) {
|
||||
if let TemplateElement::Container(c) = el {
|
||||
modes.insert(c.id.clone(), c.break_inside.clone());
|
||||
modes.insert(c.base.id.clone(), c.break_inside.clone());
|
||||
for child in &c.children {
|
||||
collect_break_modes_recursive(child, modes);
|
||||
}
|
||||
@@ -208,7 +208,7 @@ fn collect_no_repeat_recursive(el: &TemplateElement, set: &mut std::collections:
|
||||
}
|
||||
TemplateElement::RepeatingTable(t) => {
|
||||
if t.repeat_header == Some(false) {
|
||||
set.insert(t.id.clone());
|
||||
set.insert(t.base.id.clone());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -233,7 +233,7 @@ fn build_container(
|
||||
// Child'lar için kullanılabilir genişliği hesapla
|
||||
// Container'ın kendi padding ve border'ını çıkar
|
||||
let border_w = el.style.border_width.unwrap_or(0.0);
|
||||
let container_own_width = match &el.size.width {
|
||||
let container_own_width = match &el.base.size.width {
|
||||
SizeValue::Fixed { value } => *value,
|
||||
_ => page_width_mm, // Fr veya Auto ise parent'ın genişliğini kullan
|
||||
};
|
||||
@@ -268,17 +268,10 @@ fn build_container(
|
||||
node_map.insert(
|
||||
node,
|
||||
NodeInfo {
|
||||
element_id: el.id.clone(),
|
||||
element_type: "container".to_string(),
|
||||
element_id: el.base.id.clone(),
|
||||
element_type: el.type_str().to_string(),
|
||||
content: None,
|
||||
style: ResolvedStyle {
|
||||
background_color: el.style.background_color.clone(),
|
||||
border_color: el.style.border_color.clone(),
|
||||
border_width: el.style.border_width,
|
||||
border_radius: el.style.border_radius,
|
||||
border_style: el.style.border_style.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
style: (&el.style).into(),
|
||||
children_ids,
|
||||
},
|
||||
);
|
||||
@@ -286,6 +279,30 @@ fn build_container(
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
/// Leaf node oluştur ve node_map'e kaydet (tekrarlayan boilerplate'i ortadan kaldırır).
|
||||
fn register_leaf(
|
||||
taffy: &mut TaffyTree<MeasureContext>,
|
||||
node_map: &mut HashMap<NodeId, NodeInfo>,
|
||||
style: Style,
|
||||
id: &str,
|
||||
element_type: &str,
|
||||
content: Option<ResolvedContent>,
|
||||
resolved_style: ResolvedStyle,
|
||||
) -> Result<NodeId, LayoutError> {
|
||||
let node = taffy.new_leaf(style)?;
|
||||
node_map.insert(
|
||||
node,
|
||||
NodeInfo {
|
||||
element_id: id.to_string(),
|
||||
element_type: element_type.to_string(),
|
||||
content,
|
||||
style: resolved_style,
|
||||
children_ids: vec![],
|
||||
},
|
||||
);
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
/// Herhangi bir element tipini taffy node'a çevir
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_element(
|
||||
@@ -309,162 +326,97 @@ fn build_element(
|
||||
page_width_mm,
|
||||
table_cache,
|
||||
),
|
||||
TemplateElement::StaticText(e) => build_text_leaf(
|
||||
TemplateElement::StaticText(e) => build_resolved_text_leaf(
|
||||
&e.base,
|
||||
e.type_str(),
|
||||
&e.style,
|
||||
taffy,
|
||||
node_map,
|
||||
&e.id,
|
||||
"static_text",
|
||||
resolved
|
||||
.texts
|
||||
.get(&e.id)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(&e.content),
|
||||
&e.style,
|
||||
&e.size,
|
||||
&e.position,
|
||||
resolved,
|
||||
parent_direction,
|
||||
&e.content,
|
||||
),
|
||||
TemplateElement::Text(e) => build_resolved_text_leaf(
|
||||
&e.base,
|
||||
e.type_str(),
|
||||
&e.style,
|
||||
taffy,
|
||||
node_map,
|
||||
resolved,
|
||||
parent_direction,
|
||||
"",
|
||||
),
|
||||
TemplateElement::PageNumber(e) => build_resolved_text_leaf(
|
||||
&e.base,
|
||||
e.type_str(),
|
||||
&e.style,
|
||||
taffy,
|
||||
node_map,
|
||||
resolved,
|
||||
parent_direction,
|
||||
"1 / 1",
|
||||
),
|
||||
TemplateElement::CurrentDate(e) => build_resolved_text_leaf(
|
||||
&e.base,
|
||||
e.type_str(),
|
||||
&e.style,
|
||||
taffy,
|
||||
node_map,
|
||||
resolved,
|
||||
parent_direction,
|
||||
"",
|
||||
),
|
||||
TemplateElement::CalculatedText(e) => build_resolved_text_leaf(
|
||||
&e.base,
|
||||
e.type_str(),
|
||||
&e.style,
|
||||
taffy,
|
||||
node_map,
|
||||
resolved,
|
||||
parent_direction,
|
||||
"",
|
||||
),
|
||||
TemplateElement::Text(e) => {
|
||||
let text = resolved.texts.get(&e.id).map(|s| s.as_str()).unwrap_or("");
|
||||
build_text_leaf(
|
||||
taffy,
|
||||
node_map,
|
||||
&e.id,
|
||||
"text",
|
||||
text,
|
||||
&e.style,
|
||||
&e.size,
|
||||
&e.position,
|
||||
parent_direction,
|
||||
)
|
||||
}
|
||||
TemplateElement::PageNumber(e) => {
|
||||
let text = resolved
|
||||
.texts
|
||||
.get(&e.id)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("1 / 1");
|
||||
build_text_leaf(
|
||||
taffy,
|
||||
node_map,
|
||||
&e.id,
|
||||
"page_number",
|
||||
text,
|
||||
&e.style,
|
||||
&e.size,
|
||||
&e.position,
|
||||
parent_direction,
|
||||
)
|
||||
}
|
||||
TemplateElement::CurrentDate(e) => {
|
||||
let text = resolved.texts.get(&e.id).map(|s| s.as_str()).unwrap_or("");
|
||||
build_text_leaf(
|
||||
taffy,
|
||||
node_map,
|
||||
&e.id,
|
||||
"current_date",
|
||||
text,
|
||||
&e.style,
|
||||
&e.size,
|
||||
&e.position,
|
||||
parent_direction,
|
||||
)
|
||||
}
|
||||
TemplateElement::CalculatedText(e) => {
|
||||
let text = resolved.texts.get(&e.id).map(|s| s.as_str()).unwrap_or("");
|
||||
build_text_leaf(
|
||||
taffy,
|
||||
node_map,
|
||||
&e.id,
|
||||
"calculated_text",
|
||||
text,
|
||||
&e.style,
|
||||
&e.size,
|
||||
&e.position,
|
||||
parent_direction,
|
||||
)
|
||||
}
|
||||
TemplateElement::Line(e) => {
|
||||
let stroke_w = e.style.stroke_width.unwrap_or(0.5);
|
||||
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
|
||||
|
||||
// Line: genişlik parent'tan, yükseklik stroke width
|
||||
let mut leaf_style = style;
|
||||
if matches!(e.size.height, SizeValue::Auto) {
|
||||
leaf_style.size.height = Dimension::length(mm_to_pt(stroke_w));
|
||||
let mut style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
|
||||
if matches!(e.base.size.height, SizeValue::Auto) {
|
||||
style.size.height = Dimension::length(mm_to_pt(stroke_w));
|
||||
}
|
||||
|
||||
let node = taffy.new_leaf(leaf_style)?;
|
||||
node_map.insert(
|
||||
node,
|
||||
NodeInfo {
|
||||
element_id: e.id.clone(),
|
||||
element_type: "line".to_string(),
|
||||
content: Some(ResolvedContent::Line),
|
||||
style: ResolvedStyle {
|
||||
stroke_color: e.style.stroke_color.clone(),
|
||||
stroke_width: Some(stroke_w),
|
||||
..Default::default()
|
||||
},
|
||||
children_ids: vec![],
|
||||
},
|
||||
);
|
||||
Ok(node)
|
||||
let mut rs: ResolvedStyle = (&e.style).into();
|
||||
rs.stroke_width = Some(stroke_w);
|
||||
register_leaf(
|
||||
taffy, node_map, style,
|
||||
&e.base.id, e.type_str(),
|
||||
Some(ResolvedContent::Line),
|
||||
rs,
|
||||
)
|
||||
}
|
||||
TemplateElement::Image(e) => {
|
||||
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
|
||||
let src = resolved.images.get(&e.id).cloned().unwrap_or_default();
|
||||
|
||||
let node = taffy.new_leaf(style)?;
|
||||
node_map.insert(
|
||||
node,
|
||||
NodeInfo {
|
||||
element_id: e.id.clone(),
|
||||
element_type: "image".to_string(),
|
||||
content: Some(ResolvedContent::Image { src }),
|
||||
style: ResolvedStyle {
|
||||
object_fit: e.style.object_fit.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
children_ids: vec![],
|
||||
},
|
||||
);
|
||||
Ok(node)
|
||||
let style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
|
||||
let src = resolved.images.get(&e.base.id).cloned().unwrap_or_default();
|
||||
register_leaf(
|
||||
taffy, node_map, style,
|
||||
&e.base.id, e.type_str(),
|
||||
Some(ResolvedContent::Image { src }),
|
||||
(&e.style).into(),
|
||||
)
|
||||
}
|
||||
TemplateElement::Barcode(e) => {
|
||||
let mut style = sizing::leaf_style(&e.size, &e.position, parent_direction);
|
||||
let value = resolved.barcodes.get(&e.id).cloned().unwrap_or_default();
|
||||
|
||||
// Barcode leaf'e minimum boyut ver (MeasureFunc yok, Auto=0 olur)
|
||||
let mut style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
|
||||
let value = resolved.barcodes.get(&e.base.id).cloned().unwrap_or_default();
|
||||
let is_qr = e.format == "qr";
|
||||
let default_h = if is_qr { 20.0 } else { 15.0 }; // mm
|
||||
let default_w = if is_qr { 20.0 } else { 40.0 }; // mm
|
||||
if matches!(e.size.height, SizeValue::Auto) {
|
||||
style.min_size.height = Dimension::length(mm_to_pt(default_h));
|
||||
if matches!(e.base.size.height, SizeValue::Auto) {
|
||||
style.min_size.height = Dimension::length(mm_to_pt(if is_qr { 20.0 } else { 15.0 }));
|
||||
}
|
||||
if matches!(e.size.width, SizeValue::Auto) {
|
||||
style.min_size.width = Dimension::length(mm_to_pt(default_w));
|
||||
if matches!(e.base.size.width, SizeValue::Auto) {
|
||||
style.min_size.width = Dimension::length(mm_to_pt(if is_qr { 20.0 } else { 40.0 }));
|
||||
}
|
||||
|
||||
let node = taffy.new_leaf(style)?;
|
||||
node_map.insert(
|
||||
node,
|
||||
NodeInfo {
|
||||
element_id: e.id.clone(),
|
||||
element_type: "barcode".to_string(),
|
||||
content: Some(ResolvedContent::Barcode {
|
||||
format: e.format.clone(),
|
||||
value,
|
||||
}),
|
||||
style: ResolvedStyle {
|
||||
barcode_color: e.style.color.clone(),
|
||||
barcode_include_text: e.style.include_text,
|
||||
..Default::default()
|
||||
},
|
||||
children_ids: vec![],
|
||||
},
|
||||
);
|
||||
Ok(node)
|
||||
register_leaf(
|
||||
taffy, node_map, style,
|
||||
&e.base.id, e.type_str(),
|
||||
Some(ResolvedContent::Barcode { format: e.format.clone(), value }),
|
||||
(&e.style).into(),
|
||||
)
|
||||
}
|
||||
TemplateElement::RepeatingTable(e) => {
|
||||
// Tabloyu container ağacına expand et (cache ile)
|
||||
@@ -497,67 +449,37 @@ fn build_element(
|
||||
)
|
||||
}
|
||||
TemplateElement::Shape(e) => {
|
||||
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
|
||||
let node = taffy.new_leaf(style)?;
|
||||
node_map.insert(
|
||||
node,
|
||||
NodeInfo {
|
||||
element_id: e.id.clone(),
|
||||
element_type: "shape".to_string(),
|
||||
content: Some(ResolvedContent::Shape {
|
||||
shape_type: e.shape_type.clone(),
|
||||
}),
|
||||
style: ResolvedStyle {
|
||||
background_color: e.style.background_color.clone(),
|
||||
border_color: e.style.border_color.clone(),
|
||||
border_width: e.style.border_width,
|
||||
border_radius: e.style.border_radius,
|
||||
..Default::default()
|
||||
},
|
||||
children_ids: vec![],
|
||||
},
|
||||
);
|
||||
Ok(node)
|
||||
let style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
|
||||
register_leaf(
|
||||
taffy, node_map, style,
|
||||
&e.base.id, e.type_str(),
|
||||
Some(ResolvedContent::Shape { shape_type: e.shape_type.clone() }),
|
||||
(&e.style).into(),
|
||||
)
|
||||
}
|
||||
TemplateElement::Checkbox(e) => {
|
||||
let checked_str = resolved
|
||||
let checked = resolved
|
||||
.texts
|
||||
.get(&e.id)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("false");
|
||||
let checked = checked_str == "true";
|
||||
.get(&e.base.id)
|
||||
.map(|s| s == "true")
|
||||
.unwrap_or(false);
|
||||
let box_size_mm = e.style.size.unwrap_or(4.0);
|
||||
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
|
||||
|
||||
// Auto size → square based on style.size
|
||||
let mut leaf_style = style;
|
||||
if matches!(e.size.width, SizeValue::Auto) {
|
||||
leaf_style.size.width = Dimension::length(mm_to_pt(box_size_mm));
|
||||
let mut style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
|
||||
if matches!(e.base.size.width, SizeValue::Auto) {
|
||||
style.size.width = Dimension::length(mm_to_pt(box_size_mm));
|
||||
}
|
||||
if matches!(e.size.height, SizeValue::Auto) {
|
||||
leaf_style.size.height = Dimension::length(mm_to_pt(box_size_mm));
|
||||
if matches!(e.base.size.height, SizeValue::Auto) {
|
||||
style.size.height = Dimension::length(mm_to_pt(box_size_mm));
|
||||
}
|
||||
|
||||
let node = taffy.new_leaf(leaf_style)?;
|
||||
node_map.insert(
|
||||
node,
|
||||
NodeInfo {
|
||||
element_id: e.id.clone(),
|
||||
element_type: "checkbox".to_string(),
|
||||
content: Some(ResolvedContent::Checkbox { checked }),
|
||||
style: ResolvedStyle {
|
||||
color: e.style.check_color.clone(),
|
||||
border_color: e.style.border_color.clone(),
|
||||
border_width: e.style.border_width,
|
||||
..Default::default()
|
||||
},
|
||||
children_ids: vec![],
|
||||
},
|
||||
);
|
||||
Ok(node)
|
||||
register_leaf(
|
||||
taffy, node_map, style,
|
||||
&e.base.id, e.type_str(),
|
||||
Some(ResolvedContent::Checkbox { checked }),
|
||||
(&e.style).into(),
|
||||
)
|
||||
}
|
||||
TemplateElement::RichText(e) => {
|
||||
let spans = resolved.rich_texts.get(&e.id).cloned().unwrap_or_default();
|
||||
let spans = resolved.rich_texts.get(&e.base.id).cloned().unwrap_or_default();
|
||||
let rich_span_measures: Vec<crate::text_measure::RichSpanMeasure> = spans
|
||||
.iter()
|
||||
.map(|s| crate::text_measure::RichSpanMeasure {
|
||||
@@ -573,7 +495,7 @@ fn build_element(
|
||||
.map(|s| s.font_size_pt)
|
||||
.fold(11.0f32, f32::max);
|
||||
|
||||
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
|
||||
let style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
|
||||
|
||||
let context = MeasureContext {
|
||||
text: String::new(),
|
||||
@@ -600,48 +522,33 @@ fn build_element(
|
||||
node_map.insert(
|
||||
node,
|
||||
NodeInfo {
|
||||
element_id: e.id.clone(),
|
||||
element_type: "rich_text".to_string(),
|
||||
element_id: e.base.id.clone(),
|
||||
element_type: e.type_str().to_string(),
|
||||
content: Some(ResolvedContent::RichText {
|
||||
spans: resolved_spans,
|
||||
}),
|
||||
style: ResolvedStyle {
|
||||
font_size: e.style.font_size,
|
||||
font_weight: e.style.font_weight.clone(),
|
||||
font_family: e.style.font_family.clone(),
|
||||
color: e.style.color.clone(),
|
||||
text_align: e.style.align.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
style: (&e.style).into(),
|
||||
children_ids: vec![],
|
||||
},
|
||||
);
|
||||
Ok(node)
|
||||
}
|
||||
TemplateElement::Chart(e) => {
|
||||
let mut style = sizing::leaf_style(&e.size, &e.position, parent_direction);
|
||||
// Default minimum boyut — Auto ise chart cok kucuk olmasin
|
||||
if matches!(e.size.width, SizeValue::Auto) {
|
||||
let mut style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
|
||||
if matches!(e.base.size.width, SizeValue::Auto) {
|
||||
style.min_size.width = Dimension::length(mm_to_pt(80.0));
|
||||
}
|
||||
if matches!(e.size.height, SizeValue::Auto) {
|
||||
if matches!(e.base.size.height, SizeValue::Auto) {
|
||||
style.min_size.height = Dimension::length(mm_to_pt(60.0));
|
||||
}
|
||||
let node = taffy.new_leaf(style)?;
|
||||
node_map.insert(
|
||||
node,
|
||||
NodeInfo {
|
||||
element_id: e.id.clone(),
|
||||
element_type: "chart".to_string(),
|
||||
content: None, // SVG collect_layout'ta uretilecek
|
||||
style: ResolvedStyle::default(),
|
||||
children_ids: vec![],
|
||||
},
|
||||
);
|
||||
Ok(node)
|
||||
register_leaf(
|
||||
taffy, node_map, style,
|
||||
&e.base.id, e.type_str(),
|
||||
None, // SVG collect_layout'ta üretilecek
|
||||
ResolvedStyle::default(),
|
||||
)
|
||||
}
|
||||
TemplateElement::PageBreak(e) => {
|
||||
// Küçük yükseklik — editörde görünür olması için (0.5mm ≈ 1.4pt)
|
||||
let style = Style {
|
||||
size: Size {
|
||||
width: Dimension::auto(),
|
||||
@@ -649,18 +556,12 @@ fn build_element(
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let node = taffy.new_leaf(style)?;
|
||||
node_map.insert(
|
||||
node,
|
||||
NodeInfo {
|
||||
element_id: e.id.clone(),
|
||||
element_type: "page_break".to_string(),
|
||||
content: None,
|
||||
style: ResolvedStyle::default(),
|
||||
children_ids: vec![],
|
||||
},
|
||||
);
|
||||
Ok(node)
|
||||
register_leaf(
|
||||
taffy, node_map, style,
|
||||
&e.base.id, e.type_str(),
|
||||
None,
|
||||
ResolvedStyle::default(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -669,7 +570,7 @@ fn build_element(
|
||||
fn register_expanded_texts(el: &TemplateElement, resolved: &mut ResolvedData) {
|
||||
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::Container(e) => {
|
||||
for child in &e.children {
|
||||
@@ -680,6 +581,35 @@ fn register_expanded_texts(el: &TemplateElement, resolved: &mut ResolvedData) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic text leaf builder — HasTextStyle trait ile text-benzeri elementleri tek yerde build eder
|
||||
fn build_resolved_text_leaf(
|
||||
el_base: &ElementBase,
|
||||
el_type_str: &str,
|
||||
text_style: &TextStyle,
|
||||
taffy: &mut TaffyTree<MeasureContext>,
|
||||
node_map: &mut HashMap<NodeId, NodeInfo>,
|
||||
resolved: &ResolvedData,
|
||||
parent_direction: Option<&str>,
|
||||
fallback_text: &str,
|
||||
) -> Result<NodeId, LayoutError> {
|
||||
let text = resolved
|
||||
.texts
|
||||
.get(&el_base.id)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(fallback_text);
|
||||
build_text_leaf(
|
||||
taffy,
|
||||
node_map,
|
||||
&el_base.id,
|
||||
el_type_str,
|
||||
text,
|
||||
text_style,
|
||||
&el_base.size,
|
||||
&el_base.position,
|
||||
parent_direction,
|
||||
)
|
||||
}
|
||||
|
||||
/// Text leaf node oluştur (static_text, text, page_number için ortak)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_text_leaf(
|
||||
@@ -714,15 +644,7 @@ fn build_text_leaf(
|
||||
content: Some(ResolvedContent::Text {
|
||||
value: text.to_string(),
|
||||
}),
|
||||
style: ResolvedStyle {
|
||||
font_size: text_style.font_size,
|
||||
font_weight: text_style.font_weight.clone(),
|
||||
font_style: text_style.font_style.clone(),
|
||||
font_family: text_style.font_family.clone(),
|
||||
color: text_style.color.clone(),
|
||||
text_align: text_style.align.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
style: text_style.into(),
|
||||
children_ids: vec![],
|
||||
},
|
||||
);
|
||||
@@ -799,62 +721,12 @@ fn collect_layout(
|
||||
let w_mm = pt_to_mm(layout.size.width);
|
||||
let h_mm = pt_to_mm(layout.size.height);
|
||||
|
||||
// Chart elementleri icin SVG uret (boyutlar artik belli)
|
||||
// Chart elementleri için SVG üret (boyutlar artık belli)
|
||||
let content = if info.element_type == "chart" {
|
||||
resolved.charts.get(&info.element_id).map(|cd| {
|
||||
use crate::chart_layout::DEFAULT_COLORS;
|
||||
use crate::{ChartRenderData, ChartSeriesData};
|
||||
|
||||
// Renk paleti olustur
|
||||
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(|| DEFAULT_COLORS[i % DEFAULT_COLORS.len()].to_string())
|
||||
})
|
||||
.collect();
|
||||
|
||||
ResolvedContent::Chart {
|
||||
svg: crate::chart_render::render_svg(cd, w_mm, h_mm),
|
||||
chart_data: Box::new(ChartRenderData {
|
||||
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()),
|
||||
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),
|
||||
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(),
|
||||
label_color: cd.labels.as_ref().and_then(|l| l.color.clone()),
|
||||
legend_show: cd.legend.as_ref().is_some_and(|l| l.show),
|
||||
legend_position: cd.legend.as_ref().and_then(|l| l.position.clone()),
|
||||
legend_font_size: cd.legend.as_ref().and_then(|l| l.font_size),
|
||||
x_label: cd.axis.as_ref().and_then(|a| a.x_label.clone()),
|
||||
y_label: cd.axis.as_ref().and_then(|a| a.y_label.clone()),
|
||||
title_align: cd.title.as_ref().and_then(|t| t.align.clone()),
|
||||
}),
|
||||
chart_data: Box::new(crate::ChartRenderData::from(cd)),
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -902,17 +774,14 @@ mod tests {
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint {
|
||||
width: SizeValue::Auto,
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
}),
|
||||
direction: "column".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding {
|
||||
@@ -927,17 +796,14 @@ mod tests {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("title".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
}),
|
||||
style: TextStyle {
|
||||
font_size: Some(18.0),
|
||||
font_weight: Some("bold".to_string()),
|
||||
@@ -946,34 +812,28 @@ mod tests {
|
||||
content: "FATURA".to_string(),
|
||||
}),
|
||||
TemplateElement::Line(LineElement {
|
||||
id: "line1".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("line1".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
}),
|
||||
style: LineStyle {
|
||||
stroke_color: Some("#000000".to_string()),
|
||||
stroke_width: Some(0.5),
|
||||
},
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "body".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("body".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
}),
|
||||
style: TextStyle {
|
||||
font_size: Some(11.0),
|
||||
..Default::default()
|
||||
@@ -1051,17 +911,14 @@ mod tests {
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint {
|
||||
width: SizeValue::Auto,
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
}),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding {
|
||||
@@ -1075,17 +932,14 @@ mod tests {
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::Container(ContainerElement {
|
||||
id: "row".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("row".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
}),
|
||||
direction: "row".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding {
|
||||
@@ -1100,17 +954,14 @@ mod tests {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "left".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("left".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
}),
|
||||
style: TextStyle {
|
||||
font_size: Some(11.0),
|
||||
..Default::default()
|
||||
@@ -1118,17 +969,14 @@ mod tests {
|
||||
content: "Sol".to_string(),
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "right".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("right".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
}),
|
||||
style: TextStyle {
|
||||
font_size: Some(11.0),
|
||||
..Default::default()
|
||||
@@ -1184,17 +1032,14 @@ mod tests {
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint {
|
||||
width: SizeValue::Auto,
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
}),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding {
|
||||
@@ -1208,16 +1053,18 @@ mod tests {
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "abs_text".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Absolute { x: 50.0, y: 80.0 },
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 100.0 },
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
base: ElementBase {
|
||||
id: "abs_text".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Absolute { x: 50.0, y: 80.0 },
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 100.0 },
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
},
|
||||
},
|
||||
style: TextStyle {
|
||||
font_size: Some(14.0),
|
||||
@@ -1276,10 +1123,7 @@ mod tests {
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
base: ElementBase::flow("root".to_string(), sz_auto.clone()),
|
||||
direction: "column".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding {
|
||||
@@ -1295,10 +1139,7 @@ mod tests {
|
||||
children: vec![
|
||||
// Header row
|
||||
TemplateElement::Container(ContainerElement {
|
||||
id: "c_header".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_fr_auto.clone(),
|
||||
base: ElementBase::flow("c_header".to_string(), sz_fr_auto.clone()),
|
||||
direction: "row".to_string(),
|
||||
gap: 5.0,
|
||||
padding: p0.clone(),
|
||||
@@ -1309,10 +1150,7 @@ mod tests {
|
||||
children: vec![
|
||||
// Sol: firma bilgileri
|
||||
TemplateElement::Container(ContainerElement {
|
||||
id: "c_firma".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_fr_auto.clone(),
|
||||
base: ElementBase::flow("c_firma".to_string(), sz_fr_auto.clone()),
|
||||
direction: "column".to_string(),
|
||||
gap: 1.0,
|
||||
padding: p0.clone(),
|
||||
@@ -1322,10 +1160,7 @@ mod tests {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_firma_unvan".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
base: ElementBase::flow("el_firma_unvan".to_string(), sz_auto.clone()),
|
||||
style: TextStyle {
|
||||
font_size: Some(14.0),
|
||||
font_weight: Some("bold".to_string()),
|
||||
@@ -1334,10 +1169,7 @@ mod tests {
|
||||
content: "Teknova Yazılım ve Danışmanlık A.Ş.".to_string(),
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_firma_adres".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
base: ElementBase::flow("el_firma_adres".to_string(), sz_auto.clone()),
|
||||
style: TextStyle {
|
||||
font_size: Some(9.0),
|
||||
..Default::default()
|
||||
@@ -1346,10 +1178,7 @@ mod tests {
|
||||
.to_string(),
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_firma_il".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
base: ElementBase::flow("el_firma_il".to_string(), sz_auto.clone()),
|
||||
style: TextStyle {
|
||||
font_size: Some(9.0),
|
||||
..Default::default()
|
||||
@@ -1357,10 +1186,7 @@ mod tests {
|
||||
content: "Istanbul".to_string(),
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_firma_tel".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
base: ElementBase::flow("el_firma_tel".to_string(), sz_auto.clone()),
|
||||
style: TextStyle {
|
||||
font_size: Some(9.0),
|
||||
..Default::default()
|
||||
@@ -1368,10 +1194,7 @@ mod tests {
|
||||
content: "Tel: +90 212 555 0042".to_string(),
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_firma_vd".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
base: ElementBase::flow("el_firma_vd".to_string(), sz_auto.clone()),
|
||||
style: TextStyle {
|
||||
font_size: Some(9.0),
|
||||
..Default::default()
|
||||
@@ -1379,10 +1202,7 @@ mod tests {
|
||||
content: "VD: Levent VD".to_string(),
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_firma_vn".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
base: ElementBase::flow("el_firma_vn".to_string(), sz_auto.clone()),
|
||||
style: TextStyle {
|
||||
font_size: Some(9.0),
|
||||
..Default::default()
|
||||
@@ -1393,10 +1213,7 @@ mod tests {
|
||||
}),
|
||||
// Sağ: fatura başlığı
|
||||
TemplateElement::Container(ContainerElement {
|
||||
id: "c_fatura_baslik".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
base: ElementBase::flow("c_fatura_baslik".to_string(), sz_auto.clone()),
|
||||
direction: "column".to_string(),
|
||||
gap: 2.0,
|
||||
padding: p0.clone(),
|
||||
@@ -1406,10 +1223,7 @@ mod tests {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_fatura_baslik".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
base: ElementBase::flow("el_fatura_baslik".to_string(), sz_auto.clone()),
|
||||
style: TextStyle {
|
||||
font_size: Some(18.0),
|
||||
font_weight: Some("bold".to_string()),
|
||||
@@ -1418,10 +1232,7 @@ mod tests {
|
||||
content: "FATURA".to_string(),
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_fatura_no".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
base: ElementBase::flow("el_fatura_no".to_string(), sz_auto.clone()),
|
||||
style: TextStyle {
|
||||
font_size: Some(10.0),
|
||||
..Default::default()
|
||||
@@ -1429,10 +1240,7 @@ mod tests {
|
||||
content: "No: FTR-2026-001547".to_string(),
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_fatura_tarih".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
base: ElementBase::flow("el_fatura_tarih".to_string(), sz_auto.clone()),
|
||||
style: TextStyle {
|
||||
font_size: Some(10.0),
|
||||
..Default::default()
|
||||
@@ -1440,10 +1248,7 @@ mod tests {
|
||||
content: "Tarih: 2026-03-29".to_string(),
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_fatura_vade".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
base: ElementBase::flow("el_fatura_vade".to_string(), sz_auto.clone()),
|
||||
style: TextStyle {
|
||||
font_size: Some(10.0),
|
||||
..Default::default()
|
||||
|
||||
@@ -27,12 +27,9 @@ fn base_template() -> Template {
|
||||
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: 5.0,
|
||||
condition: None,
|
||||
padding: Padding {
|
||||
top: 15.0,
|
||||
right: 15.0,
|
||||
@@ -57,14 +54,11 @@ fn test_1_2_text_wrapping_layout_height() {
|
||||
// Dar bir container'da uzun metin → yükseklik tek satırdan fazla olmalı
|
||||
let mut tpl = base_template();
|
||||
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
|
||||
id: "long_text".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("long_text".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 40.0 }, // 40mm genişlik — kısa
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
style: TextStyle {
|
||||
font_size: Some(12.0),
|
||||
..Default::default()
|
||||
@@ -94,14 +88,11 @@ fn test_1_2_text_wrapping_pdf_renders() {
|
||||
// PDF render sırasında text wrapping çalışmalı — crash olmamalı
|
||||
let mut tpl = base_template();
|
||||
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
|
||||
id: "wrap_pdf".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("wrap_pdf".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 50.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
style: TextStyle {
|
||||
font_size: Some(11.0),
|
||||
..Default::default()
|
||||
@@ -125,14 +116,11 @@ fn test_1_2_text_wrapping_pdf_renders() {
|
||||
fn test_1_3_image_object_fit_in_layout() {
|
||||
let mut tpl = base_template();
|
||||
tpl.root.children.push(TemplateElement::Image(ImageElement {
|
||||
id: "img_contain".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("img_contain".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 40.0 },
|
||||
height: SizeValue::Fixed { value: 30.0 },
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
src: Some("data:image/png;base64,iVBORw0KGgo=".to_string()),
|
||||
binding: None,
|
||||
style: ImageStyle {
|
||||
@@ -167,14 +155,11 @@ fn test_1_4_italic_font_in_pdf() {
|
||||
tpl.root
|
||||
.children
|
||||
.push(TemplateElement::StaticText(StaticTextElement {
|
||||
id: "italic_text".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("italic_text".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
style: TextStyle {
|
||||
font_size: Some(12.0),
|
||||
font_style: Some("italic".to_string()),
|
||||
@@ -205,14 +190,11 @@ fn test_1_4_bold_italic_font_in_pdf() {
|
||||
tpl.root
|
||||
.children
|
||||
.push(TemplateElement::StaticText(StaticTextElement {
|
||||
id: "bold_italic".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("bold_italic".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
style: TextStyle {
|
||||
font_size: Some(14.0),
|
||||
font_weight: Some("bold".to_string()),
|
||||
@@ -239,14 +221,11 @@ fn test_2_1_repeat_header_false_no_repeat_on_second_page() {
|
||||
tpl.root
|
||||
.children
|
||||
.push(TemplateElement::RepeatingTable(RepeatingTableElement {
|
||||
id: "tbl_no_repeat".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("tbl_no_repeat".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
data_source: ArrayBinding {
|
||||
path: "items".to_string(),
|
||||
},
|
||||
@@ -305,14 +284,11 @@ fn test_2_1_repeat_header_true_repeats_on_second_page() {
|
||||
tpl.root
|
||||
.children
|
||||
.push(TemplateElement::RepeatingTable(RepeatingTableElement {
|
||||
id: "tbl_repeat".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("tbl_repeat".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
data_source: ArrayBinding {
|
||||
path: "items".to_string(),
|
||||
},
|
||||
@@ -389,14 +365,11 @@ fn test_2_2_table_column_format_currency() {
|
||||
tpl.root
|
||||
.children
|
||||
.push(TemplateElement::RepeatingTable(RepeatingTableElement {
|
||||
id: "tbl_fmt".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("tbl_fmt".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
data_source: ArrayBinding {
|
||||
path: "items".to_string(),
|
||||
},
|
||||
@@ -459,14 +432,11 @@ fn test_2_2_table_column_format_currency() {
|
||||
fn test_2_3_rounded_rectangle_renders() {
|
||||
let mut tpl = base_template();
|
||||
tpl.root.children.push(TemplateElement::Shape(ShapeElement {
|
||||
id: "rounded_shape".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("rounded_shape".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 50.0 },
|
||||
height: SizeValue::Fixed { value: 30.0 },
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
shape_type: "rounded_rectangle".to_string(),
|
||||
style: ContainerStyle {
|
||||
background_color: Some("#3b82f6".to_string()),
|
||||
@@ -505,14 +475,11 @@ fn test_2_3_container_border_radius_renders() {
|
||||
tpl.root
|
||||
.children
|
||||
.push(TemplateElement::StaticText(StaticTextElement {
|
||||
id: "text_in_rounded".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("text_in_rounded".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
style: TextStyle {
|
||||
font_size: Some(12.0),
|
||||
..Default::default()
|
||||
@@ -604,14 +571,11 @@ fn test_2_7_format_config_in_template() {
|
||||
fn test_ellipse_shape_renders() {
|
||||
let mut tpl = base_template();
|
||||
tpl.root.children.push(TemplateElement::Shape(ShapeElement {
|
||||
id: "ellipse".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("ellipse".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 40.0 },
|
||||
height: SizeValue::Fixed { value: 20.0 },
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
shape_type: "ellipse".to_string(),
|
||||
style: ContainerStyle {
|
||||
background_color: Some("#ff6600".to_string()),
|
||||
@@ -635,22 +599,21 @@ fn test_ellipse_shape_renders() {
|
||||
fn test_7_1_condition_gt_hides_element() {
|
||||
let mut tpl = base_template();
|
||||
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
|
||||
id: "always_visible".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("always_visible".to_string(), SizeConstraint::default()),
|
||||
style: TextStyle { font_size: Some(10.0), ..Default::default() },
|
||||
content: "Visible".to_string(),
|
||||
}));
|
||||
tpl.root.children.push(TemplateElement::Text(TextElement {
|
||||
id: "conditional_text".to_string(),
|
||||
condition: Some(Condition {
|
||||
path: "toplamlar.iskonto".to_string(),
|
||||
operator: "gt".to_string(),
|
||||
value: Some(serde_json::json!(0)),
|
||||
}),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase {
|
||||
id: "conditional_text".to_string(),
|
||||
condition: Some(Condition {
|
||||
path: "toplamlar.iskonto".to_string(),
|
||||
operator: "gt".to_string(),
|
||||
value: Some(serde_json::json!(0)),
|
||||
}),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
},
|
||||
style: TextStyle { font_size: Some(10.0), ..Default::default() },
|
||||
content: None,
|
||||
binding: ScalarBinding { path: "toplamlar.iskonto".to_string() },
|
||||
@@ -676,14 +639,16 @@ fn test_7_1_condition_gt_hides_element() {
|
||||
fn test_7_1_condition_gt_shows_element() {
|
||||
let mut tpl = base_template();
|
||||
tpl.root.children.push(TemplateElement::Text(TextElement {
|
||||
id: "conditional_text".to_string(),
|
||||
condition: Some(Condition {
|
||||
path: "toplamlar.iskonto".to_string(),
|
||||
operator: "gt".to_string(),
|
||||
value: Some(serde_json::json!(0)),
|
||||
}),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase {
|
||||
id: "conditional_text".to_string(),
|
||||
condition: Some(Condition {
|
||||
path: "toplamlar.iskonto".to_string(),
|
||||
operator: "gt".to_string(),
|
||||
value: Some(serde_json::json!(0)),
|
||||
}),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
},
|
||||
style: TextStyle { font_size: Some(10.0), ..Default::default() },
|
||||
content: None,
|
||||
binding: ScalarBinding { path: "toplamlar.iskonto".to_string() },
|
||||
@@ -705,14 +670,16 @@ fn test_7_1_condition_gt_shows_element() {
|
||||
fn test_7_1_condition_eq_operator() {
|
||||
let mut tpl = base_template();
|
||||
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
|
||||
id: "status_text".to_string(),
|
||||
condition: Some(Condition {
|
||||
path: "durum".to_string(),
|
||||
operator: "eq".to_string(),
|
||||
value: Some(serde_json::json!("aktif")),
|
||||
}),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase {
|
||||
id: "status_text".to_string(),
|
||||
condition: Some(Condition {
|
||||
path: "durum".to_string(),
|
||||
operator: "eq".to_string(),
|
||||
value: Some(serde_json::json!("aktif")),
|
||||
}),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
},
|
||||
style: TextStyle { font_size: Some(10.0), ..Default::default() },
|
||||
content: "Aktif".to_string(),
|
||||
}));
|
||||
@@ -732,14 +699,16 @@ fn test_7_1_condition_eq_operator() {
|
||||
fn test_7_1_condition_empty_not_empty() {
|
||||
let mut tpl = base_template();
|
||||
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
|
||||
id: "show_if_exists".to_string(),
|
||||
condition: Some(Condition {
|
||||
path: "note".to_string(),
|
||||
operator: "not_empty".to_string(),
|
||||
value: None,
|
||||
}),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase {
|
||||
id: "show_if_exists".to_string(),
|
||||
condition: Some(Condition {
|
||||
path: "note".to_string(),
|
||||
operator: "not_empty".to_string(),
|
||||
value: None,
|
||||
}),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
},
|
||||
style: TextStyle { font_size: Some(10.0), ..Default::default() },
|
||||
content: "Has note".to_string(),
|
||||
}));
|
||||
@@ -763,14 +732,16 @@ fn test_7_1_condition_empty_not_empty() {
|
||||
fn test_7_1_condition_on_container_hides_children() {
|
||||
let mut tpl = base_template();
|
||||
tpl.root.children.push(TemplateElement::Container(ContainerElement {
|
||||
id: "cond_container".to_string(),
|
||||
condition: Some(Condition {
|
||||
path: "show".to_string(),
|
||||
operator: "eq".to_string(),
|
||||
value: Some(serde_json::json!(true)),
|
||||
}),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase {
|
||||
id: "cond_container".to_string(),
|
||||
condition: Some(Condition {
|
||||
path: "show".to_string(),
|
||||
operator: "eq".to_string(),
|
||||
value: Some(serde_json::json!(true)),
|
||||
}),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
},
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
@@ -779,10 +750,7 @@ fn test_7_1_condition_on_container_hides_children() {
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "child_text".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("child_text".to_string(), SizeConstraint::default()),
|
||||
style: TextStyle { font_size: Some(10.0), ..Default::default() },
|
||||
content: "Child".to_string(),
|
||||
})],
|
||||
@@ -854,10 +822,7 @@ fn test_7_5_effective_format_config_priority() {
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
@@ -889,10 +854,7 @@ fn test_7_5_effective_format_config_locale_fallback() {
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
@@ -915,14 +877,11 @@ fn test_7_5_locale_affects_table_currency_format() {
|
||||
let mut tpl = base_template();
|
||||
tpl.locale = Some("en-US".to_string());
|
||||
tpl.root.children.push(TemplateElement::RepeatingTable(RepeatingTableElement {
|
||||
id: "tbl_locale".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("tbl_locale".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
data_source: ArrayBinding { path: "items".to_string() },
|
||||
columns: vec![
|
||||
TableColumn {
|
||||
|
||||
@@ -20,10 +20,7 @@ fn simple_template() -> Template {
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
|
||||
direction: "column".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding {
|
||||
@@ -37,14 +34,14 @@ fn simple_template() -> Template {
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
base: ElementBase::flow(
|
||||
"title".to_string(),
|
||||
SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
style: TextStyle {
|
||||
font_size: Some(14.0),
|
||||
font_weight: Some("bold".to_string()),
|
||||
@@ -166,10 +163,7 @@ fn test_compute_layout_with_data_binding() {
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding {
|
||||
@@ -183,14 +177,14 @@ fn test_compute_layout_with_data_binding() {
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::Text(TextElement {
|
||||
id: "bound_text".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
base: ElementBase::flow(
|
||||
"bound_text".to_string(),
|
||||
SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
style: TextStyle {
|
||||
font_size: Some(12.0),
|
||||
..Default::default()
|
||||
@@ -235,10 +229,7 @@ fn test_compute_layout_multiple_children_ordering() {
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
|
||||
direction: "column".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding {
|
||||
@@ -253,14 +244,14 @@ fn test_compute_layout_multiple_children_ordering() {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "first".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
base: ElementBase::flow(
|
||||
"first".to_string(),
|
||||
SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
style: TextStyle {
|
||||
font_size: Some(12.0),
|
||||
..Default::default()
|
||||
@@ -268,14 +259,14 @@ fn test_compute_layout_multiple_children_ordering() {
|
||||
content: "First".to_string(),
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "second".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
base: ElementBase::flow(
|
||||
"second".to_string(),
|
||||
SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
style: TextStyle {
|
||||
font_size: Some(12.0),
|
||||
..Default::default()
|
||||
|
||||
@@ -23,10 +23,7 @@ fn simple_template() -> Template {
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
|
||||
direction: "column".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding {
|
||||
@@ -40,14 +37,11 @@ fn simple_template() -> Template {
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("title".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
style: TextStyle {
|
||||
font_size: Some(18.0),
|
||||
font_weight: Some("bold".to_string()),
|
||||
@@ -94,10 +88,7 @@ fn test_render_pdf_with_multiple_elements() {
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
|
||||
direction: "column".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding {
|
||||
@@ -112,14 +103,11 @@ fn test_render_pdf_with_multiple_elements() {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "header".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("header".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
style: TextStyle {
|
||||
font_size: Some(16.0),
|
||||
font_weight: Some("bold".to_string()),
|
||||
@@ -128,28 +116,22 @@ fn test_render_pdf_with_multiple_elements() {
|
||||
content: "FATURA".to_string(),
|
||||
}),
|
||||
TemplateElement::Line(LineElement {
|
||||
id: "sep".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("sep".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
style: LineStyle {
|
||||
stroke_color: Some("#000000".to_string()),
|
||||
stroke_width: Some(0.5),
|
||||
},
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "body".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("body".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
style: TextStyle {
|
||||
font_size: Some(11.0),
|
||||
..Default::default()
|
||||
@@ -192,10 +174,7 @@ fn test_render_pdf_with_container_styles() {
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding {
|
||||
@@ -214,14 +193,11 @@ fn test_render_pdf_with_container_styles() {
|
||||
},
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "text".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("text".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
style: TextStyle {
|
||||
font_size: Some(12.0),
|
||||
color: Some("#ff0000".to_string()),
|
||||
@@ -257,10 +233,7 @@ fn test_page_break_produces_multiple_pages() {
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
|
||||
direction: "column".to_string(),
|
||||
gap: 5.0,
|
||||
padding: Padding {
|
||||
@@ -275,14 +248,11 @@ fn test_page_break_produces_multiple_pages() {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "t1".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("t1".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
style: TextStyle {
|
||||
font_size: Some(18.0),
|
||||
..Default::default()
|
||||
@@ -290,18 +260,14 @@ fn test_page_break_produces_multiple_pages() {
|
||||
content: "Page 1 content".to_string(),
|
||||
}),
|
||||
TemplateElement::PageBreak(PageBreakElement {
|
||||
id: "pb1".to_string(),
|
||||
condition: None,
|
||||
base: ElementBase::flow("pb1".to_string(), SizeConstraint::default()),
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "t2".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
base: ElementBase::flow("t2".to_string(), SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
style: TextStyle {
|
||||
font_size: Some(18.0),
|
||||
..Default::default()
|
||||
|
||||