commit af48f51e4c38f8415e2a41952ea7082120defd41 Author: chokchai Date: Mon May 4 11:40:16 2026 +0700 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..787fea9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +bin/ +obj/ +publish/ +*.user +*.suo +.vs/ +.vscode/ +.idea/ +*.log diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..1ead333 --- /dev/null +++ b/Program.cs @@ -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(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": [ , ... ] } +app.MapPost("/render/multi", async (HttpRequest req) => +{ + using var sr = new StreamReader(req.Body); + var bodyText = await sr.ReadToEndAsync(); + var body = JsonConvert.DeserializeObject(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 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? Sections { get; set; } +} diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..a728edf --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "pwa_pdf_service": { + "commandName": "Project", + "applicationUrl": "http://127.0.0.1:7082", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..6312e01 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "TemplateRoot": "wwwroot" +} diff --git a/global.json b/global.json new file mode 100644 index 0000000..ba01703 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "6.0.428", + "rollForward": "latestFeature" + } +} diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..80da0aa --- /dev/null +++ b/nuget.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/pwa_pdf_service.csproj b/pwa_pdf_service.csproj new file mode 100644 index 0000000..ae6587f --- /dev/null +++ b/pwa_pdf_service.csproj @@ -0,0 +1,31 @@ + + + + net6.0 + enable + enable + pwa_pdf_service + pwa_pdf_service + false + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/runtimeconfig.template.json b/runtimeconfig.template.json new file mode 100644 index 0000000..cab8bea --- /dev/null +++ b/runtimeconfig.template.json @@ -0,0 +1,6 @@ +{ + "configProperties": { + "System.Drawing.EnableUnixSupport": true, + "System.GC.Server": true + } +}