# Conflicts: # LehrerApp.TemplateDesigner.Tests/ProjectLifecycleTests.cs # LehrerApp.TemplateDesigner/DesignerViewModel.cs # LehrerApp.TemplateDesigner/MainWindow.axaml # LehrerApp.Templating/LayoutParser.cs # LehrerApp.Templating/QuestTemplateRenderer.cs
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,36 @@ public sealed class LayoutParser
|
||||
flowBox ? 0 : Number(tokens[2]), flowBox ? 0 : Number(tokens[3]),
|
||||
flowBox ? 0 : Number(tokens[4]), boxContent, boxRef.Name, boxRef.Format,
|
||||
Attributes(tokens, flowBox ? 2 : 6)); break;
|
||||
case "FLOWBOX":
|
||||
var nestedFlowBox = currentFlowName is not null;
|
||||
Require(tokens, nestedFlowBox ? 2 : 6);
|
||||
var flowContent = tokens[nestedFlowBox ? 1 : 5];
|
||||
var flowRef = Reference(flowContent);
|
||||
parsedElement = new FlowBoxElement(lineNumber,
|
||||
nestedFlowBox ? 0 : Number(tokens[1]), nestedFlowBox ? 0 : Number(tokens[2]),
|
||||
nestedFlowBox ? 0 : Number(tokens[3]), nestedFlowBox ? 0 : Number(tokens[4]),
|
||||
flowContent, flowRef.Name, flowRef.Format, Attributes(tokens, nestedFlowBox ? 2 : 6));
|
||||
break;
|
||||
case "DRAWBOX":
|
||||
var flowDrawing = currentFlowName is not null;
|
||||
Require(tokens, flowDrawing ? 2 : 6);
|
||||
var drawingAttributes = Attributes(tokens, flowDrawing ? 2 : 6);
|
||||
parsedElement = new DrawBoxElement(lineNumber,
|
||||
flowDrawing ? 0 : Number(tokens[1]), flowDrawing ? 0 : Number(tokens[2]),
|
||||
flowDrawing ? RequiredPositiveNumber(drawingAttributes, "w") : Number(tokens[3]),
|
||||
flowDrawing ? RequiredPositiveNumber(drawingAttributes, "h") : Number(tokens[4]),
|
||||
RequiredReference(tokens[flowDrawing ? 1 : 5]), drawingAttributes);
|
||||
break;
|
||||
case "FLOWDRAWBOX":
|
||||
var nestedFlowDrawing = currentFlowName is not null;
|
||||
Require(tokens, nestedFlowDrawing ? 2 : 6);
|
||||
var flowDrawingAttributes = Attributes(tokens, nestedFlowDrawing ? 2 : 6);
|
||||
parsedElement = new FlowDrawBoxElement(lineNumber,
|
||||
nestedFlowDrawing ? 0 : Number(tokens[1]), nestedFlowDrawing ? 0 : Number(tokens[2]),
|
||||
nestedFlowDrawing ? RequiredPositiveNumber(flowDrawingAttributes, "w") : Number(tokens[3]),
|
||||
nestedFlowDrawing ? RequiredPositiveNumber(flowDrawingAttributes, "h") : Number(tokens[4]),
|
||||
RequiredReference(tokens[nestedFlowDrawing ? 1 : 5]), flowDrawingAttributes);
|
||||
break;
|
||||
case "TABLE":
|
||||
var flowTable = currentFlowName is not null;
|
||||
Require(tokens, flowTable ? 2 : 6);
|
||||
@@ -201,6 +231,7 @@ public sealed class LayoutParser
|
||||
|
||||
private static (string? Name, string? Format) Reference(string value)
|
||||
{
|
||||
if (SystemVariables.IsStandalone(value)) return (null, null);
|
||||
if (!value.StartsWith('$')) return (null, null);
|
||||
var parts = value[1..].Split('|', 2);
|
||||
if (string.IsNullOrWhiteSpace(parts[0])) throw new FormatException("Platzhaltername fehlt.");
|
||||
@@ -216,6 +247,14 @@ public sealed class LayoutParser
|
||||
private static float OptionalNumber(IReadOnlyDictionary<string, string> attributes, string name) =>
|
||||
attributes.TryGetValue(name, out var value) ? Number(value) : 0;
|
||||
|
||||
private static float RequiredPositiveNumber(IReadOnlyDictionary<string, string> attributes, string name)
|
||||
{
|
||||
if (!attributes.TryGetValue(name, out var raw))
|
||||
throw new FormatException($"Das Element benötigt {name}=…");
|
||||
var value = Number(raw);
|
||||
return value > 0 ? value : throw new FormatException($"{name} muss positiv sein.");
|
||||
}
|
||||
|
||||
public static float Percentage(string value)
|
||||
{
|
||||
var normalized = value.EndsWith('%') ? value[..^1] : value;
|
||||
|
||||
@@ -2,7 +2,7 @@ using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
public enum PlaceholderType { Text, Multiline, Date, Number, Image, Table, Chart }
|
||||
public enum PlaceholderType { Text, Multiline, Date, Number, Image, Table, Chart, Drawing }
|
||||
|
||||
public abstract record PlaceholderValue(PlaceholderType Type);
|
||||
public sealed record TextValue(string Value) : PlaceholderValue(PlaceholderType.Text);
|
||||
@@ -16,6 +16,67 @@ public sealed record TableValue(IReadOnlyList<string> Columns, IReadOnlyList<IRe
|
||||
public sealed record ChartPoint(string X, decimal Y);
|
||||
public sealed record ChartSeries(string Label, IReadOnlyList<ChartPoint> Points);
|
||||
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
|
||||
{
|
||||
@@ -35,6 +96,7 @@ public sealed class TemplateManifest
|
||||
public string Description { get; set; } = "";
|
||||
public PageSizeDefinition PageSize { get; set; } = new(210, 297);
|
||||
public string LayoutFile { get; set; } = "layout.tpl";
|
||||
public string? ContinuationLayoutFile { get; set; }
|
||||
public string MetadataFile { get; set; } = TemplateMetadataText.FileName;
|
||||
[JsonIgnore]
|
||||
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -54,6 +116,15 @@ public sealed record TextElement(int Line, float X, float Y, string Content, str
|
||||
public sealed record TextBoxElement(int Line, float X, float Y, float Width, float Height,
|
||||
string Content, string? Placeholder, string? Format, IReadOnlyDictionary<string, string> Attributes)
|
||||
: TemplateElement(Line, X, Y, Width, Height, Attributes);
|
||||
public sealed record FlowBoxElement(int Line, float X, float Y, float Width, float Height,
|
||||
string Content, string? Placeholder, string? Format, IReadOnlyDictionary<string, string> 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,
|
||||
string Placeholder, IReadOnlyDictionary<string, string> Attributes)
|
||||
: TemplateElement(Line, X, Y, Width, Height, Attributes);
|
||||
@@ -82,7 +153,8 @@ public sealed record TemplateLayout(float Width, float Height, string Unit,
|
||||
}
|
||||
|
||||
public sealed record LoadedTemplate(TemplateManifest Manifest, TemplateLayout Layout,
|
||||
IReadOnlyDictionary<string, byte[]> Assets, string SourceName = "");
|
||||
IReadOnlyDictionary<string, byte[]> Assets, string SourceName = "",
|
||||
TemplateLayout? ContinuationLayout = null);
|
||||
|
||||
public enum ValidationSeverity { Warning, Error }
|
||||
public sealed record ValidationIssue(ValidationSeverity Severity, string Message, int? Line = null);
|
||||
|
||||
@@ -35,30 +35,100 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
||||
}
|
||||
|
||||
private static IDocument BuildDocument(LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values) => template.Layout.UsesPageTemplates
|
||||
? BuildFlowDocument(template, values)
|
||||
: BuildLegacyDocument(template, values);
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||
{
|
||||
values = PreparePagedDrawings(template, values);
|
||||
return template.Layout.UsesPageTemplates
|
||||
? BuildFlowDocument(template, values)
|
||||
: BuildLegacyDocument(template, values);
|
||||
}
|
||||
|
||||
private static IDocument BuildLegacyDocument(LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values) => Document.Create(document =>
|
||||
IReadOnlyDictionary<string, PlaceholderValue> 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),
|
||||
UnitConverter.Points(template.Layout.Height, template.Layout.Unit));
|
||||
page.Margin(0);
|
||||
page.Content().Layers(layers =>
|
||||
{
|
||||
layers.PrimaryLayer().Width(UnitConverter.Points(template.Layout.Width, template.Layout.Unit))
|
||||
.Height(UnitConverter.Points(template.Layout.Height, template.Layout.Unit)).Background(Colors.White);
|
||||
foreach (var element in template.Layout.Elements)
|
||||
{
|
||||
var current = element;
|
||||
layers.Layer().Element(container => RenderElement(container, current, template, values));
|
||||
}
|
||||
if (flowBox is null)
|
||||
layers.PrimaryLayer().Width(UnitConverter.Points(template.Layout.Width, template.Layout.Unit))
|
||||
.Height(UnitConverter.Points(template.Layout.Height, template.Layout.Unit)).Background(Colors.White);
|
||||
else
|
||||
RenderLegacyFlowElement(layers.PrimaryLayer(), flowBox, template, values);
|
||||
|
||||
RenderStaticLayers(layers, template.Layout, template, values,
|
||||
template.ContinuationLayout is null ? null : static container => container.ShowOnce());
|
||||
if (template.ContinuationLayout is { } continuation)
|
||||
RenderStaticLayers(layers, continuation, template, values, static container => container.SkipOnce());
|
||||
});
|
||||
});
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, PlaceholderValue> PreparePagedDrawings(LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||
{
|
||||
var result = new Dictionary<string, PlaceholderValue>(values, StringComparer.Ordinal);
|
||||
var layouts = template.ContinuationLayout is null
|
||||
? new[] { template.Layout }
|
||||
: new[] { template.Layout, template.ContinuationLayout };
|
||||
var drawingElements = layouts.SelectMany(x => x.Elements)
|
||||
.Where(x => x is DrawBoxElement or FlowDrawBoxElement).ToList();
|
||||
foreach (var group in drawingElements.GroupBy(x => x switch
|
||||
{ DrawBoxElement box => box.Placeholder, FlowDrawBoxElement box => box.Placeholder, _ => "" }))
|
||||
{
|
||||
if (values.GetValueOrDefault(group.Key) is not PagedDrawingValue) continue;
|
||||
var signatures = group.Select(x => (x.GetType(), x.Width, x.Height)).Distinct().Count();
|
||||
if (signatures > 1)
|
||||
throw new InvalidDataException($"Der Callback-Platzhalter „{group.Key}“ wird in unterschiedlich großen oder unterschiedlichen Zeichenboxen verwendet.");
|
||||
}
|
||||
foreach (var element in drawingElements)
|
||||
{
|
||||
var placeholder = element switch
|
||||
{ DrawBoxElement box => box.Placeholder, FlowDrawBoxElement box => box.Placeholder, _ => null };
|
||||
if (placeholder is null || values.GetValueOrDefault(placeholder) is not PagedDrawingValue callback) continue;
|
||||
if (result.GetValueOrDefault(placeholder) is RecordedDrawingValue) continue;
|
||||
var pages = element is FlowDrawBoxElement
|
||||
? DrawingElementRenderer.RecordPages(callback, element.Width, element.Height, callback.MaxPages)
|
||||
: [DrawingElementRenderer.RecordSingle(callback, element.Width, element.Height)];
|
||||
result[placeholder] = new RecordedDrawingValue(pages);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void RenderStaticLayers(LayersDescriptor layers, TemplateLayout layout, LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values, Func<IContainer, IContainer>? visibility)
|
||||
{
|
||||
foreach (var element in layout.Elements.Where(x => x is not (FlowBoxElement or FlowDrawBoxElement)))
|
||||
{
|
||||
var current = element;
|
||||
layers.Layer().Element(container => RenderElement(
|
||||
visibility is null ? container : visibility(container), current, template, values));
|
||||
}
|
||||
}
|
||||
|
||||
private static void RenderLegacyFlowElement(IContainer container, TemplateElement element, LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||
{
|
||||
var unit = template.Layout.Unit;
|
||||
container
|
||||
.PaddingLeft(UnitConverter.Points(element.X, unit))
|
||||
.PaddingTop(UnitConverter.Points(element.Y, unit))
|
||||
.PaddingRight(UnitConverter.Points(Math.Max(0, template.Layout.Width - element.X - element.Width), unit))
|
||||
.PaddingBottom(UnitConverter.Points(Math.Max(0, template.Layout.Height - element.Y - element.Height), unit))
|
||||
.Element(content =>
|
||||
{
|
||||
if (element is FlowBoxElement box)
|
||||
RenderResolvedText(content, box.Content, box.Placeholder, box.Format,
|
||||
values, template.Manifest, box.Attributes);
|
||||
else if (element is FlowDrawBoxElement drawing
|
||||
&& values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue)
|
||||
DrawingElementRenderer.RenderFlow(content, drawingValue, drawing.Width, drawing.Height, unit);
|
||||
});
|
||||
}
|
||||
|
||||
private static IDocument BuildFlowDocument(LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||
@@ -185,6 +255,20 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
||||
RenderResolvedText(container, box.Content, box.Placeholder, box.Format,
|
||||
values, template.Manifest, box.Attributes);
|
||||
break;
|
||||
case FlowBoxElement box:
|
||||
RenderResolvedText(container, box.Content, box.Placeholder, box.Format,
|
||||
values, template.Manifest, box.Attributes);
|
||||
break;
|
||||
case DrawBoxElement drawing when values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue:
|
||||
DrawingElementRenderer.RenderFixed(
|
||||
container.Width(UnitConverter.Points(drawing.Width, template.Layout.Unit))
|
||||
.Height(UnitConverter.Points(drawing.Height, template.Layout.Unit)),
|
||||
drawingValue, drawing.Width, drawing.Height, template.Layout.Unit);
|
||||
break;
|
||||
case FlowDrawBoxElement drawing when values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue:
|
||||
DrawingElementRenderer.RenderFlow(container, drawingValue,
|
||||
drawing.Width, drawing.Height, template.Layout.Unit);
|
||||
break;
|
||||
case ImageElement image:
|
||||
var imageContainer = container;
|
||||
if (image.Width > 0) imageContainer = imageContainer.Width(UnitConverter.Points(image.Width, template.Layout.Unit));
|
||||
@@ -229,6 +313,20 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
||||
RenderResolvedText(Position(root, box, unit).Shrink(), box.Content, box.Placeholder, box.Format,
|
||||
values, template.Manifest, box.Attributes);
|
||||
break;
|
||||
case FlowBoxElement box:
|
||||
RenderResolvedText(Position(root, box, unit).Shrink(), box.Content, box.Placeholder, box.Format,
|
||||
values, template.Manifest, box.Attributes);
|
||||
break;
|
||||
case DrawBoxElement drawing:
|
||||
if (values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue)
|
||||
DrawingElementRenderer.RenderFixed(Position(root, drawing, unit), drawingValue,
|
||||
drawing.Width, drawing.Height, unit);
|
||||
break;
|
||||
case FlowDrawBoxElement drawing:
|
||||
if (values.GetValueOrDefault(drawing.Placeholder) is { } flowDrawingValue)
|
||||
DrawingElementRenderer.RenderFixed(Position(root, drawing, unit), flowDrawingValue,
|
||||
drawing.Width, drawing.Height, unit);
|
||||
break;
|
||||
case TableElement table:
|
||||
if (values.GetValueOrDefault(table.Placeholder) is TableValue tableValue)
|
||||
TableElementRenderer.Render(Position(root, table, unit), tableValue, table.Attributes);
|
||||
@@ -252,6 +350,15 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
||||
{
|
||||
var aligned = attributes.GetValueOrDefault("align", "left").ToLowerInvariant() switch
|
||||
{ "center" => container.AlignCenter(), "right" => container.AlignRight(), _ => container.AlignLeft() };
|
||||
if (SystemVariables.Contains(content))
|
||||
{
|
||||
aligned.Text(text =>
|
||||
{
|
||||
text.DefaultTextStyle(style => ApplyTextStyle(style, attributes));
|
||||
AppendSystemVariables(text, content);
|
||||
});
|
||||
return;
|
||||
}
|
||||
var descriptor = aligned.Text(content);
|
||||
descriptor.FontSize(ParseFloat(attributes, "size", 11));
|
||||
if (ParseBool(attributes, "bold")) descriptor.SemiBold();
|
||||
@@ -291,15 +398,42 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
||||
{
|
||||
var content = run.Placeholder is null ? run.Text
|
||||
: values.TryGetValue(run.Placeholder, out var value) ? Format(value, run.Format) : "";
|
||||
var span = text.Span(content).FontSize(fontSize);
|
||||
if (globalBold || run.Bold) span.SemiBold();
|
||||
if (globalItalic || run.Italic) span.Italic();
|
||||
if (globalUnderline || run.Underline) span.Underline();
|
||||
if (color is not null) span.FontColor(color);
|
||||
foreach (var span in AppendSystemVariables(text, content))
|
||||
{
|
||||
span.FontSize(fontSize);
|
||||
if (globalBold || run.Bold) span.SemiBold();
|
||||
if (globalItalic || run.Italic) span.Italic();
|
||||
if (globalUnderline || run.Underline) span.Underline();
|
||||
if (color is not null) span.FontColor(color);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static TextStyle ApplyTextStyle(TextStyle style, IReadOnlyDictionary<string, string> attributes)
|
||||
{
|
||||
style = style.FontSize(ParseFloat(attributes, "size", 11));
|
||||
if (ParseBool(attributes, "bold")) style = style.SemiBold();
|
||||
if (ParseBool(attributes, "italic")) style = style.Italic();
|
||||
if (ParseBool(attributes, "underline")) style = style.Underline();
|
||||
if (attributes.TryGetValue("color", out var color)) style = style.FontColor(color);
|
||||
return style;
|
||||
}
|
||||
|
||||
private static IEnumerable<TextSpanDescriptor> AppendSystemVariables(TextDescriptor text, string content)
|
||||
{
|
||||
foreach (var part in SystemVariables.Split(content))
|
||||
{
|
||||
yield return part.Name switch
|
||||
{
|
||||
SystemVariables.CurrentPage => text.CurrentPageNumber(),
|
||||
SystemVariables.MaximumPageNumber => text.TotalPages(),
|
||||
SystemVariables.Today => text.Span(DateTime.Today.ToString("d", CultureInfo.GetCultureInfo("de-DE"))),
|
||||
_ => text.Span(part.Text),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> TextAttributes(TemplateManifest manifest, string? placeholder,
|
||||
IReadOnlyDictionary<string, string> elementAttributes)
|
||||
{
|
||||
@@ -340,6 +474,9 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
||||
attributes.TryGetValue(key, out var raw) && bool.TryParse(raw, out var value) && value;
|
||||
}
|
||||
|
||||
internal sealed record RecordedDrawingValue(IReadOnlyList<IReadOnlyList<DrawingCommand>> Pages)
|
||||
: PlaceholderValue(PlaceholderType.Drawing);
|
||||
|
||||
public static class UnitConverter
|
||||
{
|
||||
public static float Points(float value, string unit) => unit.ToLowerInvariant() switch
|
||||
@@ -370,6 +507,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
|
||||
{
|
||||
public static void Render(IContainer container, ChartValue value, string chartType,
|
||||
|
||||
@@ -1,5 +1,144 @@
|
||||
# LehrerApp Templating
|
||||
|
||||
## Dynamische Zeichenflächen für externe Apps
|
||||
|
||||
Externe `ITemplateDataProvider` können einen Platzhalter vom Typ `Drawing` mit einem
|
||||
`DrawingValue` befüllen. Dabei wird kein QuestPDF-/Skia-Canvas nach außen gegeben. Die portable
|
||||
Form besteht stattdessen aus einer geprüften, serialisierbaren Befehlsliste:
|
||||
|
||||
```csharp
|
||||
var drawing = new DrawingValue(
|
||||
[
|
||||
new DrawRectangle(0, 0, 160, 24, "#2563EB", 0.8f, "#EFF6FF"),
|
||||
new DrawString(4, 4, "Dynamischer Bericht", 11, "#1E3A8A", Bold: true),
|
||||
new MoveTo(4, 19),
|
||||
new LineTo(156, 19, "#93C5FD", 0.6f),
|
||||
new DrawImage(120, 2, 30, 18, pngBytes, "image/png")
|
||||
],
|
||||
ContentHeight: 24);
|
||||
```
|
||||
|
||||
Koordinaten und Längen verwenden die Einheit des Layouts; `DrawString.FontSize` wird wie bei
|
||||
`TEXT` in Punkt angegeben. Unterstützt werden `DrawString`, `MoveTo`, `LineTo`, `DrawLine`,
|
||||
`DrawRectangle` und `DrawImage` (PNG/JPEG). Die Befehle gelangen über den normalen
|
||||
`ITemplateDataProvider`, beispielsweise als `values["ExternerBericht"] = drawing`.
|
||||
|
||||
`DrawStringEx(x, y, height, width, ...)` ergänzt eine geclippte Textbox mit `AlignLeft`,
|
||||
`AlignCenter` oder `AlignRight`. Farbe und Schriftfamilie können bei beiden Textbefehlen gesetzt
|
||||
werden. Ohne Schriftangabe wird `sans-serif` verwendet. Für portable Schriften registriert die
|
||||
integrierende App TTF-/OTF-Daten einmal vor dem Rendern:
|
||||
|
||||
```csharp
|
||||
DrawingFontRegistry.RegisterFont("MeineSchulschrift", fontBytes);
|
||||
canvas.DrawStringEx(0, 0, 12, context.Width, "Zentrierte Überschrift",
|
||||
DrawingTextAlignment.AlignCenter, 11, "MeineSchulschrift", "#1E3A8A", bold: true);
|
||||
```
|
||||
|
||||
Explizite Zeilenumbrüche werden berücksichtigt; Text außerhalb von `height`/`width` wird
|
||||
abgeschnitten. Eine automatische Worttrennung findet in dieser elementaren Zeichenfunktion nicht
|
||||
statt.
|
||||
|
||||
Im Layout stehen zwei Varianten zur Verfügung:
|
||||
|
||||
```text
|
||||
DRAWBOX 20 40 170 80 $ExternerBericht
|
||||
FLOWDRAWBOX 20 40 170 237 $LangesProtokoll
|
||||
```
|
||||
|
||||
`DRAWBOX` ist ein fester, geclippter Viewport. Inhalte außerhalb seiner Breite oder Höhe werden
|
||||
nicht angezeigt. `FLOWDRAWBOX` zerlegt den vertikalen Zeichenraum anhand von `ContentHeight` in
|
||||
gleich hohe Seitenfenster und setzt ihn auf Folgeseiten fort. Wie bei `FLOWBOX` darf ein Layout
|
||||
höchstens ein fließendes Element enthalten; bei einem eigenen Folgeseitenlayout müssen Typ,
|
||||
Position und Größe übereinstimmen.
|
||||
|
||||
Ein Vorlagenpaket kann keinen Callback und keinen Typ aus einer fremden Assembly einschleusen.
|
||||
Farben, Zahlen, Bildformate, Bildgröße und Gesamtzahl der portablen Befehle werden validiert.
|
||||
Damit bleibt die paketfähige Schnittstelle deterministisch. Ein `PagedDrawingValue` ist dagegen
|
||||
ein ausdrücklich vom vertrauenswürdigen In-Process-`ITemplateDataProvider` übergebener Delegate.
|
||||
|
||||
Für umfangreiche In-Process-Integrationen gibt es zusätzlich den klassischen seitenweisen
|
||||
Callback `PagedDrawingValue`. Er wird vor QuestPDFs Layout vollständig in deklarative Seitenlisten
|
||||
aufgezeichnet und daher nicht durch interne Layoutdurchläufe mehrfach ausgeführt:
|
||||
|
||||
```csharp
|
||||
var value = new PagedDrawingValue(context =>
|
||||
{
|
||||
var nextRow = context.State is int row ? row : 0;
|
||||
|
||||
// Nur vollständige Strukturen zeichnen, die noch in die zugewiesene Box passen.
|
||||
while (nextRow < rows.Count && PasstNochVollstaendig(rows[nextRow], context))
|
||||
ZeichneZeile(context.Canvas, rows[nextRow++]);
|
||||
|
||||
context.State = nextRow;
|
||||
return nextRow == rows.Count; // true = fertig, false = weitere FLOWDRAWBOX
|
||||
}, InitialState: 0);
|
||||
```
|
||||
|
||||
`DrawingPageContext` stellt `Width`, `Height`, `PageNumber`, `Canvas` und ein über alle Aufrufe
|
||||
weitergereichtes `State`-Objekt bereit. So kann der Zeichner Tabellenzeilen, Diagrammgruppen oder
|
||||
andere unteilbare Strukturen bewusst auf die nächste Seite verschieben. Ein `DRAWBOX`-Callback
|
||||
wird genau einmal aufgerufen; bei `FLOWDRAWBOX` fordert `false` eine weitere Seite an. `MaxPages`
|
||||
verhindert Endlosschleifen. Delegates funktionieren nur innerhalb desselben .NET-Prozesses;
|
||||
prozessübergreifend bleibt `DrawingValue` die Übergabeform.
|
||||
|
||||
## PDF-Import im TemplateDesigner
|
||||
|
||||
Der Menüpunkt **Einfügen → PDF als Vorlage importieren** rekonstruiert einseitige PDF-Vorlagen.
|
||||
Der präzisere Modus verwendet ein leeres Template zusammen mit einem ausgefüllten Beispiel und
|
||||
ermittelt variable Textbereiche über einen toleranten Geometrie-Diff. Mit nur einem PDF werden
|
||||
Datum, Zahlen und typische Adressbereiche lokal heuristisch vorerkannt.
|
||||
|
||||
PdfPig extrahiert Text, Bounding-Box, Schriftgröße und verfügbare Schriftmerkmale. Die optionale
|
||||
KI-Klassifikation erhält ausschließlich diese strukturierte Zwischenrepräsentation und darf nur
|
||||
Placeholder-Namen, Typ, Gruppierung und Konfidenz liefern. Koordinaten werden nicht an die KI
|
||||
delegiert. Das gerasterte PDF bleibt als Hintergrund erhalten; erkannte variable Bereiche werden
|
||||
deterministisch mit einem weißen Asset maskiert und anschließend als `TEXT` oder `TEXTBOX`
|
||||
eingefügt. Der Nutzer prüft alle Vorschläge im Importdialog und muss die Übernahme ausdrücklich
|
||||
bestätigen. Das Paket wird dabei noch nicht gespeichert.
|
||||
|
||||
Die serverseitige Klassifikation liegt in `ai-backend/pdf-template.php` und verwendet denselben
|
||||
Login-, Bearer-Token-, Guthaben- und Abrechnungsmechanismus wie die übrigen KI-Funktionen. Das
|
||||
Passwort wird vom eigenständigen Designer nicht gespeichert. Tabellen-/Chart-Erkennung und die
|
||||
automatische Rekonstruktion mehrseitiger Vorlagen sind bewusst nicht Teil von v1.
|
||||
|
||||
## Mehrseitiger Fließtext
|
||||
|
||||
`TEXTBOX` bleibt ein absolut positionierter Bereich mit fester Höhe. Für Texte unbekannter Länge
|
||||
steht `FLOWBOX` mit derselben Syntax zur Verfügung:
|
||||
|
||||
```text
|
||||
PAGE 210 297 mm
|
||||
FLOWBOX 20 45 170 232 $Klassenbucheintraege size=11
|
||||
```
|
||||
|
||||
Der Inhalt wird innerhalb dieses Bereichs umbrochen und bei Bedarf auf beliebig vielen Seiten
|
||||
fortgesetzt. Pro Layout ist höchstens eine `FLOWBOX` zulässig. Das optionale Manifestfeld
|
||||
`continuationLayoutFile` verweist auf ein zweites Layout im Paket, das ab Seite 2 verwendet wird.
|
||||
Damit können Folgeseiten beispielsweise einen kleineren Briefkopf oder einen eigenen Hintergrund
|
||||
haben. Haupt- und Folgeseitenlayout müssen dieselbe Seitengröße besitzen; ihre `FLOWBOX` muss aus
|
||||
technischen Gründen dieselbe Position und Größe haben. Ohne Folgeseitenlayout werden die statischen
|
||||
Elemente der ersten Seite auf jeder erzeugten Seite wiederholt.
|
||||
|
||||
## Systemvariablen
|
||||
|
||||
Textinhalte können drei vom Renderer bereitgestellte Variablen verwenden. Sie werden nicht im
|
||||
Manifest deklariert und nicht vom `ITemplateDataProvider` geliefert:
|
||||
|
||||
- `$$today` – aktuelles lokales Datum im deutschen Kurzformat
|
||||
- `$$curPage` – aktuelle Seitenzahl
|
||||
- `$$maxPageNum` – Gesamtzahl der Seiten
|
||||
|
||||
Sie können allein oder innerhalb eines Literals stehen, beispielsweise:
|
||||
|
||||
```text
|
||||
TEXT 20 10 "Stand: $$today" size=9
|
||||
TEXT 145 285 "Seite $$curPage von $$maxPageNum" size=9 align=right
|
||||
```
|
||||
|
||||
QuestPDF löst aktuelle und gesamte Seitenzahl während der Dokumenterzeugung auf. Dafür ist kein
|
||||
zusätzlicher Renderdurchlauf durch die Anwendung erforderlich. Die Variablen funktionieren auch in
|
||||
konstanten Rich-Text-Platzhaltern.
|
||||
|
||||
## Konstante Platzhalter und Hervorhebung
|
||||
|
||||
Ein Platzhalter kann seinen Wert vollständig im Vorlagenpaket tragen. `IsConstant=true` bewirkt,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
internal static class SystemVariables
|
||||
{
|
||||
internal const string CurrentPage = "curPage";
|
||||
internal const string MaximumPageNumber = "maxPageNum";
|
||||
internal const string Today = "today";
|
||||
|
||||
private static readonly string[] Names = [CurrentPage, MaximumPageNumber, Today];
|
||||
|
||||
internal static bool IsStandalone(string value) =>
|
||||
TryRead(value, 0, out var length) && length == value.Length;
|
||||
|
||||
internal static bool Contains(string value) => Names.Any(name =>
|
||||
value.Contains("$$" + name, StringComparison.Ordinal));
|
||||
|
||||
internal static bool TryRead(string source, int index, out int length)
|
||||
{
|
||||
length = 0;
|
||||
if (index < 0 || index + 2 > source.Length || source[index] != '$' || source[index + 1] != '$')
|
||||
return false;
|
||||
foreach (var name in Names)
|
||||
{
|
||||
var token = "$$" + name;
|
||||
if (!source.AsSpan(index).StartsWith(token, StringComparison.Ordinal)) continue;
|
||||
var end = index + token.Length;
|
||||
if (end < source.Length && (char.IsLetterOrDigit(source[end]) || source[end] is '_' or '-' or '.'))
|
||||
continue;
|
||||
length = token.Length;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static IEnumerable<(string Text, string? Name)> Split(string source)
|
||||
{
|
||||
var literalStart = 0;
|
||||
for (var index = 0; index < source.Length;)
|
||||
{
|
||||
if (!TryRead(source, index, out var length)) { index++; continue; }
|
||||
if (index > literalStart) yield return (source[literalStart..index], null);
|
||||
yield return (source.Substring(index, length), source.Substring(index + 2, length - 2));
|
||||
index += length;
|
||||
literalStart = index;
|
||||
}
|
||||
if (literalStart < source.Length) yield return (source[literalStart..], null);
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,8 @@ public sealed class TemplateLoader(TemplateLimits? limits = null,
|
||||
throw Error($"schemaVersion {manifest.SchemaVersion} wird nicht unterstützt.");
|
||||
if (!IsSafeRelativePath(manifest.LayoutFile)) throw Error("layoutFile enthält einen unsicheren Pfad.");
|
||||
if (!IsSafeRelativePath(manifest.MetadataFile)) throw Error("metadataFile enthält einen unsicheren Pfad.");
|
||||
if (manifest.ContinuationLayoutFile is not null && !IsSafeRelativePath(manifest.ContinuationLayoutFile))
|
||||
throw Error("continuationLayoutFile enthält einen unsicheren Pfad.");
|
||||
if (Normalize(manifest.MetadataFile).Equals(Normalize(manifest.LayoutFile), StringComparison.OrdinalIgnoreCase))
|
||||
throw Error("metadataFile und layoutFile dürfen nicht identisch sein.");
|
||||
if (!entries.TryGetValue(Normalize(manifest.LayoutFile), out var layoutEntry))
|
||||
@@ -78,7 +80,33 @@ public sealed class TemplateLoader(TemplateLimits? limits = null,
|
||||
catch (InvalidDataException ex) { throw Error($"{manifest.MetadataFile} ist ungültig: {ex.Message}"); }
|
||||
}
|
||||
var layout = new LayoutParser().Parse(layoutSource);
|
||||
var referencedAssets = layout.Elements.Select(AssetPath).Where(x => x is not null).Cast<string>()
|
||||
TemplateLayout? continuationLayout = null;
|
||||
if (manifest.ContinuationLayoutFile is { } continuationPath)
|
||||
{
|
||||
if (!entries.TryGetValue(Normalize(continuationPath), out var continuationEntry))
|
||||
throw Error($"Folgeseiten-Layoutdatei „{continuationPath}“ fehlt.");
|
||||
using var reader = new StreamReader(continuationEntry.Open());
|
||||
continuationLayout = new LayoutParser().Parse(reader.ReadToEnd());
|
||||
if (continuationLayout.Width != layout.Width || continuationLayout.Height != layout.Height
|
||||
|| !continuationLayout.Unit.Equals(layout.Unit, StringComparison.OrdinalIgnoreCase))
|
||||
issues.Add(new(ValidationSeverity.Error, "Folgeseiten-Layout und Hauptlayout müssen dieselbe Seitengröße und Einheit verwenden."));
|
||||
}
|
||||
var allLayouts = continuationLayout is null ? new[] { layout } : new[] { layout, continuationLayout };
|
||||
var flowBoxes = FlowElements(layout).ToList();
|
||||
if (flowBoxes.Count > 1)
|
||||
issues.Add(new(ValidationSeverity.Error, "Ein Layout darf höchstens ein fließendes Element (FLOWBOX/FLOWDRAWBOX) enthalten."));
|
||||
if (continuationLayout is not null)
|
||||
{
|
||||
var continuationFlows = FlowElements(continuationLayout).ToList();
|
||||
if (flowBoxes.Count != 1 || continuationFlows.Count != 1)
|
||||
issues.Add(new(ValidationSeverity.Error, "Bei einem Folgeseiten-Layout müssen Haupt- und Folgeseite jeweils genau ein gleichartiges fließendes Element enthalten."));
|
||||
else if (flowBoxes[0].GetType() != continuationFlows[0].GetType())
|
||||
issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen denselben fließenden Elementtyp verwenden."));
|
||||
else if (flowBoxes[0].X != continuationFlows[0].X || flowBoxes[0].Y != continuationFlows[0].Y
|
||||
|| flowBoxes[0].Width != continuationFlows[0].Width || flowBoxes[0].Height != continuationFlows[0].Height)
|
||||
issues.Add(new(ValidationSeverity.Error, "Die FLOWBOX muss auf Haupt- und Folgeseiten dieselbe Position und Größe haben."));
|
||||
}
|
||||
var referencedAssets = allLayouts.SelectMany(x => x.Elements).Select(AssetPath).Where(x => x is not null).Cast<string>()
|
||||
.Select(Normalize).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
foreach (var path in referencedAssets)
|
||||
{
|
||||
@@ -89,9 +117,11 @@ public sealed class TemplateLoader(TemplateLimits? limits = null,
|
||||
|
||||
var assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
|
||||
var layoutPath = Normalize(manifest.LayoutFile);
|
||||
var continuationLayoutPath = manifest.ContinuationLayoutFile is null ? null : Normalize(manifest.ContinuationLayoutFile);
|
||||
foreach (var (path, asset) in entries.Where(x =>
|
||||
!x.Key.Equals("manifest.json", StringComparison.OrdinalIgnoreCase)
|
||||
&& !x.Key.Equals(layoutPath, StringComparison.OrdinalIgnoreCase)
|
||||
&& !x.Key.Equals(continuationLayoutPath, StringComparison.OrdinalIgnoreCase)
|
||||
&& !x.Key.Equals(metadataPath, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (asset.Length > _limits.MaxAssetBytes)
|
||||
@@ -107,14 +137,14 @@ public sealed class TemplateLoader(TemplateLimits? limits = null,
|
||||
}
|
||||
|
||||
var declared = manifest.Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var used in UsedPlaceholders(layout).Where(x => !declared.Contains(x)))
|
||||
foreach (var used in allLayouts.SelectMany(UsedPlaceholders).Distinct(StringComparer.Ordinal).Where(x => !declared.Contains(x)))
|
||||
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{used}“ wird im Layout verwendet, aber nicht im Manifest deklariert."));
|
||||
foreach (var duplicate in manifest.Placeholders.GroupBy(x => x.Name, StringComparer.Ordinal).Where(x => x.Count() > 1))
|
||||
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{duplicate.Key}“ ist mehrfach deklariert."));
|
||||
foreach (var issue in TemplateDataResolver.ValidateConstants(manifest))
|
||||
issues.Add(new(ValidationSeverity.Error, issue));
|
||||
if (issues.Count > 0) throw new TemplateValidationException(new(issues));
|
||||
return new(manifest, layout, assets, sourceName);
|
||||
return new(manifest, layout, assets, sourceName, continuationLayout);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{ throw Error($"Paket ist kein lesbares ZIP-Archiv: {ex.Message}"); }
|
||||
@@ -140,10 +170,15 @@ public sealed class TemplateLoader(TemplateLimits? limits = null,
|
||||
{
|
||||
TextElement { Placeholder: { } p } => p,
|
||||
TextBoxElement { Placeholder: { } p } => p,
|
||||
FlowBoxElement { Placeholder: { } p } => p,
|
||||
DrawBoxElement d => d.Placeholder,
|
||||
FlowDrawBoxElement d => d.Placeholder,
|
||||
TableElement t => t.Placeholder,
|
||||
ChartElement c => c.Placeholder,
|
||||
_ => null,
|
||||
}).Where(x => x is not null).Cast<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)
|
||||
&& !Path.IsPathRooted(path) && !path.Split('/', '\\').Any(part => part == ".." || part.Length == 0);
|
||||
|
||||
@@ -8,7 +8,7 @@ public static class TemplatePackage
|
||||
public const string Extension = ".lavorlage";
|
||||
|
||||
public static void Create(string outputPath, TemplateManifest manifest, string layoutSource,
|
||||
IReadOnlyDictionary<string, byte[]> assets)
|
||||
IReadOnlyDictionary<string, byte[]> assets, string? continuationLayoutSource = null)
|
||||
{
|
||||
if (!TemplateLoader.IsSafeRelativePath(manifest.MetadataFile))
|
||||
throw new InvalidDataException("metadataFile enthält einen unsicheren Pfad.");
|
||||
@@ -17,6 +17,14 @@ public static class TemplatePackage
|
||||
throw new InvalidDataException("metadataFile und layoutFile dürfen nicht identisch sein.");
|
||||
var reservedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{ "manifest.json", TemplateLoader.Normalize(manifest.LayoutFile), TemplateLoader.Normalize(manifest.MetadataFile) };
|
||||
if (manifest.ContinuationLayoutFile is { } continuationPath)
|
||||
{
|
||||
if (!TemplateLoader.IsSafeRelativePath(continuationPath))
|
||||
throw new InvalidDataException("continuationLayoutFile enthält einen unsicheren Pfad.");
|
||||
reservedPaths.Add(TemplateLoader.Normalize(continuationPath));
|
||||
if (continuationLayoutSource is null)
|
||||
throw new InvalidDataException("Das Folgeseiten-Layout fehlt.");
|
||||
}
|
||||
if (!string.Equals(Path.GetExtension(outputPath), Extension, StringComparison.OrdinalIgnoreCase))
|
||||
outputPath += Extension;
|
||||
var directory = Path.GetDirectoryName(outputPath);
|
||||
@@ -28,6 +36,8 @@ public static class TemplatePackage
|
||||
{
|
||||
WriteText(archive, "manifest.json", JsonSerializer.Serialize(manifest, TemplateLoader.JsonOptions));
|
||||
WriteText(archive, manifest.LayoutFile, layoutSource);
|
||||
if (manifest.ContinuationLayoutFile is { } continuationFile)
|
||||
WriteText(archive, continuationFile, continuationLayoutSource!);
|
||||
WriteText(archive, manifest.MetadataFile, TemplateMetadataText.Serialize(manifest.Metadata));
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
|
||||
@@ -30,6 +30,13 @@ public static class TemplateRichText
|
||||
&& source[index + 1] is '\\' or '[' or '$')
|
||||
{ literal.Append(source[index + 1]); index += 2; continue; }
|
||||
|
||||
if (source[index] == '$' && SystemVariables.TryRead(source, index, out var systemLength))
|
||||
{
|
||||
literal.Append(source, index, systemLength);
|
||||
index += systemLength;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryTag(source, index, out var tag, out var closing, out var tagLength))
|
||||
{
|
||||
Flush();
|
||||
|
||||
Reference in New Issue
Block a user