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) { var settings = new ImageGenerationSettings { ImageFormat = ImageFormat.Png, RasterDpi = dpi }; return BuildDocument(template, ValidateData(template, data)).GenerateImages(settings).First(); } private static IReadOnlyDictionary 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 values) { values = PreparePagedDrawings(template, 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 RenderFlowElement(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 PreparePagedDrawings(LoadedTemplate template, IReadOnlyDictionary values) { var result = new Dictionary(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 values, Func? 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 RenderFlowElement(IContainer container, TemplateElement element, LoadedTemplate template, IReadOnlyDictionary 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 void RenderElement(IContainer root, TemplateElement element, LoadedTemplate template, IReadOnlyDictionary 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: 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: 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 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 values, TemplateManifest manifest, IReadOnlyDictionary 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 runs, IReadOnlyDictionary values, IReadOnlyDictionary 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 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 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 TextAttributes(TemplateManifest manifest, string? placeholder, IReadOnlyDictionary 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(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 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 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 attributes, string key) => attributes.TryGetValue(key, out var raw) && bool.TryParse(raw, out var value) && value; } internal sealed record RecordedDrawingValue(IReadOnlyList> 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 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 => { for (var page = 0; page < pages.Count; page++) { if (page > 0) column.Item().PageBreak(); var offset = value is DrawingValue ? page * pageHeight : 0; column.Item().Height(UnitConverter.Points(pageHeight, unit)).Svg( BuildSvg(pages[page], width, pageHeight, offset, unit)); } }); } private static List Slice(DrawingValue value, float pageHeight) { var pageCount = Math.Max(1, (int)Math.Ceiling(Math.Max(0, value.ContentHeight) / pageHeight)); return Enumerable.Repeat(value, pageCount).ToList(); } internal static List> 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>(); 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 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, $""); 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, $""); 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, $"{SecurityElement.Escape(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, $""); break; default: throw new InvalidDataException($"Unbekannter Zeichenbefehl {command.GetType().Name}."); } } return svg.Append("").ToString(); } private static void AppendLine(StringBuilder svg, float x1, float y1, float x2, float y2, string color, float strokeWidth) => svg.Append(CultureInfo.InvariantCulture, $""); 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, $""); svg.Append(CultureInfo.InvariantCulture, $""); var lines = text.Text.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'); for (var index = 0; index < lines.Length; index++) svg.Append(CultureInfo.InvariantCulture, $"{SecurityElement.Escape(lines[index])}"); svg.Append(""); } 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 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 ""; var max = Math.Max(1m, points.Max(x => x.Y)); var colors = new[] { "#2563EB", "#DC2626", "#059669", "#7C3AED" }; var svg = new StringBuilder(""); svg.Append(""); 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(); 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, $""); else coordinates.Add(FormattableString.Invariant($"{x:F1},{y:F1}")); svg.Append($"{SecurityElement.Escape(series.Points[i].X)}"); } if (chartType == "line") svg.Append($""); } return svg.Append("").ToString(); } }