Initial commit: FastReport PDF rendering sidecar

Tiny .NET 6 web app that accepts a JSON dataset and a FastReport template
name, renders to PDF, and returns the bytes. Exists because System.Drawing
is unsupported on .NET 8 Linux even with libgdiplus, but works on .NET 6
with `System.Drawing.EnableUnixSupport=true`. The main pwa_api (.NET 8)
calls this over loopback HTTP.

Endpoints:
  POST /render        single template, returns one PDF
  POST /render/multi  array of sections, merges into one PDF
  GET  /health        liveness probe

Templates and Fonts are sourced at build time from the sibling pwa_api
project so there's a single source of truth for .frx files.

Deployed as pwa-pdf-service.service on api.pwabilling.com, listening on
127.0.0.1:7082, self-contained (no system .NET 6 runtime needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 11:40:16 +07:00
commit af48f51e4c
8 changed files with 278 additions and 0 deletions

198
Program.cs Normal file
View File

@@ -0,0 +1,198 @@
using System.Collections.Generic;
using System.Data;
using FastReport;
using FastReport.Export.Pdf;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using PdfSharpCore.Pdf;
using PdfSharpCore.Pdf.IO;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
var templateRoot = Path.Combine(
AppContext.BaseDirectory,
builder.Configuration["TemplateRoot"] ?? "wwwroot");
app.Logger.LogInformation("Template root: {TemplateRoot}", templateRoot);
app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow }));
// Render a single template + dataset to a PDF.
// Body shape:
// {
// "template": "report_6", // resolves to wwwroot/reports/report_6.frx
// "dataSourceName": "abnormal_meter_not_send_report",
// "rows": [ { "field": "value", ... }, ... ],
// "fileName": "report.pdf" // optional, response Content-Disposition
// }
app.MapPost("/render", async (HttpRequest req) =>
{
using var sr = new StreamReader(req.Body);
var bodyText = await sr.ReadToEndAsync();
var body = JsonConvert.DeserializeObject<RenderRequest>(bodyText)
?? throw new ArgumentException("Empty body");
var pdf = RenderSingle(templateRoot, body);
return Results.File(pdf, "application/pdf", body.FileName ?? $"{body.Template}.pdf");
});
// Render multiple sections and merge them into a single PDF.
// Body shape: { "fileName": "merged.pdf", "sections": [ <RenderRequest>, ... ] }
app.MapPost("/render/multi", async (HttpRequest req) =>
{
using var sr = new StreamReader(req.Body);
var bodyText = await sr.ReadToEndAsync();
var body = JsonConvert.DeserializeObject<MultiRenderRequest>(bodyText)
?? throw new ArgumentException("Empty body");
if (body.Sections == null || body.Sections.Count == 0)
return Results.BadRequest(new { error = "sections must contain at least one entry" });
var streams = body.Sections.Select(s => new MemoryStream(RenderSingle(templateRoot, s))).ToList();
var merged = MergePdfs(streams);
foreach (var s in streams) s.Dispose();
return Results.File(merged, "application/pdf", body.FileName ?? "merged.pdf");
});
app.Run();
return;
// ---- helpers --------------------------------------------------------------
static byte[] RenderSingle(string templateRoot, RenderRequest req)
{
if (string.IsNullOrWhiteSpace(req.Template))
throw new ArgumentException("template is required");
var path = ResolveTemplatePath(templateRoot, req.Template);
if (!File.Exists(path))
throw new FileNotFoundException($"Template not found: {req.Template}", path);
using var report = new Report();
report.Load(path);
if (req.Rows != null && req.Rows.Count > 0)
{
var dt = JArrayToDataTable(req.Rows, req.DataSourceName ?? "data");
report.RegisterData(dt, dt.TableName);
report.GetDataSource(dt.TableName).Enabled = true;
}
else if (!string.IsNullOrEmpty(req.DataSourceName))
{
// No rows but a name was given — register an empty table so the template
// renders its bands without the "data source not found" error.
var dt = new DataTable(req.DataSourceName);
report.RegisterData(dt, dt.TableName);
}
report.Prepare();
using var ms = new MemoryStream();
var pdfExport = new PDFExport();
report.Export(pdfExport, ms);
return ms.ToArray();
}
// Resolves "report_6" → wwwroot/reports/report_6.frx, with "documents/uuid"
// supported for the empty-report templates that live under wwwroot/documents.
static string ResolveTemplatePath(string root, string name)
{
if (name.EndsWith(".frx", StringComparison.OrdinalIgnoreCase))
{
var direct = Path.Combine(root, name);
if (File.Exists(direct)) return direct;
}
var withExt = name.EndsWith(".frx") ? name : name + ".frx";
var inReports = Path.Combine(root, "reports", withExt);
if (File.Exists(inReports)) return inReports;
var inDocuments = Path.Combine(root, "documents", withExt);
if (File.Exists(inDocuments)) return inDocuments;
return inReports; // let RenderSingle throw a clean FileNotFoundException
}
// Build a DataTable whose columns are the union of keys present across the
// JArray rows. FastReport binds .frx columns to DataTable columns by name.
static DataTable JArrayToDataTable(JArray rows, string tableName)
{
var dt = new DataTable(tableName);
foreach (var token in rows)
{
if (token is not JObject obj) continue;
foreach (var prop in obj.Properties())
{
if (dt.Columns.Contains(prop.Name)) continue;
dt.Columns.Add(prop.Name, InferColumnType(prop.Value));
}
}
foreach (var token in rows)
{
if (token is not JObject obj) continue;
var row = dt.NewRow();
foreach (DataColumn col in dt.Columns)
{
var jt = obj[col.ColumnName];
row[col.ColumnName] = JTokenToCell(jt, col.DataType);
}
dt.Rows.Add(row);
}
return dt;
}
static Type InferColumnType(JToken? value) => value?.Type switch
{
JTokenType.Integer => typeof(long),
JTokenType.Float => typeof(decimal),
JTokenType.Boolean => typeof(bool),
JTokenType.Date => typeof(DateTime),
_ => typeof(string),
};
static object JTokenToCell(JToken? jt, Type targetType)
{
if (jt == null || jt.Type == JTokenType.Null || jt.Type == JTokenType.Undefined)
return DBNull.Value;
try
{
if (targetType == typeof(string)) return jt.ToString();
return jt.ToObject(targetType) ?? (object)DBNull.Value;
}
catch
{
return jt.ToString();
}
}
static byte[] MergePdfs(IEnumerable<MemoryStream> sources)
{
using var output = new PdfDocument();
foreach (var src in sources)
{
src.Position = 0;
using var input = PdfReader.Open(src, PdfDocumentOpenMode.Import);
for (var i = 0; i < input.PageCount; i++)
output.AddPage(input.Pages[i]);
}
using var ms = new MemoryStream();
output.Save(ms, false);
return ms.ToArray();
}
// ---- DTOs -----------------------------------------------------------------
public class RenderRequest
{
public string Template { get; set; } = "";
public string? DataSourceName { get; set; }
public JArray? Rows { get; set; }
public string? FileName { get; set; }
}
public class MultiRenderRequest
{
public string? FileName { get; set; }
public List<RenderRequest>? Sections { get; set; }
}