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:
@@ -75,7 +75,7 @@ public partial class IntranetController
|
||||
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
var doc = _invoiceDrafts.RenderPreview(Form("token"));
|
||||
var doc = await _invoiceDrafts.RenderPreviewAsync(Form("token"));
|
||||
if (doc == null) return DraftGone();
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
|
||||
@@ -75,7 +75,7 @@ public partial class IntranetController
|
||||
{
|
||||
_logger.LogInformation("Requests sprep: invoice draft {InvId} created, user={User}", fdInv.Id, UserAccountID);
|
||||
await _events.InvoiceDraftRegisteredAsync(fdInv, changed: false, userAccountId: UserAccountID);
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
|
||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
}
|
||||
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
||||
@@ -92,7 +92,7 @@ public partial class IntranetController
|
||||
{
|
||||
_logger.LogInformation("Requests sedit: invoice draft {InvId} updated, user={User}", fdInv.Id, UserAccountID);
|
||||
await _events.InvoiceDraftRegisteredAsync(fdInv, changed: true, userAccountId: UserAccountID);
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
|
||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
}
|
||||
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht aktualisiert werden.");
|
||||
@@ -396,7 +396,7 @@ public partial class IntranetController
|
||||
{
|
||||
byte[]? ct = Form("create", "0") != "1"
|
||||
? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec)
|
||||
: _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
: _pdf.DocToPdfBytes(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
|
||||
if (ct == null)
|
||||
return await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id);
|
||||
// Serve inline for the in-browser viewer, but carry the real DocumentName so the browser's
|
||||
@@ -405,7 +405,7 @@ public partial class IntranetController
|
||||
SetInlinePdfFilename(filename);
|
||||
return File(ct, "application/pdf");
|
||||
}
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
|
||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-10
|
||||
applyTo:
|
||||
- "Fuchs/code/MigraDocExtensions.cs"
|
||||
- "Fuchs/code/FuchsPdf.cs"
|
||||
- "Fuchs/Services/InvoiceService.cs"
|
||||
- "Fuchs/Services/IInvoiceService.cs"
|
||||
- "Fuchs/Services/IPdfService.cs"
|
||||
- "Fuchs/Services/FuchsPdfService.cs"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0018 — Invoice-text images are fetched at render time from a domain whitelist, not embedded as data URIs
|
||||
|
||||
## Context
|
||||
The invoice line-item description is TinyMCE-authored HTML (`FdsInvoiceData`/`InvoiceBlock.Items`
|
||||
item `desc`), and can contain `<img src="https://portal.mobilefieldreport.com/mfr/...">` picture
|
||||
links pulled from the mfr ERP — the browser resolves and displays them in the editor. The PDF
|
||||
pipeline, however, renders through a hand-rolled HTML→MigraDoc subset converter
|
||||
(`MigraDoc.Extensions.Html.HtmlConverter` in `MigraDocExtensions.cs`), which had no `img` handler
|
||||
at all: every `<img>`, remote or embedded, was silently dropped, so images the editor showed never
|
||||
appeared on the actual invoice.
|
||||
|
||||
MigraDoc/PdfSharp does not fetch remote URLs itself, and the converter runs synchronously deep
|
||||
inside the largely-synchronous `FuchsPdf` static rendering pipeline. Fetching an arbitrary
|
||||
user-supplied URL server-side is also an SSRF surface — the invoice text is user/editor-controlled,
|
||||
so a naive "download whatever `src` says" would let it reach internal/private network addresses.
|
||||
|
||||
## Decision
|
||||
- Invoice images are inlined into the PDF only when their `src` starts with the literal prefix
|
||||
`https://portal.mobilefieldreport.com/mfr/` (`InvoiceService.AllowedInlineImagePrefix`) — the
|
||||
only origin the invoice editor's picture links come from. Anything else (any other host, `http://`,
|
||||
a different path) is left unrendered, exactly as before this feature — no generic remote fetch.
|
||||
- The fetch happens once per invoice render, in `InvoiceService.ResolveInlineImagesAsync`
|
||||
(async, off a named `IHttpClientFactory` client `"InvoiceInlineImages"`, 10s timeout), *before*
|
||||
the synchronous MigraDoc render starts. `InvoiceService.GenerateInvoicePdf` therefore became
|
||||
`GenerateInvoicePdfAsync` — every caller already ran in an async context. Each fetched image is
|
||||
capped at 5MB, must report an `image/*` content-type, and the invoice fetches at most 20 distinct
|
||||
images; failures (unreachable, wrong type, oversized) are logged and just leave that one image out
|
||||
— they never fail the whole render.
|
||||
- The resolved `{src → "base64:..."}` map flows down as a plain `IReadOnlyDictionary<string,string>?`
|
||||
parameter through `IPdfService.ApplyInvoice` → `FuchsPdf.ApplyInvoice` → `Cell.AddHtml` into the
|
||||
`HtmlConverter`, whose new `img` handler only emits an image for a `src` present in that map —
|
||||
it does no fetching itself and never sees the network. Image sizing mirrors the HTML: the
|
||||
handler reads `width`/`height` (attribute or inline `style`, as TinyMCE's resize handles write
|
||||
them) and converts CSS px → cm, capped to 7×10cm so a malformed/huge size can't blow out the
|
||||
invoice table layout.
|
||||
|
||||
## Consequences
|
||||
- Any future host serving invoice-editor images must be added to the whitelist deliberately (a
|
||||
one-line prefix, or a small allowed-prefixes list if a second origin appears) — this is not
|
||||
meant to grow into a generic "fetch any URL" facility.
|
||||
- `IInvoiceService.GenerateInvoicePdf` → `GenerateInvoicePdfAsync`, and
|
||||
`IInvoiceDraftService.RenderPreview` → `RenderPreviewAsync` (same signature change, same reason)
|
||||
are breaking signature changes; anything implementing/mocking these interfaces needs updating.
|
||||
- Image src values not covered by the whitelist — including a stray non-mfr remote URL, or a raw
|
||||
`data:` URI a user might paste in — still render as nothing, matching the pre-existing (silent)
|
||||
drop behaviour. If embedded `data:` images need support later, that's a separate, simpler
|
||||
decision (no network fetch involved) and can reuse the same `img` handler contract.
|
||||
|
||||
## Alternatives considered
|
||||
- **Fetch inside the HTML converter itself (sync-over-async `HttpClient.GetAsync(...).Result`)** —
|
||||
rejected: violates the project's no-sync-over-async rule and keeps SSRF-relevant network code
|
||||
inside a generic, reusable HTML converter instead of a single audited call site.
|
||||
- **Fully embed images as `data:` base64 URIs at editor save time** — rejected: doubles storage
|
||||
size per invoice edit, and the image is already resolved by the mfr portal itself; re-fetching a
|
||||
small number of images once at render time is simpler and keeps the source of truth external.
|
||||
- **Allow any remote `http(s)://` host** — rejected on SSRF grounds; every invoice image the editor
|
||||
can currently produce is from the mfr portal, so a whitelist costs nothing today.
|
||||
@@ -128,6 +128,10 @@ public class Program
|
||||
builder.Services.AddHttpClient(ProcessWebERechnungValidator.HttpClientName,
|
||||
c => c.Timeout = TimeSpan.FromSeconds(90));
|
||||
builder.Services.AddHttpClient("ProcessWebMailer");
|
||||
// Fetches invoice-editor images (mfr:// picture links) for inline PDF rendering; whitelist-
|
||||
// restricted to the mfr portal (see InvoiceService.AllowedInlineImagePrefix / ADR 0018).
|
||||
builder.Services.AddHttpClient(InvoiceService.InlineImageHttpClientName,
|
||||
c => c.Timeout = TimeSpan.FromSeconds(10));
|
||||
builder.Services.AddScoped<IComService, ProcessWebComService>();
|
||||
// Holds the one-shot startup self-test result for the lifetime of the process so the Admin
|
||||
// module can display it as a non-refreshable widget (must be registered before the service).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <img src> (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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <img> 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 <img> 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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 <img> 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);
|
||||
|
||||
Reference in New Issue
Block a user