feat: add flowing templates and PDF import pipeline
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-08-31 19:56:25 +02:00
parent 95e3f677e4
commit 3e5f197bdb
19 changed files with 920 additions and 44 deletions
+6
View File
@@ -58,6 +58,12 @@ public sealed class LayoutParser
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 "FLOWBOX":
Require(tokens, 6);
var flowRef = Reference(tokens[5]);
elements.Add(new FlowBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
Number(tokens[3]), Number(tokens[4]), tokens[5], flowRef.Name, flowRef.Format,
Attributes(tokens, 6))); break;
case "TABLE":
Require(tokens, 6);
elements.Add(new TableElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
+6 -1
View File
@@ -35,6 +35,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 +55,9 @@ 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 TableElement(int Line, float X, float Y, float Width, float Height,
string Placeholder, IReadOnlyDictionary<string, string> Attributes)
: TemplateElement(Line, X, Y, Width, Height, Attributes);
@@ -71,7 +75,8 @@ 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 = "");
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);
+37 -7
View File
@@ -32,6 +32,7 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
private static IDocument BuildDocument(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values) => Document.Create(document =>
{
var flowBox = template.Layout.Elements.OfType<FlowBoxElement>().SingleOrDefault();
document.Page(page =>
{
page.Size(UnitConverter.Points(template.Layout.Width, template.Layout.Unit),
@@ -39,17 +40,44 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
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
RenderFlowBox(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 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))
{
var current = element;
layers.Layer().Element(container => RenderElement(
visibility is null ? container : visibility(container), current, template, values));
}
}
private static void RenderFlowBox(IContainer container, FlowBoxElement box, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
var unit = template.Layout.Unit;
container
.PaddingLeft(UnitConverter.Points(box.X, unit))
.PaddingTop(UnitConverter.Points(box.Y, unit))
.PaddingRight(UnitConverter.Points(Math.Max(0, template.Layout.Width - box.X - box.Width), unit))
.PaddingBottom(UnitConverter.Points(Math.Max(0, template.Layout.Height - box.Y - box.Height), unit))
.Element(content => RenderResolvedText(content, box.Content, box.Placeholder, box.Format,
values, template.Manifest, box.Attributes));
}
private static void RenderElement(IContainer root, TemplateElement element, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
@@ -78,6 +106,8 @@ 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:
break;
case TableElement table:
if (values.GetValueOrDefault(table.Placeholder) is TableValue tableValue)
TableElementRenderer.Render(Position(root, table, unit), tableValue, table.Attributes);
+38
View File
@@ -1,5 +1,43 @@
# LehrerApp Templating
## 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.
## Konstante Platzhalter und Hervorhebung
Ein Platzhalter kann seinen Wert vollständig im Vorlagenpaket tragen. `IsConstant=true` bewirkt,
+32 -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,31 @@ 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 = layout.Elements.OfType<FlowBoxElement>().ToList();
if (flowBoxes.Count > 1)
issues.Add(new(ValidationSeverity.Error, "Ein Layout darf höchstens eine FLOWBOX enthalten."));
if (continuationLayout is not null)
{
var continuationFlows = continuationLayout.Elements.OfType<FlowBoxElement>().ToList();
if (flowBoxes.Count != 1 || continuationFlows.Count != 1)
issues.Add(new(ValidationSeverity.Error, "Bei einem Folgeseiten-Layout müssen Haupt- und Folgeseite jeweils genau eine FLOWBOX enthalten."));
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 +115,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 +135,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,6 +168,7 @@ public sealed class TemplateLoader(TemplateLimits? limits = null,
{
TextElement { Placeholder: { } p } => p,
TextBoxElement { Placeholder: { } p } => p,
FlowBoxElement { Placeholder: { } p } => p,
TableElement t => t.Placeholder,
ChartElement c => c.Placeholder,
_ => null,
+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)
{