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:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
publish/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.log
|
||||||
198
Program.cs
Normal file
198
Program.cs
Normal 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; }
|
||||||
|
}
|
||||||
11
Properties/launchSettings.json
Normal file
11
Properties/launchSettings.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"pwa_pdf_service": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"applicationUrl": "http://127.0.0.1:7082",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
appsettings.json
Normal file
10
appsettings.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"TemplateRoot": "wwwroot"
|
||||||
|
}
|
||||||
6
global.json
Normal file
6
global.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "6.0.428",
|
||||||
|
"rollForward": "latestFeature"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
nuget.config
Normal file
7
nuget.config
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||||
|
<add key="71dev" value="https://nuget.71dev.com/v3/index.json" />
|
||||||
|
</packageSources>
|
||||||
|
</configuration>
|
||||||
31
pwa_pdf_service.csproj
Normal file
31
pwa_pdf_service.csproj
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>pwa_pdf_service</RootNamespace>
|
||||||
|
<AssemblyName>pwa_pdf_service</AssemblyName>
|
||||||
|
<InvariantGlobalization>false</InvariantGlobalization>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FastReport.Core" Version="2021.3.0" />
|
||||||
|
<PackageReference Include="PdfSharpCore" Version="1.3.65" />
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- FastReport templates and fonts shared from main API -->
|
||||||
|
<None Include="..\pwa_api\wwwroot\reports\**\*" Link="wwwroot\reports\%(RecursiveDir)%(Filename)%(Extension)">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Include="..\pwa_api\wwwroot\documents\**\*" Link="wwwroot\documents\%(RecursiveDir)%(Filename)%(Extension)">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Include="..\pwa_api\Fonts\**\*" Link="Fonts\%(RecursiveDir)%(Filename)%(Extension)">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
runtimeconfig.template.json
Normal file
6
runtimeconfig.template.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"configProperties": {
|
||||||
|
"System.Drawing.EnableUnixSupport": true,
|
||||||
|
"System.GC.Server": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user