diff --git a/.gitignore b/.gitignore index 717b604..ce227b3 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Cargo.lock b/Cargo.lock index 8510eed..b290b66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 3362157..9b910b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["core", "backend", "layout-engine"] +members = ["core", "backend", "layout-engine", "dreport-service", "dreport-ffi"] resolver = "2" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 0fe6632..fd37aae 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -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" diff --git a/backend/src/app.rs b/backend/src/app.rs new file mode 100644 index 0000000..aa1fc42 --- /dev/null +++ b/backend/src/app.rs @@ -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 { + 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) +} diff --git a/backend/src/font_registry.rs b/backend/src/font_registry.rs deleted file mode 100644 index f2fd209..0000000 --- a/backend/src/font_registry.rs +++ /dev/null @@ -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>, - /// Original-case family names - family_names: HashMap, -} - -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) -> 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 { - 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 = 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 { - 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 { - let family_lower = family.to_lowercase(); - let key = FontVariantKey { weight, italic }; - self.families - .get(&family_lower) - .and_then(|variants| variants.get(&key)) - .cloned() - } -} diff --git a/backend/src/lib.rs b/backend/src/lib.rs new file mode 100644 index 0000000..e05220a --- /dev/null +++ b/backend/src/lib.rs @@ -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) -> Router { + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + Router::new() + .merge(routes::router()) + .layer(cors) + .with_state(service) +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 23a0fd2..0fc2227 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -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(()) } diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs deleted file mode 100644 index c166885..0000000 --- a/backend/src/models/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub use dreport_core::models::*; diff --git a/backend/src/routes/fonts.rs b/backend/src/routes/fonts.rs index f40afa4..3e57510 100644 --- a/backend/src/routes/fonts.rs +++ b/backend/src/routes/fonts.rs @@ -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>) -> Json> { - let families = registry.list_families(); - let response: Vec = families +async fn list_fonts(State(service): State) -> Json> { + let response: Vec = service + .list_font_families() .into_iter() .map(|f| FontFamilyResponse { family: f.family, @@ -45,16 +43,16 @@ async fn list_fonts(State(registry): State>) -> Json>, + State(service): State, 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> { +pub fn router() -> Router { Router::new() .route("/api/fonts", get(list_fonts)) .route("/api/fonts/{family}/{weight}/{italic}", get(get_font)) diff --git a/backend/src/routes/health.rs b/backend/src/routes/health.rs index b079506..d67b398 100644 --- a/backend/src/routes/health.rs +++ b/backend/src/routes/health.rs @@ -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 { }) } -pub fn router() -> Router> { +pub fn router() -> Router { Router::new().route("/api/health", get(health)) } diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index a1c9ba1..6ecca88 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -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; -pub fn router() -> Router> { +pub fn router() -> Router { Router::new() .merge(health::router()) .merge(render::router()) diff --git a/backend/src/routes/render.rs b/backend/src/routes/render.rs index aad4435..0c6bacb 100644 --- a/backend/src/routes/render.rs +++ b/backend/src/routes/render.rs @@ -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>, + State(service): State, Json(payload): Json, ) -> 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> { +fn status_for(err: &ServiceError) -> StatusCode { + match err { + ServiceError::InvalidTemplateJson(_) | ServiceError::InvalidDataJson(_) => { + StatusCode::BAD_REQUEST + } + _ => StatusCode::INTERNAL_SERVER_ERROR, + } +} + +pub fn router() -> Router { Router::new().route("/api/render", post(render)) } diff --git a/backend/tests/api.rs b/backend/tests/api.rs new file mode 100644 index 0000000..0a601fc --- /dev/null +++ b/backend/tests/api.rs @@ -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 { + 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::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::(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() + ); +} diff --git a/bindings/dotnet/Dreport.Service.slnx b/bindings/dotnet/Dreport.Service.slnx new file mode 100644 index 0000000..53ac9b1 --- /dev/null +++ b/bindings/dotnet/Dreport.Service.slnx @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/bindings/dotnet/src/Dreport.AspNetCore/Dreport.AspNetCore.csproj b/bindings/dotnet/src/Dreport.AspNetCore/Dreport.AspNetCore.csproj new file mode 100644 index 0000000..76918ce --- /dev/null +++ b/bindings/dotnet/src/Dreport.AspNetCore/Dreport.AspNetCore.csproj @@ -0,0 +1,16 @@ + + + + net8.0 + enable + latest + enable + false + + + + + + + + diff --git a/bindings/dotnet/src/Dreport.AspNetCore/DreportEndpointRouteBuilderExtensions.cs b/bindings/dotnet/src/Dreport.AspNetCore/DreportEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..bab9708 --- /dev/null +++ b/bindings/dotnet/src/Dreport.AspNetCore/DreportEndpointRouteBuilderExtensions.cs @@ -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; + +/// +/// 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 +/// registered by AddDreport() is fully +/// usable from your own controllers / minimal API handlers. +/// +public static class DreportEndpointRouteBuilderExtensions +{ + /// + /// Mount the dreport HTTP API under the given prefix (defaults to /api). + /// Routes added: + /// + /// GET {prefix}/health + /// POST {prefix}/render — body { template, data }application/pdf + /// POST {prefix}/layout — body { template, data } → LayoutResult JSON + /// GET {prefix}/fonts — registered families + /// GET {prefix}/fonts/{family}/{weight}/{italic} — raw font bytes + /// + /// + 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), + }; +} diff --git a/bindings/dotnet/src/Dreport.AspNetCore/DreportOptions.cs b/bindings/dotnet/src/Dreport.AspNetCore/DreportOptions.cs new file mode 100644 index 0000000..ae47dd4 --- /dev/null +++ b/bindings/dotnet/src/Dreport.AspNetCore/DreportOptions.cs @@ -0,0 +1,20 @@ +namespace Dreport.AspNetCore; + +/// +/// Configuration for the dreport ASP.NET Core integration. +/// +public sealed class DreportOptions +{ + /// + /// Optional directory whose .ttf / .otf files are loaded into the + /// engine on startup, in addition to the embedded default fonts. + /// + public string? FontsDirectory { get; set; } + + /// + /// When true (default), embedded default fonts (Noto Sans, Noto Sans Mono) + /// are registered. Set to false to start with an empty registry — useful + /// when the host wants to provide a fully custom font set. + /// + public bool LoadEmbeddedFonts { get; set; } = true; +} diff --git a/bindings/dotnet/src/Dreport.AspNetCore/DreportServiceCollectionExtensions.cs b/bindings/dotnet/src/Dreport.AspNetCore/DreportServiceCollectionExtensions.cs new file mode 100644 index 0000000..9402046 --- /dev/null +++ b/bindings/dotnet/src/Dreport.AspNetCore/DreportServiceCollectionExtensions.cs @@ -0,0 +1,41 @@ +using Dreport.Service; +using Microsoft.Extensions.DependencyInjection; + +namespace Dreport.AspNetCore; + +/// +/// DI registration for . Registers the engine as a +/// process-wide singleton so consumers can inject it into controllers, +/// endpoint handlers, background services, or test fixtures. +/// +public static class DreportServiceCollectionExtensions +{ + /// + /// Registers a singleton . Once added, you can: + /// + /// Inject into your own MVC controllers, minimal API handlers, or background services. + /// Call app.MapDreportEndpoints() to also mount the ready-made HTTP API the editor talks to. + /// + /// + public static IServiceCollection AddDreport( + this IServiceCollection services, + Action? 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; + } +} diff --git a/bindings/dotnet/src/Dreport.Service/Dreport.Service.csproj b/bindings/dotnet/src/Dreport.Service/Dreport.Service.csproj new file mode 100644 index 0000000..9aabdbd --- /dev/null +++ b/bindings/dotnet/src/Dreport.Service/Dreport.Service.csproj @@ -0,0 +1,38 @@ + + + + + + + + net8.0 + enable + latest + true + enable + + + false + + + + + <_DrHostExt Condition="$([MSBuild]::IsOSPlatform('OSX'))">.dylib + <_DrHostExt Condition="$([MSBuild]::IsOSPlatform('Linux'))">.so + <_DrHostExt Condition="$([MSBuild]::IsOSPlatform('Windows'))">.dll + <_DrHostPrefix Condition="!$([MSBuild]::IsOSPlatform('Windows'))">lib + + + + + + diff --git a/bindings/dotnet/src/Dreport.Service/Exceptions.cs b/bindings/dotnet/src/Dreport.Service/Exceptions.cs new file mode 100644 index 0000000..1f85e1e --- /dev/null +++ b/bindings/dotnet/src/Dreport.Service/Exceptions.cs @@ -0,0 +1,67 @@ +namespace Dreport.Service; + +/// +/// Thrown when the underlying dreport service returns an error. The numeric +/// mirrors the FFI return code (negative values). +/// +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) { } +} diff --git a/bindings/dotnet/src/Dreport.Service/FontFamily.cs b/bindings/dotnet/src/Dreport.Service/FontFamily.cs new file mode 100644 index 0000000..5325648 --- /dev/null +++ b/bindings/dotnet/src/Dreport.Service/FontFamily.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Dreport.Service; + +/// One font family with its registered variants. +public sealed record FontFamily( + [property: JsonPropertyName("family")] string Family, + [property: JsonPropertyName("variants")] IReadOnlyList Variants); + +/// One weight/italic combination within a family. +public sealed record FontVariant( + [property: JsonPropertyName("weight")] ushort Weight, + [property: JsonPropertyName("italic")] bool Italic); diff --git a/bindings/dotnet/src/Dreport.Service/LayoutEngine.cs b/bindings/dotnet/src/Dreport.Service/LayoutEngine.cs new file mode 100644 index 0000000..0bc4862 --- /dev/null +++ b/bindings/dotnet/src/Dreport.Service/LayoutEngine.cs @@ -0,0 +1,220 @@ +using System.Text; +using System.Text.Json; + +namespace Dreport.Service; + +/// +/// 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 from any number of threads. +/// +public sealed class LayoutEngine : IDisposable +{ + private IntPtr _handle; + private readonly object _disposeLock = new(); + private bool _disposed; + + /// Create an engine with the embedded default fonts loaded. + 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; + } + + /// Create an engine with no fonts pre-loaded. + public static LayoutEngine CreateEmpty() => new(Native.dreport_new_empty()); + + /// Native crate version, e.g. "0.2.0". + 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 + // --------------------------------------------------------------------- + + /// Number of distinct font families currently registered. + 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; + } + } + + /// Register a font from raw TTF/OTF bytes. + public unsafe void RegisterFont(ReadOnlySpan 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"); + } + } + } + + /// Register every .ttf/.otf file in . + /// Number of fonts that registered successfully. + 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; + } + + /// Get raw bytes for a specific font variant. Returns null when unknown. + 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; + } + + /// List every registered font family with its variants. + public IReadOnlyList 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(); + } + var families = JsonSerializer.Deserialize>(json); + return families ?? new List(); + } + + // --------------------------------------------------------------------- + // Render pipeline + // --------------------------------------------------------------------- + + /// Compute layout from JSON inputs. Returns the LayoutResult JSON string. + 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)); + } + + /// Render a PDF document. Returns the raw PDF bytes. + 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)); + } + } +} diff --git a/bindings/dotnet/src/Dreport.Service/Native.cs b/bindings/dotnet/src/Dreport.Service/Native.cs new file mode 100644 index 0000000..8a5758b --- /dev/null +++ b/bindings/dotnet/src/Dreport.Service/Native.cs @@ -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//native/ pattern when the + // package is consumed; for local development the file lives next to the test + // assembly under bin///runtimes//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 --------------------------------------------------------- + + /// Copy a native buffer into a managed byte[] and free the native side. + 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(); + } + + var bytes = new byte[buffer.Len]; + Marshal.Copy(buffer.Data, bytes, 0, (int)buffer.Len); + dreport_buffer_free(buffer); + return bytes; + } + + /// Read the most recent FFI error message for the current thread. + 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); + } +} diff --git a/bindings/dotnet/tests/Dreport.AspNetCore.Tests/Dreport.AspNetCore.Tests.csproj b/bindings/dotnet/tests/Dreport.AspNetCore.Tests/Dreport.AspNetCore.Tests.csproj new file mode 100644 index 0000000..1a91266 --- /dev/null +++ b/bindings/dotnet/tests/Dreport.AspNetCore.Tests/Dreport.AspNetCore.Tests.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + false + latest + + + + + + + + + + + + + + diff --git a/bindings/dotnet/tests/Dreport.AspNetCore.Tests/EndpointTests.cs b/bindings/dotnet/tests/Dreport.AspNetCore.Tests/EndpointTests.cs new file mode 100644 index 0000000..c3de174 --- /dev/null +++ b/bindings/dotnet/tests/Dreport.AspNetCore.Tests/EndpointTests.cs @@ -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; + +/// +/// 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. +/// +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(); + 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(); + 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(); + 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(); + Assert.True(json.GetProperty("count").GetInt32() >= 1); + } +} diff --git a/bindings/dotnet/tests/Dreport.Service.Tests/Dreport.Service.Tests.csproj b/bindings/dotnet/tests/Dreport.Service.Tests/Dreport.Service.Tests.csproj new file mode 100644 index 0000000..07b8ca8 --- /dev/null +++ b/bindings/dotnet/tests/Dreport.Service.Tests/Dreport.Service.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + latest + true + + true + + + + + + + + + + + + + + + + + + diff --git a/bindings/dotnet/tests/Dreport.Service.Tests/LayoutEngineTests.cs b/bindings/dotnet/tests/Dreport.Service.Tests/LayoutEngineTests.cs new file mode 100644 index 0000000..7b2e9c5 --- /dev/null +++ b/bindings/dotnet/tests/Dreport.Service.Tests/LayoutEngineTests.cs @@ -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(() => engine.RenderPdf(Template, Data)); + Assert.Throws(() => 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(() => + 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(() => + 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(() => engine.ComputeLayout("{not json", Data)); + } + + [Fact] + public void ComputeLayout_InvalidData_ThrowsInvalidData() + { + using var engine = new LayoutEngine(); + Assert.Throws(() => 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(() => 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); + } +} diff --git a/dreport-ffi/Cargo.toml b/dreport-ffi/Cargo.toml new file mode 100644 index 0000000..a72e9d8 --- /dev/null +++ b/dreport-ffi/Cargo.toml @@ -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 } diff --git a/dreport-ffi/build.rs b/dreport-ffi/build.rs new file mode 100644 index 0000000..c66fd7d --- /dev/null +++ b/dreport-ffi/build.rs @@ -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); + } + } +} diff --git a/dreport-ffi/cbindgen.toml b/dreport-ffi/cbindgen.toml new file mode 100644 index 0000000..45127bb --- /dev/null +++ b/dreport-ffi/cbindgen.toml @@ -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 diff --git a/dreport-ffi/src/lib.rs b/dreport-ffi/src/lib.rs new file mode 100644 index 0000000..0fe1a09 --- /dev/null +++ b/dreport-ffi/src/lib.rs @@ -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`, so the same engine can be cloned and +/// driven from multiple threads. +pub struct DreportHandle { + inner: Arc, +} + +// --------------------------------------------------------------------------- +// 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) -> 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> = const { RefCell::new(None) }; +} + +fn set_last_error(msg: impl Into) { + 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 +} diff --git a/dreport-ffi/tests/ffi.rs b/dreport-ffi/tests/ffi.rs new file mode 100644 index 0000000..44e1d19 --- /dev/null +++ b/dreport-ffi/tests/ffi.rs @@ -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; 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, 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" + ); +} diff --git a/dreport-service/Cargo.toml b/dreport-service/Cargo.toml new file mode 100644 index 0000000..7e3020b --- /dev/null +++ b/dreport-service/Cargo.toml @@ -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" diff --git a/backend/fonts/NotoSans-Bold.ttf b/dreport-service/assets/fonts/NotoSans-Bold.ttf similarity index 100% rename from backend/fonts/NotoSans-Bold.ttf rename to dreport-service/assets/fonts/NotoSans-Bold.ttf diff --git a/backend/fonts/NotoSans-BoldItalic.ttf b/dreport-service/assets/fonts/NotoSans-BoldItalic.ttf similarity index 100% rename from backend/fonts/NotoSans-BoldItalic.ttf rename to dreport-service/assets/fonts/NotoSans-BoldItalic.ttf diff --git a/backend/fonts/NotoSans-Italic.ttf b/dreport-service/assets/fonts/NotoSans-Italic.ttf similarity index 100% rename from backend/fonts/NotoSans-Italic.ttf rename to dreport-service/assets/fonts/NotoSans-Italic.ttf diff --git a/backend/fonts/NotoSans-Regular.ttf b/dreport-service/assets/fonts/NotoSans-Regular.ttf similarity index 100% rename from backend/fonts/NotoSans-Regular.ttf rename to dreport-service/assets/fonts/NotoSans-Regular.ttf diff --git a/backend/fonts/NotoSansMono-Regular.ttf b/dreport-service/assets/fonts/NotoSansMono-Regular.ttf similarity index 100% rename from backend/fonts/NotoSansMono-Regular.ttf rename to dreport-service/assets/fonts/NotoSansMono-Regular.ttf diff --git a/dreport-service/src/error.rs b/dreport-service/src/error.rs new file mode 100644 index 0000000..86d3f96 --- /dev/null +++ b/dreport-service/src/error.rs @@ -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 = Result; diff --git a/dreport-service/src/font_registry.rs b/dreport-service/src/font_registry.rs new file mode 100644 index 0000000..e9f1e5c --- /dev/null +++ b/dreport-service/src/font_registry.rs @@ -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>, + /// Original-case family names for display (`list_families`). + family_names: HashMap, +} + +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) -> ServiceResult { + 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 { + 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 { + let mut result = Vec::new(); + let mut loaded: std::collections::HashSet = std::collections::HashSet::new(); + + let mut to_load: Vec = 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 { + 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 { + 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, +} diff --git a/dreport-service/src/lib.rs b/dreport-service/src/lib.rs new file mode 100644 index 0000000..a581647 --- /dev/null +++ b/dreport-service/src/lib.rs @@ -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, +} + +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) -> ServiceResult { + 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>(&self, dir: P) -> ServiceResult { + 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 { + 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> { + 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 { + 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 { + 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> { + 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> { + 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 { + 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 { + self.list_font_families() + } + + fn load_font(&self, family: &str, weight: u16, italic: bool) -> Option { + let reg = self.registry.read().expect("font registry poisoned"); + reg.load_font(family, weight, italic) + } +} diff --git a/dreport-service/tests/service.rs b/dreport-service/tests/service.rs new file mode 100644 index 0000000..9293019 --- /dev/null +++ b/dreport-service/tests/service.rs @@ -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 = 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); +} diff --git a/justfile b/justfile index fe58a02..6f8aa8f 100644 --- a/justfile +++ b/justfile @@ -113,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 + + # entries for every native binary that exists on disk. + FILES="" + while IFS= read -r path; do + rel="${path#${PROJ_DIR}/}" + FILES+=" "$'\n' + done < <(find "$PROJ_DIR/runtimes" -type f \( -name '*.dylib' -o -name '*.so' -o -name '*.dll' \) 2>/dev/null | sort) + + { + echo '' + echo '' + echo ' ' + echo ' Dreport.Service' + echo " {{NUGET_VERSION}}" + echo ' dreport' + echo ' Native layout engine + PDF renderer for dreport templates. Wraps the dreport-ffi C ABI.' + echo ' pdf layout template rendering' + echo ' ' + echo ' ' + echo ' ' + echo ' ' + printf '%s' "$FILES" + echo ' ' + echo '' + } > "$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 '' + echo '' + echo ' ' + echo ' Dreport.AspNetCore' + echo " {{NUGET_VERSION}}" + echo ' dreport' + echo ' ASP.NET Core integration for Dreport.Service: DI registration plus optional /api endpoint mapping.' + echo ' pdf layout aspnetcore dreport' + echo ' ' + echo ' ' + echo " " + echo ' ' + echo ' ' + echo ' ' + echo ' ' + echo ' ' + echo ' ' + echo ' ' + echo ' ' + echo ' ' + echo ' ' + echo ' ' + echo '' + } > "$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