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
+3 -2
View File
@@ -709,7 +709,8 @@ public static class FuchsPdf
}
// ── ApplyInvoice ──────────────────────────────────────────────────────────
public static void ApplyInvoice(Document doc, FdsTextBlocks tb, FdsInvoiceData inv, bool draft = false)
public static void ApplyInvoice(Document doc, FdsTextBlocks tb, FdsInvoiceData inv, bool draft = false,
IReadOnlyDictionary<string, string>? inlineImages = null)
{
string invoiceType = inv.InvoiceType;
bool p13b = inv.InvoiceRegistration?.getString("InvoiceOptions")
@@ -813,7 +814,7 @@ public static class FuchsPdf
if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold);
else titleCell.AddText(line.Title);
}
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>");
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>", inlineImages);
row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base";
row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base";
row.Cells[4].AddParagraph(line.ShowPrice ? Currency(line.TotalNet) : "").Style = "TblCell_RSum";
+63 -3
View File
@@ -2,7 +2,9 @@
using MigraDoc.DocumentObjectModel;
using MigraDoc.DocumentObjectModel.Shapes;
using MigraDoc.DocumentObjectModel.Tables;
using System.Globalization;
using System.Net;
using System.Text.RegularExpressions;
// Ported from migradoc_Extensions.vb + migradoc_HtmlConverter.vb
@@ -24,8 +26,13 @@ namespace MigraDoc.Extensions
public static Section AddHtml(this Section section, string html)
=> section.Add(html, new MigraDoc.Extensions.Html.HtmlConverter());
public static Cell AddHtml(this Cell cell, string html)
=> cell.Add(html, new MigraDoc.Extensions.Html.HtmlConverter());
/// <param name="inlineImages">Maps an &lt;img&gt; element's original <c>src</c> to a MigraDoc
/// image source ("base64:...") for images already fetched/vetted by the caller (see
/// InvoiceService.ResolveInlineImagesAsync / ADR 0018). Src values not present here are
/// dropped, matching the converter's long-standing behaviour for unsupported elements.</param>
public static Cell AddHtml(this Cell cell, string html,
IReadOnlyDictionary<string, string>? inlineImages = null)
=> cell.Add(html, new MigraDoc.Extensions.Html.HtmlConverter(inlineImages));
public static TextFrame AddHtml(this TextFrame frame, string html)
=> frame.Add(html, new MigraDoc.Extensions.Html.HtmlConverter());
@@ -59,9 +66,11 @@ namespace MigraDoc.Extensions.Html
public class HtmlConverter : MigraDoc.Extensions.IConverter
{
private readonly IDictionary<string, Func<HtmlNode, DocumentObject, DocumentObject>> _handlers;
private readonly IReadOnlyDictionary<string, string>? _inlineImages;
public HtmlConverter()
public HtmlConverter(IReadOnlyDictionary<string, string>? inlineImages = null)
{
_inlineImages = inlineImages;
_handlers = new Dictionary<string, Func<HtmlNode, DocumentObject, DocumentObject>>();
AddDefaultHandlers();
}
@@ -103,6 +112,32 @@ namespace MigraDoc.Extensions.Html
};
_handlers["hr"] = (n, p) => { var para = GetPara(p); if (para != null) para.Style = "HorizontalRule"; return (DocumentObject?)para ?? p; };
// Image — only src values pre-resolved by the caller (see ctor) are rendered; anything
// else (including any remote URL the caller didn't fetch/vet) is silently dropped, same
// as every other unsupported element. Size mirrors what the editor shows (the width/height
// TinyMCE writes onto the <img> from its resize handles), capped so a stray huge size from
// hand-edited HTML can't blow out the invoice table layout.
_handlers["img"] = (n, p) =>
{
string src = n.GetAttributeValue("src", "");
if (src.Length == 0 || _inlineImages == null || !_inlineImages.TryGetValue(src, out var source))
return p;
var para = GetPara(p);
if (para == null) return p;
var img = para.AddImage(source);
img.LockAspectRatio = true;
double? wPx = ReadPxDimension(n, "width");
double? hPx = ReadPxDimension(n, "height");
if (wPx is > 0 && hPx is > 0) { img.LockAspectRatio = false; img.Width = PxToUnit(wPx.Value); img.Height = PxToUnit(hPx.Value); }
else if (wPx is > 0) img.Width = PxToUnit(wPx.Value);
else if (hPx is > 0) img.Height = PxToUnit(hPx.Value);
if (img.Width > MaxInlineImageWidth) { img.LockAspectRatio = true; img.Width = MaxInlineImageWidth; }
if (img.Height > MaxInlineImageHeight) { img.LockAspectRatio = true; img.Height = MaxInlineImageHeight; }
return para;
};
// Headings
foreach (int i in Enumerable.Range(1, 6))
{
@@ -148,6 +183,31 @@ namespace MigraDoc.Extensions.Html
}
// ── Helpers ───────────────────────────────────────────────────────────
private const double CssPxPerCm = 96 / 2.54; // CSS px (96dpi) → cm, matches browser rendering
private static readonly Unit MaxInlineImageWidth = Unit.FromCentimeter(7);
private static readonly Unit MaxInlineImageHeight = Unit.FromCentimeter(10);
private static readonly Regex StyleSizeRegex =
new(@"(?<prop>width|height)\s*:\s*(?<val>[\d.]+)px", RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>Reads a pixel width/height from the HTML <c>width</c>/<c>height</c> attribute
/// (as TinyMCE's image resize handles write it), falling back to an inline <c>style</c>
/// declaration. Percentage/unit-less-other sizes and missing values return null.</summary>
private static double? ReadPxDimension(HtmlNode n, string attr)
{
string raw = n.GetAttributeValue(attr, "");
if (double.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out var px) && px > 0)
return px;
string style = n.GetAttributeValue("style", "");
foreach (Match m in StyleSizeRegex.Matches(style))
if (string.Equals(m.Groups["prop"].Value, attr, StringComparison.OrdinalIgnoreCase)
&& double.TryParse(m.Groups["val"].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var spx)
&& spx > 0)
return spx;
return null;
}
private static Unit PxToUnit(double px) => Unit.FromCentimeter(px / CssPxPerCm);
private static DocumentObject Fmt(DocumentObject parent, TextFormat fmt)
{
if (parent is FormattedText ft) return ft.AddFormattedText(fmt);