Render invoice-editor images (mfr portal only) into the PDF

The invoice line editor already shows <img> pictures pulled from the mfr
portal, but the PDF pipeline's HTML-to-MigraDoc converter had no <img>
handler, so they never appeared on the actual invoice.

InvoiceService now fetches only https://portal.mobilefieldreport.com/mfr/*
image sources before rendering (size/type/count capped, failures logged and
skipped) and hands the resolved images down through ApplyInvoice into the
HTML converter, which renders them inline at their original HTML position
and size (px converted to cm, capped against layout overflow). See ADR 0018.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 19:36:31 +02:00
co-authored by Claude Sonnet 5
parent 1a8a14791d
commit 071982caec
15 changed files with 516 additions and 28 deletions
+2 -2
View File
@@ -107,10 +107,10 @@ public class FuchsPdfService : IPdfService
}
public void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks,
FdsInvoiceData invoice, bool draft = false)
FdsInvoiceData invoice, bool draft = false, IReadOnlyDictionary<string, string>? inlineImages = null)
{
_logger.LogDebug("ApplyInvoice id={Id} draft={Draft}", invoice.Id, draft);
FuchsPdf.ApplyInvoice(doc, textBlocks, invoice, draft);
FuchsPdf.ApplyInvoice(doc, textBlocks, invoice, draft, inlineImages);
}
public void ApplyReminder(Document doc, FuchsPdf.FdsTextBlocks textBlocks,
+1 -1
View File
@@ -51,7 +51,7 @@ public interface IInvoiceDraftService
Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
Document? RenderPreview(string token);
Task<Document?> RenderPreviewAsync(string token);
/// <summary>Removes the session from the cache (explicit close/discard/finalise). Returns true if one was present.</summary>
bool Close(string token);
+2 -2
View File
@@ -17,8 +17,8 @@ public interface IInvoiceService
Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
string userAccountId, DatabaseSecurity dbSec);
/// <summary>Generates a PDF document for an invoice.</summary>
Document GenerateInvoicePdf(FdsInvoiceData invoice, bool draft);
/// <summary>Generates a PDF document for an invoice, inlining its whitelisted-origin images (ADR 0018).</summary>
Task<Document> GenerateInvoicePdfAsync(FdsInvoiceData invoice, bool draft);
/// <summary>Renders invoice PDF to bytes.</summary>
Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft);
+5 -1
View File
@@ -12,7 +12,11 @@ public interface IPdfService
Task<Document> WriteLetterAsync(FuchsPdf.FdsTextBlocks textBlocks, bool draft);
/// <summary>Applies invoice data to a letter document.</summary>
void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks, FdsInvoiceData invoice, bool draft = false);
/// <param name="inlineImages">Pre-fetched/vetted invoice-text images to inline into line
/// descriptions, keyed by their original &lt;img src&gt; (see InvoiceService.ResolveInlineImagesAsync
/// / ADR 0018). Null renders line descriptions without images, as before.</param>
void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks, FdsInvoiceData invoice, bool draft = false,
IReadOnlyDictionary<string, string>? inlineImages = null);
/// <summary>Applies reminder data to a letter document.</summary>
void ApplyReminder(Document doc, FuchsPdf.FdsTextBlocks textBlocks, FdsReminderData reminder, bool draft = false);
+2 -2
View File
@@ -527,14 +527,14 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
return reg;
}
public Document? RenderPreview(string token)
public async Task<Document?> RenderPreviewAsync(string token)
{
var session = _cache.Get(token);
if (session == null) return null;
var fds = BuildFdsData(session);
fds.InvoiceRegistration = SynthesizeRegistration(session);
fds.IsDraft = true;
return _invoices.GenerateInvoicePdf(fds, draft: true);
return await _invoices.GenerateInvoicePdfAsync(fds, draft: true);
}
public bool Close(string token) => _cache.Remove(token) != null;
+85 -5
View File
@@ -1,5 +1,6 @@
using System.Data;
using System.Diagnostics;
using System.Text.RegularExpressions;
using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Observability;
@@ -22,21 +23,40 @@ namespace Fuchs.Services;
/// </summary>
public class InvoiceService : IInvoiceService
{
/// <summary>Named <see cref="IHttpClientFactory"/> client used to fetch invoice-text images.</summary>
public const string InlineImageHttpClientName = "InvoiceInlineImages";
/// <summary>
/// The only origin invoice-text &lt;img&gt; sources are fetched from for inline PDF rendering
/// (ADR 0018) — the mfr portal picture links the invoice editor already displays. Any other
/// src (including other remote hosts) is left unrendered, exactly as before this feature.
/// </summary>
internal const string AllowedInlineImagePrefix = "https://portal.mobilefieldreport.com/mfr/";
private const int MaxInlineImageBytes = 5 * 1024 * 1024;
private const int MaxInlineImagesPerInvoice = 20;
private static readonly Regex ImgSrcRegex =
new(@"<img[^>]+src\s*=\s*[""']([^""']+)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private readonly Fuchs_intranet _intranet;
private readonly IPdfService _pdf;
private readonly IBlobStorageService _blobStorage;
private readonly IEventService _events;
private readonly IERechnungService _erechnung;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<InvoiceService> _logger;
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
IEventService events, IERechnungService erechnung, ILogger<InvoiceService> logger)
IEventService events, IERechnungService erechnung, IHttpClientFactory httpClientFactory,
ILogger<InvoiceService> logger)
{
_intranet = intranet;
_pdf = pdf;
_blobStorage = blobStorage;
_events = events;
_erechnung = erechnung;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
@@ -259,7 +279,7 @@ public class InvoiceService : IInvoiceService
}
}
public Document GenerateInvoicePdf(FdsInvoiceData invoice, bool draft)
public async Task<Document> GenerateInvoicePdfAsync(FdsInvoiceData invoice, bool draft)
{
using var act = FuchsTelemetry.StartActivity("invoice.render");
act?.SetTag("fuchs.invoice.id", invoice.Id);
@@ -276,17 +296,77 @@ public class InvoiceService : IInvoiceService
AdminUserEmail = reg?.getString("UserEmailFinalized") ?? "",
AdminDatumValue = reg?.getString("DateCreated") is { Length: > 0 } dc ? DateTime.Parse(dc) : DateTime.Now
};
var inlineImages = await ResolveInlineImagesAsync(invoice);
// WriteLetter is effectively synchronous; no Task.Run thread-hop (safe: no sync context in ASP.NET Core).
var doc = _pdf.WriteLetterAsync(tb, draft).GetAwaiter().GetResult();
var doc = await _pdf.WriteLetterAsync(tb, draft);
doc.Info.Title = reg?.getString("InvoiceTitle") ?? "";
_pdf.ApplyInvoice(doc, tb, invoice, draft);
_pdf.ApplyInvoice(doc, tb, invoice, draft, inlineImages);
FuchsTelemetry.InvoicesRendered.Add(1, new KeyValuePair<string, object?>("draft", draft));
return doc;
}
/// <summary>
/// Fetches the invoice-text &lt;img&gt; sources that come from the mfr portal (the only origin
/// the invoice editor's images can come from) so they can be inlined into the PDF, which — unlike
/// the browser-rendered editor — cannot itself resolve remote URLs. See ADR 0018.
/// Fetch failures (unreachable, wrong content-type, oversized) are logged and simply leave that
/// image out of the PDF rather than failing the whole render.
/// </summary>
internal async Task<Dictionary<string, string>> ResolveInlineImagesAsync(FdsInvoiceData invoice)
{
var result = new Dictionary<string, string>();
var urls = new List<string>();
foreach (var block in invoice.InvoiceBlocks)
foreach (var item in block.Items)
{
string desc = item.nz("desc", "");
if (desc.Length == 0) continue;
foreach (Match m in ImgSrcRegex.Matches(desc))
{
string src = m.Groups[1].Value;
if (src.StartsWith(AllowedInlineImagePrefix, StringComparison.Ordinal)
&& !urls.Contains(src) && urls.Count < MaxInlineImagesPerInvoice)
urls.Add(src);
}
}
if (urls.Count == 0) return result;
var client = _httpClientFactory.CreateClient(InlineImageHttpClientName);
foreach (var url in urls)
{
try
{
using var resp = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode)
{
_logger.LogWarning("ResolveInlineImagesAsync: {Url} returned {Status}", url, (int)resp.StatusCode);
continue;
}
string contentType = resp.Content.Headers.ContentType?.MediaType ?? "";
if (!contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("ResolveInlineImagesAsync: {Url} returned non-image content-type '{ContentType}'", url, contentType);
continue;
}
var bytes = await resp.Content.ReadAsByteArrayAsync();
if (bytes.Length == 0 || bytes.Length > MaxInlineImageBytes)
{
_logger.LogWarning("ResolveInlineImagesAsync: {Url} image size {Size} out of bounds", url, bytes.Length);
continue;
}
result[url] = "base64:" + Convert.ToBase64String(bytes);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ResolveInlineImagesAsync: failed to fetch invoice image {Url}", url);
}
}
return result;
}
public async Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
{
var doc = GenerateInvoicePdf(invoice, draft);
var doc = await GenerateInvoicePdfAsync(invoice, draft);
// Finalized invoices are emitted as a ZUGFeRD/Factur-X hybrid when eRechnung is enabled:
// eRechnungLib embeds the CII XML into the render-only visual PDF and owns the single
// PDF/A-3 layer (ADR 0012). Drafts/previews keep the plain PDF/A. Any failure falls back.