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:
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"
|
||||
BIN
dreport-service/assets/fonts/NotoSans-Bold.ttf
Normal file
BIN
dreport-service/assets/fonts/NotoSans-Bold.ttf
Normal file
Binary file not shown.
BIN
dreport-service/assets/fonts/NotoSans-BoldItalic.ttf
Normal file
BIN
dreport-service/assets/fonts/NotoSans-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
dreport-service/assets/fonts/NotoSans-Italic.ttf
Normal file
BIN
dreport-service/assets/fonts/NotoSans-Italic.ttf
Normal file
Binary file not shown.
BIN
dreport-service/assets/fonts/NotoSans-Regular.ttf
Normal file
BIN
dreport-service/assets/fonts/NotoSans-Regular.ttf
Normal file
Binary file not shown.
BIN
dreport-service/assets/fonts/NotoSansMono-Regular.ttf
Normal file
BIN
dreport-service/assets/fonts/NotoSansMono-Regular.ttf
Normal file
Binary file not shown.
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);
|
||||
}
|
||||
Reference in New Issue
Block a user