Files
LehrerApp/LehrerApp.Templating/QuestTemplateRenderer.cs
T
adminandClaude Sonnet 5 92e497b54f
CI / build-and-test (push) Waiting to run
fix: Fehlzeitenkalender Spaltenbreiten und unnötigen Seitenumbruch
Datum-Spalte in der Fehltage-Tabelle war für "dd.MM.yyyy" zu schmal
bemessen (an der kurzen Kopfzeile "Datum" orientiert) und wurde
abgeschnitten. RenderFlow erzwang außerdem zwischen den Ausschnitten
einer zu hohen Zeichenbox einen harten Seitenumbruch unabhängig vom
tatsächlich verbleibenden Platz auf der Seite - dadurch begann z.B.
der zweite Monat auf Seite 3, obwohl Seite 2 noch reichlich Platz
hatte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:42:29 +02:00

768 lines
42 KiB
C#

using System.Globalization;
using System.Security;
using System.Text;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace LehrerApp.Templating;
public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewRenderer
{
static QuestTemplateRenderer() => QuestPDF.Settings.License = LicenseType.Community;
public byte[] RenderToPdf(LoadedTemplate template, ITemplateDataProvider data) =>
BuildDocument(template, ValidateData(template, data)).GeneratePdf();
public byte[] RenderFirstPageToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
{
return RenderPagesToPng(template, data, dpi).First();
}
public IReadOnlyList<byte[]> RenderPagesToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
{
var settings = new ImageGenerationSettings { ImageFormat = ImageFormat.Png, RasterDpi = dpi };
return BuildDocument(template, ValidateData(template, data)).GenerateImages(settings).ToList();
}
private static IReadOnlyDictionary<string, PlaceholderValue> ValidateData(LoadedTemplate template,
ITemplateDataProvider provider)
{
var values = TemplateDataResolver.Resolve(template.Manifest, provider.GetValues());
var validation = new TemplateLoader().Validate(template, values.ToDictionary(x => x.Key, x => x.Value.Type));
if (!validation.IsValid) throw new TemplateValidationException(validation);
return values;
}
private static IDocument BuildDocument(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
values = PreparePagedDrawings(template, values);
return template.Layout.UsesPageTemplates
? BuildFlowDocument(template, values)
: BuildLegacyDocument(template, values);
}
private static IDocument BuildLegacyDocument(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
var flowBox = template.Layout.Elements.SingleOrDefault(x => x is FlowBoxElement or FlowDrawBoxElement);
return Document.Create(document => document.Page(page =>
{
page.Size(UnitConverter.Points(template.Layout.Width, template.Layout.Unit),
UnitConverter.Points(template.Layout.Height, template.Layout.Unit));
page.Margin(0);
page.Content().Layers(layers =>
{
if (flowBox is null)
layers.PrimaryLayer().Width(UnitConverter.Points(template.Layout.Width, template.Layout.Unit))
.Height(UnitConverter.Points(template.Layout.Height, template.Layout.Unit)).Background(Colors.White);
else
RenderLegacyFlowElement(layers.PrimaryLayer(), flowBox, template, values);
RenderStaticLayers(layers, template.Layout, template, values,
template.ContinuationLayout is null ? null : static container => container.ShowOnce());
if (template.ContinuationLayout is { } continuation)
RenderStaticLayers(layers, continuation, template, values, static container => container.SkipOnce());
});
}));
}
private static IReadOnlyDictionary<string, PlaceholderValue> PreparePagedDrawings(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
var result = new Dictionary<string, PlaceholderValue>(values, StringComparer.Ordinal);
var layouts = template.ContinuationLayout is null
? new[] { template.Layout }
: new[] { template.Layout, template.ContinuationLayout };
var drawingElements = layouts.SelectMany(x => x.Elements)
.Where(x => x is DrawBoxElement or FlowDrawBoxElement).ToList();
foreach (var group in drawingElements.GroupBy(x => x switch
{ DrawBoxElement box => box.Placeholder, FlowDrawBoxElement box => box.Placeholder, _ => "" }))
{
if (values.GetValueOrDefault(group.Key) is not PagedDrawingValue) continue;
var signatures = group.Select(x => (x.GetType(), x.Width, x.Height)).Distinct().Count();
if (signatures > 1)
throw new InvalidDataException($"Der Callback-Platzhalter „{group.Key}“ wird in unterschiedlich großen oder unterschiedlichen Zeichenboxen verwendet.");
}
foreach (var element in drawingElements)
{
var placeholder = element switch
{ DrawBoxElement box => box.Placeholder, FlowDrawBoxElement box => box.Placeholder, _ => null };
if (placeholder is null || values.GetValueOrDefault(placeholder) is not PagedDrawingValue callback) continue;
if (result.GetValueOrDefault(placeholder) is RecordedDrawingValue) continue;
var pages = element is FlowDrawBoxElement
? DrawingElementRenderer.RecordPages(callback, element.Width, element.Height, callback.MaxPages)
: [DrawingElementRenderer.RecordSingle(callback, element.Width, element.Height)];
result[placeholder] = new RecordedDrawingValue(pages);
}
return result;
}
private static void RenderStaticLayers(LayersDescriptor layers, TemplateLayout layout, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values, Func<IContainer, IContainer>? visibility)
{
foreach (var element in layout.Elements.Where(x => x is not (FlowBoxElement or FlowDrawBoxElement)))
{
var current = element;
layers.Layer().Element(container => RenderElement(
visibility is null ? container : visibility(container), current, template, values));
}
}
private static void RenderLegacyFlowElement(IContainer container, TemplateElement element, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
var unit = template.Layout.Unit;
container
.PaddingLeft(UnitConverter.Points(element.X, unit))
.PaddingTop(UnitConverter.Points(element.Y, unit))
.PaddingRight(UnitConverter.Points(Math.Max(0, template.Layout.Width - element.X - element.Width), unit))
.PaddingBottom(UnitConverter.Points(Math.Max(0, template.Layout.Height - element.Y - element.Height), unit))
.Element(content =>
{
if (element is FlowBoxElement box)
RenderResolvedText(content, box.Content, box.Placeholder, box.Format,
values, template.Manifest, box.Attributes);
else if (element is FlowDrawBoxElement drawing
&& values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue)
DrawingElementRenderer.RenderFlow(content, drawingValue, drawing.Width, drawing.Height, unit);
});
}
private static IDocument BuildFlowDocument(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
var firstPage = template.Layout.PageTemplates.First(x =>
x.Name.Equals("first", StringComparison.OrdinalIgnoreCase));
var continuationPage = template.Layout.PageTemplates.FirstOrDefault(x =>
x.Name.Equals("continuation", StringComparison.OrdinalIgnoreCase)) ?? firstPage;
var populatedFlows = template.Layout.ContentFlows.Where(x => x.Elements.Count > 0).ToList();
if (populatedFlows.Count > 1)
throw new InvalidDataException("Aktuell darf genau ein Content-Flow Inhalt enthalten. Weitere Flow-Slots können bereits gestaltet werden, parallele paginierende Flows folgen in einer späteren Formatstufe.");
var primaryFlow = populatedFlows.FirstOrDefault() ?? template.Layout.ContentFlows.FirstOrDefault();
return Document.Create(document =>
{
document.Page(page =>
{
var pageWidth = UnitConverter.Points(template.Layout.Width, template.Layout.Unit);
var pageHeight = UnitConverter.Points(template.Layout.Height, template.Layout.Unit);
page.Size(pageWidth, pageHeight);
page.Margin(0);
ConfigurePageCanvas(page.Background(), firstPage, continuationPage, template, values);
if (primaryFlow is null)
{
page.Content().Height(1);
return;
}
var firstSlot = FindSlot(firstPage, primaryFlow.Name);
var continuationSlot = FindSlot(continuationPage, primaryFlow.Name) ?? firstSlot;
if (firstSlot is null)
throw new InvalidDataException($"Für content-flow „{primaryFlow.Name}“ fehlt ein flow-slot auf der ersten Seite.");
if (Math.Abs(firstSlot.X - continuationSlot!.X) > 0.01f ||
Math.Abs(firstSlot.Width - continuationSlot.Width) > 0.01f)
throw new InvalidDataException($"Der primäre Flow „{primaryFlow.Name}“ muss vorerst auf erster und Folgeseite dieselbe X-Position und Breite besitzen.");
var left = UnitConverter.Points(firstSlot.X, template.Layout.Unit);
var right = UnitConverter.Points(template.Layout.Width - firstSlot.X - firstSlot.Width, template.Layout.Unit);
page.MarginLeft(left);
page.MarginRight(right);
ConfigureVariableVerticalSlot(page, firstSlot, continuationSlot, template.Layout);
page.Content().Column(column => RenderFlowColumn(column, primaryFlow.Elements, template, values));
});
});
}
private static void ConfigureVariableVerticalSlot(PageDescriptor page, FlowSlotDefinition first,
FlowSlotDefinition continuation, TemplateLayout layout)
{
var firstTop = UnitConverter.Points(first.Y, layout.Unit);
var continuationTop = UnitConverter.Points(continuation.Y, layout.Unit);
var firstBottom = UnitConverter.Points(layout.Height - first.Y - first.Height, layout.Unit);
var continuationBottom = UnitConverter.Points(layout.Height - continuation.Y - continuation.Height, layout.Unit);
page.Header().Column(column =>
{
column.Item().ShowOnce().Height(firstTop);
column.Item().SkipOnce().Height(continuationTop);
});
page.Footer().Column(column =>
{
column.Item().ShowOnce().Height(firstBottom);
column.Item().SkipOnce().Height(continuationBottom);
});
}
private static FlowSlotDefinition? FindSlot(PageTemplateDefinition page, string name) =>
page.FlowSlots.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
private static void ConfigurePageCanvas(IContainer canvas, PageTemplateDefinition first,
PageTemplateDefinition continuation, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
canvas.Layers(layers =>
{
layers.PrimaryLayer().Background(Colors.White);
foreach (var element in first.Elements)
{
var current = element;
layers.Layer().ShowOnce().Element(root => RenderElement(root, current, template, values));
}
foreach (var element in continuation.Elements)
{
var current = element;
layers.Layer().SkipOnce().Element(root => RenderElement(root, current, template, values));
}
});
}
private static void RenderFlowColumn(ColumnDescriptor column, IReadOnlyList<TemplateElement> elements,
LoadedTemplate template, IReadOnlyDictionary<string, PlaceholderValue> values)
{
for (var index = 0; index < elements.Count; index++)
{
var element = elements[index];
var gap = UnitConverter.Points(ParseFloat(element.Attributes, "gap", 0), template.Layout.Unit);
var keepWithNext = ParseBool(element.Attributes, "keep-with-next") && index + 1 < elements.Count;
if (keepWithNext)
{
var next = elements[++index];
column.Item().PaddingTop(gap).PreventPageBreak().Column(group =>
{
group.Item().Element(container => RenderFlowElement(container, element, template, values));
var nextGap = UnitConverter.Points(ParseFloat(next.Attributes, "gap", 0), template.Layout.Unit);
group.Item().PaddingTop(nextGap).Element(container => RenderFlowElement(container, next, template, values));
});
}
else
column.Item().PaddingTop(gap).Element(container => RenderFlowElement(container, element, template, values));
}
}
private static void RenderFlowElement(IContainer container, TemplateElement element, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
switch (element)
{
case TextElement text:
RenderResolvedText(container, text.Content, text.Placeholder, text.Format,
values, template.Manifest, text.Attributes);
break;
case TextBoxElement box:
RenderResolvedText(container, box.Content, box.Placeholder, box.Format,
values, template.Manifest, box.Attributes);
break;
case FlowBoxElement box:
RenderResolvedText(container, box.Content, box.Placeholder, box.Format,
values, template.Manifest, box.Attributes);
break;
case DrawBoxElement drawing when values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue:
DrawingElementRenderer.RenderFixed(
container.Width(UnitConverter.Points(drawing.Width, template.Layout.Unit))
.Height(UnitConverter.Points(drawing.Height, template.Layout.Unit)),
drawingValue, drawing.Width, drawing.Height, template.Layout.Unit);
break;
case FlowDrawBoxElement drawing when values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue:
DrawingElementRenderer.RenderFlow(container, drawingValue,
drawing.Width, drawing.Height, template.Layout.Unit);
break;
case ImageElement image:
var imageContainer = container;
if (image.Width > 0) imageContainer = imageContainer.Width(UnitConverter.Points(image.Width, template.Layout.Unit));
if (image.Height > 0) imageContainer = imageContainer.Height(UnitConverter.Points(image.Height, template.Layout.Unit));
imageContainer.Image(GetAsset(template, image.Path)).FitArea();
break;
case TableElement table when values.GetValueOrDefault(table.Placeholder) is TableValue tableValue:
TableElementRenderer.Render(container, tableValue, table.Attributes);
break;
case ChartElement chart when values.GetValueOrDefault(chart.Placeholder) is ChartValue chartValue:
var chartHeight = UnitConverter.Points(ParseFloat(chart.Attributes, "h", 50), template.Layout.Unit);
ChartElementRenderer.Render(container.Height(chartHeight), chartValue, chart.ChartType, chart.Attributes);
break;
}
}
private static void RenderElement(IContainer root, TemplateElement element, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
var unit = template.Layout.Unit;
switch (element)
{
case BackgroundElement background:
root.Width(UnitConverter.Points(template.Layout.Width, unit))
.Height(UnitConverter.Points(template.Layout.Height, unit))
.Image(GetAsset(template, background.Path)).FitArea();
break;
case ImageElement image:
var imageScale = image.Attributes.TryGetValue("scale", out var scale)
? LayoutParser.Percentage(scale) : 1f;
Position(root, image, unit, imageScale).Image(GetAsset(template, image.Path)).FitArea();
break;
case TextElement text:
var textContainer = root.TranslateX(UnitConverter.Points(text.X, unit))
.TranslateY(UnitConverter.Points(text.Y, unit))
.Width(UnitConverter.Points(Math.Max(0, template.Layout.Width - text.X), unit))
.Height(QuestTemplateRenderer.ParseFloat(text.Attributes, "size", 11) * 1.8f);
RenderResolvedText(textContainer, text.Content, text.Placeholder, text.Format,
values, template.Manifest, text.Attributes);
break;
case TextBoxElement box:
RenderResolvedText(Position(root, box, unit).Shrink(), box.Content, box.Placeholder, box.Format,
values, template.Manifest, box.Attributes);
break;
case FlowBoxElement box:
RenderResolvedText(Position(root, box, unit).Shrink(), box.Content, box.Placeholder, box.Format,
values, template.Manifest, box.Attributes);
break;
case DrawBoxElement drawing:
if (values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue)
DrawingElementRenderer.RenderFixed(Position(root, drawing, unit), drawingValue,
drawing.Width, drawing.Height, unit);
break;
case FlowDrawBoxElement drawing:
if (values.GetValueOrDefault(drawing.Placeholder) is { } flowDrawingValue)
DrawingElementRenderer.RenderFixed(Position(root, drawing, unit), flowDrawingValue,
drawing.Width, drawing.Height, unit);
break;
case TableElement table:
if (values.GetValueOrDefault(table.Placeholder) is TableValue tableValue)
TableElementRenderer.Render(Position(root, table, unit), tableValue, table.Attributes);
break;
case ChartElement chart:
if (values.GetValueOrDefault(chart.Placeholder) is ChartValue chartValue)
ChartElementRenderer.Render(Position(root, chart, unit), chartValue, chart.ChartType, chart.Attributes);
break;
}
}
private static IContainer Position(IContainer root, TemplateElement element, string unit, float scale = 1) => root
.TranslateX(UnitConverter.Points(element.X, unit)).TranslateY(UnitConverter.Points(element.Y, unit))
.Width(UnitConverter.Points(element.Width * scale, unit)).Height(UnitConverter.Points(element.Height * scale, unit));
private static byte[] GetAsset(LoadedTemplate template, string path) =>
template.Assets.TryGetValue(TemplateLoader.Normalize(path), out var bytes) ? bytes
: throw new InvalidDataException($"Asset „{path}“ fehlt.");
internal static void RenderText(IContainer container, string content, IReadOnlyDictionary<string, string> attributes)
{
var aligned = attributes.GetValueOrDefault("align", "left").ToLowerInvariant() switch
{ "center" => container.AlignCenter(), "right" => container.AlignRight(), _ => container.AlignLeft() };
if (SystemVariables.Contains(content))
{
aligned.Text(text =>
{
text.DefaultTextStyle(style => ApplyTextStyle(style, attributes));
AppendSystemVariables(text, content);
});
return;
}
var descriptor = aligned.Text(content);
descriptor.FontSize(ParseFloat(attributes, "size", 11));
if (ParseBool(attributes, "bold")) descriptor.SemiBold();
if (ParseBool(attributes, "italic")) descriptor.Italic();
if (ParseBool(attributes, "underline")) descriptor.Underline();
if (attributes.TryGetValue("color", out var color)) descriptor.FontColor(color);
}
private static void RenderResolvedText(IContainer container, string content, string? placeholder, string? format,
IReadOnlyDictionary<string, PlaceholderValue> values, TemplateManifest manifest,
IReadOnlyDictionary<string, string> elementAttributes)
{
var attributes = TextAttributes(manifest, placeholder, elementAttributes);
var definition = placeholder is null ? null
: manifest.Placeholders.FirstOrDefault(x => x.Name.Equals(placeholder, StringComparison.Ordinal));
if (definition is { IsConstant: true, Type: PlaceholderType.Text or PlaceholderType.Multiline })
{
RenderRichText(container, TemplateRichText.Parse(definition.ConstantValue ?? ""), values, attributes);
return;
}
RenderText(container, ResolveContent(content, placeholder, format, values), attributes);
}
private static void RenderRichText(IContainer container, IReadOnlyList<TemplateRichTextRun> runs,
IReadOnlyDictionary<string, PlaceholderValue> values, IReadOnlyDictionary<string, string> attributes)
{
var aligned = attributes.GetValueOrDefault("align", "left").ToLowerInvariant() switch
{ "center" => container.AlignCenter(), "right" => container.AlignRight(), _ => container.AlignLeft() };
var fontSize = ParseFloat(attributes, "size", 11);
var globalBold = ParseBool(attributes, "bold");
var globalItalic = ParseBool(attributes, "italic");
var globalUnderline = ParseBool(attributes, "underline");
attributes.TryGetValue("color", out var color);
aligned.Text(text =>
{
foreach (var run in runs)
{
var content = run.Placeholder is null ? run.Text
: values.TryGetValue(run.Placeholder, out var value) ? Format(value, run.Format) : "";
foreach (var span in AppendSystemVariables(text, content))
{
span.FontSize(fontSize);
if (globalBold || run.Bold) span.SemiBold();
if (globalItalic || run.Italic) span.Italic();
if (globalUnderline || run.Underline) span.Underline();
if (color is not null) span.FontColor(color);
}
}
});
}
private static TextStyle ApplyTextStyle(TextStyle style, IReadOnlyDictionary<string, string> attributes)
{
style = style.FontSize(ParseFloat(attributes, "size", 11));
if (ParseBool(attributes, "bold")) style = style.SemiBold();
if (ParseBool(attributes, "italic")) style = style.Italic();
if (ParseBool(attributes, "underline")) style = style.Underline();
if (attributes.TryGetValue("color", out var color)) style = style.FontColor(color);
return style;
}
private static IEnumerable<TextSpanDescriptor> AppendSystemVariables(TextDescriptor text, string content)
{
foreach (var part in SystemVariables.Split(content))
{
yield return part.Name switch
{
SystemVariables.CurrentPage => text.CurrentPageNumber(),
SystemVariables.MaximumPageNumber => text.TotalPages(),
SystemVariables.Today => text.Span(DateTime.Today.ToString("d", CultureInfo.GetCultureInfo("de-DE"))),
_ => text.Span(part.Text),
};
}
}
private static IReadOnlyDictionary<string, string> TextAttributes(TemplateManifest manifest, string? placeholder,
IReadOnlyDictionary<string, string> elementAttributes)
{
if (placeholder is null) return elementAttributes;
var definition = manifest.Placeholders.FirstOrDefault(x => x.Name.Equals(placeholder, StringComparison.Ordinal));
if (definition is null || (!definition.Bold && !definition.Italic && !definition.Underline)) return elementAttributes;
var result = new Dictionary<string, string>(elementAttributes, StringComparer.OrdinalIgnoreCase);
if (definition.Bold) result["bold"] = "true";
if (definition.Italic) result["italic"] = "true";
if (definition.Underline) result["underline"] = "true";
return result;
}
private static string ResolveContent(string content, string? placeholder, string? format,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
if (placeholder is not null)
return values.TryGetValue(placeholder, out var value) ? Format(value, format) : "";
var result = content;
foreach (var entry in values)
result = result.Replace("$" + entry.Key, Format(entry.Value, null), StringComparison.Ordinal);
return result;
}
private static string Format(PlaceholderValue value, string? format) => value switch
{
TextValue text => text.Value,
MultilineValue text => text.Value,
DateValue date => date.Value.ToString(format ?? "d", CultureInfo.GetCultureInfo("de-DE")),
NumberValue number => number.Value.ToString(format, CultureInfo.GetCultureInfo("de-DE")),
_ => "",
};
internal static float ParseFloat(IReadOnlyDictionary<string, string> attributes, string key, float fallback) =>
attributes.TryGetValue(key, out var raw) && float.TryParse(raw, NumberStyles.Float,
CultureInfo.InvariantCulture, out var value) ? value : fallback;
internal static bool ParseBool(IReadOnlyDictionary<string, string> attributes, string key) =>
attributes.TryGetValue(key, out var raw) && bool.TryParse(raw, out var value) && value;
}
internal sealed record RecordedDrawingValue(IReadOnlyList<IReadOnlyList<DrawingCommand>> Pages)
: PlaceholderValue(PlaceholderType.Drawing);
public static class UnitConverter
{
public static float Points(float value, string unit) => unit.ToLowerInvariant() switch
{ "mm" => value * 72f / 25.4f, "cm" => value * 72f / 2.54f, "in" => value * 72f, "pt" => value,
_ => throw new ArgumentOutOfRangeException(nameof(unit), unit, "Unbekannte Einheit.") };
}
internal static class TableElementRenderer
{
public static void Render(IContainer container, TableValue value, IReadOnlyDictionary<string, string> attributes)
{
if (value.Columns.Count == 0) return;
var fontSize = QuestTemplateRenderer.ParseFloat(attributes, "size", 9);
container.Table(table =>
{
table.ColumnsDefinition(columns => { foreach (var _ in value.Columns) columns.RelativeColumn(); });
table.Header(header =>
{
foreach (var column in value.Columns)
header.Cell().Background(Colors.Grey.Lighten2).Border(0.5f).BorderColor(Colors.Grey.Medium)
.Padding(3).Text(column).FontSize(fontSize).SemiBold();
});
foreach (var row in value.Rows)
for (var i = 0; i < value.Columns.Count; i++)
table.Cell().Border(0.5f).BorderColor(Colors.Grey.Lighten1).Padding(3)
.Text(i < row.Count ? row[i] : "").FontSize(fontSize);
});
}
}
internal static class DrawingElementRenderer
{
public static void RenderFixed(IContainer container, PlaceholderValue value, float width, float height, string unit)
{
var drawing = value switch
{
DrawingValue direct => direct,
RecordedDrawingValue recorded => new DrawingValue(recorded.Pages.FirstOrDefault() ?? [], height),
_ => throw new InvalidDataException("DRAWBOX erwartet einen Drawing-Platzhalter."),
};
container.Svg(BuildSvg(drawing, width, height, 0, unit));
}
public static void RenderFlow(IContainer container, PlaceholderValue value, float width, float pageHeight, string unit)
{
var pages = value switch
{
DrawingValue direct => Slice(direct, pageHeight),
RecordedDrawingValue recorded => recorded.Pages
.Select(commands => new DrawingValue(commands, pageHeight)).ToList(),
_ => throw new InvalidDataException("FLOWDRAWBOX erwartet einen Drawing-Platzhalter."),
};
container.Column(column =>
{
// Kein manueller PageBreak() zwischen den Ausschnitten: RenderFlow sitzt in einer
// ohnehin automatisch paginierenden Column (Flow-Dokument) bzw. füllt im Legacy-Modell
// fast die ganze Seite aus. Ein erzwungener Break hier sprang unabhängig vom
// tatsächlich verbleibenden Platz auf der aktuellen physischen Seite auf eine neue -
// z.B. begann der zweite Monat der Fehlzeitenliste auf Seite 3, obwohl auf Seite 2
// noch reichlich Platz war. QuestPDF schiebt einen Ausschnitt, der nicht mehr passt,
// ohnehin automatisch auf die nächste Seite.
foreach (var page in pages)
{
// Koordinaten sind nach Slice()/RecordPages() bereits seitenlokal (bei 0 beginnend) -
// kein zusätzlicher viewBox-Y-Offset nötig oder sinnvoll (siehe Slice()).
column.Item().Height(UnitConverter.Points(pageHeight, unit)).Svg(
BuildSvg(page, width, pageHeight, 0, unit));
}
});
}
// Schneidet die Zeichenbefehle anhand ihrer Y-Koordinate in Seiten auf und verschiebt sie auf
// seitenlokale Koordinaten (Y=0 am Seitenanfang). Ein SVG-Element mit "viewBox" und
// "overflow=hidden" wird von QuestPDFs Svg()-Renderer NICHT zuverlässig geclippt - ein
// (Y-)Offset im viewBox allein reicht nicht, um pro Seite nur den jeweiligen Ausschnitt zu
// zeigen; ohne dieses echte Zerschneiden landet auf jeder Seite (nahezu) der komplette
// Zeicheninhalt erneut (Bug: doppelte/überlappende Fehlzeitenliste über mehrere Seiten).
private static List<DrawingValue> Slice(DrawingValue value, float pageHeight)
{
var pageCount = Math.Max(1, (int)Math.Ceiling(Math.Max(0, value.ContentHeight) / pageHeight));
var pages = new List<List<DrawingCommand>>();
for (var i = 0; i < pageCount; i++) pages.Add([]);
var pendingLinePage = 0;
foreach (var command in value.Commands)
{
var y = CommandY(command);
if (y is null) { pages[0].Add(command); continue; }
var page = Math.Clamp((int)(y.Value / pageHeight), 0, pageCount - 1);
// LineTo gehört inhaltlich zum vorherigen MoveTo (eine Linie) - beide müssen auf
// derselben Seite landen, sonst fehlt beim Rendern der Linie der Startpunkt.
if (command is MoveTo) pendingLinePage = page;
else if (command is LineTo) page = pendingLinePage;
pages[page].Add(ShiftY(command, page * pageHeight));
}
return pages.Select(commands => new DrawingValue(commands, pageHeight)).ToList();
}
private static float? CommandY(DrawingCommand command) => command switch
{
DrawString s => s.Y, DrawStringEx s => s.Y, MoveTo m => m.Y, LineTo l => l.Y,
DrawRectangle r => r.Y, DrawImage i => i.Y, DrawLine ln => Math.Min(ln.Y1, ln.Y2), _ => null,
};
private static DrawingCommand ShiftY(DrawingCommand command, float dy) => command switch
{
DrawString s => s with { Y = s.Y - dy }, DrawStringEx s => s with { Y = s.Y - dy },
MoveTo m => m with { Y = m.Y - dy }, LineTo l => l with { Y = l.Y - dy },
DrawRectangle r => r with { Y = r.Y - dy }, DrawImage i => i with { Y = i.Y - dy },
DrawLine ln => ln with { Y1 = ln.Y1 - dy, Y2 = ln.Y2 - dy },
_ => command,
};
internal static List<IReadOnlyList<DrawingCommand>> RecordPages(PagedDrawingValue value,
float width, float height, int maxPages)
{
if (maxPages is < 1 or > 10_000)
throw new InvalidDataException("PagedDrawingValue.MaxPages muss zwischen 1 und 10000 liegen.");
var pages = new List<IReadOnlyList<DrawingCommand>>();
var state = value.InitialState;
for (var pageNumber = 1; pageNumber <= maxPages; pageNumber++)
{
var canvas = new DrawingCanvas();
var context = new DrawingPageContext(width, height, pageNumber, canvas, state);
var finished = value.DrawPage(context);
pages.Add(canvas.Commands.ToList());
state = context.State;
if (finished) return pages;
}
throw new InvalidDataException($"Der Zeichen-Callback war nach {maxPages} Seiten noch nicht beendet.");
}
internal static IReadOnlyList<DrawingCommand> RecordSingle(PagedDrawingValue value, float width, float height)
{
var canvas = new DrawingCanvas();
value.DrawPage(new DrawingPageContext(width, height, 1, canvas, value.InitialState));
return canvas.Commands.ToList();
}
private static string BuildSvg(DrawingValue value, float width, float height, float verticalOffset, string unit)
{
if (!float.IsFinite(value.ContentHeight) || value.ContentHeight < 0)
throw new InvalidDataException("DrawingValue.ContentHeight muss eine endliche, nichtnegative Zahl sein.");
if (value.Commands.Count > 100_000)
throw new InvalidDataException("DrawingValue enthält zu viele Zeichenbefehle.");
var svg = new StringBuilder();
svg.Append(CultureInfo.InvariantCulture,
$"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 {verticalOffset} {width} {height}\" overflow=\"hidden\">");
float? currentX = null, currentY = null;
foreach (var command in value.Commands)
{
switch (command)
{
case MoveTo move:
Validate(move.X, move.Y); currentX = move.X; currentY = move.Y;
break;
case LineTo line when currentX is not null && currentY is not null:
Validate(line.X, line.Y, line.StrokeWidth); Positive(line.StrokeWidth);
AppendLine(svg, currentX.Value, currentY.Value, line.X, line.Y, line.Color, line.StrokeWidth);
currentX = line.X; currentY = line.Y;
break;
case LineTo:
throw new InvalidDataException("LineTo benötigt ein vorheriges MoveTo.");
case DrawLine line:
Validate(line.X1, line.Y1, line.X2, line.Y2, line.StrokeWidth); Positive(line.StrokeWidth);
AppendLine(svg, line.X1, line.Y1, line.X2, line.Y2, line.Color, line.StrokeWidth);
break;
case DrawRectangle rectangle:
Validate(rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height, rectangle.StrokeWidth);
Positive(rectangle.Width, rectangle.Height, rectangle.StrokeWidth);
svg.Append(CultureInfo.InvariantCulture,
$"<rect x=\"{rectangle.X}\" y=\"{rectangle.Y}\" width=\"{rectangle.Width}\" height=\"{rectangle.Height}\" stroke=\"{Attribute(rectangle.StrokeColor)}\" stroke-width=\"{rectangle.StrokeWidth}\" fill=\"{Attribute(rectangle.FillColor)}\"/>");
break;
case DrawString text:
Validate(text.X, text.Y, text.FontSize);
Positive(text.FontSize);
var localFontSize = text.FontSize / UnitConverter.Points(1, unit);
svg.Append(CultureInfo.InvariantCulture,
$"<text x=\"{text.X}\" y=\"{text.Y + localFontSize}\" font-size=\"{localFontSize}\" fill=\"{Attribute(text.Color)}\" font-family=\"{FontFamily(text.FontFamily)}\" font-weight=\"{(text.Bold ? "600" : "400")}\" font-style=\"{(text.Italic ? "italic" : "normal")}\">{SecurityElement.Escape(text.Text)}</text>");
break;
case DrawStringEx text:
Validate(text.X, text.Y, text.Height, text.Width, text.FontSize);
Positive(text.Height, text.Width, text.FontSize);
AppendTextBox(svg, text, unit);
break;
case DrawImage image:
Validate(image.X, image.Y, image.Width, image.Height);
Positive(image.Width, image.Height);
if (image.MimeType is not ("image/png" or "image/jpeg"))
throw new InvalidDataException("DrawImage unterstützt nur PNG und JPEG.");
if (image.Data.Length > 20 * 1024 * 1024)
throw new InvalidDataException("Ein DrawImage-Bild darf höchstens 20 MB groß sein.");
svg.Append(CultureInfo.InvariantCulture,
$"<image x=\"{image.X}\" y=\"{image.Y}\" width=\"{image.Width}\" height=\"{image.Height}\" href=\"data:{image.MimeType};base64,{Convert.ToBase64String(image.Data)}\" preserveAspectRatio=\"xMidYMid meet\"/>");
break;
default: throw new InvalidDataException($"Unbekannter Zeichenbefehl {command.GetType().Name}.");
}
}
return svg.Append("</svg>").ToString();
}
private static void AppendLine(StringBuilder svg, float x1, float y1, float x2, float y2,
string color, float strokeWidth) => svg.Append(CultureInfo.InvariantCulture,
$"<line x1=\"{x1}\" y1=\"{y1}\" x2=\"{x2}\" y2=\"{y2}\" stroke=\"{Attribute(color)}\" stroke-width=\"{strokeWidth}\"/>");
private static string Attribute(string value)
{
if (value != "none" && !System.Text.RegularExpressions.Regex.IsMatch(value,
@"^(#[0-9a-fA-F]{3,8}|[a-zA-Z]{1,24})$"))
throw new InvalidDataException($"Ungültiger Zeichenfarbwert „{value}“.");
return value;
}
private static string FontFamily(string value)
{
if (string.IsNullOrWhiteSpace(value)) return "sans-serif";
if (!System.Text.RegularExpressions.Regex.IsMatch(value, @"^[\p{L}\p{N} _.,-]{1,80}$"))
throw new InvalidDataException($"Ungültige Schriftfamilie „{value}“.");
return SecurityElement.Escape(value) ?? "sans-serif";
}
private static void AppendTextBox(StringBuilder svg, DrawStringEx text, string unit)
{
var fontSize = text.FontSize / UnitConverter.Points(1, unit);
var (x, anchor) = text.Alignment switch
{
DrawingTextAlignment.AlignCenter => (text.X + text.Width / 2, "middle"),
DrawingTextAlignment.AlignRight => (text.X + text.Width, "end"),
_ => (text.X, "start"),
};
var clipId = "clip" + Guid.NewGuid().ToString("N");
svg.Append(CultureInfo.InvariantCulture,
$"<defs><clipPath id=\"{clipId}\"><rect x=\"{text.X}\" y=\"{text.Y}\" width=\"{text.Width}\" height=\"{text.Height}\"/></clipPath></defs>");
svg.Append(CultureInfo.InvariantCulture,
$"<text x=\"{x}\" y=\"{text.Y + fontSize}\" text-anchor=\"{anchor}\" clip-path=\"url(#{clipId})\" font-size=\"{fontSize}\" fill=\"{Attribute(text.Color)}\" font-family=\"{FontFamily(text.FontFamily)}\" font-weight=\"{(text.Bold ? "600" : "400")}\" font-style=\"{(text.Italic ? "italic" : "normal")}\">");
var lines = text.Text.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
for (var index = 0; index < lines.Length; index++)
svg.Append(CultureInfo.InvariantCulture,
$"<tspan x=\"{x}\" dy=\"{(index == 0 ? 0 : fontSize * 1.2f)}\">{SecurityElement.Escape(lines[index])}</tspan>");
svg.Append("</text>");
}
private static void Validate(params float[] values)
{
if (values.Any(x => !float.IsFinite(x))) throw new InvalidDataException("Zeichenkoordinaten müssen endlich sein.");
}
private static void Positive(params float[] values)
{
if (values.Any(x => x < 0)) throw new InvalidDataException("Zeichengrößen dürfen nicht negativ sein.");
}
}
internal static class ChartElementRenderer
{
public static void Render(IContainer container, ChartValue value, string chartType,
IReadOnlyDictionary<string, string> attributes)
{
var svg = BuildSvg(value, chartType);
container.Svg(svg);
}
private static string BuildSvg(ChartValue value, string chartType)
{
var points = value.Series.SelectMany(x => x.Points).ToList();
if (points.Count == 0) return "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 600 200\"/>";
var max = Math.Max(1m, points.Max(x => x.Y));
var colors = new[] { "#2563EB", "#DC2626", "#059669", "#7C3AED" };
var svg = new StringBuilder("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 600 200\">");
svg.Append("<line x1=\"40\" y1=\"170\" x2=\"590\" y2=\"170\" stroke=\"#94A3B8\"/><line x1=\"40\" y1=\"10\" x2=\"40\" y2=\"170\" stroke=\"#94A3B8\"/>");
for (var seriesIndex = 0; seriesIndex < value.Series.Count; seriesIndex++)
{
var series = value.Series[seriesIndex];
var color = colors[seriesIndex % colors.Length];
var step = 530d / Math.Max(1, series.Points.Count);
var coordinates = new List<string>();
for (var i = 0; i < series.Points.Count; i++)
{
var x = 50 + i * step + step / 2; var y = 165 - (double)(series.Points[i].Y / max) * 145;
if (chartType == "bar") svg.Append(CultureInfo.InvariantCulture,
$"<rect x=\"{x - step * .28:F1}\" y=\"{y:F1}\" width=\"{step * .56:F1}\" height=\"{165 - y:F1}\" fill=\"{color}\" opacity=\".8\"/>");
else coordinates.Add(FormattableString.Invariant($"{x:F1},{y:F1}"));
svg.Append($"<text x=\"{x:F1}\" y=\"188\" font-size=\"10\" text-anchor=\"middle\" fill=\"#475569\">{SecurityElement.Escape(series.Points[i].X)}</text>");
}
if (chartType == "line") svg.Append($"<polyline points=\"{string.Join(' ', coordinates)}\" fill=\"none\" stroke=\"{color}\" stroke-width=\"3\"/>");
}
return svg.Append("</svg>").ToString();
}
}