This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
public enum PlaceholderType { Text, Multiline, Date, Number, Image, Table, Chart }
|
||||
@@ -31,6 +33,9 @@ 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 MetadataFile { get; set; } = TemplateMetadataText.FileName;
|
||||
[JsonIgnore]
|
||||
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public List<PlaceholderDefinition> Placeholders { get; set; } = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# LehrerApp Templating
|
||||
|
||||
## Freie Paketmetadaten
|
||||
|
||||
Jedes neu gespeicherte `.lavorlage`-Paket enthält eine lesbare `metadata.txt`. Pro Zeile steht ein
|
||||
frei wählbares Schlüssel-Wert-Paar; leere Zeilen und mit `#` beginnende Kommentare werden beim
|
||||
Einlesen ignoriert:
|
||||
|
||||
```text
|
||||
language=de-DE
|
||||
report-type=parent-letter
|
||||
school-year=2026/27
|
||||
```
|
||||
|
||||
Werte dürfen ein weiteres `=` enthalten. Schlüssel sind ohne Beachtung der Groß-/Kleinschreibung
|
||||
eindeutig und Werte bleiben einzeilig. Der Loader stellt sie der Anwendung direkt über
|
||||
`loadedTemplate.Manifest.Metadata` zur Verfügung. Bestehende Pakete ohne `metadata.txt` werden
|
||||
weiterhin mit einer leeren Metadatensammlung geladen. Die empfohlenen Standardschlüssel stehen
|
||||
zusätzlich als `TemplateMetadataKeys.Language` und `TemplateMetadataKeys.ReportType` bereit.
|
||||
|
||||
## Bildskalierung
|
||||
|
||||
`IMG` unterstützt neben dem festen Begrenzungsrahmen eine optionale prozentuale Skalierung:
|
||||
|
||||
@@ -57,11 +57,26 @@ public sealed class TemplateLoader(TemplateLimits? limits = null,
|
||||
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 (!IsSafeRelativePath(manifest.MetadataFile)) throw Error("metadataFile 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))
|
||||
throw Error($"Layoutdatei „{manifest.LayoutFile}“ fehlt.");
|
||||
|
||||
string layoutSource;
|
||||
using (var reader = new StreamReader(layoutEntry.Open())) layoutSource = reader.ReadToEnd();
|
||||
var metadataPath = Normalize(manifest.MetadataFile);
|
||||
manifest.Metadata = new(StringComparer.OrdinalIgnoreCase);
|
||||
if (entries.TryGetValue(metadataPath, out var metadataEntry))
|
||||
{
|
||||
if (metadataEntry.Length > 1024 * 1024) throw Error("metadata.txt überschreitet das Größenlimit.");
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(metadataEntry.Open());
|
||||
manifest.Metadata = TemplateMetadataText.Parse(reader.ReadToEnd());
|
||||
}
|
||||
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>()
|
||||
.Select(Normalize).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
@@ -76,7 +91,8 @@ public sealed class TemplateLoader(TemplateLimits? limits = null,
|
||||
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)))
|
||||
&& !x.Key.Equals(layoutPath, StringComparison.OrdinalIgnoreCase)
|
||||
&& !x.Key.Equals(metadataPath, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (asset.Length > _limits.MaxAssetBytes)
|
||||
{ issues.Add(new(ValidationSeverity.Error, $"Bild „{path}“ überschreitet das Größenlimit.")); continue; }
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
public static class TemplateMetadataKeys
|
||||
{
|
||||
public const string Language = "language";
|
||||
public const string ReportType = "report-type";
|
||||
}
|
||||
|
||||
public static class TemplateMetadataText
|
||||
{
|
||||
public const string FileName = "metadata.txt";
|
||||
|
||||
public static Dictionary<string, string> Parse(string source)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var lines = source.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
|
||||
for (var index = 0; index < lines.Length; index++)
|
||||
{
|
||||
var line = lines[index].Trim();
|
||||
if (line.Length == 0 || line.StartsWith('#')) continue;
|
||||
var separator = line.IndexOf('=');
|
||||
if (separator < 1)
|
||||
throw new InvalidDataException($"Metadatenzeile {index + 1} muss Schlüssel=Wert enthalten.");
|
||||
var key = line[..separator].Trim();
|
||||
var value = line[(separator + 1)..].Trim();
|
||||
Validate(key, value);
|
||||
if (!result.TryAdd(key, value))
|
||||
throw new InvalidDataException($"Metadatenschlüssel „{key}“ ist mehrfach vorhanden.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static string Serialize(IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
if (metadata.Count == 0) return "";
|
||||
var lines = new List<string>(metadata.Count);
|
||||
foreach (var (key, value) in metadata.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
Validate(key, value);
|
||||
lines.Add($"{key.Trim()}={value.Trim()}");
|
||||
}
|
||||
return string.Join('\n', lines) + "\n";
|
||||
}
|
||||
|
||||
private static void Validate(string key, string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) throw new InvalidDataException("Ein Metadatenschlüssel darf nicht leer sein.");
|
||||
if (key.Contains('=') || key.Contains('\n') || key.Contains('\r'))
|
||||
throw new InvalidDataException($"Metadatenschlüssel „{key}“ enthält ein unzulässiges Zeichen.");
|
||||
if (value.Contains('\n') || value.Contains('\r'))
|
||||
throw new InvalidDataException($"Metadatenwert für „{key}“ darf nur eine Zeile umfassen.");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,13 @@ public static class TemplatePackage
|
||||
public static void Create(string outputPath, TemplateManifest manifest, string layoutSource,
|
||||
IReadOnlyDictionary<string, byte[]> assets)
|
||||
{
|
||||
if (!TemplateLoader.IsSafeRelativePath(manifest.MetadataFile))
|
||||
throw new InvalidDataException("metadataFile enthält einen unsicheren Pfad.");
|
||||
if (TemplateLoader.Normalize(manifest.MetadataFile).Equals(
|
||||
TemplateLoader.Normalize(manifest.LayoutFile), StringComparison.OrdinalIgnoreCase))
|
||||
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 (!string.Equals(Path.GetExtension(outputPath), Extension, StringComparison.OrdinalIgnoreCase))
|
||||
outputPath += Extension;
|
||||
var directory = Path.GetDirectoryName(outputPath);
|
||||
@@ -21,10 +28,13 @@ public static class TemplatePackage
|
||||
{
|
||||
WriteText(archive, "manifest.json", JsonSerializer.Serialize(manifest, TemplateLoader.JsonOptions));
|
||||
WriteText(archive, manifest.LayoutFile, layoutSource);
|
||||
WriteText(archive, manifest.MetadataFile, TemplateMetadataText.Serialize(manifest.Metadata));
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
if (!TemplateLoader.IsSafeRelativePath(asset.Key))
|
||||
throw new InvalidDataException($"Unsicherer Assetpfad „{asset.Key}“.");
|
||||
if (reservedPaths.Contains(TemplateLoader.Normalize(asset.Key)))
|
||||
throw new InvalidDataException($"Assetpfad „{asset.Key}“ ist für Paketdaten reserviert.");
|
||||
var entry = archive.CreateEntry(TemplateLoader.Normalize(asset.Key), CompressionLevel.Optimal);
|
||||
using var stream = entry.Open(); stream.Write(asset.Value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user