feat: add external drawing boxes and paged callbacks
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-08-31 22:10:06 +02:00
parent 3e5f197bdb
commit e2e2bfb854
10 changed files with 534 additions and 25 deletions
@@ -44,7 +44,8 @@ public partial class DesignerViewModel : ObservableObject
public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"]; public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"];
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>(); public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
public IReadOnlyList<string> ElementTypes { get; } = ["TEXT", "TEXTBOX", "FLOWBOX", "IMG", "TABLE", "CHART"]; public IReadOnlyList<string> ElementTypes { get; } =
["TEXT", "TEXTBOX", "FLOWBOX", "DRAWBOX", "FLOWDRAWBOX", "IMG", "TABLE", "CHART"];
public ObservableCollection<DesignerPlaceholder> Placeholders { get; } = public ObservableCollection<DesignerPlaceholder> Placeholders { get; } =
[ [
new("Datum", PlaceholderType.Date, true, DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), 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.")); 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)) 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.")); if (!Assets.ContainsKey(path!)) issues.Add(new(ValidationSeverity.Error, $"Asset „{path}“ fehlt."));
var firstFlows = layout.Elements.OfType<FlowBoxElement>().ToList(); var firstFlows = layout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement).ToList();
if (firstFlows.Count > 1) if (firstFlows.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 eine FLOWBOX enthalten."));
if (continuationLayout is not null) if (continuationLayout is not null)
{ {
var continuationFlows = continuationLayout.Elements.OfType<FlowBoxElement>().ToList(); var continuationFlows = continuationLayout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement).ToList();
if (firstFlows.Count != 1 || continuationFlows.Count != 1) 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 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) || 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.")); 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}", "TEXT" => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}",
"TEXTBOX" => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}", "TEXTBOX" => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
"FLOWBOX" => $"FLOWBOX {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()}%", "IMG" => $"IMG {NewContent} {NewX} {NewY} {NewWidth} {NewHeight} scale={NormalizedImageScale()}%",
"TABLE" => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}", "TABLE" => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
"CHART" => $"CHART {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)}", + $"{Content(box.Content, box.Placeholder, box.Format)}{Attributes(box.Attributes)}",
FlowBoxElement box => $"FLOWBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} " FlowBoxElement box => $"FLOWBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"{Content(box.Content, box.Placeholder, box.Format)}{Attributes(box.Attributes)}", + $"{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)} " TableElement table => $"TABLE {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"${table.Placeholder}{Attributes(table.Attributes)}", + $"${table.Placeholder}{Attributes(table.Attributes)}",
ChartElement chart => $"CHART {x} {y} {FormatNumber(width)} {FormatNumber(height)} " 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.Image => new ImageValue([], "image/png"),
PlaceholderType.Table => ParseTable(Sample), PlaceholderType.Table => ParseTable(Sample),
PlaceholderType.Chart => ParseChart(Sample), PlaceholderType.Chart => ParseChart(Sample),
PlaceholderType.Drawing => SampleDrawing(),
_ => new TextValue(Sample), _ => new TextValue(Sample),
}; };
public static string SampleFor(PlaceholderType type) => type switch public static string SampleFor(PlaceholderType type) => type switch
{ PlaceholderType.Date => DateTime.Today.ToString("yyyy-MM-dd"), PlaceholderType.Number => "42,5", { 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) private static TableValue ParseTable(string value)
{ {
var lines = value.Split('|', StringSplitOptions.RemoveEmptyEntries); 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) 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())]); .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<string, PlaceholderValue> values) : ITemplateDataProvider internal sealed class DesignerDataProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
@@ -192,7 +192,8 @@ public sealed class LayoutOverlayEditor : Control
{ {
if (element is BackgroundElement) continue; if (element is BackgroundElement) continue;
var keyword = element switch 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", _ => "?" }; TableElement => "TABLE", ChartElement => "CHART", _ => "?" };
var (width, height, resizable) = element switch var (width, height, resizable) = element switch
{ {
@@ -196,6 +196,8 @@
TextWrapping="Wrap" Opacity="0.65" FontSize="11"/> TextWrapping="Wrap" Opacity="0.65" FontSize="11"/>
<TextBlock Text="FLOWBOX verteilt Text automatisch über beliebig viele Seiten. TEXTBOX bleibt ein fester Bereich." <TextBlock Text="FLOWBOX verteilt Text automatisch über beliebig viele Seiten. TEXTBOX bleibt ein fester Bereich."
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/> TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
<TextBlock Text="DRAWBOX und FLOWDRAWBOX nehmen deklarative Zeichenbefehle (Text, Linien, Rechtecke, Bilder) aus einer externen App entgegen."
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
@@ -55,6 +55,75 @@ public sealed class TemplatingTests : IDisposable
Assert.True(System.Text.RegularExpressions.Regex.Matches(source, @"/Type\s*/Page\b").Count > 1); 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<string, byte[]>());
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<string, PlaceholderValue> { ["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<string, byte[]>());
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<string, PlaceholderValue> { ["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<string, byte[]>());
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<string, PlaceholderValue> { ["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] [Theory]
[InlineData("scale=0%")] [InlineData("scale=0%")]
[InlineData("scale=-10%")] [InlineData("scale=-10%")]
@@ -0,0 +1,29 @@
using System.Security.Cryptography;
using QuestPDF.Drawing;
namespace LehrerApp.Templating;
/// <summary>
/// Registers application-supplied TTF/OTF font bytes for TEXT and drawing commands without
/// exposing QuestPDF types to the calling application.
/// </summary>
public static class DrawingFontRegistry
{
private static readonly object Gate = new();
private static readonly HashSet<string> 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);
}
}
}
+10
View File
@@ -64,6 +64,16 @@ public sealed class LayoutParser
elements.Add(new FlowBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]), elements.Add(new FlowBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
Number(tokens[3]), Number(tokens[4]), tokens[5], flowRef.Name, flowRef.Format, Number(tokens[3]), Number(tokens[4]), tokens[5], flowRef.Name, flowRef.Format,
Attributes(tokens, 6))); break; 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": case "TABLE":
Require(tokens, 6); Require(tokens, 6);
elements.Add(new TableElement(lineNumber, Number(tokens[1]), Number(tokens[2]), elements.Add(new TableElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
+68 -1
View File
@@ -2,7 +2,7 @@ using System.Text.Json.Serialization;
namespace LehrerApp.Templating; 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 abstract record PlaceholderValue(PlaceholderType Type);
public sealed record TextValue(string Value) : PlaceholderValue(PlaceholderType.Text); public sealed record TextValue(string Value) : PlaceholderValue(PlaceholderType.Text);
@@ -16,6 +16,67 @@ public sealed record TableValue(IReadOnlyList<string> Columns, IReadOnlyList<IRe
public sealed record ChartPoint(string X, decimal Y); public sealed record ChartPoint(string X, decimal Y);
public sealed record ChartSeries(string Label, IReadOnlyList<ChartPoint> Points); public sealed record ChartSeries(string Label, IReadOnlyList<ChartPoint> Points);
public sealed record ChartValue(IReadOnlyList<ChartSeries> Series) : PlaceholderValue(PlaceholderType.Chart); public sealed record ChartValue(IReadOnlyList<ChartSeries> 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;
/// <summary>Declarative drawing supplied by an external data provider. Coordinates use the template layout unit; font sizes use points.</summary>
public sealed record DrawingValue(IReadOnlyList<DrawingCommand> Commands, float ContentHeight)
: PlaceholderValue(PlaceholderType.Drawing);
/// <summary>A recorded drawing surface handed to an in-process external renderer.</summary>
public sealed class DrawingCanvas
{
private readonly List<DrawingCommand> _commands = [];
public IReadOnlyList<DrawingCommand> 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);
/// <summary>
/// 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.
/// </summary>
public sealed record PagedDrawingValue(DrawingPageCallback DrawPage, object? InitialState = null,
int MaxPages = 1_000) : PlaceholderValue(PlaceholderType.Drawing);
public interface ITemplateDataProvider 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, public sealed record FlowBoxElement(int Line, float X, float Y, float Width, float Height,
string Content, string? Placeholder, string? Format, IReadOnlyDictionary<string, string> Attributes) string Content, string? Placeholder, string? Format, IReadOnlyDictionary<string, string> Attributes)
: TemplateElement(Line, X, Y, Width, Height, 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<string, string> 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<string, string> Attributes)
: TemplateElement(Line, X, Y, Width, Height, Attributes);
public sealed record TableElement(int Line, float X, float Y, float Width, float Height, public sealed record TableElement(int Line, float X, float Y, float Width, float Height,
string Placeholder, IReadOnlyDictionary<string, string> Attributes) string Placeholder, IReadOnlyDictionary<string, string> Attributes)
: TemplateElement(Line, X, Y, Width, Height, Attributes); : TemplateElement(Line, X, Y, Width, Height, Attributes);
+243 -14
View File
@@ -30,10 +30,11 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
} }
private static IDocument BuildDocument(LoadedTemplate template, private static IDocument BuildDocument(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values) => Document.Create(document => IReadOnlyDictionary<string, PlaceholderValue> values)
{ {
var flowBox = template.Layout.Elements.OfType<FlowBoxElement>().SingleOrDefault(); values = PreparePagedDrawings(template, values);
document.Page(page => 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), page.Size(UnitConverter.Points(template.Layout.Width, template.Layout.Unit),
UnitConverter.Points(template.Layout.Height, 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)) layers.PrimaryLayer().Width(UnitConverter.Points(template.Layout.Width, template.Layout.Unit))
.Height(UnitConverter.Points(template.Layout.Height, template.Layout.Unit)).Background(Colors.White); .Height(UnitConverter.Points(template.Layout.Height, template.Layout.Unit)).Background(Colors.White);
else else
RenderFlowBox(layers.PrimaryLayer(), flowBox, template, values); RenderFlowElement(layers.PrimaryLayer(), flowBox, template, values);
RenderStaticLayers(layers, template.Layout, template, values, RenderStaticLayers(layers, template.Layout, template, values,
template.ContinuationLayout is null ? null : static container => container.ShowOnce()); template.ContinuationLayout is null ? null : static container => container.ShowOnce());
if (template.ContinuationLayout is { } continuation) if (template.ContinuationLayout is { } continuation)
RenderStaticLayers(layers, continuation, template, values, static container => container.SkipOnce()); 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, private static void RenderStaticLayers(LayersDescriptor layers, TemplateLayout layout, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values, Func<IContainer, IContainer>? visibility) IReadOnlyDictionary<string, PlaceholderValue> values, Func<IContainer, IContainer>? 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; var current = element;
layers.Layer().Element(container => RenderElement( 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<string, PlaceholderValue> values) IReadOnlyDictionary<string, PlaceholderValue> values)
{ {
var unit = template.Layout.Unit; var unit = template.Layout.Unit;
container container
.PaddingLeft(UnitConverter.Points(box.X, unit)) .PaddingLeft(UnitConverter.Points(element.X, unit))
.PaddingTop(UnitConverter.Points(box.Y, unit)) .PaddingTop(UnitConverter.Points(element.Y, unit))
.PaddingRight(UnitConverter.Points(Math.Max(0, template.Layout.Width - box.X - box.Width), unit)) .PaddingRight(UnitConverter.Points(Math.Max(0, template.Layout.Width - element.X - element.Width), unit))
.PaddingBottom(UnitConverter.Points(Math.Max(0, template.Layout.Height - box.Y - box.Height), unit)) .PaddingBottom(UnitConverter.Points(Math.Max(0, template.Layout.Height - element.Y - element.Height), unit))
.Element(content => RenderResolvedText(content, box.Content, box.Placeholder, box.Format, .Element(content =>
values, template.Manifest, box.Attributes)); {
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, private static void RenderElement(IContainer root, TemplateElement element, LoadedTemplate template,
@@ -108,6 +147,13 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
break; break;
case FlowBoxElement: case FlowBoxElement:
break; 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: case TableElement table:
if (values.GetValueOrDefault(table.Placeholder) is TableValue tableValue) if (values.GetValueOrDefault(table.Placeholder) is TableValue tableValue)
TableElementRenderer.Render(Position(root, table, unit), tableValue, table.Attributes); 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; 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 class UnitConverter
{ {
public static float Points(float value, string unit) => unit.ToLowerInvariant() switch 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<DrawingValue> 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<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 internal static class ChartElementRenderer
{ {
public static void Render(IContainer container, ChartValue value, string chartType, public static void Render(IContainer container, ChartValue value, string chartType,
+81
View File
@@ -1,5 +1,86 @@
# LehrerApp Templating # 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 ## PDF-Import im TemplateDesigner
Der Menüpunkt **Einfügen → PDF als Vorlage importieren** rekonstruiert einseitige PDF-Vorlagen. Der Menüpunkt **Einfügen → PDF als Vorlage importieren** rekonstruiert einseitige PDF-Vorlagen.
+10 -4
View File
@@ -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.")); 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 allLayouts = continuationLayout is null ? new[] { layout } : new[] { layout, continuationLayout };
var flowBoxes = layout.Elements.OfType<FlowBoxElement>().ToList(); var flowBoxes = FlowElements(layout).ToList();
if (flowBoxes.Count > 1) 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) if (continuationLayout is not null)
{ {
var continuationFlows = continuationLayout.Elements.OfType<FlowBoxElement>().ToList(); var continuationFlows = FlowElements(continuationLayout).ToList();
if (flowBoxes.Count != 1 || continuationFlows.Count != 1) 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 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) || 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.")); 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, TextElement { Placeholder: { } p } => p,
TextBoxElement { Placeholder: { } p } => p, TextBoxElement { Placeholder: { } p } => p,
FlowBoxElement { Placeholder: { } p } => p, FlowBoxElement { Placeholder: { } p } => p,
DrawBoxElement d => d.Placeholder,
FlowDrawBoxElement d => d.Placeholder,
TableElement t => t.Placeholder, TableElement t => t.Placeholder,
ChartElement c => c.Placeholder, ChartElement c => c.Placeholder,
_ => null, _ => null,
}).Where(x => x is not null).Cast<string>().Distinct(StringComparer.Ordinal); }).Where(x => x is not null).Cast<string>().Distinct(StringComparer.Ordinal);
private static IEnumerable<TemplateElement> FlowElements(TemplateLayout layout) =>
layout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement);
internal static bool IsSafeRelativePath(string path) => !string.IsNullOrWhiteSpace(path) internal static bool IsSafeRelativePath(string path) => !string.IsNullOrWhiteSpace(path)
&& !Path.IsPathRooted(path) && !path.Split('/', '\\').Any(part => part == ".." || part.Length == 0); && !Path.IsPathRooted(path) && !path.Split('/', '\\').Any(part => part == ".." || part.Length == 0);