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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user