This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user