This commit is contained in:
@@ -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]),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user