feat: dreport-service + dreport-ffi + nuget packages
Some checks failed
CI / rust (push) Failing after 40s
CI / frontend (push) Failing after 1m53s
CI / wasm (push) Successful in 1m45s
CI / publish-crates (push) Has been skipped
CI / publish-npm (push) Has been skipped

Extract orchestration (font registry + render pipeline) from the Axum
backend into a standalone dreport-service crate. Backend becomes a thin
HTTP adapter on top.

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 16:19:47 +03:00
parent 92583141c9
commit 2db5929e39
44 changed files with 3377 additions and 252 deletions

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

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

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

View File

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