diff --git a/LehrerApp.TemplateDesigner/DesignerViewModel.cs b/LehrerApp.TemplateDesigner/DesignerViewModel.cs index 39ef667..157577c 100644 --- a/LehrerApp.TemplateDesigner/DesignerViewModel.cs +++ b/LehrerApp.TemplateDesigner/DesignerViewModel.cs @@ -44,7 +44,8 @@ public partial class DesignerViewModel : ObservableObject public IReadOnlyList Units { get; } = ["mm", "cm", "pt", "in"]; public IReadOnlyList PlaceholderTypes { get; } = Enum.GetValues(); - public IReadOnlyList ElementTypes { get; } = ["TEXT", "TEXTBOX", "FLOWBOX", "IMG", "TABLE", "CHART"]; + public IReadOnlyList ElementTypes { get; } = + ["TEXT", "TEXTBOX", "FLOWBOX", "DRAWBOX", "FLOWDRAWBOX", "IMG", "TABLE", "CHART"]; public ObservableCollection Placeholders { get; } = [ new("Datum", PlaceholderType.Date, true, DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), @@ -117,14 +118,16 @@ public partial class DesignerViewModel : ObservableObject issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{used}“ ist nicht deklariert.")); foreach (var path in layouts.SelectMany(x => x.Elements).Select(x => x switch { BackgroundElement b => b.Path, ImageElement i => i.Path, _ => null }).Where(x => x is not null)) if (!Assets.ContainsKey(path!)) issues.Add(new(ValidationSeverity.Error, $"Asset „{path}“ fehlt.")); - var firstFlows = layout.Elements.OfType().ToList(); + var firstFlows = layout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement).ToList(); if (firstFlows.Count > 1) issues.Add(new(ValidationSeverity.Error, "Ein Layout darf höchstens eine FLOWBOX enthalten.")); if (continuationLayout is not null) { - var continuationFlows = continuationLayout.Elements.OfType().ToList(); + var continuationFlows = continuationLayout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement).ToList(); if (firstFlows.Count != 1 || continuationFlows.Count != 1) - issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen jeweils genau eine FLOWBOX enthalten.")); + issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen jeweils genau ein fließendes Element enthalten.")); + else if (firstFlows[0].GetType() != continuationFlows[0].GetType()) + issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen denselben fließenden Elementtyp verwenden.")); else if (firstFlows[0].X != continuationFlows[0].X || firstFlows[0].Y != continuationFlows[0].Y || firstFlows[0].Width != continuationFlows[0].Width || firstFlows[0].Height != continuationFlows[0].Height) issues.Add(new(ValidationSeverity.Error, "Die FLOWBOX muss auf Haupt- und Folgeseiten dieselbe Position und Größe haben.")); @@ -269,6 +272,8 @@ public partial class DesignerViewModel : ObservableObject "TEXT" => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}", "TEXTBOX" => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}", "FLOWBOX" => $"FLOWBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}", + "DRAWBOX" => $"DRAWBOX {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}", + "FLOWDRAWBOX" => $"FLOWDRAWBOX {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}", "IMG" => $"IMG {NewContent} {NewX} {NewY} {NewWidth} {NewHeight} scale={NormalizedImageScale()}%", "TABLE" => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}", "CHART" => $"CHART {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}", @@ -353,6 +358,10 @@ public partial class DesignerViewModel : ObservableObject + $"{Content(box.Content, box.Placeholder, box.Format)}{Attributes(box.Attributes)}", FlowBoxElement box => $"FLOWBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} " + $"{Content(box.Content, box.Placeholder, box.Format)}{Attributes(box.Attributes)}", + DrawBoxElement box => $"DRAWBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} " + + $"${box.Placeholder}{Attributes(box.Attributes)}", + FlowDrawBoxElement box => $"FLOWDRAWBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} " + + $"${box.Placeholder}{Attributes(box.Attributes)}", TableElement table => $"TABLE {x} {y} {FormatNumber(width)} {FormatNumber(height)} " + $"${table.Placeholder}{Attributes(table.Attributes)}", ChartElement chart => $"CHART {x} {y} {FormatNumber(width)} {FormatNumber(height)} " @@ -525,11 +534,13 @@ public partial class DesignerPlaceholder : ObservableObject PlaceholderType.Image => new ImageValue([], "image/png"), PlaceholderType.Table => ParseTable(Sample), PlaceholderType.Chart => ParseChart(Sample), + PlaceholderType.Drawing => SampleDrawing(), _ => new TextValue(Sample), }; public static string SampleFor(PlaceholderType type) => type switch { PlaceholderType.Date => DateTime.Today.ToString("yyyy-MM-dd"), PlaceholderType.Number => "42,5", - PlaceholderType.Table => "Datum;Grund|01.09.;Krank", PlaceholderType.Chart => "Sep:2;Okt:3;Nov:1", _ => "Beispielwert" }; + PlaceholderType.Table => "Datum;Grund|01.09.;Krank", PlaceholderType.Chart => "Sep:2;Okt:3;Nov:1", + PlaceholderType.Drawing => "Externe Zeichenbefehle", _ => "Beispielwert" }; private static TableValue ParseTable(string value) { var lines = value.Split('|', StringSplitOptions.RemoveEmptyEntries); @@ -538,6 +549,10 @@ public partial class DesignerPlaceholder : ObservableObject } private static ChartValue ParseChart(string value) => new([new("Werte", value.Split(';', StringSplitOptions.RemoveEmptyEntries) .Select((x, i) => { var parts = x.Split(':', 2); return new ChartPoint(parts[0], parts.Length == 2 && decimal.TryParse(parts[1], CultureInfo.InvariantCulture, out var y) ? y : i + 1); }).ToList())]); + private static DrawingValue SampleDrawing() => new DrawingValue( + [new DrawRectangle(0, 0, 80, 24, "#2563EB", 0.8f, "#EFF6FF"), + new DrawString(4, 4, "Dynamischer Inhalt", 10, "#1E3A8A", Bold: true), + new MoveTo(4, 19), new LineTo(76, 19, "#93C5FD", 0.6f)], 24); } internal sealed class DesignerDataProvider(IReadOnlyDictionary values) : ITemplateDataProvider diff --git a/LehrerApp.TemplateDesigner/LayoutOverlayEditor.cs b/LehrerApp.TemplateDesigner/LayoutOverlayEditor.cs index 1ca8a5a..48beea4 100644 --- a/LehrerApp.TemplateDesigner/LayoutOverlayEditor.cs +++ b/LehrerApp.TemplateDesigner/LayoutOverlayEditor.cs @@ -192,7 +192,8 @@ public sealed class LayoutOverlayEditor : Control { if (element is BackgroundElement) continue; var keyword = element switch - { ImageElement => "IMG", TextElement => "TEXT", TextBoxElement => "TEXTBOX", + { ImageElement => "IMG", TextElement => "TEXT", TextBoxElement => "TEXTBOX", FlowBoxElement => "FLOWBOX", + DrawBoxElement => "DRAWBOX", FlowDrawBoxElement => "FLOWDRAWBOX", TableElement => "TABLE", ChartElement => "CHART", _ => "?" }; var (width, height, resizable) = element switch { diff --git a/LehrerApp.TemplateDesigner/MainWindow.axaml b/LehrerApp.TemplateDesigner/MainWindow.axaml index 8c6d5b5..633580b 100644 --- a/LehrerApp.TemplateDesigner/MainWindow.axaml +++ b/LehrerApp.TemplateDesigner/MainWindow.axaml @@ -196,6 +196,8 @@ TextWrapping="Wrap" Opacity="0.65" FontSize="11"/> + diff --git a/LehrerApp.Templating.Tests/TemplatingTests.cs b/LehrerApp.Templating.Tests/TemplatingTests.cs index a9473b6..150b3e8 100644 --- a/LehrerApp.Templating.Tests/TemplatingTests.cs +++ b/LehrerApp.Templating.Tests/TemplatingTests.cs @@ -55,6 +55,75 @@ public sealed class TemplatingTests : IDisposable Assert.True(System.Text.RegularExpressions.Regex.Matches(source, @"/Type\s*/Page\b").Count > 1); } + [Fact] + public void DrawBox_RendertDeklarativeExterneZeichenbefehleInFesterBox() + { + var template = new LoadedTemplate(new TemplateManifest + { + Placeholders = [new("Grafik", PlaceholderType.Drawing, true)], + }, new LayoutParser().Parse("PAGE 210 297 mm\nDRAWBOX 20 20 100 60 $Grafik"), + new Dictionary()); + var drawing = new DrawingValue( + [new DrawRectangle(0, 0, 100, 60, "#1D4ED8", 1, "#EFF6FF"), + new DrawString(5, 5, "Externer Inhalt", 11, Bold: true), + new MoveTo(5, 25), new LineTo(95, 25, "#DC2626", 1.5f), + new DrawLine(5, 35, 95, 50, "#059669", 1)], 60); + + var pdf = new QuestTemplateRenderer().RenderToPdf(template, + new DictionaryProvider(new Dictionary { ["Grafik"] = drawing })); + + Assert.Single(System.Text.RegularExpressions.Regex.Matches( + System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b")); + } + + [Fact] + public void FlowDrawBox_PaginertHohenDeklarativenZeichenraum() + { + var template = new LoadedTemplate(new TemplateManifest + { + Placeholders = [new("Protokoll", PlaceholderType.Drawing, true)], + }, new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 257 $Protokoll"), + new Dictionary()); + var drawing = new DrawingValue( + Enumerable.Range(0, 80).SelectMany(i => (DrawingCommand[]) + [new DrawString(0, i * 10, $"Zeile {i + 1}", 8), new DrawLine(0, i * 10 + 9, 160, i * 10 + 9, "#CBD5E1", .3f)]) + .ToList(), 800); + + var pdf = new QuestTemplateRenderer().RenderToPdf(template, + new DictionaryProvider(new Dictionary { ["Protokoll"] = drawing })); + + Assert.True(System.Text.RegularExpressions.Regex.Matches( + System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b").Count >= 4); + } + + [Fact] + public void FlowDrawBox_CallbackErhaeltSeitenflaecheStateUndSteuertSeitenwechsel() + { + var template = new LoadedTemplate(new TemplateManifest + { + Placeholders = [new("Bericht", PlaceholderType.Drawing, true)], + }, new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 257 $Bericht"), + new Dictionary()); + var invocations = 0; + var drawing = new PagedDrawingValue(context => + { + invocations++; + var item = context.State is int value ? value : 0; + context.Canvas.DrawRectangle(0, 0, context.Width, 20, "#1D4ED8", .5f, "#EFF6FF"); + context.Canvas.DrawStringEx(0, 2, 14, context.Width, $"Untrennbarer Block {item + 1}", + DrawingTextAlignment.AlignCenter, 11, "Arial", "#1E3A8A", bold: true); + context.State = item + 1; + return context.PageNumber == 3; + }, InitialState: 0); + + var pdf = new QuestTemplateRenderer().RenderToPdf(template, + new DictionaryProvider(new Dictionary { ["Bericht"] = drawing })); + + Assert.Equal(3, invocations); + Assert.Equal(3, System.Text.RegularExpressions.Regex.Matches( + System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b").Count); + } + [Theory] [InlineData("scale=0%")] [InlineData("scale=-10%")] diff --git a/LehrerApp.Templating/DrawingFontRegistry.cs b/LehrerApp.Templating/DrawingFontRegistry.cs new file mode 100644 index 0000000..e6e111b --- /dev/null +++ b/LehrerApp.Templating/DrawingFontRegistry.cs @@ -0,0 +1,29 @@ +using System.Security.Cryptography; +using QuestPDF.Drawing; + +namespace LehrerApp.Templating; + +/// +/// Registers application-supplied TTF/OTF font bytes for TEXT and drawing commands without +/// exposing QuestPDF types to the calling application. +/// +public static class DrawingFontRegistry +{ + private static readonly object Gate = new(); + private static readonly HashSet Registered = new(StringComparer.Ordinal); + + public static void RegisterFont(string familyName, byte[] fontData) + { + if (string.IsNullOrWhiteSpace(familyName) || familyName.Length > 80) + throw new ArgumentException("Der Schriftname fehlt oder ist zu lang.", nameof(familyName)); + if (fontData.Length is 0 or > 20 * 1024 * 1024) + throw new ArgumentException("Eine Schriftdatei muss zwischen 1 Byte und 20 MB groß sein.", nameof(fontData)); + var key = familyName + ":" + Convert.ToHexString(SHA256.HashData(fontData)); + lock (Gate) + { + if (!Registered.Add(key)) return; + using var stream = new MemoryStream(fontData, writable: false); + FontManager.RegisterFontWithCustomName(familyName, stream); + } + } +} diff --git a/LehrerApp.Templating/LayoutParser.cs b/LehrerApp.Templating/LayoutParser.cs index 10c261b..4d738dc 100644 --- a/LehrerApp.Templating/LayoutParser.cs +++ b/LehrerApp.Templating/LayoutParser.cs @@ -64,6 +64,16 @@ public sealed class LayoutParser elements.Add(new FlowBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]), Number(tokens[3]), Number(tokens[4]), tokens[5], flowRef.Name, flowRef.Format, Attributes(tokens, 6))); break; + case "DRAWBOX": + Require(tokens, 6); + elements.Add(new DrawBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]), + Number(tokens[3]), Number(tokens[4]), RequiredReference(tokens[5]), Attributes(tokens, 6))); + break; + case "FLOWDRAWBOX": + Require(tokens, 6); + elements.Add(new FlowDrawBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]), + Number(tokens[3]), Number(tokens[4]), RequiredReference(tokens[5]), Attributes(tokens, 6))); + break; case "TABLE": Require(tokens, 6); elements.Add(new TableElement(lineNumber, Number(tokens[1]), Number(tokens[2]), diff --git a/LehrerApp.Templating/Model.cs b/LehrerApp.Templating/Model.cs index eedde03..fce036d 100644 --- a/LehrerApp.Templating/Model.cs +++ b/LehrerApp.Templating/Model.cs @@ -2,7 +2,7 @@ using System.Text.Json.Serialization; namespace LehrerApp.Templating; -public enum PlaceholderType { Text, Multiline, Date, Number, Image, Table, Chart } +public enum PlaceholderType { Text, Multiline, Date, Number, Image, Table, Chart, Drawing } public abstract record PlaceholderValue(PlaceholderType Type); public sealed record TextValue(string Value) : PlaceholderValue(PlaceholderType.Text); @@ -16,6 +16,67 @@ public sealed record TableValue(IReadOnlyList Columns, IReadOnlyList Points); public sealed record ChartValue(IReadOnlyList Series) : PlaceholderValue(PlaceholderType.Chart); +public abstract record DrawingCommand; +public sealed record DrawString(float X, float Y, string Text, float FontSize = 11, + string Color = "#000000", bool Bold = false, bool Italic = false, string FontFamily = "") : DrawingCommand; +public enum DrawingTextAlignment { AlignLeft, AlignCenter, AlignRight } +public sealed record DrawStringEx(float X, float Y, float Height, float Width, string Text, + DrawingTextAlignment Alignment = DrawingTextAlignment.AlignLeft, float FontSize = 11, + string FontFamily = "", string Color = "#000000", bool Bold = false, bool Italic = false) + : DrawingCommand; +public sealed record MoveTo(float X, float Y) : DrawingCommand; +public sealed record LineTo(float X, float Y, string Color = "#000000", float StrokeWidth = 1) : DrawingCommand; +public sealed record DrawLine(float X1, float Y1, float X2, float Y2, + string Color = "#000000", float StrokeWidth = 1) : DrawingCommand; +public sealed record DrawRectangle(float X, float Y, float Width, float Height, + string StrokeColor = "#000000", float StrokeWidth = 1, string FillColor = "none") : DrawingCommand; +public sealed record DrawImage(float X, float Y, float Width, float Height, byte[] Data, string MimeType) : DrawingCommand; +/// Declarative drawing supplied by an external data provider. Coordinates use the template layout unit; font sizes use points. +public sealed record DrawingValue(IReadOnlyList Commands, float ContentHeight) + : PlaceholderValue(PlaceholderType.Drawing); + +/// A recorded drawing surface handed to an in-process external renderer. +public sealed class DrawingCanvas +{ + private readonly List _commands = []; + public IReadOnlyList Commands => _commands; + public void DrawString(float x, float y, string text, float fontSize = 11, string color = "#000000", + bool bold = false, bool italic = false, string fontFamily = "") => + _commands.Add(new LehrerApp.Templating.DrawString(x, y, text, fontSize, color, bold, italic, fontFamily)); + public void DrawStringEx(float x, float y, float height, float width, string text, + DrawingTextAlignment alignment = DrawingTextAlignment.AlignLeft, float fontSize = 11, + string fontFamily = "", string color = "#000000", bool bold = false, bool italic = false) => + _commands.Add(new LehrerApp.Templating.DrawStringEx(x, y, height, width, text, alignment, + fontSize, fontFamily, color, bold, italic)); + public void MoveTo(float x, float y) => _commands.Add(new LehrerApp.Templating.MoveTo(x, y)); + public void LineTo(float x, float y, string color = "#000000", float strokeWidth = 1) => + _commands.Add(new LehrerApp.Templating.LineTo(x, y, color, strokeWidth)); + public void DrawLine(float x1, float y1, float x2, float y2, string color = "#000000", float strokeWidth = 1) => + _commands.Add(new LehrerApp.Templating.DrawLine(x1, y1, x2, y2, color, strokeWidth)); + public void DrawRectangle(float x, float y, float width, float height, string strokeColor = "#000000", + float strokeWidth = 1, string fillColor = "none") => + _commands.Add(new LehrerApp.Templating.DrawRectangle(x, y, width, height, strokeColor, strokeWidth, fillColor)); + public void DrawImage(float x, float y, float width, float height, byte[] data, string mimeType) => + _commands.Add(new LehrerApp.Templating.DrawImage(x, y, width, height, data, mimeType)); +} + +public sealed class DrawingPageContext(float width, float height, int pageNumber, DrawingCanvas canvas, object? state) +{ + public float Width { get; } = width; + public float Height { get; } = height; + public int PageNumber { get; } = pageNumber; + public DrawingCanvas Canvas { get; } = canvas; + public object? State { get; set; } = state; +} + +public delegate bool DrawingPageCallback(DrawingPageContext context); + +/// +/// In-process callback drawing. The callback returns true when finished or false to request another box. +/// State is carried across callbacks. It is recorded before QuestPDF performs layout and is never executed by a package. +/// +public sealed record PagedDrawingValue(DrawingPageCallback DrawPage, object? InitialState = null, + int MaxPages = 1_000) : PlaceholderValue(PlaceholderType.Drawing); public interface ITemplateDataProvider { @@ -58,6 +119,12 @@ public sealed record TextBoxElement(int Line, float X, float Y, float Width, flo public sealed record FlowBoxElement(int Line, float X, float Y, float Width, float Height, string Content, string? Placeholder, string? Format, IReadOnlyDictionary Attributes) : TemplateElement(Line, X, Y, Width, Height, Attributes); +public sealed record DrawBoxElement(int Line, float X, float Y, float Width, float Height, + string Placeholder, IReadOnlyDictionary Attributes) + : TemplateElement(Line, X, Y, Width, Height, Attributes); +public sealed record FlowDrawBoxElement(int Line, float X, float Y, float Width, float Height, + string Placeholder, IReadOnlyDictionary Attributes) + : TemplateElement(Line, X, Y, Width, Height, Attributes); public sealed record TableElement(int Line, float X, float Y, float Width, float Height, string Placeholder, IReadOnlyDictionary Attributes) : TemplateElement(Line, X, Y, Width, Height, Attributes); diff --git a/LehrerApp.Templating/QuestTemplateRenderer.cs b/LehrerApp.Templating/QuestTemplateRenderer.cs index 638ed99..74e4bbf 100644 --- a/LehrerApp.Templating/QuestTemplateRenderer.cs +++ b/LehrerApp.Templating/QuestTemplateRenderer.cs @@ -30,10 +30,11 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR } private static IDocument BuildDocument(LoadedTemplate template, - IReadOnlyDictionary values) => Document.Create(document => + IReadOnlyDictionary values) { - var flowBox = template.Layout.Elements.OfType().SingleOrDefault(); - document.Page(page => + 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)); @@ -44,20 +45,51 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR layers.PrimaryLayer().Width(UnitConverter.Points(template.Layout.Width, template.Layout.Unit)) .Height(UnitConverter.Points(template.Layout.Height, template.Layout.Unit)).Background(Colors.White); else - RenderFlowBox(layers.PrimaryLayer(), flowBox, template, values); + 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)) + foreach (var element in layout.Elements.Where(x => x is not (FlowBoxElement or FlowDrawBoxElement))) { var current = element; layers.Layer().Element(container => RenderElement( @@ -65,17 +97,24 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR } } - private static void RenderFlowBox(IContainer container, FlowBoxElement box, LoadedTemplate template, + private static void RenderFlowElement(IContainer container, TemplateElement element, LoadedTemplate template, IReadOnlyDictionary values) { var unit = template.Layout.Unit; container - .PaddingLeft(UnitConverter.Points(box.X, unit)) - .PaddingTop(UnitConverter.Points(box.Y, unit)) - .PaddingRight(UnitConverter.Points(Math.Max(0, template.Layout.Width - box.X - box.Width), unit)) - .PaddingBottom(UnitConverter.Points(Math.Max(0, template.Layout.Height - box.Y - box.Height), unit)) - .Element(content => RenderResolvedText(content, box.Content, box.Placeholder, box.Format, - values, template.Manifest, box.Attributes)); + .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, @@ -108,6 +147,13 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR 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); @@ -219,6 +265,9 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR 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 @@ -249,6 +298,186 @@ internal static class TableElementRenderer } } +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, diff --git a/LehrerApp.Templating/README.md b/LehrerApp.Templating/README.md index 5adb919..33dfc98 100644 --- a/LehrerApp.Templating/README.md +++ b/LehrerApp.Templating/README.md @@ -1,5 +1,86 @@ # LehrerApp Templating +## Dynamische Zeichenflächen für externe Apps + +Externe `ITemplateDataProvider` können einen Platzhalter vom Typ `Drawing` mit einem +`DrawingValue` befüllen. Dabei wird kein QuestPDF-/Skia-Canvas nach außen gegeben. Die portable +Form besteht stattdessen aus einer geprüften, serialisierbaren Befehlsliste: + +```csharp +var drawing = new DrawingValue( + [ + new DrawRectangle(0, 0, 160, 24, "#2563EB", 0.8f, "#EFF6FF"), + new DrawString(4, 4, "Dynamischer Bericht", 11, "#1E3A8A", Bold: true), + new MoveTo(4, 19), + new LineTo(156, 19, "#93C5FD", 0.6f), + new DrawImage(120, 2, 30, 18, pngBytes, "image/png") + ], + ContentHeight: 24); +``` + +Koordinaten und Längen verwenden die Einheit des Layouts; `DrawString.FontSize` wird wie bei +`TEXT` in Punkt angegeben. Unterstützt werden `DrawString`, `MoveTo`, `LineTo`, `DrawLine`, +`DrawRectangle` und `DrawImage` (PNG/JPEG). Die Befehle gelangen über den normalen +`ITemplateDataProvider`, beispielsweise als `values["ExternerBericht"] = drawing`. + +`DrawStringEx(x, y, height, width, ...)` ergänzt eine geclippte Textbox mit `AlignLeft`, +`AlignCenter` oder `AlignRight`. Farbe und Schriftfamilie können bei beiden Textbefehlen gesetzt +werden. Ohne Schriftangabe wird `sans-serif` verwendet. Für portable Schriften registriert die +integrierende App TTF-/OTF-Daten einmal vor dem Rendern: + +```csharp +DrawingFontRegistry.RegisterFont("MeineSchulschrift", fontBytes); +canvas.DrawStringEx(0, 0, 12, context.Width, "Zentrierte Überschrift", + DrawingTextAlignment.AlignCenter, 11, "MeineSchulschrift", "#1E3A8A", bold: true); +``` + +Explizite Zeilenumbrüche werden berücksichtigt; Text außerhalb von `height`/`width` wird +abgeschnitten. Eine automatische Worttrennung findet in dieser elementaren Zeichenfunktion nicht +statt. + +Im Layout stehen zwei Varianten zur Verfügung: + +```text +DRAWBOX 20 40 170 80 $ExternerBericht +FLOWDRAWBOX 20 40 170 237 $LangesProtokoll +``` + +`DRAWBOX` ist ein fester, geclippter Viewport. Inhalte außerhalb seiner Breite oder Höhe werden +nicht angezeigt. `FLOWDRAWBOX` zerlegt den vertikalen Zeichenraum anhand von `ContentHeight` in +gleich hohe Seitenfenster und setzt ihn auf Folgeseiten fort. Wie bei `FLOWBOX` darf ein Layout +höchstens ein fließendes Element enthalten; bei einem eigenen Folgeseitenlayout müssen Typ, +Position und Größe übereinstimmen. + +Ein Vorlagenpaket kann keinen Callback und keinen Typ aus einer fremden Assembly einschleusen. +Farben, Zahlen, Bildformate, Bildgröße und Gesamtzahl der portablen Befehle werden validiert. +Damit bleibt die paketfähige Schnittstelle deterministisch. Ein `PagedDrawingValue` ist dagegen +ein ausdrücklich vom vertrauenswürdigen In-Process-`ITemplateDataProvider` übergebener Delegate. + +Für umfangreiche In-Process-Integrationen gibt es zusätzlich den klassischen seitenweisen +Callback `PagedDrawingValue`. Er wird vor QuestPDFs Layout vollständig in deklarative Seitenlisten +aufgezeichnet und daher nicht durch interne Layoutdurchläufe mehrfach ausgeführt: + +```csharp +var value = new PagedDrawingValue(context => +{ + var nextRow = context.State is int row ? row : 0; + + // Nur vollständige Strukturen zeichnen, die noch in die zugewiesene Box passen. + while (nextRow < rows.Count && PasstNochVollstaendig(rows[nextRow], context)) + ZeichneZeile(context.Canvas, rows[nextRow++]); + + context.State = nextRow; + return nextRow == rows.Count; // true = fertig, false = weitere FLOWDRAWBOX +}, InitialState: 0); +``` + +`DrawingPageContext` stellt `Width`, `Height`, `PageNumber`, `Canvas` und ein über alle Aufrufe +weitergereichtes `State`-Objekt bereit. So kann der Zeichner Tabellenzeilen, Diagrammgruppen oder +andere unteilbare Strukturen bewusst auf die nächste Seite verschieben. Ein `DRAWBOX`-Callback +wird genau einmal aufgerufen; bei `FLOWDRAWBOX` fordert `false` eine weitere Seite an. `MaxPages` +verhindert Endlosschleifen. Delegates funktionieren nur innerhalb desselben .NET-Prozesses; +prozessübergreifend bleibt `DrawingValue` die Übergabeform. + ## PDF-Import im TemplateDesigner Der Menüpunkt **Einfügen → PDF als Vorlage importieren** rekonstruiert einseitige PDF-Vorlagen. diff --git a/LehrerApp.Templating/TemplateLoader.cs b/LehrerApp.Templating/TemplateLoader.cs index 4848869..c10a3bd 100644 --- a/LehrerApp.Templating/TemplateLoader.cs +++ b/LehrerApp.Templating/TemplateLoader.cs @@ -92,14 +92,16 @@ public sealed class TemplateLoader(TemplateLimits? limits = null, issues.Add(new(ValidationSeverity.Error, "Folgeseiten-Layout und Hauptlayout müssen dieselbe Seitengröße und Einheit verwenden.")); } var allLayouts = continuationLayout is null ? new[] { layout } : new[] { layout, continuationLayout }; - var flowBoxes = layout.Elements.OfType().ToList(); + var flowBoxes = FlowElements(layout).ToList(); if (flowBoxes.Count > 1) - issues.Add(new(ValidationSeverity.Error, "Ein Layout darf höchstens eine FLOWBOX enthalten.")); + issues.Add(new(ValidationSeverity.Error, "Ein Layout darf höchstens ein fließendes Element (FLOWBOX/FLOWDRAWBOX) enthalten.")); if (continuationLayout is not null) { - var continuationFlows = continuationLayout.Elements.OfType().ToList(); + var continuationFlows = FlowElements(continuationLayout).ToList(); if (flowBoxes.Count != 1 || continuationFlows.Count != 1) - issues.Add(new(ValidationSeverity.Error, "Bei einem Folgeseiten-Layout müssen Haupt- und Folgeseite jeweils genau eine FLOWBOX enthalten.")); + issues.Add(new(ValidationSeverity.Error, "Bei einem Folgeseiten-Layout müssen Haupt- und Folgeseite jeweils genau ein gleichartiges fließendes Element enthalten.")); + else if (flowBoxes[0].GetType() != continuationFlows[0].GetType()) + issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen denselben fließenden Elementtyp verwenden.")); else if (flowBoxes[0].X != continuationFlows[0].X || flowBoxes[0].Y != continuationFlows[0].Y || flowBoxes[0].Width != continuationFlows[0].Width || flowBoxes[0].Height != continuationFlows[0].Height) issues.Add(new(ValidationSeverity.Error, "Die FLOWBOX muss auf Haupt- und Folgeseiten dieselbe Position und Größe haben.")); @@ -169,10 +171,14 @@ public sealed class TemplateLoader(TemplateLimits? limits = null, TextElement { Placeholder: { } p } => p, TextBoxElement { Placeholder: { } p } => p, FlowBoxElement { Placeholder: { } p } => p, + DrawBoxElement d => d.Placeholder, + FlowDrawBoxElement d => d.Placeholder, TableElement t => t.Placeholder, ChartElement c => c.Placeholder, _ => null, }).Where(x => x is not null).Cast().Distinct(StringComparer.Ordinal); + private static IEnumerable FlowElements(TemplateLayout layout) => + layout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement); internal static bool IsSafeRelativePath(string path) => !string.IsNullOrWhiteSpace(path) && !Path.IsPathRooted(path) && !path.Split('/', '\\').Any(part => part == ".." || part.Length == 0);