From 3e5f197bdbfc442e6df3e40acbf28c4c79943836 Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Mon, 31 Aug 2026 19:56:25 +0200 Subject: [PATCH 1/5] feat: add flowing templates and PDF import pipeline --- Directory.Packages.props | 1 + .../PdfImportPipelineTests.cs | 93 ++++++ .../DesignerViewModel.cs | 61 +++- .../LehrerApp.TemplateDesigner.csproj | 1 + LehrerApp.TemplateDesigner/MainWindow.axaml | 26 +- .../MainWindow.axaml.cs | 42 ++- .../PdfImportDialog.axaml | 53 ++++ .../PdfImportDialog.axaml.cs | 89 ++++++ .../PdfImportPipeline.cs | 294 ++++++++++++++++++ .../StarterTemplateLibrary.cs | 36 ++- LehrerApp.Templating.Tests/TemplatingTests.cs | 30 +- LehrerApp.Templating/LayoutParser.cs | 6 + LehrerApp.Templating/Model.cs | 7 +- LehrerApp.Templating/QuestTemplateRenderer.cs | 44 ++- LehrerApp.Templating/README.md | 38 +++ LehrerApp.Templating/TemplateLoader.cs | 35 ++- LehrerApp.Templating/TemplatePackage.cs | 12 +- ai-backend/pdf-template.php | 81 +++++ ai-backend/providers/FakeProvider.php | 15 + 19 files changed, 920 insertions(+), 44 deletions(-) create mode 100644 LehrerApp.TemplateDesigner.Tests/PdfImportPipelineTests.cs create mode 100644 LehrerApp.TemplateDesigner/PdfImportDialog.axaml create mode 100644 LehrerApp.TemplateDesigner/PdfImportDialog.axaml.cs create mode 100644 LehrerApp.TemplateDesigner/PdfImportPipeline.cs create mode 100644 ai-backend/pdf-template.php diff --git a/Directory.Packages.props b/Directory.Packages.props index 31bc9c9..485b98b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,6 +20,7 @@ + diff --git a/LehrerApp.TemplateDesigner.Tests/PdfImportPipelineTests.cs b/LehrerApp.TemplateDesigner.Tests/PdfImportPipelineTests.cs new file mode 100644 index 0000000..fdaad79 --- /dev/null +++ b/LehrerApp.TemplateDesigner.Tests/PdfImportPipelineTests.cs @@ -0,0 +1,93 @@ +using LehrerApp.TemplateDesigner; +using LehrerApp.Templating; +using Xunit; +using System.Runtime.Versioning; +using QuestPDF.Fluent; +using QuestPDF.Infrastructure; + +namespace LehrerApp.TemplateDesigner.Tests; + +[SupportedOSPlatform("windows")] +[SupportedOSPlatform("linux")] +[SupportedOSPlatform("macos")] +public sealed class PdfImportPipelineTests +{ + [Fact] + public void TemplateDiff_LaesstIdentischenTextStatischUndMarkiertAbweichung() + { + var pipeline = new PdfImportPipeline(); + var template = Document( + Block("a", 40, 30, "Schule am Park"), + Block("b", 40, 100, "Max Mustermann")); + var example = Document( + Block("a2", 40, 30, "Schule am Park"), + Block("b2", 40, 100, "Erika Beispiel")); + + var candidate = Assert.Single(pipeline.FindCandidates(example, template)); + + Assert.Equal("Erika Beispiel", candidate.OriginalText); + Assert.Equal(PdfImportConfidence.High, candidate.Confidence); + } + + [Fact] + public void EinzelPdf_ErkenntDatumTypDeterministisch() + { + var pipeline = new PdfImportPipeline(); + + var candidate = Assert.Single(pipeline.FindCandidates(Document(Block("d", 400, 60, "31.08.2026")), null)); + + Assert.Equal("Datum", candidate.Name); + Assert.Equal(PlaceholderType.Date, candidate.Type); + Assert.Equal(PdfImportConfidence.High, candidate.Confidence); + } + + [Fact] + public void UebernahmeErsetztDasAktuelleDesignerprojekt() + { + var result = new PdfImportResult("PAGE 595 842 pt\nTEXT 40 100 $Name\n", + new TemplateManifest + { + Id = "pdf-import", Name = "PDF-Import", PageSize = new(595, 842, "pt"), + Placeholders = [new("Name", PlaceholderType.Text)], + }, new Dictionary(), + [new PdfImportCandidate { Id = "x", BlockIds = ["x"], OriginalText = "Erika Beispiel", Name = "Name", Confidence = PdfImportConfidence.High }]); + var viewModel = new DesignerViewModel(); + + viewModel.ApplyPdfImport(result); + + Assert.Equal("pt", viewModel.Unit); + Assert.Equal("Erika Beispiel", Assert.Single(viewModel.Placeholders).Sample); + Assert.Contains("$Name", viewModel.LayoutSource); + Assert.False(viewModel.CanExport); + } + + [Fact] + public async Task EchtesPdf_WirdExtrahiertUndAlsRenderbareVorlageErzeugt() + { + QuestPDF.Settings.License = LicenseType.Community; + var path = Path.Combine(Path.GetTempPath(), $"pdf-import-{Guid.NewGuid():N}.pdf"); + try + { + QuestPDF.Fluent.Document.Create(document => document.Page(page => + { + page.Size(595, 842); page.Margin(40); page.Content().Text("Erika Beispiel").FontSize(11); + })).GeneratePdf(path); + + var result = await new PdfImportPipeline().BuildAsync(path, null, null, null); + var viewModel = new DesignerViewModel(); + viewModel.ApplyPdfImport(result); + var png = new QuestTemplateRenderer().RenderFirstPageToPng(viewModel.BuildLoaded(), viewModel.BuildDataProvider()); + + Assert.NotEmpty(result.Candidates); + Assert.Contains("pdf-import-background.png", result.Assets.Keys); + Assert.True(png.Length > 1_000); + } + finally { if (File.Exists(path)) File.Delete(path); } + } + + private static PdfImportDocument Document(params PdfTextBlock[] blocks) => + new([new PdfImportPage(1, 595, 842, blocks)]); + + private static PdfTextBlock Block(string id, double x, double y, string text) => + new(id, 1, x, y, 120, 12, 11, "Arial", false, false, text); +} diff --git a/LehrerApp.TemplateDesigner/DesignerViewModel.cs b/LehrerApp.TemplateDesigner/DesignerViewModel.cs index c302fcd..39ef667 100644 --- a/LehrerApp.TemplateDesigner/DesignerViewModel.cs +++ b/LehrerApp.TemplateDesigner/DesignerViewModel.cs @@ -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 Units { get; } = ["mm", "cm", "pt", "in"]; public IReadOnlyList PlaceholderTypes { get; } = Enum.GetValues(); - public IReadOnlyList ElementTypes { get; } = ["TEXT", "TEXTBOX", "IMG", "TABLE", "CHART"]; + public IReadOnlyList ElementTypes { get; } = ["TEXT", "TEXTBOX", "FLOWBOX", "IMG", "TABLE", "CHART"]; public ObservableCollection 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(); + 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().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().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(Assets)); + return new(manifest, layout, new Dictionary(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)} " diff --git a/LehrerApp.TemplateDesigner/LehrerApp.TemplateDesigner.csproj b/LehrerApp.TemplateDesigner/LehrerApp.TemplateDesigner.csproj index 45889e8..40f9f3e 100644 --- a/LehrerApp.TemplateDesigner/LehrerApp.TemplateDesigner.csproj +++ b/LehrerApp.TemplateDesigner/LehrerApp.TemplateDesigner.csproj @@ -12,5 +12,6 @@ + diff --git a/LehrerApp.TemplateDesigner/MainWindow.axaml b/LehrerApp.TemplateDesigner/MainWindow.axaml index 217b538..8c6d5b5 100644 --- a/LehrerApp.TemplateDesigner/MainWindow.axaml +++ b/LehrerApp.TemplateDesigner/MainWindow.axaml @@ -25,6 +25,8 @@ + + @@ -192,8 +194,8 @@