QuestPDF-Vorlagensystem und Designer hinzufügen
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-08-30 15:37:22 +02:00
parent a088823b30
commit 2f2761caf8
40 changed files with 2177 additions and 738 deletions
+150
View File
@@ -0,0 +1,150 @@
using System.Globalization;
using System.Text;
namespace LehrerApp.Templating;
public sealed class LayoutParser
{
public TemplateLayout Parse(string source)
{
var issues = new List<ValidationIssue>();
var elements = new List<TemplateElement>();
float width = 0, height = 0;
var unit = "mm";
var pageSeen = false;
var lines = source.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
for (var index = 0; index < lines.Length; index++)
{
var lineNumber = index + 1;
var raw = lines[index].Trim();
if (raw.Length == 0 || raw.StartsWith('#')) continue;
try
{
var tokens = Tokenize(raw);
if (tokens.Count == 0) continue;
var keyword = tokens[0].ToUpperInvariant();
if (!pageSeen && keyword != "PAGE")
throw new FormatException("PAGE muss das erste Statement sein.");
switch (keyword)
{
case "PAGE":
if (pageSeen) throw new FormatException("PAGE darf nur einmal vorkommen.");
Require(tokens, 4);
width = Number(tokens[1]); height = Number(tokens[2]); unit = tokens[3].ToLowerInvariant();
if (width <= 0 || height <= 0) throw new FormatException("Seitengröße muss positiv sein.");
if (unit is not ("mm" or "cm" or "pt" or "in"))
throw new FormatException("Einheit muss mm, cm, pt oder in sein.");
pageSeen = true;
break;
case "BG":
Require(tokens, 2); elements.Add(new BackgroundElement(lineNumber, tokens[1])); break;
case "IMG":
Require(tokens, 6);
var imageAttributes = Attributes(tokens, 6);
if (imageAttributes.TryGetValue("scale", out var scale)) Percentage(scale);
elements.Add(new ImageElement(lineNumber, tokens[1],
Number(tokens[2]), Number(tokens[3]), Number(tokens[4]), Number(tokens[5]), imageAttributes));
break;
case "TEXT":
Require(tokens, 4);
var textRef = Reference(tokens[3]);
elements.Add(new TextElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
tokens[3], textRef.Name, textRef.Format, Attributes(tokens, 4))); break;
case "TEXTBOX":
Require(tokens, 6);
var boxRef = Reference(tokens[5]);
elements.Add(new TextBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
Number(tokens[3]), Number(tokens[4]), tokens[5], boxRef.Name, boxRef.Format,
Attributes(tokens, 6))); break;
case "TABLE":
Require(tokens, 6);
elements.Add(new TableElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
Number(tokens[3]), Number(tokens[4]), RequiredReference(tokens[5]), Attributes(tokens, 6)));
break;
case "CHART":
Require(tokens, 6);
var attributes = Attributes(tokens, 6);
var chartType = attributes.GetValueOrDefault("type", "bar").ToLowerInvariant();
if (chartType is not ("line" or "bar")) throw new FormatException("CHART type muss line oder bar sein.");
elements.Add(new ChartElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
Number(tokens[3]), Number(tokens[4]), RequiredReference(tokens[5]), chartType, attributes));
break;
default: throw new FormatException($"Unbekanntes Element „{tokens[0]}“.");
}
}
catch (FormatException ex)
{
issues.Add(new(ValidationSeverity.Error, $"Zeile {lineNumber}: {ex.Message}", lineNumber));
}
}
if (!pageSeen) issues.Add(new(ValidationSeverity.Error, "PAGE fehlt."));
if (issues.Count > 0) throw new TemplateValidationException(new(issues));
return new(width, height, unit, elements);
}
private static (string? Name, string? Format) Reference(string value)
{
if (!value.StartsWith('$')) return (null, null);
var parts = value[1..].Split('|', 2);
if (string.IsNullOrWhiteSpace(parts[0])) throw new FormatException("Platzhaltername fehlt.");
return (parts[0], parts.Length == 2 ? parts[1] : null);
}
private static string RequiredReference(string value) => Reference(value).Name
?? throw new FormatException("Das Element erwartet einen $Platzhalter.");
private static float Number(string value) => float.TryParse(value, NumberStyles.Float,
CultureInfo.InvariantCulture, out var result) ? result : throw new FormatException($"„{value}“ ist keine Zahl.");
internal static float Percentage(string value)
{
var normalized = value.EndsWith('%') ? value[..^1] : value;
var result = Number(normalized);
if (result <= 0 || result > 1000)
throw new FormatException("scale muss zwischen 0 und 1000 Prozent liegen.");
return result / 100f;
}
private static Dictionary<string, string> Attributes(IReadOnlyList<string> tokens, int start)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = start; i < tokens.Count; i++)
{
var separator = tokens[i].IndexOf('=');
if (separator <= 0 || separator == tokens[i].Length - 1)
throw new FormatException($"Ungültiges Attribut „{tokens[i]}“.");
result[tokens[i][..separator]] = tokens[i][(separator + 1)..];
}
return result;
}
private static void Require(IReadOnlyCollection<string> tokens, int minimum)
{
if (tokens.Count < minimum) throw new FormatException("Zu wenige Argumente.");
}
internal static List<string> Tokenize(string line)
{
var tokens = new List<string>();
var current = new StringBuilder();
var quoted = false;
var escaped = false;
foreach (var character in line)
{
if (escaped) { current.Append(character); escaped = false; continue; }
if (character == '\\' && quoted) { escaped = true; continue; }
if (character == '"') { quoted = !quoted; continue; }
if (char.IsWhiteSpace(character) && !quoted)
{
if (current.Length > 0) { tokens.Add(current.ToString()); current.Clear(); }
}
else current.Append(character);
}
if (quoted) throw new FormatException("Nicht abgeschlossenes Anführungszeichen.");
if (current.Length > 0) tokens.Add(current.ToString());
return tokens;
}
}
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="QuestPDF" />
</ItemGroup>
</Project>
+105
View File
@@ -0,0 +1,105 @@
namespace LehrerApp.Templating;
public enum PlaceholderType { Text, Multiline, Date, Number, Image, Table, Chart }
public abstract record PlaceholderValue(PlaceholderType Type);
public sealed record TextValue(string Value) : PlaceholderValue(PlaceholderType.Text);
public sealed record MultilineValue(string Value) : PlaceholderValue(PlaceholderType.Multiline);
public sealed record DateValue(DateOnly Value) : PlaceholderValue(PlaceholderType.Date);
public sealed record NumberValue(decimal Value) : PlaceholderValue(PlaceholderType.Number);
public sealed record ImageValue(byte[] Data, string MimeType) : PlaceholderValue(PlaceholderType.Image);
public sealed record ImageMetadata(int Width, int Height);
public sealed record TableValue(IReadOnlyList<string> Columns, IReadOnlyList<IReadOnlyList<string>> Rows)
: PlaceholderValue(PlaceholderType.Table);
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 interface ITemplateDataProvider
{
IReadOnlyDictionary<string, PlaceholderValue> GetValues();
}
public sealed record PageSizeDefinition(float Width, float Height, string Unit = "mm");
public sealed record PlaceholderDefinition(string Name, PlaceholderType Type, bool Required = false);
public sealed class TemplateManifest
{
public int SchemaVersion { get; set; } = 1;
public string Id { get; set; } = "neue-vorlage";
public string Name { get; set; } = "Neue Vorlage";
public string Description { get; set; } = "";
public PageSizeDefinition PageSize { get; set; } = new(210, 297);
public string LayoutFile { get; set; } = "layout.tpl";
public List<PlaceholderDefinition> Placeholders { get; set; } = [];
}
public abstract record TemplateElement(int Line, float X, float Y, float Width, float Height,
IReadOnlyDictionary<string, string> Attributes);
public sealed record BackgroundElement(int Line, string Path)
: TemplateElement(Line, 0, 0, 0, 0, EmptyAttributes.Value);
public sealed record ImageElement(int Line, string Path, float X, float Y, float Width, float Height,
IReadOnlyDictionary<string, string> Attributes)
: TemplateElement(Line, X, Y, Width, Height, Attributes);
public sealed record TextElement(int Line, float X, float Y, string Content, string? Placeholder,
string? Format, IReadOnlyDictionary<string, string> Attributes)
: TemplateElement(Line, X, Y, 0, 0, Attributes);
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 TableElement(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 ChartElement(int Line, float X, float Y, float Width, float Height,
string Placeholder, string ChartType, IReadOnlyDictionary<string, string> Attributes)
: TemplateElement(Line, X, Y, Width, Height, Attributes);
internal static class EmptyAttributes
{
public static readonly IReadOnlyDictionary<string, string> Value =
new Dictionary<string, string>();
}
public sealed record TemplateLayout(float Width, float Height, string Unit,
IReadOnlyList<TemplateElement> Elements);
public sealed record LoadedTemplate(TemplateManifest Manifest, TemplateLayout Layout,
IReadOnlyDictionary<string, byte[]> Assets, string SourceName = "");
public enum ValidationSeverity { Warning, Error }
public sealed record ValidationIssue(ValidationSeverity Severity, string Message, int? Line = null);
public sealed record ValidationResult(IReadOnlyList<ValidationIssue> Issues)
{
public bool IsValid => Issues.All(x => x.Severity != ValidationSeverity.Error);
public static ValidationResult Success { get; } = new([]);
}
public sealed class TemplateValidationException(ValidationResult result)
: Exception(string.Join(Environment.NewLine, result.Issues.Select(x => x.Message)))
{
public ValidationResult Result { get; } = result;
}
public interface ITemplateRenderer
{
byte[] RenderToPdf(LoadedTemplate template, ITemplateDataProvider data);
}
public interface ITemplatePreviewRenderer
{
byte[] RenderFirstPageToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120);
}
public interface ITemplateLoader
{
LoadedTemplate LoadFromPackage(string packagePath);
LoadedTemplate LoadFromPackage(Stream package, string sourceName = "");
ValidationResult Validate(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderType> providedTypes);
}
public interface ITemplateMigrator
{
int SourceVersion { get; }
TemplateManifest Migrate(TemplateManifest manifest);
}
@@ -0,0 +1,202 @@
using System.Globalization;
using System.Security;
using System.Text;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace LehrerApp.Templating;
public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewRenderer
{
static QuestTemplateRenderer() => QuestPDF.Settings.License = LicenseType.Community;
public byte[] RenderToPdf(LoadedTemplate template, ITemplateDataProvider data) =>
BuildDocument(template, ValidateData(template, data)).GeneratePdf();
public byte[] RenderFirstPageToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
{
var settings = new ImageGenerationSettings { ImageFormat = ImageFormat.Png, RasterDpi = dpi };
return BuildDocument(template, ValidateData(template, data)).GenerateImages(settings).First();
}
private static IReadOnlyDictionary<string, PlaceholderValue> ValidateData(LoadedTemplate template,
ITemplateDataProvider provider)
{
var values = provider.GetValues();
var validation = new TemplateLoader().Validate(template, values.ToDictionary(x => x.Key, x => x.Value.Type));
if (!validation.IsValid) throw new TemplateValidationException(validation);
return values;
}
private static IDocument BuildDocument(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values) => 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));
}
});
});
});
private static void RenderElement(IContainer root, TemplateElement element, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
var unit = template.Layout.Unit;
switch (element)
{
case BackgroundElement background:
root.Width(UnitConverter.Points(template.Layout.Width, unit))
.Height(UnitConverter.Points(template.Layout.Height, unit))
.Image(GetAsset(template, background.Path)).FitArea();
break;
case ImageElement image:
var imageScale = image.Attributes.TryGetValue("scale", out var scale)
? LayoutParser.Percentage(scale) : 1f;
Position(root, image, unit, imageScale).Image(GetAsset(template, image.Path)).FitArea();
break;
case TextElement text:
var textContainer = root.TranslateX(UnitConverter.Points(text.X, unit))
.TranslateY(UnitConverter.Points(text.Y, unit))
.Width(UnitConverter.Points(Math.Max(0, template.Layout.Width - text.X), unit))
.Height(QuestTemplateRenderer.ParseFloat(text.Attributes, "size", 11) * 1.8f);
RenderText(textContainer, ResolveContent(text.Content, text.Placeholder, text.Format, values), text.Attributes);
break;
case TextBoxElement box:
RenderText(Position(root, box, unit).Shrink(),
ResolveContent(box.Content, box.Placeholder, box.Format, values), box.Attributes);
break;
case TableElement table:
if (values.GetValueOrDefault(table.Placeholder) is TableValue tableValue)
TableElementRenderer.Render(Position(root, table, unit), tableValue, table.Attributes);
break;
case ChartElement chart:
if (values.GetValueOrDefault(chart.Placeholder) is ChartValue chartValue)
ChartElementRenderer.Render(Position(root, chart, unit), chartValue, chart.ChartType, chart.Attributes);
break;
}
}
private static IContainer Position(IContainer root, TemplateElement element, string unit, float scale = 1) => root
.TranslateX(UnitConverter.Points(element.X, unit)).TranslateY(UnitConverter.Points(element.Y, unit))
.Width(UnitConverter.Points(element.Width * scale, unit)).Height(UnitConverter.Points(element.Height * scale, unit));
private static byte[] GetAsset(LoadedTemplate template, string path) =>
template.Assets.TryGetValue(TemplateLoader.Normalize(path), out var bytes) ? bytes
: throw new InvalidDataException($"Asset „{path}“ fehlt.");
internal static void RenderText(IContainer container, string content, IReadOnlyDictionary<string, string> attributes)
{
var aligned = attributes.GetValueOrDefault("align", "left").ToLowerInvariant() switch
{ "center" => container.AlignCenter(), "right" => container.AlignRight(), _ => container.AlignLeft() };
var descriptor = aligned.Text(content);
descriptor.FontSize(ParseFloat(attributes, "size", 11));
if (ParseBool(attributes, "bold")) descriptor.SemiBold();
if (ParseBool(attributes, "italic")) descriptor.Italic();
if (attributes.TryGetValue("color", out var color)) descriptor.FontColor(color);
}
private static string ResolveContent(string content, string? placeholder, string? format,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
if (placeholder is not null)
return values.TryGetValue(placeholder, out var value) ? Format(value, format) : "";
var result = content;
foreach (var entry in values)
result = result.Replace("$" + entry.Key, Format(entry.Value, null), StringComparison.Ordinal);
return result;
}
private static string Format(PlaceholderValue value, string? format) => value switch
{
TextValue text => text.Value,
MultilineValue text => text.Value,
DateValue date => date.Value.ToString(format ?? "d", CultureInfo.GetCultureInfo("de-DE")),
NumberValue number => number.Value.ToString(format, CultureInfo.GetCultureInfo("de-DE")),
_ => "",
};
internal static float ParseFloat(IReadOnlyDictionary<string, string> attributes, string key, float fallback) =>
attributes.TryGetValue(key, out var raw) && float.TryParse(raw, NumberStyles.Float,
CultureInfo.InvariantCulture, out var value) ? value : fallback;
internal static bool ParseBool(IReadOnlyDictionary<string, string> attributes, string key) =>
attributes.TryGetValue(key, out var raw) && bool.TryParse(raw, out var value) && value;
}
public static class UnitConverter
{
public static float Points(float value, string unit) => unit.ToLowerInvariant() switch
{ "mm" => value * 72f / 25.4f, "cm" => value * 72f / 2.54f, "in" => value * 72f, "pt" => value,
_ => throw new ArgumentOutOfRangeException(nameof(unit), unit, "Unbekannte Einheit.") };
}
internal static class TableElementRenderer
{
public static void Render(IContainer container, TableValue value, IReadOnlyDictionary<string, string> attributes)
{
if (value.Columns.Count == 0) return;
var fontSize = QuestTemplateRenderer.ParseFloat(attributes, "size", 9);
container.Table(table =>
{
table.ColumnsDefinition(columns => { foreach (var _ in value.Columns) columns.RelativeColumn(); });
table.Header(header =>
{
foreach (var column in value.Columns)
header.Cell().Background(Colors.Grey.Lighten2).Border(0.5f).BorderColor(Colors.Grey.Medium)
.Padding(3).Text(column).FontSize(fontSize).SemiBold();
});
foreach (var row in value.Rows)
for (var i = 0; i < value.Columns.Count; i++)
table.Cell().Border(0.5f).BorderColor(Colors.Grey.Lighten1).Padding(3)
.Text(i < row.Count ? row[i] : "").FontSize(fontSize);
});
}
}
internal static class ChartElementRenderer
{
public static void Render(IContainer container, ChartValue value, string chartType,
IReadOnlyDictionary<string, string> attributes)
{
var svg = BuildSvg(value, chartType);
container.Svg(svg);
}
private static string BuildSvg(ChartValue value, string chartType)
{
var points = value.Series.SelectMany(x => x.Points).ToList();
if (points.Count == 0) return "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 600 200\"/>";
var max = Math.Max(1m, points.Max(x => x.Y));
var colors = new[] { "#2563EB", "#DC2626", "#059669", "#7C3AED" };
var svg = new StringBuilder("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 600 200\">");
svg.Append("<line x1=\"40\" y1=\"170\" x2=\"590\" y2=\"170\" stroke=\"#94A3B8\"/><line x1=\"40\" y1=\"10\" x2=\"40\" y2=\"170\" stroke=\"#94A3B8\"/>");
for (var seriesIndex = 0; seriesIndex < value.Series.Count; seriesIndex++)
{
var series = value.Series[seriesIndex];
var color = colors[seriesIndex % colors.Length];
var step = 530d / Math.Max(1, series.Points.Count);
var coordinates = new List<string>();
for (var i = 0; i < series.Points.Count; i++)
{
var x = 50 + i * step + step / 2; var y = 165 - (double)(series.Points[i].Y / max) * 145;
if (chartType == "bar") svg.Append(CultureInfo.InvariantCulture,
$"<rect x=\"{x - step * .28:F1}\" y=\"{y:F1}\" width=\"{step * .56:F1}\" height=\"{165 - y:F1}\" fill=\"{color}\" opacity=\".8\"/>");
else coordinates.Add(FormattableString.Invariant($"{x:F1},{y:F1}"));
svg.Append($"<text x=\"{x:F1}\" y=\"188\" font-size=\"10\" text-anchor=\"middle\" fill=\"#475569\">{SecurityElement.Escape(series.Points[i].X)}</text>");
}
if (chartType == "line") svg.Append($"<polyline points=\"{string.Join(' ', coordinates)}\" fill=\"none\" stroke=\"{color}\" stroke-width=\"3\"/>");
}
return svg.Append("</svg>").ToString();
}
}
+27
View File
@@ -0,0 +1,27 @@
# LehrerApp Templating
## Bildskalierung
`IMG` unterstützt neben dem festen Begrenzungsrahmen eine optionale prozentuale Skalierung:
```text
IMG logo.png 15 15 30 12 scale=50%
```
Der Rahmen von `30 × 12` Layout-Einheiten wird dabei auf `15 × 6` skaliert. `x` und `y`
bleiben unverändert. Das Bild wird mit erhaltenem Seitenverhältnis in diesen Rahmen eingepasst.
Ohne `scale` gilt wie bisher `100%`. Zulässig sind Werte größer als `0` bis einschließlich
`1000%`.
## Wiederverwendbare Ausgangsvorlagen
Der TemplateDesigner verwaltet lokale Ausgangsvorlagen im Benutzerprofil. Eine Ausgangsvorlage
ist weiterhin ein normales `.lavorlage`-Paket und kann deshalb importiert oder exportiert werden.
- **Bearbeiten** öffnet die Ausgangsvorlage mit ihrer stabilen ID. Erneutes Speichern aktualisiert sie.
- **Als neues Projekt** kopiert Layout, Platzhalter und sämtliche Assets, vergibt aber eine neue ID.
- **Duplizieren** erzeugt eine weitere unabhängige Ausgangsvorlage.
- **Vorschau** rendert die Vorlage mit typgerechten Beispieldaten, ohne das aktuelle Projekt zu ändern.
Die Bibliothek liegt unter `LehrerApp/TemplateDesigner/starter-templates` im plattformspezifischen
Anwendungsdatenverzeichnis und wird nicht in das LehrerApp-Repository oder Release eingebettet.
+179
View File
@@ -0,0 +1,179 @@
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LehrerApp.Templating;
public sealed record TemplateLimits(long MaxPackageBytes = 50 * 1024 * 1024,
long MaxAssetBytes = 20 * 1024 * 1024, int MaxImagePixels = 50_000_000);
public sealed class TemplateLoader(TemplateLimits? limits = null,
IEnumerable<ITemplateMigrator>? migrators = null) : ITemplateLoader
{
public const int CurrentSchemaVersion = 1;
private readonly TemplateLimits _limits = limits ?? new();
private readonly IReadOnlyDictionary<int, ITemplateMigrator> _migrators =
(migrators ?? []).ToDictionary(x => x.SourceVersion);
internal static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
public LoadedTemplate LoadFromPackage(string packagePath)
{
if (!File.Exists(packagePath)) throw new FileNotFoundException("Vorlagenpaket nicht gefunden.", packagePath);
var info = new FileInfo(packagePath);
if (info.Length > _limits.MaxPackageBytes) throw new InvalidDataException("Vorlagenpaket überschreitet das Größenlimit.");
using var stream = File.OpenRead(packagePath);
return LoadFromPackage(stream, Path.GetFileName(packagePath));
}
public LoadedTemplate LoadFromPackage(Stream package, string sourceName = "")
{
var issues = new List<ValidationIssue>();
try
{
using var archive = new ZipArchive(package, ZipArchiveMode.Read, leaveOpen: true);
var entries = new Dictionary<string, ZipArchiveEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in archive.Entries)
{
if (!IsSafeRelativePath(entry.FullName))
issues.Add(new(ValidationSeverity.Error, $"Unsicherer Paketpfad: „{entry.FullName}“."));
else if (!string.IsNullOrEmpty(entry.Name)) entries[Normalize(entry.FullName)] = entry;
}
if (issues.Count > 0) throw new TemplateValidationException(new(issues));
if (!entries.TryGetValue("manifest.json", out var manifestEntry))
throw Error("manifest.json fehlt.");
TemplateManifest manifest;
try
{
using var manifestStream = manifestEntry.Open();
manifest = JsonSerializer.Deserialize<TemplateManifest>(manifestStream, JsonOptions)
?? throw new JsonException("Leeres Manifest.");
}
catch (JsonException ex) { throw Error($"manifest.json ist ungültig: {ex.Message}"); }
while (manifest.SchemaVersion != CurrentSchemaVersion &&
_migrators.TryGetValue(manifest.SchemaVersion, out var migrator))
manifest = migrator.Migrate(manifest);
if (manifest.SchemaVersion != CurrentSchemaVersion)
throw Error($"schemaVersion {manifest.SchemaVersion} wird nicht unterstützt.");
if (!IsSafeRelativePath(manifest.LayoutFile)) throw Error("layoutFile enthält einen unsicheren Pfad.");
if (!entries.TryGetValue(Normalize(manifest.LayoutFile), out var layoutEntry))
throw Error($"Layoutdatei „{manifest.LayoutFile}“ fehlt.");
string layoutSource;
using (var reader = new StreamReader(layoutEntry.Open())) layoutSource = reader.ReadToEnd();
var layout = new LayoutParser().Parse(layoutSource);
var referencedAssets = layout.Elements.Select(AssetPath).Where(x => x is not null).Cast<string>()
.Select(Normalize).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
foreach (var path in referencedAssets)
{
if (!IsSafeRelativePath(path)) { issues.Add(new(ValidationSeverity.Error, $"Unsicherer Assetpfad „{path}“.")); continue; }
if (!entries.TryGetValue(Normalize(path), out var asset))
issues.Add(new(ValidationSeverity.Error, $"Referenziertes Bild „{path}“ fehlt."));
}
var assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
var layoutPath = Normalize(manifest.LayoutFile);
foreach (var (path, asset) in entries.Where(x =>
!x.Key.Equals("manifest.json", StringComparison.OrdinalIgnoreCase)
&& !x.Key.Equals(layoutPath, StringComparison.OrdinalIgnoreCase)))
{
if (asset.Length > _limits.MaxAssetBytes)
{ issues.Add(new(ValidationSeverity.Error, $"Bild „{path}“ überschreitet das Größenlimit.")); continue; }
using var stream = asset.Open();
using var memory = new MemoryStream(); stream.CopyTo(memory);
var bytes = memory.ToArray();
if (!ImageDimensions.TryRead(bytes, out var imageWidth, out var imageHeight))
{ issues.Add(new(ValidationSeverity.Error, $"„{path}“ ist kein unterstütztes PNG/JPEG-Bild.")); continue; }
if ((long)imageWidth * imageHeight > _limits.MaxImagePixels)
issues.Add(new(ValidationSeverity.Error, $"Bild „{path}“ überschreitet das Auflösungslimit."));
else assets[path] = bytes;
}
var declared = manifest.Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal);
foreach (var used in UsedPlaceholders(layout).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."));
if (issues.Count > 0) throw new TemplateValidationException(new(issues));
return new(manifest, layout, assets, sourceName);
}
catch (InvalidDataException ex)
{ throw Error($"Paket ist kein lesbares ZIP-Archiv: {ex.Message}"); }
}
public ValidationResult Validate(LoadedTemplate template, IReadOnlyDictionary<string, PlaceholderType> providedTypes)
{
var issues = new List<ValidationIssue>();
foreach (var placeholder in template.Manifest.Placeholders)
{
if (!providedTypes.TryGetValue(placeholder.Name, out var actual))
{
if (placeholder.Required) issues.Add(new(ValidationSeverity.Error, $"Pflichtwert „{placeholder.Name}“ fehlt."));
}
else if (actual != placeholder.Type)
issues.Add(new(ValidationSeverity.Error, $"„{placeholder.Name}“ erwartet {placeholder.Type}, geliefert wurde {actual}."));
}
return new(issues);
}
public static IEnumerable<string> UsedPlaceholders(TemplateLayout layout) => layout.Elements.Select(x => x switch
{
TextElement { Placeholder: { } p } => p,
TextBoxElement { Placeholder: { } p } => p,
TableElement t => t.Placeholder,
ChartElement c => c.Placeholder,
_ => null,
}).Where(x => x is not null).Cast<string>().Distinct(StringComparer.Ordinal);
internal static bool IsSafeRelativePath(string path) => !string.IsNullOrWhiteSpace(path)
&& !Path.IsPathRooted(path) && !path.Split('/', '\\').Any(part => part == ".." || part.Length == 0);
internal static string Normalize(string path) => path.Replace('\\', '/');
private static string? AssetPath(TemplateElement element) => element switch
{ BackgroundElement bg => bg.Path, ImageElement img => img.Path, _ => null };
private static TemplateValidationException Error(string message) =>
new(new([new(ValidationSeverity.Error, message)]));
private static JsonSerializerOptions CreateJsonOptions()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true };
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
return options;
}
}
internal static class ImageDimensions
{
public static bool TryRead(byte[] data, out int width, out int height)
{
width = height = 0;
if (data.Length >= 24 && data.AsSpan(0, 8).SequenceEqual(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }))
{
width = ReadBigEndian(data, 16); height = ReadBigEndian(data, 20); return width > 0 && height > 0;
}
if (data.Length >= 4 && data[0] == 0xFF && data[1] == 0xD8)
{
var offset = 2;
while (offset + 9 < data.Length)
{
if (data[offset] != 0xFF) { offset++; continue; }
var marker = data[offset + 1];
if (marker is >= 0xC0 and <= 0xC3)
{ height = (data[offset + 5] << 8) | data[offset + 6]; width = (data[offset + 7] << 8) | data[offset + 8]; return true; }
if (offset + 3 >= data.Length) break;
var length = (data[offset + 2] << 8) | data[offset + 3];
if (length < 2) break; offset += length + 2;
}
}
return false;
}
private static int ReadBigEndian(byte[] data, int offset) =>
(data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3];
}
public static class ImageInspector
{
public static ImageMetadata Inspect(byte[] data) => ImageDimensions.TryRead(data, out var width, out var height)
? new(width, height)
: throw new InvalidDataException("Das Asset ist kein unterstütztes PNG/JPEG-Bild.");
}
+94
View File
@@ -0,0 +1,94 @@
using System.IO.Compression;
using System.Text.Json;
namespace LehrerApp.Templating;
public static class TemplatePackage
{
public const string Extension = ".lavorlage";
public static void Create(string outputPath, TemplateManifest manifest, string layoutSource,
IReadOnlyDictionary<string, byte[]> assets)
{
if (!string.Equals(Path.GetExtension(outputPath), Extension, StringComparison.OrdinalIgnoreCase))
outputPath += Extension;
var directory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
var temporary = outputPath + ".tmp";
try
{
using (var archive = ZipFile.Open(temporary, ZipArchiveMode.Create))
{
WriteText(archive, "manifest.json", JsonSerializer.Serialize(manifest, TemplateLoader.JsonOptions));
WriteText(archive, manifest.LayoutFile, layoutSource);
foreach (var asset in assets)
{
if (!TemplateLoader.IsSafeRelativePath(asset.Key))
throw new InvalidDataException($"Unsicherer Assetpfad „{asset.Key}“.");
var entry = archive.CreateEntry(TemplateLoader.Normalize(asset.Key), CompressionLevel.Optimal);
using var stream = entry.Open(); stream.Write(asset.Value);
}
}
using (var verify = File.OpenRead(temporary)) new TemplateLoader().LoadFromPackage(verify, Path.GetFileName(outputPath));
File.Move(temporary, outputPath, overwrite: true);
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
private static void WriteText(ZipArchive archive, string path, string content)
{
var entry = archive.CreateEntry(TemplateLoader.Normalize(path), CompressionLevel.Optimal);
using var writer = new StreamWriter(entry.Open()); writer.Write(content);
}
}
public sealed record InstalledTemplate(string Id, string Name, string Description, string PackagePath,
DateTime ImportedAtUtc);
public sealed class TemplateStore
{
private readonly string _directory;
private readonly ITemplateLoader _loader;
public TemplateStore(string appDataPath, ITemplateLoader? loader = null)
{
_directory = Path.Combine(appDataPath, "letter-template-packages");
Directory.CreateDirectory(_directory);
_loader = loader ?? new TemplateLoader();
}
public IReadOnlyList<InstalledTemplate> GetTemplates()
{
var result = new List<InstalledTemplate>();
foreach (var path in Directory.EnumerateFiles(_directory, $"*{TemplatePackage.Extension}"))
{
try
{
var loaded = _loader.LoadFromPackage(path);
result.Add(new(loaded.Manifest.Id, loaded.Manifest.Name, loaded.Manifest.Description, path,
File.GetLastWriteTimeUtc(path)));
}
catch (Exception ex) when (ex is InvalidDataException or TemplateValidationException) { }
}
return result.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
}
public InstalledTemplate Import(string sourcePath)
{
var loaded = _loader.LoadFromPackage(sourcePath);
var safeId = string.Concat(loaded.Manifest.Id.Select(c => char.IsAsciiLetterOrDigit(c) || c == '-' ? c : '-')).Trim('-');
if (safeId.Length == 0) throw new InvalidDataException("Die Vorlagen-ID ist ungültig.");
var destination = Path.Combine(_directory, safeId + TemplatePackage.Extension);
var temporary = destination + ".tmp";
File.Copy(sourcePath, temporary, overwrite: true);
File.Move(temporary, destination, overwrite: true);
return new(loaded.Manifest.Id, loaded.Manifest.Name, loaded.Manifest.Description, destination, DateTime.UtcNow);
}
public LoadedTemplate Load(InstalledTemplate template) => _loader.LoadFromPackage(template.PackagePath);
public void Delete(string id)
{
var match = GetTemplates().FirstOrDefault(x => x.Id == id);
if (match is not null && File.Exists(match.PackagePath)) File.Delete(match.PackagePath);
}
}