Merge remote-tracking branch 'origin/main'
CI / build-and-test (push) Canceled after 0s

# 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:
2026-09-01 10:15:54 +02:00
28 changed files with 1729 additions and 65 deletions
@@ -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);
}
}
}
+39
View File
@@ -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;
+74 -2
View File
@@ -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);
+336 -19
View File
@@ -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,
+139
View File
@@ -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,
+48
View File
@@ -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);
}
}
+38 -3
View File
@@ -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);
+11 -1
View File
@@ -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)
{
+7
View File
@@ -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();