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
@@ -15,6 +15,8 @@ public partial class DesignerViewModel : ObservableObject
[ObservableProperty] private decimal _pageHeight = 297;
[ObservableProperty] private string _unit = "mm";
[ObservableProperty] private string _layoutSource = DefaultLayout;
[ObservableProperty] private bool _useContinuationLayout;
[ObservableProperty] private string _continuationLayoutSource = "PAGE 210 297 mm\nFLOWBOX 20 20 170 257 $Brieftext size=11\n";
[ObservableProperty] private string _newElementType = "TEXT";
[ObservableProperty] private string _newX = "20";
[ObservableProperty] private string _newY = "50";
@@ -42,7 +44,7 @@ public partial class DesignerViewModel : ObservableObject
public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"];
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
public IReadOnlyList<string> ElementTypes { get; } = ["TEXT", "TEXTBOX", "IMG", "TABLE", "CHART"];
public IReadOnlyList<string> ElementTypes { get; } = ["TEXT", "TEXTBOX", "FLOWBOX", "IMG", "TABLE", "CHART"];
public ObservableCollection<DesignerPlaceholder> Placeholders { get; } =
[
new("Datum", PlaceholderType.Date, true, DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
@@ -74,6 +76,8 @@ public partial class DesignerViewModel : ObservableObject
{
TemplateId = "neue-vorlage"; TemplateName = "Neue Vorlage"; Description = "";
PageWidth = 210; PageHeight = 297; Unit = "mm"; LayoutSource = "PAGE 210 297 mm\n";
UseContinuationLayout = false;
ContinuationLayoutSource = "PAGE 210 297 mm\nFLOWBOX 20 20 170 257 $Brieftext size=11\n";
Placeholders.Clear(); SelectedPlaceholder = null;
MetadataItems.Clear(); MetadataItems.Add(new(TemplateMetadataKeys.Language, "de-DE"));
MetadataItems.Add(new(TemplateMetadataKeys.ReportType, "letter"));
@@ -92,6 +96,7 @@ public partial class DesignerViewModel : ObservableObject
SchemaVersion = TemplateLoader.CurrentSchemaVersion,
Id = TemplateId.Trim(), Name = TemplateName.Trim(), Description = Description.Trim(),
PageSize = new((float)PageWidth, (float)PageHeight, Unit), LayoutFile = "layout.tpl",
ContinuationLayoutFile = UseContinuationLayout ? "continuation.tpl" : null,
Metadata = BuildMetadata(),
Placeholders = BuildPlaceholders(),
};
@@ -103,21 +108,58 @@ public partial class DesignerViewModel : ObservableObject
throw new InvalidDataException("Die ID darf nur ASCII-Buchstaben, Ziffern und Bindestriche enthalten.");
if (string.IsNullOrWhiteSpace(manifest.Name)) throw new InvalidDataException("Der Vorlagenname fehlt.");
var layout = new LayoutParser().Parse(LayoutSource);
var continuationLayout = UseContinuationLayout ? new LayoutParser().Parse(ContinuationLayoutSource) : null;
var issues = new List<ValidationIssue>();
var layouts = continuationLayout is null ? new[] { layout } : new[] { layout, continuationLayout };
var declared = manifest.Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal);
foreach (var used in TemplateLoader.UsedPlaceholders(layout).Where(x => !declared.Contains(x)))
foreach (var used in layouts.SelectMany(TemplateLoader.UsedPlaceholders).Distinct(StringComparer.Ordinal)
.Where(x => !declared.Contains(x)))
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{used}“ ist nicht deklariert."));
foreach (var path in layout.Elements.Select(x => x switch { BackgroundElement b => b.Path, ImageElement i => i.Path, _ => null }).Where(x => x is not null))
foreach (var path in layouts.SelectMany(x => x.Elements).Select(x => x switch { BackgroundElement b => b.Path, ImageElement i => i.Path, _ => null }).Where(x => x is not null))
if (!Assets.ContainsKey(path!)) issues.Add(new(ValidationSeverity.Error, $"Asset „{path}“ fehlt."));
var firstFlows = layout.Elements.OfType<FlowBoxElement>().ToList();
if (firstFlows.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 (firstFlows.Count != 1 || continuationFlows.Count != 1)
issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen jeweils genau eine FLOWBOX enthalten."));
else if (firstFlows[0].X != continuationFlows[0].X || firstFlows[0].Y != continuationFlows[0].Y
|| firstFlows[0].Width != continuationFlows[0].Width || firstFlows[0].Height != continuationFlows[0].Height)
issues.Add(new(ValidationSeverity.Error, "Die FLOWBOX muss auf Haupt- und Folgeseiten dieselbe Position und Größe haben."));
}
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, new Dictionary<string, byte[]>(Assets));
return new(manifest, layout, new Dictionary<string, byte[]>(Assets), ContinuationLayout: continuationLayout);
}
public ITemplateDataProvider BuildDataProvider() => new DesignerDataProvider(Placeholders.ToDictionary(
x => x.Name.Trim(), x => x.ToValue(), StringComparer.Ordinal));
public void ApplyPdfImport(PdfImportResult import)
{
TemplateId = import.Manifest.Id; TemplateName = import.Manifest.Name; Description = import.Manifest.Description;
PageWidth = (decimal)import.Manifest.PageSize.Width; PageHeight = (decimal)import.Manifest.PageSize.Height;
Unit = import.Manifest.PageSize.Unit; LayoutSource = import.LayoutSource;
UseContinuationLayout = false;
Placeholders.Clear();
foreach (var definition in import.Manifest.Placeholders)
{
var candidate = import.Candidates.FirstOrDefault(x => x.Name.Equals(definition.Name, StringComparison.Ordinal));
Placeholders.Add(new(definition.Name, definition.Type, definition.Required,
candidate?.OriginalText ?? DesignerPlaceholder.SampleFor(definition.Type)));
}
SelectedPlaceholder = Placeholders.FirstOrDefault();
Assets.Clear(); AssetItems.Clear();
foreach (var asset in import.Assets) AddOrReplaceAsset(asset.Key, asset.Value, keepName: true);
MetadataItems.Clear();
foreach (var metadata in import.Manifest.Metadata) MetadataItems.Add(new(metadata.Key, metadata.Value));
CanExport = false;
SetStatus("PDF-Import übernommen. Bitte Vorschau, Platzhalter und Layout vor dem Speichern prüfen.", false);
}
public DesignerPlaceholder AddPlaceholder()
{
var existing = Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal);
@@ -136,11 +178,13 @@ public partial class DesignerViewModel : ObservableObject
SetStatus($"Platzhalter „{selected.Name}“ entfernt.", false);
}
public void Load(LoadedTemplate template, string layoutSource)
public void Load(LoadedTemplate template, string layoutSource, string? continuationLayoutSource = null)
{
TemplateId = template.Manifest.Id; TemplateName = template.Manifest.Name; Description = template.Manifest.Description;
PageWidth = (decimal)template.Manifest.PageSize.Width; PageHeight = (decimal)template.Manifest.PageSize.Height;
Unit = template.Manifest.PageSize.Unit; LayoutSource = layoutSource;
UseContinuationLayout = template.Manifest.ContinuationLayoutFile is not null;
ContinuationLayoutSource = continuationLayoutSource ?? "PAGE 210 297 mm\n";
MetadataItems.Clear();
foreach (var item in template.Manifest.Metadata.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase))
MetadataItems.Add(new(item.Key, item.Value));
@@ -157,9 +201,9 @@ public partial class DesignerViewModel : ObservableObject
CanExport = true; SetStatus($"„{template.Manifest.Name}“ geladen.", false);
}
public void LoadAsNewProject(LoadedTemplate template, string layoutSource, string? newId = null)
public void LoadAsNewProject(LoadedTemplate template, string layoutSource, string? continuationLayoutSource = null, string? newId = null)
{
Load(template, layoutSource);
Load(template, layoutSource, continuationLayoutSource);
TemplateId = newId ?? template.Manifest.Id + "-neu";
TemplateName = template.Manifest.Name + " - Neu";
CanExport = false;
@@ -224,6 +268,7 @@ public partial class DesignerViewModel : ObservableObject
{
"TEXT" => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}",
"TEXTBOX" => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
"FLOWBOX" => $"FLOWBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
"IMG" => $"IMG {NewContent} {NewX} {NewY} {NewWidth} {NewHeight} scale={NormalizedImageScale()}%",
"TABLE" => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
"CHART" => $"CHART {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
@@ -306,6 +351,8 @@ public partial class DesignerViewModel : ObservableObject
TextElement text => $"TEXT {x} {y} {Content(text.Content, text.Placeholder, text.Format)}{Attributes(text.Attributes)}",
TextBoxElement box => $"TEXTBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"{Content(box.Content, box.Placeholder, box.Format)}{Attributes(box.Attributes)}",
FlowBoxElement box => $"FLOWBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"{Content(box.Content, box.Placeholder, box.Format)}{Attributes(box.Attributes)}",
TableElement table => $"TABLE {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"${table.Placeholder}{Attributes(table.Attributes)}",
ChartElement chart => $"CHART {x} {y} {FormatNumber(width)} {FormatNumber(height)} "