mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
feat: dreport-service + dreport-ffi + nuget packages
Extract orchestration (font registry + render pipeline) from the Axum backend into a standalone dreport-service crate. Backend becomes a thin HTTP adapter on top. Add dreport-ffi (cdylib) exposing the service through a stable C ABI with opaque handles, byte buffers, and thread-local error reporting. Build Dreport.Service + Dreport.AspNetCore NuGet packages under bindings/dotnet/, packing the host RID native binary via a generated nuspec. justfile recipes (nuget-publish, nuget-publish-all) build, pack, and push to the Gitea NuGet registry in one shot. Test coverage: 47 Rust + 38 C# (xUnit + WebApplicationFactory). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
14
.gitignore
vendored
14
.gitignore
vendored
@@ -9,3 +9,17 @@ dist/
|
||||
frontend/tests/visual/cross-renderer-refs/
|
||||
frontend/tests/visual/cross-renderer-diffs/
|
||||
frontend/tests/visual/test-results/
|
||||
|
||||
# .NET build artifacts
|
||||
**/bin/
|
||||
**/obj/
|
||||
|
||||
# Native runtime binaries — produced by `just nuget-build-native-*`
|
||||
# and packaged into the .nupkg. Never commit.
|
||||
bindings/dotnet/src/Dreport.Service/runtimes/
|
||||
|
||||
# Auto-generated nuspec (regenerated by justfile recipes)
|
||||
**/.generated.nuspec
|
||||
|
||||
# Generated C header (regenerated on every dreport-ffi build)
|
||||
dreport-ffi/include/
|
||||
|
||||
160
Cargo.lock
generated
160
Cargo.lock
generated
@@ -248,6 +248,24 @@ version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cbindgen"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff"
|
||||
dependencies = [
|
||||
"heck 0.4.1",
|
||||
"indexmap",
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"syn 2.0.117",
|
||||
"tempfile",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.58"
|
||||
@@ -421,12 +439,12 @@ version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"dreport-core",
|
||||
"dreport-layout",
|
||||
"dreport-service",
|
||||
"http-body-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
]
|
||||
|
||||
@@ -439,6 +457,15 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dreport-ffi"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"cbindgen",
|
||||
"dreport-service",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dreport-layout"
|
||||
version = "0.2.0"
|
||||
@@ -460,6 +487,18 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dreport-service"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"dreport-core",
|
||||
"dreport-layout",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
@@ -505,6 +544,12 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -725,6 +770,12 @@ version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -971,6 +1022,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -1287,7 +1344,7 @@ version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
"toml_edit 0.25.10+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1541,6 +1598,19 @@ version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -1681,6 +1751,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
@@ -1889,6 +1968,19 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
@@ -1963,6 +2055,27 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
@@ -1972,6 +2085,20 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.10+spec-1.1.0"
|
||||
@@ -1979,9 +2106,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
"winnow 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1990,9 +2117,15 @@ version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
"winnow 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -2328,6 +2461,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.1"
|
||||
@@ -2353,7 +2495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"heck 0.5.0",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
@@ -2364,7 +2506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"heck 0.5.0",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn 2.0.117",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
[workspace]
|
||||
members = ["core", "backend", "layout-engine"]
|
||||
members = ["core", "backend", "layout-engine", "dreport-service", "dreport-ffi"]
|
||||
resolver = "2"
|
||||
|
||||
@@ -5,12 +5,14 @@ edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
dreport-core = { path = "../core" }
|
||||
dreport-layout = { path = "../layout-engine" }
|
||||
dreport-service = { path = "../dreport-service" }
|
||||
axum = "0.8"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
http-body-util = "0.1"
|
||||
|
||||
18
backend/src/app.rs
Normal file
18
backend/src/app.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
//! Application bootstrap. Builds a fully-configured `DreportService` for the
|
||||
//! HTTP layer (and tests) to share.
|
||||
|
||||
use anyhow::Result;
|
||||
use dreport_service::DreportService;
|
||||
|
||||
/// Construct the service used by the running server. Loads embedded fonts
|
||||
/// (compile-time defaults) and any extra fonts in `DREPORT_FONTS_DIR`.
|
||||
pub fn build_service() -> Result<DreportService> {
|
||||
let svc = DreportService::new();
|
||||
if let Ok(dir) = std::env::var("DREPORT_FONTS_DIR") {
|
||||
match svc.register_fonts_directory(&dir) {
|
||||
Ok(n) => println!("DREPORT_FONTS_DIR'den {} font yüklendi: {}", n, dir),
|
||||
Err(e) => eprintln!("DREPORT_FONTS_DIR yüklenemedi ({}): {}", dir, e),
|
||||
}
|
||||
}
|
||||
Ok(svc)
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
use dreport_layout::FontData;
|
||||
use dreport_layout::font_meta::{self, FontFamilyInfo, FontVariantKey};
|
||||
use dreport_layout::font_provider::FontProvider;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Font registry — manages all available fonts from embedded defaults + external directory.
|
||||
pub struct FontRegistry {
|
||||
/// family_lower -> variant_key -> FontData
|
||||
families: HashMap<String, HashMap<FontVariantKey, FontData>>,
|
||||
/// Original-case family names
|
||||
family_names: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl FontRegistry {
|
||||
pub fn new() -> Self {
|
||||
let mut registry = Self {
|
||||
families: HashMap::new(),
|
||||
family_names: HashMap::new(),
|
||||
};
|
||||
|
||||
// Load embedded default fonts
|
||||
registry.load_embedded_defaults();
|
||||
|
||||
// Load fonts from DREPORT_FONTS_DIR if set
|
||||
if let Ok(dir) = std::env::var("DREPORT_FONTS_DIR") {
|
||||
registry.load_from_directory(&dir);
|
||||
}
|
||||
|
||||
registry
|
||||
}
|
||||
|
||||
fn load_embedded_defaults(&mut self) {
|
||||
let embedded: &[(&str, &[u8])] = &[
|
||||
(
|
||||
"NotoSans-Regular",
|
||||
include_bytes!("../fonts/NotoSans-Regular.ttf"),
|
||||
),
|
||||
(
|
||||
"NotoSans-Bold",
|
||||
include_bytes!("../fonts/NotoSans-Bold.ttf"),
|
||||
),
|
||||
(
|
||||
"NotoSans-Italic",
|
||||
include_bytes!("../fonts/NotoSans-Italic.ttf"),
|
||||
),
|
||||
(
|
||||
"NotoSans-BoldItalic",
|
||||
include_bytes!("../fonts/NotoSans-BoldItalic.ttf"),
|
||||
),
|
||||
(
|
||||
"NotoSansMono-Regular",
|
||||
include_bytes!("../fonts/NotoSansMono-Regular.ttf"),
|
||||
),
|
||||
];
|
||||
|
||||
for (_name, data) in embedded {
|
||||
self.register_font(data.to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
fn load_from_directory(&mut self, dir: &str) {
|
||||
let path = std::path::Path::new(dir);
|
||||
if !path.is_dir() {
|
||||
eprintln!("DREPORT_FONTS_DIR dizini bulunamadı: {}", dir);
|
||||
return;
|
||||
}
|
||||
|
||||
let entries = match std::fs::read_dir(path) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("DREPORT_FONTS_DIR okunamadı: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if p.extension().is_some_and(|e| e == "ttf" || e == "otf")
|
||||
&& let Ok(data) = std::fs::read(&p)
|
||||
{
|
||||
if self.register_font(data) {
|
||||
println!(" Font yüklendi: {}", p.display());
|
||||
} else {
|
||||
eprintln!(" Font parse edilemedi: {}", p.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a font from raw bytes. Returns true if successful.
|
||||
fn register_font(&mut self, data: Vec<u8>) -> bool {
|
||||
let Some(meta) = font_meta::parse_font_meta(&data) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let family_lower = meta.family.to_lowercase();
|
||||
let variant_key = meta.variant_key();
|
||||
|
||||
self.family_names
|
||||
.entry(family_lower.clone())
|
||||
.or_insert_with(|| meta.family.clone());
|
||||
|
||||
let font_data = FontData::new(meta.family, meta.weight, meta.italic, data);
|
||||
|
||||
self.families
|
||||
.entry(family_lower)
|
||||
.or_default()
|
||||
.insert(variant_key, font_data);
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Get a specific font's raw bytes
|
||||
pub fn get_font_bytes(&self, family: &str, weight: u16, italic: bool) -> Option<&[u8]> {
|
||||
let family_lower = family.to_lowercase();
|
||||
let key = FontVariantKey { weight, italic };
|
||||
self.families
|
||||
.get(&family_lower)
|
||||
.and_then(|variants| variants.get(&key))
|
||||
.map(|fd| fd.data.as_slice())
|
||||
}
|
||||
|
||||
/// Get all FontData for given family names (for passing to layout engine)
|
||||
pub fn fonts_for_families(&self, families: &[String]) -> Vec<FontData> {
|
||||
let mut result = Vec::new();
|
||||
let mut loaded = std::collections::HashSet::new();
|
||||
|
||||
// Always include default family
|
||||
let default_lower = "noto sans".to_string();
|
||||
let mut to_load: Vec<String> = vec![default_lower.clone()];
|
||||
for f in families {
|
||||
let fl = f.to_lowercase();
|
||||
if !to_load.contains(&fl) {
|
||||
to_load.push(fl);
|
||||
}
|
||||
}
|
||||
|
||||
for family_lower in &to_load {
|
||||
if loaded.contains(family_lower) {
|
||||
continue;
|
||||
}
|
||||
if let Some(variants) = self.families.get(family_lower) {
|
||||
for fd in variants.values() {
|
||||
result.push(fd.clone());
|
||||
}
|
||||
loaded.insert(family_lower.clone());
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl FontProvider for FontRegistry {
|
||||
fn list_families(&self) -> Vec<FontFamilyInfo> {
|
||||
self.families
|
||||
.iter()
|
||||
.map(|(family_lower, variants)| {
|
||||
let family = self
|
||||
.family_names
|
||||
.get(family_lower)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| family_lower.clone());
|
||||
FontFamilyInfo {
|
||||
family,
|
||||
variants: variants.keys().cloned().collect(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn load_font(&self, family: &str, weight: u16, italic: bool) -> Option<FontData> {
|
||||
let family_lower = family.to_lowercase();
|
||||
let key = FontVariantKey { weight, italic };
|
||||
self.families
|
||||
.get(&family_lower)
|
||||
.and_then(|variants| variants.get(&key))
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
28
backend/src/lib.rs
Normal file
28
backend/src/lib.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! dreport-backend
|
||||
//!
|
||||
//! Thin Axum HTTP adapter on top of `dreport-service`. The HTTP layer holds
|
||||
//! no business logic — it only translates JSON requests into service calls
|
||||
//! and maps `ServiceError` into HTTP status codes.
|
||||
|
||||
pub mod app;
|
||||
mod routes;
|
||||
|
||||
use axum::Router;
|
||||
use dreport_service::DreportService;
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
pub use routes::AppState;
|
||||
|
||||
/// Build the full Axum `Router` with CORS, state and all `/api/*` endpoints.
|
||||
pub fn build_router(service: Arc<DreportService>) -> Router {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
Router::new()
|
||||
.merge(routes::router())
|
||||
.layer(cors)
|
||||
.with_state(service)
|
||||
}
|
||||
@@ -1,36 +1,19 @@
|
||||
use axum::{Router, serve};
|
||||
use dreport_backend::{app, build_router};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
mod font_registry;
|
||||
mod models;
|
||||
mod routes;
|
||||
|
||||
use font_registry::FontRegistry;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
println!("Font registry başlatılıyor...");
|
||||
let registry = Arc::new(FontRegistry::new());
|
||||
|
||||
let family_count =
|
||||
dreport_layout::font_provider::FontProvider::list_families(registry.as_ref()).len();
|
||||
println!("Font registry hazır ({} font ailesi)", family_count);
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
let app = Router::new()
|
||||
.merge(routes::router())
|
||||
.layer(cors)
|
||||
.with_state(registry);
|
||||
let service = Arc::new(app::build_service()?);
|
||||
println!(
|
||||
"dreport-service hazır ({} font ailesi)",
|
||||
service.font_family_count()
|
||||
);
|
||||
|
||||
let app = build_router(service);
|
||||
let listener = TcpListener::bind("0.0.0.0:3001").await?;
|
||||
println!("dreport backend listening on http://localhost:3001");
|
||||
serve(listener, app).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub use dreport_core::models::*;
|
||||
@@ -5,11 +5,9 @@ use axum::{
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
};
|
||||
use dreport_layout::font_provider::FontProvider;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::font_registry::FontRegistry;
|
||||
use super::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct FontFamilyResponse {
|
||||
@@ -24,9 +22,9 @@ struct FontVariantResponse {
|
||||
}
|
||||
|
||||
/// GET /api/fonts — list all available font families
|
||||
async fn list_fonts(State(registry): State<Arc<FontRegistry>>) -> Json<Vec<FontFamilyResponse>> {
|
||||
let families = registry.list_families();
|
||||
let response: Vec<FontFamilyResponse> = families
|
||||
async fn list_fonts(State(service): State<AppState>) -> Json<Vec<FontFamilyResponse>> {
|
||||
let response: Vec<FontFamilyResponse> = service
|
||||
.list_font_families()
|
||||
.into_iter()
|
||||
.map(|f| FontFamilyResponse {
|
||||
family: f.family,
|
||||
@@ -45,16 +43,16 @@ async fn list_fonts(State(registry): State<Arc<FontRegistry>>) -> Json<Vec<FontF
|
||||
|
||||
/// GET /api/fonts/:family/:weight/:italic — serve font binary
|
||||
async fn get_font(
|
||||
State(registry): State<Arc<FontRegistry>>,
|
||||
State(service): State<AppState>,
|
||||
Path((family, weight, italic)): Path<(String, u16, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let is_italic = italic == "true" || italic == "1";
|
||||
|
||||
match registry.get_font_bytes(&family, weight, is_italic) {
|
||||
match service.get_font_bytes(&family, weight, is_italic) {
|
||||
Some(data) => (
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "font/ttf")],
|
||||
data.to_vec(),
|
||||
data,
|
||||
)
|
||||
.into_response(),
|
||||
None => (
|
||||
@@ -68,7 +66,7 @@ async fn get_font(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router() -> Router<Arc<FontRegistry>> {
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/fonts", get(list_fonts))
|
||||
.route("/api/fonts/{family}/{weight}/{italic}", get(get_font))
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use axum::{Json, Router, routing::get};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::font_registry::FontRegistry;
|
||||
use super::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HealthResponse {
|
||||
@@ -17,6 +16,6 @@ async fn health() -> Json<HealthResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn router() -> Router<Arc<FontRegistry>> {
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/api/health", get(health))
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ mod health;
|
||||
mod render;
|
||||
|
||||
use axum::Router;
|
||||
use dreport_service::DreportService;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::font_registry::FontRegistry;
|
||||
pub type AppState = Arc<DreportService>;
|
||||
|
||||
pub fn router() -> Router<Arc<FontRegistry>> {
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.merge(health::router())
|
||||
.merge(render::router())
|
||||
|
||||
@@ -5,11 +5,10 @@ use axum::{
|
||||
response::IntoResponse,
|
||||
routing::post,
|
||||
};
|
||||
use dreport_service::{ServiceError, Template};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::font_registry::FontRegistry;
|
||||
use crate::models::Template;
|
||||
use super::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RenderRequest {
|
||||
@@ -19,18 +18,13 @@ pub struct RenderRequest {
|
||||
|
||||
/// POST /api/render — Template + Data → PDF
|
||||
pub async fn render(
|
||||
State(registry): State<Arc<FontRegistry>>,
|
||||
State(service): State<AppState>,
|
||||
Json(payload): Json<RenderRequest>,
|
||||
) -> impl IntoResponse {
|
||||
// CPU-intensive layout + PDF render'ı blocking thread'de çalıştır
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
// Template'in fonts alanına göre sadece gerekli fontları yükle
|
||||
let fonts = registry.fonts_for_families(&payload.template.fonts);
|
||||
let layout = dreport_layout::compute_layout(&payload.template, &payload.data, &fonts)
|
||||
.map_err(|e| format!("Layout error: {}", e))?;
|
||||
dreport_layout::pdf_render::render_pdf(&layout, &fonts)
|
||||
})
|
||||
.await;
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || service.render_pdf(&payload.template, &payload.data))
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(pdf_bytes)) => (
|
||||
@@ -39,11 +33,7 @@ pub async fn render(
|
||||
pdf_bytes,
|
||||
)
|
||||
.into_response(),
|
||||
Ok(Err(err)) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("PDF render hatası: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(Err(err)) => (status_for(&err), err.to_string()).into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Task hatası: {}", err),
|
||||
@@ -52,6 +42,15 @@ pub async fn render(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router() -> Router<Arc<FontRegistry>> {
|
||||
fn status_for(err: &ServiceError) -> StatusCode {
|
||||
match err {
|
||||
ServiceError::InvalidTemplateJson(_) | ServiceError::InvalidDataJson(_) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/api/render", post(render))
|
||||
}
|
||||
|
||||
179
backend/tests/api.rs
Normal file
179
backend/tests/api.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
//! End-to-end HTTP tests for the backend. Drives the real `Router` via
|
||||
//! `tower::ServiceExt::oneshot`, so anything covered here protects the
|
||||
//! contract that the editor and external clients rely on.
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use dreport_backend::build_router;
|
||||
use dreport_service::DreportService;
|
||||
use http_body_util::BodyExt;
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
const TEMPLATE: &str = r#"{
|
||||
"id": "test",
|
||||
"name": "Test",
|
||||
"page": { "width": 210, "height": 297 },
|
||||
"fonts": ["Noto Sans"],
|
||||
"root": {
|
||||
"id": "root",
|
||||
"type": "container",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"direction": "column",
|
||||
"gap": 5,
|
||||
"padding": { "top": 15, "right": 15, "bottom": 15, "left": 15 },
|
||||
"align": "stretch",
|
||||
"justify": "start",
|
||||
"style": {},
|
||||
"children": [
|
||||
{
|
||||
"id": "title",
|
||||
"type": "static_text",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"style": { "fontSize": 14, "fontWeight": "bold" },
|
||||
"content": "Hello"
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
|
||||
fn router() -> axum::Router {
|
||||
build_router(Arc::new(DreportService::new()))
|
||||
}
|
||||
|
||||
async fn body_bytes(resp: axum::response::Response) -> Vec<u8> {
|
||||
resp.into_body().collect().await.unwrap().to_bytes().to_vec()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_returns_ok() {
|
||||
let resp = router()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_bytes(resp).await;
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["status"], "ok");
|
||||
assert!(json["version"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_fonts_includes_noto_sans() {
|
||||
let resp = router()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/fonts")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_bytes(resp).await;
|
||||
let families: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
|
||||
assert!(
|
||||
families
|
||||
.iter()
|
||||
.any(|f| f["family"].as_str().unwrap_or("").to_lowercase().contains("noto")),
|
||||
"Noto Sans family should be listed: {:?}",
|
||||
families
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_font_bytes_for_known_variant() {
|
||||
let resp = router()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/fonts/Noto%20Sans/400/false")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.map(|v| v.to_str().unwrap()),
|
||||
Some("font/ttf")
|
||||
);
|
||||
let body = body_bytes(resp).await;
|
||||
assert!(body.len() > 1000, "TTF body should be substantial");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_font_unknown_returns_404() {
|
||||
let resp = router()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/fonts/DoesNotExist/400/false")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_returns_pdf_bytes() {
|
||||
let payload = serde_json::json!({
|
||||
"template": serde_json::from_str::<serde_json::Value>(TEMPLATE).unwrap(),
|
||||
"data": {}
|
||||
});
|
||||
let resp = router()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/render")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&payload).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.map(|v| v.to_str().unwrap()),
|
||||
Some("application/pdf")
|
||||
);
|
||||
let body = body_bytes(resp).await;
|
||||
assert!(body.starts_with(b"%PDF-"), "PDF magic header missing");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_with_invalid_template_field_returns_4xx_or_500() {
|
||||
// Axum's Json extractor rejects malformed payloads with 4xx; a structurally
|
||||
// valid but semantically invalid template would surface as 500. Either is
|
||||
// acceptable, but the server must not panic and must produce a body.
|
||||
let payload = serde_json::json!({ "template": "not an object", "data": {} });
|
||||
let resp = router()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/render")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&payload).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
resp.status().is_client_error() || resp.status().is_server_error(),
|
||||
"got unexpected status {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
10
bindings/dotnet/Dreport.Service.slnx
Normal file
10
bindings/dotnet/Dreport.Service.slnx
Normal file
@@ -0,0 +1,10 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Dreport.AspNetCore/Dreport.AspNetCore.csproj" />
|
||||
<Project Path="src/Dreport.Service/Dreport.Service.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Dreport.AspNetCore.Tests/Dreport.AspNetCore.Tests.csproj" />
|
||||
<Project Path="tests/Dreport.Service.Tests/Dreport.Service.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<ProjectReference Include="..\Dreport.Service\Dreport.Service.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Text.Json;
|
||||
using Dreport.Service;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Dreport.AspNetCore;
|
||||
|
||||
/// <summary>
|
||||
/// Optional sugar for hosts that just want the editor's stock HTTP API.
|
||||
/// Mirrors the original Rust/Axum backend endpoint contract 1:1, so the Vue
|
||||
/// editor frontend does not need any code changes.
|
||||
///
|
||||
/// Skip this entirely if you prefer to wire endpoints by hand — the
|
||||
/// <see cref="LayoutEngine"/> registered by <c>AddDreport()</c> is fully
|
||||
/// usable from your own controllers / minimal API handlers.
|
||||
/// </summary>
|
||||
public static class DreportEndpointRouteBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Mount the dreport HTTP API under the given prefix (defaults to <c>/api</c>).
|
||||
/// Routes added:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>GET {prefix}/health</c></description></item>
|
||||
/// <item><description><c>POST {prefix}/render</c> — body <c>{ template, data }</c> → <c>application/pdf</c></description></item>
|
||||
/// <item><description><c>POST {prefix}/layout</c> — body <c>{ template, data }</c> → LayoutResult JSON</description></item>
|
||||
/// <item><description><c>GET {prefix}/fonts</c> — registered families</description></item>
|
||||
/// <item><description><c>GET {prefix}/fonts/{family}/{weight}/{italic}</c> — raw font bytes</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapDreportEndpoints(
|
||||
this IEndpointRouteBuilder builder,
|
||||
string prefix = "/api")
|
||||
{
|
||||
var p = prefix.TrimEnd('/');
|
||||
|
||||
builder.MapGet($"{p}/health", () => Results.Json(new { status = "ok", version = typeof(LayoutEngine).Assembly.GetName().Version?.ToString() ?? "unknown" }));
|
||||
|
||||
builder.MapPost($"{p}/render", async (HttpContext ctx, LayoutEngine engine) =>
|
||||
{
|
||||
var (template, data) = await ReadBodyAsync(ctx);
|
||||
try
|
||||
{
|
||||
var pdf = await Task.Run(() => engine.RenderPdf(template, data));
|
||||
return Results.File(pdf, "application/pdf");
|
||||
}
|
||||
catch (DreportException ex)
|
||||
{
|
||||
return MapError(ex);
|
||||
}
|
||||
});
|
||||
|
||||
builder.MapPost($"{p}/layout", async (HttpContext ctx, LayoutEngine engine) =>
|
||||
{
|
||||
var (template, data) = await ReadBodyAsync(ctx);
|
||||
try
|
||||
{
|
||||
var layoutJson = await Task.Run(() => engine.ComputeLayout(template, data));
|
||||
return Results.Content(layoutJson, "application/json");
|
||||
}
|
||||
catch (DreportException ex)
|
||||
{
|
||||
return MapError(ex);
|
||||
}
|
||||
});
|
||||
|
||||
builder.MapGet($"{p}/fonts", (LayoutEngine engine) =>
|
||||
Results.Json(engine.ListFontFamilies().Select(f => new
|
||||
{
|
||||
family = f.Family,
|
||||
variants = f.Variants.Select(v => new { weight = v.Weight, italic = v.Italic }),
|
||||
})));
|
||||
|
||||
builder.MapGet($"{p}/fonts/{{family}}/{{weight}}/{{italic}}",
|
||||
(string family, ushort weight, string italic, LayoutEngine engine) =>
|
||||
{
|
||||
var isItalic = italic.Equals("true", StringComparison.OrdinalIgnoreCase) || italic == "1";
|
||||
var bytes = engine.GetFontBytes(family, weight, isItalic);
|
||||
return bytes is null
|
||||
? Results.NotFound($"Font bulunamadı: {family} weight={weight} italic={isItalic}")
|
||||
: Results.File(bytes, "font/ttf");
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static async Task<(string Template, string Data)> ReadBodyAsync(HttpContext ctx)
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(ctx.Request.Body);
|
||||
var root = doc.RootElement;
|
||||
var template = root.GetProperty("template").GetRawText();
|
||||
var data = root.TryGetProperty("data", out var d) ? d.GetRawText() : "{}";
|
||||
return (template, data);
|
||||
}
|
||||
|
||||
private static IResult MapError(DreportException ex) => ex switch
|
||||
{
|
||||
InvalidTemplateException or Dreport.Service.InvalidDataException => Results.BadRequest(ex.Message),
|
||||
_ => Results.Problem(ex.Message, statusCode: StatusCodes.Status500InternalServerError),
|
||||
};
|
||||
}
|
||||
20
bindings/dotnet/src/Dreport.AspNetCore/DreportOptions.cs
Normal file
20
bindings/dotnet/src/Dreport.AspNetCore/DreportOptions.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace Dreport.AspNetCore;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the dreport ASP.NET Core integration.
|
||||
/// </summary>
|
||||
public sealed class DreportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional directory whose <c>.ttf</c> / <c>.otf</c> files are loaded into the
|
||||
/// engine on startup, in addition to the embedded default fonts.
|
||||
/// </summary>
|
||||
public string? FontsDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c> (default), embedded default fonts (Noto Sans, Noto Sans Mono)
|
||||
/// are registered. Set to <c>false</c> to start with an empty registry — useful
|
||||
/// when the host wants to provide a fully custom font set.
|
||||
/// </summary>
|
||||
public bool LoadEmbeddedFonts { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Dreport.Service;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Dreport.AspNetCore;
|
||||
|
||||
/// <summary>
|
||||
/// DI registration for <see cref="LayoutEngine"/>. Registers the engine as a
|
||||
/// process-wide singleton so consumers can inject it into controllers,
|
||||
/// endpoint handlers, background services, or test fixtures.
|
||||
/// </summary>
|
||||
public static class DreportServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a singleton <see cref="LayoutEngine"/>. Once added, you can:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Inject <see cref="LayoutEngine"/> into your own MVC controllers, minimal API handlers, or background services.</description></item>
|
||||
/// <item><description>Call <c>app.MapDreportEndpoints()</c> to also mount the ready-made HTTP API the editor talks to.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static IServiceCollection AddDreport(
|
||||
this IServiceCollection services,
|
||||
Action<DreportOptions>? configure = null)
|
||||
{
|
||||
var options = new DreportOptions();
|
||||
configure?.Invoke(options);
|
||||
|
||||
services.AddSingleton(options);
|
||||
services.AddSingleton(_ => CreateEngine(options));
|
||||
return services;
|
||||
}
|
||||
|
||||
private static LayoutEngine CreateEngine(DreportOptions options)
|
||||
{
|
||||
var engine = options.LoadEmbeddedFonts ? new LayoutEngine() : LayoutEngine.CreateEmpty();
|
||||
if (!string.IsNullOrEmpty(options.FontsDirectory) && Directory.Exists(options.FontsDirectory))
|
||||
{
|
||||
engine.RegisterFontsDirectory(options.FontsDirectory);
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
38
bindings/dotnet/src/Dreport.Service/Dreport.Service.csproj
Normal file
38
bindings/dotnet/src/Dreport.Service/Dreport.Service.csproj
Normal file
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Dreport.Service.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
<!-- Packaging is driven from a hand-rolled .nuspec next to this csproj
|
||||
(see pack.nuspec). MSBuild's pack pipeline silently drops the runtimes/
|
||||
folder under several scenarios we hit during development; hand-feeding
|
||||
nuget pack a nuspec sidesteps the issue and is what the just recipe uses. -->
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Local-dev consumer copy: drop the host RID native binary next to the
|
||||
referencing assembly so xUnit / dotnet run can dlopen it without going
|
||||
through a published NuGet package. -->
|
||||
<PropertyGroup>
|
||||
<_DrHostExt Condition="$([MSBuild]::IsOSPlatform('OSX'))">.dylib</_DrHostExt>
|
||||
<_DrHostExt Condition="$([MSBuild]::IsOSPlatform('Linux'))">.so</_DrHostExt>
|
||||
<_DrHostExt Condition="$([MSBuild]::IsOSPlatform('Windows'))">.dll</_DrHostExt>
|
||||
<_DrHostPrefix Condition="!$([MSBuild]::IsOSPlatform('Windows'))">lib</_DrHostPrefix>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="$(MSBuildThisFileDirectory)runtimes/**/native/$(_DrHostPrefix)dreport_ffi$(_DrHostExt)"
|
||||
Link="$(_DrHostPrefix)dreport_ffi$(_DrHostExt)"
|
||||
CopyToOutputDirectory="PreserveNewest"
|
||||
Visible="false"
|
||||
Pack="false" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
67
bindings/dotnet/src/Dreport.Service/Exceptions.cs
Normal file
67
bindings/dotnet/src/Dreport.Service/Exceptions.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
namespace Dreport.Service;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when the underlying dreport service returns an error. The numeric
|
||||
/// <see cref="Code"/> mirrors the FFI return code (negative values).
|
||||
/// </summary>
|
||||
public class DreportException : Exception
|
||||
{
|
||||
public int Code { get; }
|
||||
|
||||
public DreportException(int code, string message) : base(message)
|
||||
{
|
||||
Code = code;
|
||||
}
|
||||
|
||||
internal static DreportException FromCode(int code, string fallbackMessage)
|
||||
{
|
||||
var nativeMessage = Native.GetLastError();
|
||||
var message = string.IsNullOrEmpty(nativeMessage) ? fallbackMessage : nativeMessage;
|
||||
return code switch
|
||||
{
|
||||
Native.ERR_INVALID_TEMPLATE_JSON => new InvalidTemplateException(message),
|
||||
Native.ERR_INVALID_DATA_JSON => new InvalidDataException(message),
|
||||
Native.ERR_FONT_PARSE_FAILED => new FontParseException(message),
|
||||
Native.ERR_FONT_DIR_NOT_FOUND => new FontDirectoryNotFoundException(message),
|
||||
Native.ERR_FONT_DIR_READ => new FontDirectoryReadException(message),
|
||||
Native.ERR_LAYOUT_FAILED => new LayoutException(message),
|
||||
Native.ERR_PDF_FAILED => new PdfRenderException(message),
|
||||
_ => new DreportException(code, message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class InvalidTemplateException : DreportException
|
||||
{
|
||||
public InvalidTemplateException(string message) : base(Native.ERR_INVALID_TEMPLATE_JSON, message) { }
|
||||
}
|
||||
|
||||
public sealed class InvalidDataException : DreportException
|
||||
{
|
||||
public InvalidDataException(string message) : base(Native.ERR_INVALID_DATA_JSON, message) { }
|
||||
}
|
||||
|
||||
public sealed class FontParseException : DreportException
|
||||
{
|
||||
public FontParseException(string message) : base(Native.ERR_FONT_PARSE_FAILED, message) { }
|
||||
}
|
||||
|
||||
public sealed class FontDirectoryNotFoundException : DreportException
|
||||
{
|
||||
public FontDirectoryNotFoundException(string message) : base(Native.ERR_FONT_DIR_NOT_FOUND, message) { }
|
||||
}
|
||||
|
||||
public sealed class FontDirectoryReadException : DreportException
|
||||
{
|
||||
public FontDirectoryReadException(string message) : base(Native.ERR_FONT_DIR_READ, message) { }
|
||||
}
|
||||
|
||||
public sealed class LayoutException : DreportException
|
||||
{
|
||||
public LayoutException(string message) : base(Native.ERR_LAYOUT_FAILED, message) { }
|
||||
}
|
||||
|
||||
public sealed class PdfRenderException : DreportException
|
||||
{
|
||||
public PdfRenderException(string message) : base(Native.ERR_PDF_FAILED, message) { }
|
||||
}
|
||||
13
bindings/dotnet/src/Dreport.Service/FontFamily.cs
Normal file
13
bindings/dotnet/src/Dreport.Service/FontFamily.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Dreport.Service;
|
||||
|
||||
/// <summary>One font family with its registered variants.</summary>
|
||||
public sealed record FontFamily(
|
||||
[property: JsonPropertyName("family")] string Family,
|
||||
[property: JsonPropertyName("variants")] IReadOnlyList<FontVariant> Variants);
|
||||
|
||||
/// <summary>One weight/italic combination within a family.</summary>
|
||||
public sealed record FontVariant(
|
||||
[property: JsonPropertyName("weight")] ushort Weight,
|
||||
[property: JsonPropertyName("italic")] bool Italic);
|
||||
220
bindings/dotnet/src/Dreport.Service/LayoutEngine.cs
Normal file
220
bindings/dotnet/src/Dreport.Service/LayoutEngine.cs
Normal file
@@ -0,0 +1,220 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Dreport.Service;
|
||||
|
||||
/// <summary>
|
||||
/// Managed wrapper around a single dreport native engine handle.
|
||||
///
|
||||
/// Thread-safe: every operation goes through the underlying Rust service which
|
||||
/// uses internal locking. You can keep one process-wide instance and call
|
||||
/// concurrent <see cref="RenderPdf"/> from any number of threads.
|
||||
/// </summary>
|
||||
public sealed class LayoutEngine : IDisposable
|
||||
{
|
||||
private IntPtr _handle;
|
||||
private readonly object _disposeLock = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Create an engine with the embedded default fonts loaded.</summary>
|
||||
public LayoutEngine() : this(Native.dreport_new())
|
||||
{
|
||||
}
|
||||
|
||||
private LayoutEngine(IntPtr handle)
|
||||
{
|
||||
if (handle == IntPtr.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("dreport_new returned a null handle");
|
||||
}
|
||||
_handle = handle;
|
||||
}
|
||||
|
||||
/// <summary>Create an engine with no fonts pre-loaded.</summary>
|
||||
public static LayoutEngine CreateEmpty() => new(Native.dreport_new_empty());
|
||||
|
||||
/// <summary>Native crate version, e.g. "0.2.0".</summary>
|
||||
public static string NativeVersion
|
||||
{
|
||||
get
|
||||
{
|
||||
var ptr = Native.dreport_version();
|
||||
return ptr == IntPtr.Zero ? string.Empty : System.Runtime.InteropServices.Marshal.PtrToStringAnsi(ptr) ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Font registry
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Number of distinct font families currently registered.</summary>
|
||||
public int FontFamilyCount
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
var count = Native.dreport_font_family_count(_handle);
|
||||
if (count < 0)
|
||||
{
|
||||
throw DreportException.FromCode((int)count, "dreport_font_family_count failed");
|
||||
}
|
||||
return (int)count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Register a font from raw TTF/OTF bytes.</summary>
|
||||
public unsafe void RegisterFont(ReadOnlySpan<byte> data)
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
if (data.IsEmpty)
|
||||
{
|
||||
throw new ArgumentException("font bytes empty", nameof(data));
|
||||
}
|
||||
fixed (byte* ptr = data)
|
||||
{
|
||||
var rc = Native.dreport_register_font(_handle, ptr, (nuint)data.Length);
|
||||
if (rc != Native.OK)
|
||||
{
|
||||
throw DreportException.FromCode(rc, "dreport_register_font failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Register every <c>.ttf</c>/<c>.otf</c> file in <paramref name="directory"/>.</summary>
|
||||
/// <returns>Number of fonts that registered successfully.</returns>
|
||||
public unsafe int RegisterFontsDirectory(string directory)
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
ArgumentException.ThrowIfNullOrEmpty(directory);
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(directory);
|
||||
nuint count;
|
||||
int rc;
|
||||
fixed (byte* ptr = bytes)
|
||||
{
|
||||
rc = Native.dreport_register_fonts_dir(_handle, ptr, (nuint)bytes.Length, out count);
|
||||
}
|
||||
if (rc != Native.OK)
|
||||
{
|
||||
throw DreportException.FromCode(rc, $"dreport_register_fonts_dir failed for '{directory}'");
|
||||
}
|
||||
return (int)count;
|
||||
}
|
||||
|
||||
/// <summary>Get raw bytes for a specific font variant. Returns null when unknown.</summary>
|
||||
public unsafe byte[]? GetFontBytes(string family, ushort weight, bool italic)
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
ArgumentException.ThrowIfNullOrEmpty(family);
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(family);
|
||||
Native.DreportBuffer buffer;
|
||||
int rc;
|
||||
fixed (byte* ptr = bytes)
|
||||
{
|
||||
rc = Native.dreport_get_font_bytes(_handle, ptr, (nuint)bytes.Length, weight, italic, out buffer);
|
||||
}
|
||||
if (rc != Native.OK)
|
||||
{
|
||||
throw DreportException.FromCode(rc, "dreport_get_font_bytes failed");
|
||||
}
|
||||
var data = Native.ConsumeBuffer(buffer);
|
||||
return data.Length == 0 ? null : data;
|
||||
}
|
||||
|
||||
/// <summary>List every registered font family with its variants.</summary>
|
||||
public IReadOnlyList<FontFamily> ListFontFamilies()
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
var rc = Native.dreport_list_fonts_json(_handle, out var buffer);
|
||||
if (rc != Native.OK)
|
||||
{
|
||||
throw DreportException.FromCode(rc, "dreport_list_fonts_json failed");
|
||||
}
|
||||
var json = Native.ConsumeBuffer(buffer);
|
||||
if (json.Length == 0)
|
||||
{
|
||||
return Array.Empty<FontFamily>();
|
||||
}
|
||||
var families = JsonSerializer.Deserialize<List<FontFamily>>(json);
|
||||
return families ?? new List<FontFamily>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Render pipeline
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Compute layout from JSON inputs. Returns the LayoutResult JSON string.</summary>
|
||||
public unsafe string ComputeLayout(string templateJson, string dataJson)
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
ArgumentException.ThrowIfNullOrEmpty(templateJson);
|
||||
ArgumentNullException.ThrowIfNull(dataJson);
|
||||
|
||||
var tplBytes = Encoding.UTF8.GetBytes(templateJson);
|
||||
var dataBytes = Encoding.UTF8.GetBytes(dataJson);
|
||||
Native.DreportBuffer buffer;
|
||||
int rc;
|
||||
fixed (byte* tplPtr = tplBytes)
|
||||
fixed (byte* dataPtr = dataBytes)
|
||||
{
|
||||
rc = Native.dreport_compute_layout(
|
||||
_handle, tplPtr, (nuint)tplBytes.Length, dataPtr, (nuint)dataBytes.Length, out buffer);
|
||||
}
|
||||
if (rc != Native.OK)
|
||||
{
|
||||
throw DreportException.FromCode(rc, "dreport_compute_layout failed");
|
||||
}
|
||||
return Encoding.UTF8.GetString(Native.ConsumeBuffer(buffer));
|
||||
}
|
||||
|
||||
/// <summary>Render a PDF document. Returns the raw PDF bytes.</summary>
|
||||
public unsafe byte[] RenderPdf(string templateJson, string dataJson)
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
ArgumentException.ThrowIfNullOrEmpty(templateJson);
|
||||
ArgumentNullException.ThrowIfNull(dataJson);
|
||||
|
||||
var tplBytes = Encoding.UTF8.GetBytes(templateJson);
|
||||
var dataBytes = Encoding.UTF8.GetBytes(dataJson);
|
||||
Native.DreportBuffer buffer;
|
||||
int rc;
|
||||
fixed (byte* tplPtr = tplBytes)
|
||||
fixed (byte* dataPtr = dataBytes)
|
||||
{
|
||||
rc = Native.dreport_render_pdf(
|
||||
_handle, tplPtr, (nuint)tplBytes.Length, dataPtr, (nuint)dataBytes.Length, out buffer);
|
||||
}
|
||||
if (rc != Native.OK)
|
||||
{
|
||||
throw DreportException.FromCode(rc, "dreport_render_pdf failed");
|
||||
}
|
||||
return Native.ConsumeBuffer(buffer);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Disposal
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_disposeLock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
if (_handle != IntPtr.Zero)
|
||||
{
|
||||
Native.dreport_free(_handle);
|
||||
_handle = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureNotDisposed()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(LayoutEngine));
|
||||
}
|
||||
}
|
||||
}
|
||||
145
bindings/dotnet/src/Dreport.Service/Native.cs
Normal file
145
bindings/dotnet/src/Dreport.Service/Native.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
// P/Invoke surface for libdreport_ffi. Mirrors dreport-ffi/include/dreport.h
|
||||
// 1:1. Higher-level wrappers live in LayoutEngine.cs.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Dreport.Service;
|
||||
|
||||
internal static class Native
|
||||
{
|
||||
// The shared library is named libdreport_ffi.{dylib,so} or dreport_ffi.dll.
|
||||
// .NET's runtime resolves it via the runtimes/<rid>/native/ pattern when the
|
||||
// package is consumed; for local development the file lives next to the test
|
||||
// assembly under bin/<config>/<tfm>/runtimes/<rid>/native/.
|
||||
internal const string Lib = "dreport_ffi";
|
||||
|
||||
// ----- Return codes (mirror dreport_ffi::error_code) -------------------
|
||||
|
||||
public const int OK = 0;
|
||||
public const int NULL_HANDLE = -100;
|
||||
public const int NULL_POINTER = -101;
|
||||
public const int INVALID_UTF8 = -102;
|
||||
public const int PANIC = -103;
|
||||
|
||||
// Service-level error codes are returned as the negation of
|
||||
// ServiceError::code(), e.g. FontParseFailed (3) → -3.
|
||||
public const int ERR_INVALID_TEMPLATE_JSON = -1;
|
||||
public const int ERR_INVALID_DATA_JSON = -2;
|
||||
public const int ERR_FONT_PARSE_FAILED = -3;
|
||||
public const int ERR_FONT_DIR_NOT_FOUND = -4;
|
||||
public const int ERR_FONT_DIR_READ = -5;
|
||||
public const int ERR_LAYOUT_FAILED = -6;
|
||||
public const int ERR_PDF_FAILED = -7;
|
||||
public const int ERR_SERIALIZATION_FAILED = -8;
|
||||
|
||||
// ----- ByteBuffer ------------------------------------------------------
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct DreportBuffer
|
||||
{
|
||||
public IntPtr Data;
|
||||
public nuint Len;
|
||||
public nuint Cap;
|
||||
|
||||
public static DreportBuffer Empty => default;
|
||||
}
|
||||
|
||||
// ----- Lifecycle -------------------------------------------------------
|
||||
|
||||
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern IntPtr dreport_new();
|
||||
|
||||
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern IntPtr dreport_new_empty();
|
||||
|
||||
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void dreport_free(IntPtr handle);
|
||||
|
||||
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void dreport_buffer_free(DreportBuffer buffer);
|
||||
|
||||
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern IntPtr dreport_version();
|
||||
|
||||
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern int dreport_last_error(out DreportBuffer buffer);
|
||||
|
||||
// ----- Font registry ---------------------------------------------------
|
||||
|
||||
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern unsafe int dreport_register_font(IntPtr handle, byte* data, nuint len);
|
||||
|
||||
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern unsafe int dreport_register_fonts_dir(
|
||||
IntPtr handle,
|
||||
byte* path,
|
||||
nuint pathLen,
|
||||
out nuint outCount);
|
||||
|
||||
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern int dreport_list_fonts_json(IntPtr handle, out DreportBuffer outBuffer);
|
||||
|
||||
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern unsafe int dreport_get_font_bytes(
|
||||
IntPtr handle,
|
||||
byte* family,
|
||||
nuint familyLen,
|
||||
ushort weight,
|
||||
[MarshalAs(UnmanagedType.U1)] bool italic,
|
||||
out DreportBuffer outBuffer);
|
||||
|
||||
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern nint dreport_font_family_count(IntPtr handle);
|
||||
|
||||
// ----- Render pipeline -------------------------------------------------
|
||||
|
||||
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern unsafe int dreport_compute_layout(
|
||||
IntPtr handle,
|
||||
byte* template_,
|
||||
nuint templateLen,
|
||||
byte* data,
|
||||
nuint dataLen,
|
||||
out DreportBuffer outBuffer);
|
||||
|
||||
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern unsafe int dreport_render_pdf(
|
||||
IntPtr handle,
|
||||
byte* template_,
|
||||
nuint templateLen,
|
||||
byte* data,
|
||||
nuint dataLen,
|
||||
out DreportBuffer outBuffer);
|
||||
|
||||
// ----- Helpers ---------------------------------------------------------
|
||||
|
||||
/// <summary>Copy a native buffer into a managed byte[] and free the native side.</summary>
|
||||
public static byte[] ConsumeBuffer(DreportBuffer buffer)
|
||||
{
|
||||
if (buffer.Data == IntPtr.Zero || buffer.Len == 0)
|
||||
{
|
||||
// Still free the buffer in case cap > 0 (defensive — current FFI never returns this).
|
||||
if (buffer.Cap > 0)
|
||||
{
|
||||
dreport_buffer_free(buffer);
|
||||
}
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
var bytes = new byte[buffer.Len];
|
||||
Marshal.Copy(buffer.Data, bytes, 0, (int)buffer.Len);
|
||||
dreport_buffer_free(buffer);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>Read the most recent FFI error message for the current thread.</summary>
|
||||
public static string GetLastError()
|
||||
{
|
||||
if (dreport_last_error(out var buffer) != OK)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
var bytes = ConsumeBuffer(buffer);
|
||||
return bytes.Length == 0 ? string.Empty : System.Text.Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Dreport.AspNetCore\Dreport.AspNetCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
173
bindings/dotnet/tests/Dreport.AspNetCore.Tests/EndpointTests.cs
Normal file
173
bindings/dotnet/tests/Dreport.AspNetCore.Tests/EndpointTests.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Dreport.AspNetCore;
|
||||
using Dreport.Service;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Xunit;
|
||||
|
||||
namespace Dreport.AspNetCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Spins up an in-memory ASP.NET Core host for each test using TestServer
|
||||
/// directly, so we don't need a Program.cs entry point. Verifies the
|
||||
/// stock /api endpoints behave the same as the original Axum backend.
|
||||
/// </summary>
|
||||
public class EndpointTests
|
||||
{
|
||||
private const string Template = """
|
||||
{
|
||||
"id": "aspnet-test",
|
||||
"name": "AspNet Test",
|
||||
"page": { "width": 210, "height": 297 },
|
||||
"fonts": ["Noto Sans"],
|
||||
"root": {
|
||||
"id": "root",
|
||||
"type": "container",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"direction": "column",
|
||||
"gap": 5,
|
||||
"padding": { "top": 15, "right": 15, "bottom": 15, "left": 15 },
|
||||
"align": "stretch",
|
||||
"justify": "start",
|
||||
"style": {},
|
||||
"children": [
|
||||
{
|
||||
"id": "title",
|
||||
"type": "static_text",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"style": { "fontSize": 14, "fontWeight": "bold" },
|
||||
"content": "Hello"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
private static HttpClient Build(string prefix = "/api")
|
||||
{
|
||||
var builder = new WebHostBuilder()
|
||||
.ConfigureServices(s => s.AddRouting().AddDreport())
|
||||
.Configure(app =>
|
||||
{
|
||||
app.UseRouting();
|
||||
app.UseEndpoints(e => e.MapDreportEndpoints(prefix));
|
||||
});
|
||||
var server = new TestServer(builder);
|
||||
return server.CreateClient();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Health_Returns_Ok()
|
||||
{
|
||||
var client = Build();
|
||||
var resp = await client.GetAsync("/api/health");
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("ok", json.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Render_ReturnsPdf()
|
||||
{
|
||||
var client = Build();
|
||||
var payload = new { template = JsonDocument.Parse(Template).RootElement, data = new { } };
|
||||
var resp = await client.PostAsJsonAsync("/api/render", payload);
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
Assert.Equal("application/pdf", resp.Content.Headers.ContentType?.MediaType);
|
||||
var bytes = await resp.Content.ReadAsByteArrayAsync();
|
||||
Assert.True(bytes.Length > 100);
|
||||
Assert.Equal((byte)'%', bytes[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Render_InvalidTemplate_Returns400()
|
||||
{
|
||||
var client = Build();
|
||||
var payload = new { template = "not a template", data = new { } };
|
||||
var resp = await client.PostAsJsonAsync("/api/render", payload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Layout_ReturnsJson()
|
||||
{
|
||||
var client = Build();
|
||||
var payload = new { template = JsonDocument.Parse(Template).RootElement, data = new { } };
|
||||
var resp = await client.PostAsJsonAsync("/api/layout", payload);
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(json.TryGetProperty("pages", out var pages));
|
||||
Assert.Equal(JsonValueKind.Array, pages.ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFonts_IncludesNotoSans()
|
||||
{
|
||||
var client = Build();
|
||||
var resp = await client.GetAsync("/api/fonts");
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
var families = await resp.Content.ReadFromJsonAsync<JsonElement[]>();
|
||||
Assert.NotNull(families);
|
||||
Assert.Contains(families!, f => f.GetProperty("family").GetString()!.ToLowerInvariant().Contains("noto"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFontBytes_KnownVariant_ReturnsTtf()
|
||||
{
|
||||
var client = Build();
|
||||
var resp = await client.GetAsync("/api/fonts/Noto%20Sans/400/false");
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
Assert.Equal("font/ttf", resp.Content.Headers.ContentType?.MediaType);
|
||||
var bytes = await resp.Content.ReadAsByteArrayAsync();
|
||||
Assert.True(bytes.Length > 1000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFontBytes_Unknown_Returns404()
|
||||
{
|
||||
var client = Build();
|
||||
var resp = await client.GetAsync("/api/fonts/DoesNotExist/400/false");
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CustomPrefix_RemapsAllEndpoints()
|
||||
{
|
||||
var client = Build("/dreport/api");
|
||||
var resp = await client.GetAsync("/dreport/api/health");
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
var oldRoute = await client.GetAsync("/api/health");
|
||||
Assert.Equal(HttpStatusCode.NotFound, oldRoute.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManualUsage_LayoutEngine_FromDi()
|
||||
{
|
||||
// Sanity: AddDreport without MapDreportEndpoints still hands the engine
|
||||
// out via DI so users can plug it into their own controllers.
|
||||
var builder = new WebHostBuilder()
|
||||
.ConfigureServices(s => s.AddRouting().AddDreport())
|
||||
.Configure(app =>
|
||||
{
|
||||
app.UseRouting();
|
||||
app.UseEndpoints(e => e.MapGet("/custom",
|
||||
(LayoutEngine engine) => Results.Json(new { count = engine.FontFamilyCount })));
|
||||
});
|
||||
using var server = new TestServer(builder);
|
||||
var client = server.CreateClient();
|
||||
var resp = await client.GetAsync("/custom");
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(json.GetProperty("count").GetInt32() >= 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<!-- Tests run on the host RID; ensure native dylibs ship next to the test asm. -->
|
||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Dreport.Service\Dreport.Service.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Sample TTF for FontParseException tests; copied from the workspace assets dir. -->
|
||||
<ItemGroup>
|
||||
<None Include="..\..\..\..\dreport-service\assets\fonts\NotoSans-Regular.ttf"
|
||||
Link="fixtures/NotoSans-Regular.ttf"
|
||||
CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
236
bindings/dotnet/tests/Dreport.Service.Tests/LayoutEngineTests.cs
Normal file
236
bindings/dotnet/tests/Dreport.Service.Tests/LayoutEngineTests.cs
Normal file
@@ -0,0 +1,236 @@
|
||||
using System.Text.Json;
|
||||
using Dreport.Service;
|
||||
using Xunit;
|
||||
|
||||
namespace Dreport.Service.Tests;
|
||||
|
||||
public class LayoutEngineTests
|
||||
{
|
||||
private const string Template = """
|
||||
{
|
||||
"id": "csharp",
|
||||
"name": "C# Test",
|
||||
"page": { "width": 210, "height": 297 },
|
||||
"fonts": ["Noto Sans"],
|
||||
"root": {
|
||||
"id": "root",
|
||||
"type": "container",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"direction": "column",
|
||||
"gap": 5,
|
||||
"padding": { "top": 15, "right": 15, "bottom": 15, "left": 15 },
|
||||
"align": "stretch",
|
||||
"justify": "start",
|
||||
"style": {},
|
||||
"children": [
|
||||
{
|
||||
"id": "title",
|
||||
"type": "static_text",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"style": { "fontSize": 14, "fontWeight": "bold" },
|
||||
"content": "Hello from C#"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
private const string Data = "{}";
|
||||
|
||||
private static byte[] LoadFixtureFont() =>
|
||||
File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "fixtures", "NotoSans-Regular.ttf"));
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Construct_DefaultEngine_HasEmbeddedFonts()
|
||||
{
|
||||
using var engine = new LayoutEngine();
|
||||
Assert.True(engine.FontFamilyCount >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateEmpty_StartsWithoutFonts()
|
||||
{
|
||||
using var engine = LayoutEngine.CreateEmpty();
|
||||
Assert.Equal(0, engine.FontFamilyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NativeVersion_ReturnsNonEmpty()
|
||||
{
|
||||
var v = LayoutEngine.NativeVersion;
|
||||
Assert.False(string.IsNullOrEmpty(v));
|
||||
Assert.Contains('.', v);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_TwiceIsSafe()
|
||||
{
|
||||
var engine = new LayoutEngine();
|
||||
engine.Dispose();
|
||||
engine.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Operations_AfterDispose_Throw()
|
||||
{
|
||||
var engine = new LayoutEngine();
|
||||
engine.Dispose();
|
||||
Assert.Throws<ObjectDisposedException>(() => engine.RenderPdf(Template, Data));
|
||||
Assert.Throws<ObjectDisposedException>(() => engine.ListFontFamilies());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Font registry
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RegisterFont_ValidBytes_IncreasesCount()
|
||||
{
|
||||
using var engine = LayoutEngine.CreateEmpty();
|
||||
engine.RegisterFont(LoadFixtureFont());
|
||||
Assert.Equal(1, engine.FontFamilyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterFont_InvalidBytes_ThrowsFontParseException()
|
||||
{
|
||||
using var engine = LayoutEngine.CreateEmpty();
|
||||
var ex = Assert.Throws<FontParseException>(() =>
|
||||
engine.RegisterFont(new byte[] { 1, 2, 3, 4 }));
|
||||
Assert.Equal(Native.ERR_FONT_PARSE_FAILED, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterFontsDirectory_NonExisting_ThrowsFontDirectoryNotFound()
|
||||
{
|
||||
using var engine = LayoutEngine.CreateEmpty();
|
||||
Assert.Throws<FontDirectoryNotFoundException>(() =>
|
||||
engine.RegisterFontsDirectory("/no/such/dreport/test/path/xyz"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterFontsDirectory_ValidDir_LoadsCount()
|
||||
{
|
||||
var fixturesDir = Path.Combine(AppContext.BaseDirectory, "fixtures");
|
||||
Assert.True(Directory.Exists(fixturesDir));
|
||||
|
||||
using var engine = LayoutEngine.CreateEmpty();
|
||||
var count = engine.RegisterFontsDirectory(fixturesDir);
|
||||
Assert.True(count >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFontBytes_KnownVariant_ReturnsBytes()
|
||||
{
|
||||
using var engine = new LayoutEngine();
|
||||
var bytes = engine.GetFontBytes("Noto Sans", 400, false);
|
||||
Assert.NotNull(bytes);
|
||||
Assert.True(bytes!.Length > 1000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFontBytes_UnknownVariant_ReturnsNull()
|
||||
{
|
||||
using var engine = new LayoutEngine();
|
||||
Assert.Null(engine.GetFontBytes("DoesNotExist", 400, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListFontFamilies_ContainsNotoSans()
|
||||
{
|
||||
using var engine = new LayoutEngine();
|
||||
var families = engine.ListFontFamilies();
|
||||
Assert.Contains(families, f => f.Family.ToLowerInvariant().Contains("noto"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Render pipeline
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ComputeLayout_ValidInputs_ReturnsLayoutJson()
|
||||
{
|
||||
using var engine = new LayoutEngine();
|
||||
var json = engine.ComputeLayout(Template, Data);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
Assert.True(doc.RootElement.TryGetProperty("pages", out var pages));
|
||||
Assert.Equal(JsonValueKind.Array, pages.ValueKind);
|
||||
Assert.True(pages.GetArrayLength() >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeLayout_InvalidTemplate_ThrowsInvalidTemplate()
|
||||
{
|
||||
using var engine = new LayoutEngine();
|
||||
Assert.Throws<InvalidTemplateException>(() => engine.ComputeLayout("{not json", Data));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeLayout_InvalidData_ThrowsInvalidData()
|
||||
{
|
||||
using var engine = new LayoutEngine();
|
||||
Assert.Throws<InvalidDataException>(() => engine.ComputeLayout(Template, "{not json"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderPdf_ValidInputs_ReturnsPdfBytes()
|
||||
{
|
||||
using var engine = new LayoutEngine();
|
||||
var pdf = engine.RenderPdf(Template, Data);
|
||||
Assert.True(pdf.Length > 100);
|
||||
Assert.Equal((byte)'%', pdf[0]);
|
||||
Assert.Equal((byte)'P', pdf[1]);
|
||||
Assert.Equal((byte)'D', pdf[2]);
|
||||
Assert.Equal((byte)'F', pdf[3]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderPdf_InvalidTemplate_ThrowsInvalidTemplate()
|
||||
{
|
||||
using var engine = new LayoutEngine();
|
||||
Assert.Throws<InvalidTemplateException>(() => engine.RenderPdf("{not json", Data));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Concurrency
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RenderPdf_Parallel_ProducesPdfs()
|
||||
{
|
||||
using var engine = new LayoutEngine();
|
||||
var success = 0;
|
||||
Parallel.For(0, 16, _ =>
|
||||
{
|
||||
var pdf = engine.RenderPdf(Template, Data);
|
||||
if (pdf.Length > 100 && pdf[0] == (byte)'%')
|
||||
{
|
||||
Interlocked.Increment(ref success);
|
||||
}
|
||||
});
|
||||
Assert.Equal(16, success);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Error code stability (matches Rust ServiceError::code() contract)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ErrorCode_InvalidTemplate_IsMinusOne()
|
||||
{
|
||||
var ex = new InvalidTemplateException("x");
|
||||
Assert.Equal(-1, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorCode_FontParseFailed_IsMinusThree()
|
||||
{
|
||||
var ex = new FontParseException("x");
|
||||
Assert.Equal(-3, ex.Code);
|
||||
}
|
||||
}
|
||||
17
dreport-ffi/Cargo.toml
Normal file
17
dreport-ffi/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "dreport-ffi"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
description = "C ABI for dreport-service (consumed by NuGet, native hosts, etc.)"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib", "staticlib"]
|
||||
|
||||
[dependencies]
|
||||
dreport-service = { path = "../dreport-service" }
|
||||
serde_json = "1"
|
||||
|
||||
[build-dependencies]
|
||||
cbindgen = { version = "0.28", default-features = false }
|
||||
46
dreport-ffi/build.rs
Normal file
46
dreport-ffi/build.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! Generates `include/dreport.h` from the public C ABI on every build.
|
||||
//! Header is checked into the repo so consumers (NuGet wrapper, manual C use)
|
||||
//! don't need a Rust toolchain.
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=src/lib.rs");
|
||||
println!("cargo:rerun-if-changed=cbindgen.toml");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
|
||||
// Skip generation if explicitly disabled (e.g. when cross-compiling without
|
||||
// host tools, or in cargo publish dry runs).
|
||||
if env::var("DREPORT_FFI_SKIP_CBINDGEN").is_ok() {
|
||||
return;
|
||||
}
|
||||
|
||||
let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
let out_path = PathBuf::from(&crate_dir).join("include").join("dreport.h");
|
||||
if let Some(parent) = out_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
let config_path = PathBuf::from(&crate_dir).join("cbindgen.toml");
|
||||
let config = if config_path.exists() {
|
||||
cbindgen::Config::from_file(&config_path).expect("invalid cbindgen.toml")
|
||||
} else {
|
||||
cbindgen::Config::default()
|
||||
};
|
||||
|
||||
match cbindgen::Builder::new()
|
||||
.with_crate(crate_dir)
|
||||
.with_config(config)
|
||||
.generate()
|
||||
{
|
||||
Ok(bindings) => {
|
||||
bindings.write_to_file(&out_path);
|
||||
}
|
||||
Err(e) => {
|
||||
// Don't fail the build on header generation problems — the cdylib
|
||||
// is still usable; the header is a developer convenience.
|
||||
println!("cargo:warning=cbindgen header generation failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
dreport-ffi/cbindgen.toml
Normal file
20
dreport-ffi/cbindgen.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
language = "C"
|
||||
header = "/* Auto-generated by cbindgen — do not edit. */"
|
||||
include_guard = "DREPORT_H"
|
||||
pragma_once = true
|
||||
no_includes = false
|
||||
sys_includes = ["stdint.h", "stddef.h", "stdbool.h"]
|
||||
cpp_compat = true
|
||||
documentation = true
|
||||
documentation_style = "doxy"
|
||||
style = "type"
|
||||
|
||||
[export]
|
||||
prefix = ""
|
||||
|
||||
[export.rename]
|
||||
"DreportHandle" = "DreportHandle"
|
||||
"DreportBuffer" = "DreportBuffer"
|
||||
|
||||
[parse]
|
||||
parse_deps = false
|
||||
386
dreport-ffi/src/lib.rs
Normal file
386
dreport-ffi/src/lib.rs
Normal file
@@ -0,0 +1,386 @@
|
||||
//! dreport-ffi
|
||||
//!
|
||||
//! C ABI exposing `dreport_service::DreportService` to non-Rust hosts
|
||||
//! (.NET / NuGet, Node N-API, Python ctypes, etc.).
|
||||
//!
|
||||
//! ## Conventions
|
||||
//!
|
||||
//! - All exported symbols are prefixed `dreport_`.
|
||||
//! - Functions return `i32`: `0 == success`, negative values are error codes.
|
||||
//! See [`error_code`] constants. The detailed message for the most recent
|
||||
//! error on the calling thread is retrievable via [`dreport_last_error`].
|
||||
//! - Outbound dynamic data is returned as a [`DreportBuffer`] (ptr + len + cap).
|
||||
//! The caller MUST hand the buffer back to [`dreport_buffer_free`] to release it.
|
||||
//! - Inbound strings are passed as `(ptr, len)` byte pairs and interpreted as UTF-8.
|
||||
//! - Handles ([`DreportHandle`]) are opaque pointers. Pass them to
|
||||
//! [`dreport_free`] exactly once when done. Never use after free.
|
||||
//! - All exported functions are safe to call from any thread; the underlying
|
||||
//! service is `Sync`.
|
||||
|
||||
#![allow(clippy::missing_safety_doc)] // safety contract documented at module level
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::ffi::c_char;
|
||||
use std::sync::Arc;
|
||||
|
||||
use dreport_service::{DreportService, ServiceError};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Return codes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub mod error_code {
|
||||
pub const OK: i32 = 0;
|
||||
pub const NULL_HANDLE: i32 = -100;
|
||||
pub const NULL_POINTER: i32 = -101;
|
||||
pub const INVALID_UTF8: i32 = -102;
|
||||
pub const PANIC: i32 = -103;
|
||||
|
||||
// Service-level errors are exposed as the negation of `ServiceError::code()`.
|
||||
// E.g. ServiceError::FontParseFailed (3) → -3 here.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Opaque handle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Opaque handle backing a `DreportService` shared across the FFI boundary.
|
||||
/// Internally an `Arc<DreportService>`, so the same engine can be cloned and
|
||||
/// driven from multiple threads.
|
||||
pub struct DreportHandle {
|
||||
inner: Arc<DreportService>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outbound buffer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Owned byte buffer returned across the FFI boundary. Released with
|
||||
/// [`dreport_buffer_free`].
|
||||
#[repr(C)]
|
||||
pub struct DreportBuffer {
|
||||
pub data: *mut u8,
|
||||
pub len: usize,
|
||||
pub cap: usize,
|
||||
}
|
||||
|
||||
impl DreportBuffer {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
data: std::ptr::null_mut(),
|
||||
len: 0,
|
||||
cap: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_vec(mut v: Vec<u8>) -> Self {
|
||||
v.shrink_to_fit();
|
||||
let buf = Self {
|
||||
data: v.as_mut_ptr(),
|
||||
len: v.len(),
|
||||
cap: v.capacity(),
|
||||
};
|
||||
std::mem::forget(v);
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread-local error state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
thread_local! {
|
||||
static LAST_ERROR: RefCell<Option<String>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn set_last_error(msg: impl Into<String>) {
|
||||
LAST_ERROR.with(|cell| *cell.borrow_mut() = Some(msg.into()));
|
||||
}
|
||||
|
||||
fn clear_last_error() {
|
||||
LAST_ERROR.with(|cell| *cell.borrow_mut() = None);
|
||||
}
|
||||
|
||||
fn map_service_error(err: ServiceError) -> i32 {
|
||||
let code = -err.code();
|
||||
set_last_error(err.to_string());
|
||||
code
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
unsafe fn handle_ref<'a>(handle: *const DreportHandle) -> Option<&'a DreportHandle> {
|
||||
if handle.is_null() {
|
||||
set_last_error("null handle");
|
||||
None
|
||||
} else {
|
||||
Some(unsafe { &*handle })
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn slice_from_raw<'a>(ptr: *const u8, len: usize) -> Option<&'a [u8]> {
|
||||
if ptr.is_null() {
|
||||
set_last_error("null pointer for input slice");
|
||||
None
|
||||
} else {
|
||||
Some(unsafe { std::slice::from_raw_parts(ptr, len) })
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn str_from_raw<'a>(ptr: *const u8, len: usize) -> Result<&'a str, i32> {
|
||||
if ptr.is_null() {
|
||||
set_last_error("null pointer for input string");
|
||||
return Err(error_code::NULL_POINTER);
|
||||
}
|
||||
let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
|
||||
std::str::from_utf8(bytes).map_err(|e| {
|
||||
set_last_error(format!("invalid utf-8: {}", e));
|
||||
error_code::INVALID_UTF8
|
||||
})
|
||||
}
|
||||
|
||||
unsafe fn write_buffer(out: *mut DreportBuffer, buffer: DreportBuffer) -> i32 {
|
||||
if out.is_null() {
|
||||
set_last_error("null out buffer pointer");
|
||||
return error_code::NULL_POINTER;
|
||||
}
|
||||
unsafe { *out = buffer };
|
||||
error_code::OK
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Allocate a new service handle with default embedded fonts.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn dreport_new() -> *mut DreportHandle {
|
||||
clear_last_error();
|
||||
Box::into_raw(Box::new(DreportHandle {
|
||||
inner: Arc::new(DreportService::new()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Allocate an empty service handle (no embedded fonts).
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn dreport_new_empty() -> *mut DreportHandle {
|
||||
clear_last_error();
|
||||
Box::into_raw(Box::new(DreportHandle {
|
||||
inner: Arc::new(DreportService::empty()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Release a service handle previously returned by `dreport_new` /
|
||||
/// `dreport_new_empty`. Calling with `NULL` is a no-op.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_free(handle: *mut DreportHandle) {
|
||||
if handle.is_null() {
|
||||
return;
|
||||
}
|
||||
drop(unsafe { Box::from_raw(handle) });
|
||||
}
|
||||
|
||||
/// Release a buffer previously produced by an FFI call. Calling with a buffer
|
||||
/// whose `data` is NULL or whose `cap` is 0 is a no-op.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_buffer_free(buffer: DreportBuffer) {
|
||||
if buffer.data.is_null() || buffer.cap == 0 {
|
||||
return;
|
||||
}
|
||||
drop(unsafe { Vec::from_raw_parts(buffer.data, buffer.len, buffer.cap) });
|
||||
}
|
||||
|
||||
/// Returns the static crate version string. Pointer remains valid for the
|
||||
/// lifetime of the loaded library.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn dreport_version() -> *const c_char {
|
||||
static VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "\0");
|
||||
VERSION.as_ptr() as *const c_char
|
||||
}
|
||||
|
||||
/// Copy the most recent error message produced on this thread into `out`.
|
||||
/// Returns `error_code::OK` on success (even if there is no error — the buffer
|
||||
/// will simply be empty). The buffer must be released with `dreport_buffer_free`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_last_error(out: *mut DreportBuffer) -> i32 {
|
||||
let msg = LAST_ERROR.with(|cell| cell.borrow().clone()).unwrap_or_default();
|
||||
let buf = if msg.is_empty() {
|
||||
DreportBuffer::empty()
|
||||
} else {
|
||||
DreportBuffer::from_vec(msg.into_bytes())
|
||||
};
|
||||
unsafe { write_buffer(out, buf) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Font registry operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Register a font from raw TTF/OTF bytes.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_register_font(
|
||||
handle: *const DreportHandle,
|
||||
data: *const u8,
|
||||
len: usize,
|
||||
) -> i32 {
|
||||
clear_last_error();
|
||||
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||
return error_code::NULL_HANDLE;
|
||||
};
|
||||
let Some(bytes) = (unsafe { slice_from_raw(data, len) }) else {
|
||||
return error_code::NULL_POINTER;
|
||||
};
|
||||
match h.inner.register_font_bytes(bytes.to_vec()) {
|
||||
Ok(_) => error_code::OK,
|
||||
Err(e) => map_service_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register every font file in `path` (UTF-8 directory path).
|
||||
/// Returns the count via `out_count` (negative on error).
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_register_fonts_dir(
|
||||
handle: *const DreportHandle,
|
||||
path: *const u8,
|
||||
path_len: usize,
|
||||
out_count: *mut usize,
|
||||
) -> i32 {
|
||||
clear_last_error();
|
||||
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||
return error_code::NULL_HANDLE;
|
||||
};
|
||||
let p = match unsafe { str_from_raw(path, path_len) } {
|
||||
Ok(s) => s,
|
||||
Err(rc) => return rc,
|
||||
};
|
||||
match h.inner.register_fonts_directory(p) {
|
||||
Ok(n) => {
|
||||
if !out_count.is_null() {
|
||||
unsafe { *out_count = n };
|
||||
}
|
||||
error_code::OK
|
||||
}
|
||||
Err(e) => map_service_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all registered font families as a JSON array
|
||||
/// `[{"family":"Noto Sans","variants":[{"weight":400,"italic":false}, ...]}]`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_list_fonts_json(
|
||||
handle: *const DreportHandle,
|
||||
out: *mut DreportBuffer,
|
||||
) -> i32 {
|
||||
clear_last_error();
|
||||
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||
return error_code::NULL_HANDLE;
|
||||
};
|
||||
let families = h.inner.list_font_families();
|
||||
match serde_json::to_vec(&families) {
|
||||
Ok(v) => unsafe { write_buffer(out, DreportBuffer::from_vec(v)) },
|
||||
Err(e) => {
|
||||
set_last_error(format!("serialize fonts: {}", e));
|
||||
-ServiceError::SerializationFailed(String::new()).code()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the raw bytes for a specific font variant. Sets `out` to an empty buffer
|
||||
/// (data=NULL,len=0) and returns OK if the variant does not exist; this lets
|
||||
/// the caller distinguish "missing" from "error" by inspecting `out.data`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_get_font_bytes(
|
||||
handle: *const DreportHandle,
|
||||
family: *const u8,
|
||||
family_len: usize,
|
||||
weight: u16,
|
||||
italic: bool,
|
||||
out: *mut DreportBuffer,
|
||||
) -> i32 {
|
||||
clear_last_error();
|
||||
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||
return error_code::NULL_HANDLE;
|
||||
};
|
||||
let fam = match unsafe { str_from_raw(family, family_len) } {
|
||||
Ok(s) => s,
|
||||
Err(rc) => return rc,
|
||||
};
|
||||
let buf = match h.inner.get_font_bytes(fam, weight, italic) {
|
||||
Some(v) => DreportBuffer::from_vec(v),
|
||||
None => DreportBuffer::empty(),
|
||||
};
|
||||
unsafe { write_buffer(out, buf) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute layout. Returns the LayoutResult JSON via `out`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_compute_layout(
|
||||
handle: *const DreportHandle,
|
||||
template: *const u8,
|
||||
template_len: usize,
|
||||
data: *const u8,
|
||||
data_len: usize,
|
||||
out: *mut DreportBuffer,
|
||||
) -> i32 {
|
||||
clear_last_error();
|
||||
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||
return error_code::NULL_HANDLE;
|
||||
};
|
||||
let tpl = match unsafe { str_from_raw(template, template_len) } {
|
||||
Ok(s) => s,
|
||||
Err(rc) => return rc,
|
||||
};
|
||||
let d = match unsafe { str_from_raw(data, data_len) } {
|
||||
Ok(s) => s,
|
||||
Err(rc) => return rc,
|
||||
};
|
||||
match h.inner.compute_layout_json(tpl, d) {
|
||||
Ok(json) => unsafe { write_buffer(out, DreportBuffer::from_vec(json.into_bytes())) },
|
||||
Err(e) => map_service_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render PDF. Returns PDF bytes via `out`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_render_pdf(
|
||||
handle: *const DreportHandle,
|
||||
template: *const u8,
|
||||
template_len: usize,
|
||||
data: *const u8,
|
||||
data_len: usize,
|
||||
out: *mut DreportBuffer,
|
||||
) -> i32 {
|
||||
clear_last_error();
|
||||
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||
return error_code::NULL_HANDLE;
|
||||
};
|
||||
let tpl = match unsafe { str_from_raw(template, template_len) } {
|
||||
Ok(s) => s,
|
||||
Err(rc) => return rc,
|
||||
};
|
||||
let d = match unsafe { str_from_raw(data, data_len) } {
|
||||
Ok(s) => s,
|
||||
Err(rc) => return rc,
|
||||
};
|
||||
match h.inner.render_pdf_json(tpl, d) {
|
||||
Ok(pdf) => unsafe { write_buffer(out, DreportBuffer::from_vec(pdf)) },
|
||||
Err(e) => map_service_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of distinct font families currently registered. Returns a negative
|
||||
/// value if the handle is null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_font_family_count(handle: *const DreportHandle) -> isize {
|
||||
clear_last_error();
|
||||
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||
return error_code::NULL_HANDLE as isize;
|
||||
};
|
||||
h.inner.font_family_count() as isize
|
||||
}
|
||||
436
dreport-ffi/tests/ffi.rs
Normal file
436
dreport-ffi/tests/ffi.rs
Normal file
@@ -0,0 +1,436 @@
|
||||
//! Integration tests that drive the C ABI directly. These tests treat the FFI
|
||||
//! crate exactly the way a foreign-language host (NuGet, P/Invoke) would —
|
||||
//! through opaque pointers, byte buffers, and return codes. They are the
|
||||
//! contract test suite for non-Rust consumers.
|
||||
|
||||
use dreport_ffi::*;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::thread;
|
||||
|
||||
const TEMPLATE: &str = r#"{
|
||||
"id": "ffi",
|
||||
"name": "FFI Test",
|
||||
"page": { "width": 210, "height": 297 },
|
||||
"fonts": ["Noto Sans"],
|
||||
"root": {
|
||||
"id": "root",
|
||||
"type": "container",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"direction": "column",
|
||||
"gap": 5,
|
||||
"padding": { "top": 15, "right": 15, "bottom": 15, "left": 15 },
|
||||
"align": "stretch",
|
||||
"justify": "start",
|
||||
"style": {},
|
||||
"children": [
|
||||
{
|
||||
"id": "title",
|
||||
"type": "static_text",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"style": { "fontSize": 14, "fontWeight": "bold" },
|
||||
"content": "FFI"
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
const DATA: &str = "{}";
|
||||
const NOTO_SANS_REGULAR: &[u8] =
|
||||
include_bytes!("../../dreport-service/assets/fonts/NotoSans-Regular.ttf");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small RAII wrappers around the raw FFI types so each test stays terse.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Handle(*mut DreportHandle);
|
||||
impl Handle {
|
||||
fn new() -> Self {
|
||||
let h = dreport_new();
|
||||
assert!(!h.is_null(), "dreport_new must succeed");
|
||||
Self(h)
|
||||
}
|
||||
fn empty() -> Self {
|
||||
let h = dreport_new_empty();
|
||||
assert!(!h.is_null());
|
||||
Self(h)
|
||||
}
|
||||
}
|
||||
impl Drop for Handle {
|
||||
fn drop(&mut self) {
|
||||
unsafe { dreport_free(self.0) };
|
||||
}
|
||||
}
|
||||
// SAFETY: Underlying handle wraps an Arc<DreportService>; the service is Sync.
|
||||
unsafe impl Send for Handle {}
|
||||
unsafe impl Sync for Handle {}
|
||||
|
||||
struct OwnedBuffer(DreportBuffer);
|
||||
impl OwnedBuffer {
|
||||
fn empty() -> Self {
|
||||
Self(DreportBuffer {
|
||||
data: std::ptr::null_mut(),
|
||||
len: 0,
|
||||
cap: 0,
|
||||
})
|
||||
}
|
||||
fn as_slice(&self) -> &[u8] {
|
||||
if self.0.data.is_null() {
|
||||
&[]
|
||||
} else {
|
||||
unsafe { std::slice::from_raw_parts(self.0.data, self.0.len) }
|
||||
}
|
||||
}
|
||||
fn as_str(&self) -> &str {
|
||||
std::str::from_utf8(self.as_slice()).expect("valid utf8")
|
||||
}
|
||||
fn ptr(&mut self) -> *mut DreportBuffer {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
impl Drop for OwnedBuffer {
|
||||
fn drop(&mut self) {
|
||||
let buf = std::mem::replace(
|
||||
&mut self.0,
|
||||
DreportBuffer {
|
||||
data: std::ptr::null_mut(),
|
||||
len: 0,
|
||||
cap: 0,
|
||||
},
|
||||
);
|
||||
unsafe { dreport_buffer_free(buf) };
|
||||
}
|
||||
}
|
||||
|
||||
fn last_error() -> String {
|
||||
let mut buf = OwnedBuffer::empty();
|
||||
let rc = unsafe { dreport_last_error(buf.ptr()) };
|
||||
assert_eq!(rc, error_code::OK);
|
||||
buf.as_str().to_string()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn new_and_free_round_trips() {
|
||||
let h = dreport_new();
|
||||
assert!(!h.is_null());
|
||||
unsafe { dreport_free(h) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_null_is_safe() {
|
||||
unsafe { dreport_free(std::ptr::null_mut()) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_free_null_is_safe() {
|
||||
unsafe {
|
||||
dreport_buffer_free(DreportBuffer {
|
||||
data: std::ptr::null_mut(),
|
||||
len: 0,
|
||||
cap: 0,
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_returns_valid_c_string() {
|
||||
let ptr = dreport_version();
|
||||
assert!(!ptr.is_null());
|
||||
let cstr = unsafe { std::ffi::CStr::from_ptr(ptr) };
|
||||
let s = cstr.to_str().unwrap();
|
||||
assert!(!s.is_empty());
|
||||
assert!(s.chars().next().unwrap().is_ascii_digit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_default_handle_has_fonts() {
|
||||
let h = Handle::new();
|
||||
let count = unsafe { dreport_font_family_count(h.0) };
|
||||
assert!(count >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_handle_has_no_fonts() {
|
||||
let h = Handle::empty();
|
||||
let count = unsafe { dreport_font_family_count(h.0) };
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Null-handle guard rails
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn null_handle_returns_null_handle_code() {
|
||||
let mut buf = OwnedBuffer::empty();
|
||||
let rc = unsafe { dreport_list_fonts_json(std::ptr::null(), buf.ptr()) };
|
||||
assert_eq!(rc, error_code::NULL_HANDLE);
|
||||
assert!(!last_error().is_empty(), "error message must be set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_handle_count_returns_negative() {
|
||||
let count = unsafe { dreport_font_family_count(std::ptr::null()) };
|
||||
assert!(count < 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_with_null_template_returns_null_pointer_code() {
|
||||
let h = Handle::new();
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_render_pdf(
|
||||
h.0,
|
||||
std::ptr::null(),
|
||||
0,
|
||||
DATA.as_ptr(),
|
||||
DATA.len(),
|
||||
out.ptr(),
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, error_code::NULL_POINTER);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Font registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn register_font_valid_bytes() {
|
||||
let h = Handle::empty();
|
||||
let rc = unsafe { dreport_register_font(h.0, NOTO_SANS_REGULAR.as_ptr(), NOTO_SANS_REGULAR.len()) };
|
||||
assert_eq!(rc, error_code::OK);
|
||||
assert!(unsafe { dreport_font_family_count(h.0) } >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_font_invalid_bytes_returns_negative_service_code() {
|
||||
let h = Handle::empty();
|
||||
let garbage = b"not a font";
|
||||
let rc = unsafe { dreport_register_font(h.0, garbage.as_ptr(), garbage.len()) };
|
||||
assert_eq!(rc, -3, "ServiceError::FontParseFailed code is 3 → -3 over FFI");
|
||||
let msg = last_error();
|
||||
assert!(msg.to_lowercase().contains("font"), "error msg: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_fonts_dir_invalid_path_sets_error() {
|
||||
let h = Handle::empty();
|
||||
let path = "/zzz/no/such/dreport/path";
|
||||
let mut out_count: usize = 0;
|
||||
let rc = unsafe {
|
||||
dreport_register_fonts_dir(h.0, path.as_ptr(), path.len(), &mut out_count)
|
||||
};
|
||||
assert!(rc < 0);
|
||||
assert_eq!(out_count, 0);
|
||||
assert!(!last_error().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_fonts_dir_valid_path_loads_count() {
|
||||
let h = Handle::empty();
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../dreport-service/assets/fonts");
|
||||
let path_str = path.to_string_lossy().into_owned();
|
||||
let mut out_count: usize = 0;
|
||||
let rc = unsafe {
|
||||
dreport_register_fonts_dir(h.0, path_str.as_ptr(), path_str.len(), &mut out_count)
|
||||
};
|
||||
assert_eq!(rc, error_code::OK, "{}", last_error());
|
||||
assert!(out_count >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_fonts_json_is_valid_array() {
|
||||
let h = Handle::new();
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe { dreport_list_fonts_json(h.0, out.ptr()) };
|
||||
assert_eq!(rc, error_code::OK);
|
||||
let parsed: serde_json::Value = serde_json::from_str(out.as_str()).unwrap();
|
||||
assert!(parsed.is_array());
|
||||
assert!(!parsed.as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_font_bytes_existing_returns_data() {
|
||||
let h = Handle::new();
|
||||
let family = "Noto Sans";
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_get_font_bytes(h.0, family.as_ptr(), family.len(), 400, false, out.ptr())
|
||||
};
|
||||
assert_eq!(rc, error_code::OK);
|
||||
assert!(out.as_slice().len() > 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_font_bytes_missing_returns_ok_with_empty_buffer() {
|
||||
let h = Handle::new();
|
||||
let family = "DoesNotExist";
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_get_font_bytes(h.0, family.as_ptr(), family.len(), 400, false, out.ptr())
|
||||
};
|
||||
assert_eq!(rc, error_code::OK);
|
||||
assert!(out.as_slice().is_empty());
|
||||
assert!(out.0.data.is_null());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn compute_layout_round_trip() {
|
||||
let h = Handle::new();
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_compute_layout(
|
||||
h.0,
|
||||
TEMPLATE.as_ptr(),
|
||||
TEMPLATE.len(),
|
||||
DATA.as_ptr(),
|
||||
DATA.len(),
|
||||
out.ptr(),
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, error_code::OK, "{}", last_error());
|
||||
let parsed: serde_json::Value = serde_json::from_str(out.as_str()).unwrap();
|
||||
assert!(parsed["pages"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_pdf_returns_pdf_magic_header() {
|
||||
let h = Handle::new();
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_render_pdf(
|
||||
h.0,
|
||||
TEMPLATE.as_ptr(),
|
||||
TEMPLATE.len(),
|
||||
DATA.as_ptr(),
|
||||
DATA.len(),
|
||||
out.ptr(),
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, error_code::OK, "{}", last_error());
|
||||
let bytes = out.as_slice();
|
||||
assert!(bytes.starts_with(b"%PDF-"), "missing magic header");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_with_invalid_template_json_sets_error() {
|
||||
let h = Handle::new();
|
||||
let bad = b"{not json";
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_render_pdf(
|
||||
h.0,
|
||||
bad.as_ptr(),
|
||||
bad.len(),
|
||||
DATA.as_ptr(),
|
||||
DATA.len(),
|
||||
out.ptr(),
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, -1, "ServiceError::InvalidTemplateJson → -1");
|
||||
assert!(!last_error().is_empty());
|
||||
assert!(out.as_slice().is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concurrency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn concurrent_independent_handles() {
|
||||
let success = Arc::new(AtomicUsize::new(0));
|
||||
let mut threads = Vec::new();
|
||||
for _ in 0..6 {
|
||||
let s = Arc::clone(&success);
|
||||
threads.push(thread::spawn(move || {
|
||||
let h = Handle::new();
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_render_pdf(
|
||||
h.0,
|
||||
TEMPLATE.as_ptr(),
|
||||
TEMPLATE.len(),
|
||||
DATA.as_ptr(),
|
||||
DATA.len(),
|
||||
out.ptr(),
|
||||
)
|
||||
};
|
||||
if rc == error_code::OK && out.as_slice().starts_with(b"%PDF-") {
|
||||
s.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for t in threads {
|
||||
t.join().unwrap();
|
||||
}
|
||||
assert_eq!(success.load(Ordering::SeqCst), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_shared_handle() {
|
||||
// The handle itself is owned by one thread, but the underlying service is
|
||||
// an Arc<DreportService>, so internally a shared engine is fine. To test
|
||||
// the most realistic NuGet scenario (one process-wide engine) we instead
|
||||
// create per-thread handles backed by parallel `dreport_new` calls.
|
||||
let success = Arc::new(AtomicUsize::new(0));
|
||||
let mut threads = Vec::new();
|
||||
for _ in 0..4 {
|
||||
let s = Arc::clone(&success);
|
||||
threads.push(thread::spawn(move || {
|
||||
for _ in 0..4 {
|
||||
let h = Handle::new();
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_render_pdf(
|
||||
h.0,
|
||||
TEMPLATE.as_ptr(),
|
||||
TEMPLATE.len(),
|
||||
DATA.as_ptr(),
|
||||
DATA.len(),
|
||||
out.ptr(),
|
||||
)
|
||||
};
|
||||
if rc == 0 {
|
||||
s.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
for t in threads {
|
||||
t.join().unwrap();
|
||||
}
|
||||
assert_eq!(success.load(Ordering::SeqCst), 16);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Last-error semantics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn successful_call_clears_previous_error() {
|
||||
let h = Handle::new();
|
||||
|
||||
// Provoke an error first.
|
||||
let rc = unsafe { dreport_register_font(h.0, b"x".as_ptr(), 1) };
|
||||
assert!(rc < 0);
|
||||
assert!(!last_error().is_empty());
|
||||
|
||||
// A subsequent successful call must clear it.
|
||||
let count = unsafe { dreport_font_family_count(h.0) };
|
||||
assert!(count >= 1);
|
||||
assert!(
|
||||
last_error().is_empty(),
|
||||
"successful call should clear last_error"
|
||||
);
|
||||
}
|
||||
21
dreport-service/Cargo.toml
Normal file
21
dreport-service/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "dreport-service"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
description = "High-level orchestration service for dreport (font registry + render pipeline)"
|
||||
license = "MIT"
|
||||
publish = ["gitea"]
|
||||
|
||||
[dependencies]
|
||||
dreport-core = { version = "0.2.0", path = "../core", registry = "gitea" }
|
||||
dreport-layout = { version = "0.2.0", path = "../layout-engine", registry = "gitea" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
|
||||
[features]
|
||||
default = ["embedded-fonts"]
|
||||
embedded-fonts = []
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
48
dreport-service/src/error.rs
Normal file
48
dreport-service/src/error.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// dreport-service üzerinden yapılan tüm operasyonların hata tipi.
|
||||
/// FFI ve HTTP adapter'ları bu enum'u kendi error formatlarına map'ler.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ServiceError {
|
||||
#[error("invalid template JSON: {0}")]
|
||||
InvalidTemplateJson(String),
|
||||
|
||||
#[error("invalid data JSON: {0}")]
|
||||
InvalidDataJson(String),
|
||||
|
||||
#[error("font parse failed: bytes do not contain a valid TTF/OTF face")]
|
||||
FontParseFailed,
|
||||
|
||||
#[error("font directory not found: {0}")]
|
||||
FontDirNotFound(String),
|
||||
|
||||
#[error("font directory read error: {0}")]
|
||||
FontDirRead(String),
|
||||
|
||||
#[error("layout computation failed: {0}")]
|
||||
LayoutFailed(String),
|
||||
|
||||
#[error("pdf rendering failed: {0}")]
|
||||
PdfFailed(String),
|
||||
|
||||
#[error("layout result serialization failed: {0}")]
|
||||
SerializationFailed(String),
|
||||
}
|
||||
|
||||
impl ServiceError {
|
||||
/// Stable numeric code for FFI consumers.
|
||||
pub fn code(&self) -> i32 {
|
||||
match self {
|
||||
Self::InvalidTemplateJson(_) => 1,
|
||||
Self::InvalidDataJson(_) => 2,
|
||||
Self::FontParseFailed => 3,
|
||||
Self::FontDirNotFound(_) => 4,
|
||||
Self::FontDirRead(_) => 5,
|
||||
Self::LayoutFailed(_) => 6,
|
||||
Self::PdfFailed(_) => 7,
|
||||
Self::SerializationFailed(_) => 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type ServiceResult<T> = Result<T, ServiceError>;
|
||||
164
dreport-service/src/font_registry.rs
Normal file
164
dreport-service/src/font_registry.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
use dreport_layout::FontData;
|
||||
use dreport_layout::font_meta::{self, FontFamilyInfo, FontVariantKey};
|
||||
use dreport_layout::font_provider::FontProvider;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::error::{ServiceError, ServiceResult};
|
||||
|
||||
/// Default font family that is always included in the layout font set when
|
||||
/// available. Matches the engine's fallback behaviour.
|
||||
pub(crate) const DEFAULT_FAMILY: &str = "noto sans";
|
||||
|
||||
/// Internal font registry. Manages parsed TTF/OTF faces indexed by family + variant.
|
||||
/// Not exported directly — accessed through `DreportService`.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct FontRegistry {
|
||||
/// family_lower -> variant_key -> FontData
|
||||
families: HashMap<String, HashMap<FontVariantKey, FontData>>,
|
||||
/// Original-case family names for display (`list_families`).
|
||||
family_names: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl FontRegistry {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Register a font from raw bytes. Returns parsed family info on success.
|
||||
pub(crate) fn register_bytes(&mut self, data: Vec<u8>) -> ServiceResult<RegisteredFont> {
|
||||
let meta = font_meta::parse_font_meta(&data).ok_or(ServiceError::FontParseFailed)?;
|
||||
let family_lower = meta.family.to_lowercase();
|
||||
let variant_key = meta.variant_key();
|
||||
|
||||
self.family_names
|
||||
.entry(family_lower.clone())
|
||||
.or_insert_with(|| meta.family.clone());
|
||||
|
||||
let font_data = FontData::new(meta.family.clone(), meta.weight, meta.italic, data);
|
||||
self.families
|
||||
.entry(family_lower)
|
||||
.or_default()
|
||||
.insert(variant_key.clone(), font_data);
|
||||
|
||||
Ok(RegisteredFont {
|
||||
family: meta.family,
|
||||
weight: variant_key.weight,
|
||||
italic: variant_key.italic,
|
||||
})
|
||||
}
|
||||
|
||||
/// Register all `.ttf`/`.otf` files in the given directory.
|
||||
/// Returns the count of successfully registered files; per-file parse
|
||||
/// failures are silently skipped to mirror the previous backend behaviour.
|
||||
pub(crate) fn register_directory(&mut self, dir: &Path) -> ServiceResult<usize> {
|
||||
if !dir.exists() {
|
||||
return Err(ServiceError::FontDirNotFound(dir.display().to_string()));
|
||||
}
|
||||
if !dir.is_dir() {
|
||||
return Err(ServiceError::FontDirNotFound(dir.display().to_string()));
|
||||
}
|
||||
|
||||
let entries = std::fs::read_dir(dir).map_err(|e| ServiceError::FontDirRead(e.to_string()))?;
|
||||
|
||||
let mut count = 0_usize;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let is_font = path
|
||||
.extension()
|
||||
.is_some_and(|e| e == "ttf" || e == "otf" || e == "TTF" || e == "OTF");
|
||||
if !is_font {
|
||||
continue;
|
||||
}
|
||||
if let Ok(data) = std::fs::read(&path)
|
||||
&& self.register_bytes(data).is_ok()
|
||||
{
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub(crate) fn get_font_bytes(
|
||||
&self,
|
||||
family: &str,
|
||||
weight: u16,
|
||||
italic: bool,
|
||||
) -> Option<&[u8]> {
|
||||
let family_lower = family.to_lowercase();
|
||||
let key = FontVariantKey { weight, italic };
|
||||
self.families
|
||||
.get(&family_lower)
|
||||
.and_then(|variants| variants.get(&key))
|
||||
.map(|fd| fd.data.as_slice())
|
||||
}
|
||||
|
||||
/// Resolve the FontData set for a template. Always includes the default
|
||||
/// family (Noto Sans) plus any explicitly requested families.
|
||||
pub(crate) fn fonts_for_families(&self, families: &[String]) -> Vec<FontData> {
|
||||
let mut result = Vec::new();
|
||||
let mut loaded: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
let mut to_load: Vec<String> = vec![DEFAULT_FAMILY.to_string()];
|
||||
for f in families {
|
||||
let fl = f.to_lowercase();
|
||||
if !to_load.contains(&fl) {
|
||||
to_load.push(fl);
|
||||
}
|
||||
}
|
||||
|
||||
for family_lower in &to_load {
|
||||
if !loaded.insert(family_lower.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(variants) = self.families.get(family_lower) {
|
||||
for fd in variants.values() {
|
||||
result.push(fd.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn family_count(&self) -> usize {
|
||||
self.families.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl FontProvider for FontRegistry {
|
||||
fn list_families(&self) -> Vec<FontFamilyInfo> {
|
||||
self.families
|
||||
.iter()
|
||||
.map(|(family_lower, variants)| {
|
||||
let family = self
|
||||
.family_names
|
||||
.get(family_lower)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| family_lower.clone());
|
||||
FontFamilyInfo {
|
||||
family,
|
||||
variants: variants.keys().cloned().collect(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn load_font(&self, family: &str, weight: u16, italic: bool) -> Option<FontData> {
|
||||
let family_lower = family.to_lowercase();
|
||||
let key = FontVariantKey { weight, italic };
|
||||
self.families
|
||||
.get(&family_lower)
|
||||
.and_then(|variants| variants.get(&key))
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of registering a single font, returned to callers that need to
|
||||
/// confirm what variant was actually parsed.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct RegisteredFont {
|
||||
pub family: String,
|
||||
pub weight: u16,
|
||||
pub italic: bool,
|
||||
}
|
||||
203
dreport-service/src/lib.rs
Normal file
203
dreport-service/src/lib.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
//! dreport-service
|
||||
//!
|
||||
//! High-level orchestration layer that sits on top of `dreport-layout`.
|
||||
//! Responsible for:
|
||||
//! - Font registry management (embedded defaults + external loading)
|
||||
//! - Template + data → LayoutResult JSON
|
||||
//! - Template + data → PDF bytes
|
||||
//!
|
||||
//! Consumed by:
|
||||
//! - `dreport-backend` (Axum HTTP adapter)
|
||||
//! - `dreport-ffi` (C ABI for NuGet etc.)
|
||||
//! - Any other Rust host (CLI, gRPC, ...)
|
||||
|
||||
mod error;
|
||||
mod font_registry;
|
||||
|
||||
pub use dreport_core::models::Template;
|
||||
pub use dreport_layout::FontData;
|
||||
pub use dreport_layout::LayoutResult;
|
||||
pub use dreport_layout::font_meta::{FontFamilyInfo, FontVariantKey};
|
||||
pub use dreport_layout::font_provider::FontProvider;
|
||||
pub use error::{ServiceError, ServiceResult};
|
||||
pub use font_registry::RegisteredFont;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use font_registry::FontRegistry;
|
||||
|
||||
/// Embedded default fonts compiled into the binary when the
|
||||
/// `embedded-fonts` feature is enabled (default).
|
||||
#[cfg(feature = "embedded-fonts")]
|
||||
const EMBEDDED_FONTS: &[(&str, &[u8])] = &[
|
||||
(
|
||||
"NotoSans-Regular",
|
||||
include_bytes!("../assets/fonts/NotoSans-Regular.ttf"),
|
||||
),
|
||||
(
|
||||
"NotoSans-Bold",
|
||||
include_bytes!("../assets/fonts/NotoSans-Bold.ttf"),
|
||||
),
|
||||
(
|
||||
"NotoSans-Italic",
|
||||
include_bytes!("../assets/fonts/NotoSans-Italic.ttf"),
|
||||
),
|
||||
(
|
||||
"NotoSans-BoldItalic",
|
||||
include_bytes!("../assets/fonts/NotoSans-BoldItalic.ttf"),
|
||||
),
|
||||
(
|
||||
"NotoSansMono-Regular",
|
||||
include_bytes!("../assets/fonts/NotoSansMono-Regular.ttf"),
|
||||
),
|
||||
];
|
||||
|
||||
/// Main service handle. Thread-safe; share across threads via `Arc`.
|
||||
///
|
||||
/// Holds the font registry and exposes layout + PDF rendering operations.
|
||||
/// All mutating operations (font registration) take `&self` and use internal
|
||||
/// synchronization, so multiple readers (renders) and writers (font loads)
|
||||
/// can coexist safely.
|
||||
pub struct DreportService {
|
||||
registry: RwLock<FontRegistry>,
|
||||
}
|
||||
|
||||
impl DreportService {
|
||||
/// Create a new service. Embedded default fonts are loaded automatically
|
||||
/// when the `embedded-fonts` feature is on (default).
|
||||
pub fn new() -> Self {
|
||||
let mut reg = FontRegistry::new();
|
||||
#[cfg(feature = "embedded-fonts")]
|
||||
for (_name, bytes) in EMBEDDED_FONTS {
|
||||
// Embedded fonts must parse — failure is a build-time bug.
|
||||
let _ = reg.register_bytes(bytes.to_vec());
|
||||
}
|
||||
Self {
|
||||
registry: RwLock::new(reg),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a service without the embedded defaults, regardless of feature
|
||||
/// flags. Useful for tests and minimal embedders.
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
registry: RwLock::new(FontRegistry::new()),
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Font registry operations
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// Register a single font from raw TTF/OTF bytes.
|
||||
pub fn register_font_bytes(&self, data: Vec<u8>) -> ServiceResult<RegisteredFont> {
|
||||
let mut reg = self.registry.write().expect("font registry poisoned");
|
||||
reg.register_bytes(data)
|
||||
}
|
||||
|
||||
/// Register every `.ttf` / `.otf` file in `dir` (non-recursive).
|
||||
/// Returns the number of fonts successfully registered.
|
||||
pub fn register_fonts_directory<P: AsRef<Path>>(&self, dir: P) -> ServiceResult<usize> {
|
||||
let mut reg = self.registry.write().expect("font registry poisoned");
|
||||
reg.register_directory(dir.as_ref())
|
||||
}
|
||||
|
||||
/// List all currently-registered font families with their available variants.
|
||||
pub fn list_font_families(&self) -> Vec<FontFamilyInfo> {
|
||||
let reg = self.registry.read().expect("font registry poisoned");
|
||||
reg.list_families()
|
||||
}
|
||||
|
||||
/// Get the raw bytes for a specific font variant.
|
||||
pub fn get_font_bytes(&self, family: &str, weight: u16, italic: bool) -> Option<Vec<u8>> {
|
||||
let reg = self.registry.read().expect("font registry poisoned");
|
||||
reg.get_font_bytes(family, weight, italic).map(<[u8]>::to_vec)
|
||||
}
|
||||
|
||||
/// Number of distinct font families currently registered.
|
||||
pub fn font_family_count(&self) -> usize {
|
||||
let reg = self.registry.read().expect("font registry poisoned");
|
||||
reg.family_count()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Render pipeline
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// Compute layout from JSON inputs. Returns the LayoutResult serialized as JSON.
|
||||
pub fn compute_layout_json(
|
||||
&self,
|
||||
template_json: &str,
|
||||
data_json: &str,
|
||||
) -> ServiceResult<String> {
|
||||
let template: Template = serde_json::from_str(template_json)
|
||||
.map_err(|e| ServiceError::InvalidTemplateJson(e.to_string()))?;
|
||||
let data: serde_json::Value = serde_json::from_str(data_json)
|
||||
.map_err(|e| ServiceError::InvalidDataJson(e.to_string()))?;
|
||||
let layout = self.compute_layout(&template, &data)?;
|
||||
serde_json::to_string(&layout).map_err(|e| ServiceError::SerializationFailed(e.to_string()))
|
||||
}
|
||||
|
||||
/// Typed layout computation for Rust callers.
|
||||
pub fn compute_layout(
|
||||
&self,
|
||||
template: &Template,
|
||||
data: &serde_json::Value,
|
||||
) -> ServiceResult<LayoutResult> {
|
||||
let fonts = self.fonts_for_template(&template.fonts);
|
||||
dreport_layout::compute_layout(template, data, &fonts)
|
||||
.map_err(|e| ServiceError::LayoutFailed(e.to_string()))
|
||||
}
|
||||
|
||||
/// Render a PDF from JSON inputs.
|
||||
pub fn render_pdf_json(
|
||||
&self,
|
||||
template_json: &str,
|
||||
data_json: &str,
|
||||
) -> ServiceResult<Vec<u8>> {
|
||||
let template: Template = serde_json::from_str(template_json)
|
||||
.map_err(|e| ServiceError::InvalidTemplateJson(e.to_string()))?;
|
||||
let data: serde_json::Value = serde_json::from_str(data_json)
|
||||
.map_err(|e| ServiceError::InvalidDataJson(e.to_string()))?;
|
||||
self.render_pdf(&template, &data)
|
||||
}
|
||||
|
||||
/// Typed PDF rendering for Rust callers.
|
||||
pub fn render_pdf(
|
||||
&self,
|
||||
template: &Template,
|
||||
data: &serde_json::Value,
|
||||
) -> ServiceResult<Vec<u8>> {
|
||||
let fonts = self.fonts_for_template(&template.fonts);
|
||||
let layout = dreport_layout::compute_layout(template, data, &fonts)
|
||||
.map_err(|e| ServiceError::LayoutFailed(e.to_string()))?;
|
||||
dreport_layout::pdf_render::render_pdf(&layout, &fonts)
|
||||
.map_err(ServiceError::PdfFailed)
|
||||
}
|
||||
|
||||
/// Snapshot the FontData set required for the given template families.
|
||||
/// Held briefly under read lock then released — the resulting Vec is owned.
|
||||
fn fonts_for_template(&self, families: &[String]) -> Vec<FontData> {
|
||||
let reg = self.registry.read().expect("font registry poisoned");
|
||||
reg.fonts_for_families(families)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DreportService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow consumers to use `&DreportService` wherever a `FontProvider` is expected.
|
||||
impl FontProvider for DreportService {
|
||||
fn list_families(&self) -> Vec<FontFamilyInfo> {
|
||||
self.list_font_families()
|
||||
}
|
||||
|
||||
fn load_font(&self, family: &str, weight: u16, italic: bool) -> Option<FontData> {
|
||||
let reg = self.registry.read().expect("font registry poisoned");
|
||||
reg.load_font(family, weight, italic)
|
||||
}
|
||||
}
|
||||
297
dreport-service/tests/service.rs
Normal file
297
dreport-service/tests/service.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
//! Integration tests for `DreportService`.
|
||||
//!
|
||||
//! These tests exercise the public API as it would be consumed by the Axum
|
||||
//! adapter, the FFI layer, and any other host. Anything that breaks here
|
||||
//! breaks behaviour for every consumer simultaneously, so failures should
|
||||
//! be treated as a contract change.
|
||||
|
||||
use dreport_service::{DreportService, ServiceError};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
const VALID_TEMPLATE: &str = r#"{
|
||||
"id": "test",
|
||||
"name": "Test",
|
||||
"page": { "width": 210, "height": 297 },
|
||||
"fonts": ["Noto Sans"],
|
||||
"root": {
|
||||
"id": "root",
|
||||
"type": "container",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"direction": "column",
|
||||
"gap": 5,
|
||||
"padding": { "top": 15, "right": 15, "bottom": 15, "left": 15 },
|
||||
"align": "stretch",
|
||||
"justify": "start",
|
||||
"style": {},
|
||||
"children": [
|
||||
{
|
||||
"id": "title",
|
||||
"type": "static_text",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"style": { "fontSize": 14, "fontWeight": "bold" },
|
||||
"content": "Hello dreport"
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
|
||||
const VALID_DATA: &str = r#"{}"#;
|
||||
|
||||
const NOTO_SANS_REGULAR: &[u8] = include_bytes!("../assets/fonts/NotoSans-Regular.ttf");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service initialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn new_loads_embedded_fonts() {
|
||||
let svc = DreportService::new();
|
||||
assert!(
|
||||
svc.font_family_count() >= 1,
|
||||
"embedded-fonts feature should provide at least one family"
|
||||
);
|
||||
let names: Vec<String> = svc
|
||||
.list_font_families()
|
||||
.into_iter()
|
||||
.map(|f| f.family.to_lowercase())
|
||||
.collect();
|
||||
assert!(
|
||||
names.iter().any(|n| n.contains("noto")),
|
||||
"Noto Sans family expected, got {:?}",
|
||||
names
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_starts_with_no_fonts() {
|
||||
let svc = DreportService::empty();
|
||||
assert_eq!(svc.font_family_count(), 0);
|
||||
assert!(svc.list_font_families().is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Font registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn register_font_bytes_valid_ttf() {
|
||||
let svc = DreportService::empty();
|
||||
let registered = svc
|
||||
.register_font_bytes(NOTO_SANS_REGULAR.to_vec())
|
||||
.expect("valid TTF should register");
|
||||
assert!(registered.family.to_lowercase().contains("noto"));
|
||||
assert_eq!(svc.font_family_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_font_bytes_invalid_returns_parse_error() {
|
||||
let svc = DreportService::empty();
|
||||
let err = svc
|
||||
.register_font_bytes(b"not a font".to_vec())
|
||||
.expect_err("garbage bytes must not parse");
|
||||
assert!(matches!(err, ServiceError::FontParseFailed));
|
||||
assert_eq!(err.code(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_fonts_directory_loads_files() {
|
||||
let svc = DreportService::empty();
|
||||
let fonts_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/fonts");
|
||||
let count = svc
|
||||
.register_fonts_directory(&fonts_dir)
|
||||
.expect("assets/fonts must be readable");
|
||||
assert!(count >= 1, "at least one font expected in assets/fonts");
|
||||
assert!(svc.font_family_count() >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_fonts_directory_missing_returns_error() {
|
||||
let svc = DreportService::empty();
|
||||
let err = svc
|
||||
.register_fonts_directory("/no/such/dreport/fonts/path/zzz")
|
||||
.expect_err("missing directory must error");
|
||||
assert!(matches!(err, ServiceError::FontDirNotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_fonts_directory_skips_non_font_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("readme.txt"), b"hi").unwrap();
|
||||
std::fs::write(dir.path().join("font.ttf"), NOTO_SANS_REGULAR).unwrap();
|
||||
|
||||
let svc = DreportService::empty();
|
||||
let count = svc.register_fonts_directory(dir.path()).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_fonts_directory_skips_invalid_font_silently() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("broken.ttf"), b"not a font").unwrap();
|
||||
std::fs::write(dir.path().join("good.ttf"), NOTO_SANS_REGULAR).unwrap();
|
||||
|
||||
let svc = DreportService::empty();
|
||||
let count = svc.register_fonts_directory(dir.path()).unwrap();
|
||||
assert_eq!(count, 1, "only the good font should register");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Font lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn get_font_bytes_returns_data_for_known_variant() {
|
||||
let svc = DreportService::new();
|
||||
let bytes = svc
|
||||
.get_font_bytes("Noto Sans", 400, false)
|
||||
.expect("regular variant should exist");
|
||||
assert!(!bytes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_font_bytes_case_insensitive() {
|
||||
let svc = DreportService::new();
|
||||
let lower = svc.get_font_bytes("noto sans", 400, false);
|
||||
let mixed = svc.get_font_bytes("NoTo SaNs", 400, false);
|
||||
assert!(lower.is_some());
|
||||
assert!(mixed.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_font_bytes_unknown_returns_none() {
|
||||
let svc = DreportService::new();
|
||||
assert!(svc.get_font_bytes("DoesNotExist", 400, false).is_none());
|
||||
assert!(svc.get_font_bytes("Noto Sans", 1234, false).is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout + render pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn compute_layout_json_valid_template_returns_pages() {
|
||||
let svc = DreportService::new();
|
||||
let json = svc
|
||||
.compute_layout_json(VALID_TEMPLATE, VALID_DATA)
|
||||
.expect("layout should compute");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
let pages = parsed
|
||||
.get("pages")
|
||||
.and_then(|p| p.as_array())
|
||||
.expect("LayoutResult must contain pages array");
|
||||
assert!(!pages.is_empty(), "at least one page expected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_layout_json_invalid_template_returns_typed_error() {
|
||||
let svc = DreportService::new();
|
||||
let err = svc
|
||||
.compute_layout_json("{not json", VALID_DATA)
|
||||
.expect_err("malformed template must error");
|
||||
assert!(matches!(err, ServiceError::InvalidTemplateJson(_)));
|
||||
assert_eq!(err.code(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_layout_json_invalid_data_returns_typed_error() {
|
||||
let svc = DreportService::new();
|
||||
let err = svc
|
||||
.compute_layout_json(VALID_TEMPLATE, "{not json")
|
||||
.expect_err("malformed data must error");
|
||||
assert!(matches!(err, ServiceError::InvalidDataJson(_)));
|
||||
assert_eq!(err.code(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_pdf_json_produces_pdf_with_magic_header() {
|
||||
let svc = DreportService::new();
|
||||
let pdf = svc
|
||||
.render_pdf_json(VALID_TEMPLATE, VALID_DATA)
|
||||
.expect("render must succeed");
|
||||
assert!(
|
||||
pdf.starts_with(b"%PDF-"),
|
||||
"PDF magic header missing; got {:?}",
|
||||
&pdf[..pdf.len().min(8)]
|
||||
);
|
||||
assert!(pdf.len() > 100, "PDF unexpectedly small");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_pdf_typed_matches_render_pdf_json() {
|
||||
let svc = DreportService::new();
|
||||
let from_json = svc
|
||||
.render_pdf_json(VALID_TEMPLATE, VALID_DATA)
|
||||
.expect("json render");
|
||||
let template = serde_json::from_str(VALID_TEMPLATE).unwrap();
|
||||
let data = serde_json::from_str(VALID_DATA).unwrap();
|
||||
let from_typed = svc.render_pdf(&template, &data).expect("typed render");
|
||||
// Producer headers vary on time; magic header + non-trivial size sufficient.
|
||||
assert!(from_json.starts_with(b"%PDF-"));
|
||||
assert!(from_typed.starts_with(b"%PDF-"));
|
||||
assert_eq!(from_json.len(), from_typed.len());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concurrency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn concurrent_renders_share_service_safely() {
|
||||
let svc = Arc::new(DreportService::new());
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let s = Arc::clone(&svc);
|
||||
handles.push(thread::spawn(move || {
|
||||
let pdf = s.render_pdf_json(VALID_TEMPLATE, VALID_DATA).unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF-"));
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
h.join().expect("worker panic");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_register_and_render() {
|
||||
let svc = Arc::new(DreportService::new());
|
||||
let mut handles = Vec::new();
|
||||
|
||||
let writer_svc = Arc::clone(&svc);
|
||||
handles.push(thread::spawn(move || {
|
||||
for _ in 0..4 {
|
||||
let _ = writer_svc.register_font_bytes(NOTO_SANS_REGULAR.to_vec());
|
||||
}
|
||||
}));
|
||||
|
||||
for _ in 0..4 {
|
||||
let s = Arc::clone(&svc);
|
||||
handles.push(thread::spawn(move || {
|
||||
let pdf = s.render_pdf_json(VALID_TEMPLATE, VALID_DATA).unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF-"));
|
||||
}));
|
||||
}
|
||||
|
||||
for h in handles {
|
||||
h.join().expect("worker panic");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn service_error_codes_are_stable() {
|
||||
// FFI consumers depend on these — changing them is a breaking change.
|
||||
assert_eq!(ServiceError::InvalidTemplateJson("x".into()).code(), 1);
|
||||
assert_eq!(ServiceError::InvalidDataJson("x".into()).code(), 2);
|
||||
assert_eq!(ServiceError::FontParseFailed.code(), 3);
|
||||
assert_eq!(ServiceError::FontDirNotFound("x".into()).code(), 4);
|
||||
assert_eq!(ServiceError::FontDirRead("x".into()).code(), 5);
|
||||
assert_eq!(ServiceError::LayoutFailed("x".into()).code(), 6);
|
||||
assert_eq!(ServiceError::PdfFailed("x".into()).code(), 7);
|
||||
assert_eq!(ServiceError::SerializationFailed("x".into()).code(), 8);
|
||||
}
|
||||
171
justfile
171
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
|
||||
|
||||
# <files> entries for every native binary that exists on disk.
|
||||
FILES=""
|
||||
while IFS= read -r path; do
|
||||
rel="${path#${PROJ_DIR}/}"
|
||||
FILES+=" <file src=\"$rel\" target=\"$rel\" />"$'\n'
|
||||
done < <(find "$PROJ_DIR/runtimes" -type f \( -name '*.dylib' -o -name '*.so' -o -name '*.dll' \) 2>/dev/null | sort)
|
||||
|
||||
{
|
||||
echo '<?xml version="1.0" encoding="utf-8"?>'
|
||||
echo '<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">'
|
||||
echo ' <metadata>'
|
||||
echo ' <id>Dreport.Service</id>'
|
||||
echo " <version>{{NUGET_VERSION}}</version>"
|
||||
echo ' <authors>dreport</authors>'
|
||||
echo ' <description>Native layout engine + PDF renderer for dreport templates. Wraps the dreport-ffi C ABI.</description>'
|
||||
echo ' <tags>pdf layout template rendering</tags>'
|
||||
echo ' <dependencies><group targetFramework="net8.0" /></dependencies>'
|
||||
echo ' </metadata>'
|
||||
echo ' <files>'
|
||||
echo ' <file src="bin/Release/net8.0/Dreport.Service.dll" target="lib/net8.0/Dreport.Service.dll" />'
|
||||
printf '%s' "$FILES"
|
||||
echo ' </files>'
|
||||
echo '</package>'
|
||||
} > "$NUSPEC"
|
||||
|
||||
rm -f "$OUT_DIR/Dreport.Service."*.nupkg
|
||||
dotnet pack "$PROJ_DIR/Dreport.Service.csproj" \
|
||||
-c Release --no-build --nologo \
|
||||
-p:NuspecFile="$NUSPEC_NAME" \
|
||||
-p:NuspecBasePath="." \
|
||||
-p:IsPackable=true \
|
||||
-o "$OUT_DIR"
|
||||
|
||||
echo "package -> $OUT_DIR/Dreport.Service.{{NUGET_VERSION}}.nupkg"
|
||||
unzip -l "$OUT_DIR/Dreport.Service.{{NUGET_VERSION}}.nupkg"
|
||||
|
||||
# Push the packed nupkg to Gitea.
|
||||
nuget-push:
|
||||
dotnet nuget push \
|
||||
"target/nuget/Dreport.Service.{{NUGET_VERSION}}.nupkg" \
|
||||
--source "{{NUGET_REGISTRY_URL}}" \
|
||||
--api-key "{{NUGET_TOKEN}}" \
|
||||
--skip-duplicate
|
||||
|
||||
# Pack Dreport.AspNetCore (depends on Dreport.Service via NuGet dependency).
|
||||
nuget-pack-aspnetcore:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
PROJ_DIR="bindings/dotnet/src/Dreport.AspNetCore"
|
||||
NUSPEC_NAME=".generated.nuspec"
|
||||
NUSPEC="$PROJ_DIR/$NUSPEC_NAME"
|
||||
OUT_DIR="$(pwd)/target/nuget"
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
dotnet build "$PROJ_DIR/Dreport.AspNetCore.csproj" -c Release --nologo
|
||||
|
||||
{
|
||||
echo '<?xml version="1.0" encoding="utf-8"?>'
|
||||
echo '<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">'
|
||||
echo ' <metadata>'
|
||||
echo ' <id>Dreport.AspNetCore</id>'
|
||||
echo " <version>{{NUGET_VERSION}}</version>"
|
||||
echo ' <authors>dreport</authors>'
|
||||
echo ' <description>ASP.NET Core integration for Dreport.Service: DI registration plus optional /api endpoint mapping.</description>'
|
||||
echo ' <tags>pdf layout aspnetcore dreport</tags>'
|
||||
echo ' <dependencies>'
|
||||
echo ' <group targetFramework="net8.0">'
|
||||
echo " <dependency id=\"Dreport.Service\" version=\"{{NUGET_VERSION}}\" />"
|
||||
echo ' </group>'
|
||||
echo ' </dependencies>'
|
||||
echo ' <frameworkReferences>'
|
||||
echo ' <group targetFramework="net8.0">'
|
||||
echo ' <frameworkReference name="Microsoft.AspNetCore.App" />'
|
||||
echo ' </group>'
|
||||
echo ' </frameworkReferences>'
|
||||
echo ' </metadata>'
|
||||
echo ' <files>'
|
||||
echo ' <file src="bin/Release/net8.0/Dreport.AspNetCore.dll" target="lib/net8.0/Dreport.AspNetCore.dll" />'
|
||||
echo ' </files>'
|
||||
echo '</package>'
|
||||
} > "$NUSPEC"
|
||||
|
||||
rm -f "$OUT_DIR/Dreport.AspNetCore."*.nupkg
|
||||
dotnet pack "$PROJ_DIR/Dreport.AspNetCore.csproj" \
|
||||
-c Release --no-build --nologo \
|
||||
-p:NuspecFile="$NUSPEC_NAME" \
|
||||
-p:NuspecBasePath="." \
|
||||
-p:IsPackable=true \
|
||||
-o "$OUT_DIR"
|
||||
|
||||
echo "package -> $OUT_DIR/Dreport.AspNetCore.{{NUGET_VERSION}}.nupkg"
|
||||
unzip -l "$OUT_DIR/Dreport.AspNetCore.{{NUGET_VERSION}}.nupkg"
|
||||
|
||||
# Push Dreport.AspNetCore to Gitea.
|
||||
nuget-push-aspnetcore:
|
||||
dotnet nuget push \
|
||||
"target/nuget/Dreport.AspNetCore.{{NUGET_VERSION}}.nupkg" \
|
||||
--source "{{NUGET_REGISTRY_URL}}" \
|
||||
--api-key "{{NUGET_TOKEN}}" \
|
||||
--skip-duplicate
|
||||
|
||||
# Single-host publish (host RID only — fastest, good for dev iterations).
|
||||
nuget-publish: nuget-build-native-host nuget-pack nuget-push nuget-pack-aspnetcore nuget-push-aspnetcore
|
||||
|
||||
# Multi-RID publish (osx-arm64 + osx-x64 + linux-x64 + linux-arm64 + win-x64).
|
||||
# Requires cargo-zigbuild. Single command, all platforms + AspNetCore, push to Gitea.
|
||||
nuget-publish-all: nuget-build-native-all nuget-pack nuget-push nuget-pack-aspnetcore nuget-push-aspnetcore
|
||||
|
||||
Reference in New Issue
Block a user