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.Tests/ProjectLifecycleTests.cs b/LehrerApp.TemplateDesigner.Tests/ProjectLifecycleTests.cs
index 608f4c1..7f6e6e4 100644
--- a/LehrerApp.TemplateDesigner.Tests/ProjectLifecycleTests.cs
+++ b/LehrerApp.TemplateDesigner.Tests/ProjectLifecycleTests.cs
@@ -108,6 +108,15 @@ public sealed class ProjectLifecycleTests
Assert.Empty(parsed.ContentFlows.Single().Elements);
}
+ [Fact]
+ public void AktuelleAnsicht_KannAlsPdfGerendertWerden()
+ {
+ var pdf = new DesignerViewModel().RenderCurrentPdf();
+
+ Assert.True(pdf.Length > 100);
+ Assert.Equal("%PDF-", System.Text.Encoding.ASCII.GetString(pdf, 0, 5));
+ }
+
private static readonly byte[] OnePixelPng = Convert.FromBase64String(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=");
}
diff --git a/LehrerApp.TemplateDesigner/DesignerViewModel.cs b/LehrerApp.TemplateDesigner/DesignerViewModel.cs
index 68d6ad3..d0975e6 100644
--- a/LehrerApp.TemplateDesigner/DesignerViewModel.cs
+++ b/LehrerApp.TemplateDesigner/DesignerViewModel.cs
@@ -16,6 +16,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";
@@ -49,7 +51,8 @@ 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", "DRAWBOX", "FLOWDRAWBOX", "IMG", "TABLE", "CHART"];
public IReadOnlyList ElementScopes { get; } = ["Seitenvorlage (fest)", "Content-Flow"];
public ObservableCollection Placeholders { get; } =
[
@@ -87,6 +90,8 @@ public partial class DesignerViewModel : ObservableObject
{
TemplateId = "neue-vorlage"; TemplateName = "Neue Vorlage"; Description = "";
PageWidth = 210; PageHeight = 297; Unit = "mm"; LayoutSource = EmptyStructuredLayout;
+ 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"));
@@ -105,6 +110,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(),
};
@@ -116,21 +122,69 @@ 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."));
+ if (layout.UsesPageTemplates && continuationLayout is not null)
+ issues.Add(new(ValidationSeverity.Error,
+ "Layoutformat 3 enthält Folgeseiten als page-template und kann nicht zusätzlich continuation.tpl verwenden."));
+ if (!layout.UsesPageTemplates)
+ {
+ var firstFlows = layout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement).ToList();
+ if (firstFlows.Count > 1)
+ issues.Add(new(ValidationSeverity.Error, "Ein Legacy-Layout darf höchstens eine FLOWBOX enthalten."));
+ if (continuationLayout is not null)
+ {
+ var continuationFlows = continuationLayout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement).ToList();
+ if (firstFlows.Count != 1 || continuationFlows.Count != 1)
+ issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen jeweils genau ein fließendes Element enthalten."));
+ else if (firstFlows[0].GetType() != continuationFlows[0].GetType())
+ issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen denselben fließenden Elementtyp verwenden."));
+ 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 byte[] RenderCurrentPdf() =>
+ new QuestTemplateRenderer().RenderToPdf(BuildLoaded(), BuildDataProvider());
+
+ 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);
@@ -149,13 +203,16 @@ 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)
{
- if (!template.Layout.UsesPageTemplates)
+ if (!template.Layout.UsesPageTemplates && continuationLayoutSource is null)
layoutSource = MigrateLegacyLayout(layoutSource, template.Layout);
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.Layout.UsesPageTemplates
+ && 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));
@@ -202,9 +259,9 @@ public partial class DesignerViewModel : ObservableObject
return string.Join('\n', result);
}
- 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;
@@ -304,18 +361,27 @@ public partial class DesignerViewModel : ObservableObject
{
("TEXT", false) => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}",
("TEXTBOX", false) => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
+ ("FLOWBOX", false) => $"FLOWBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
+ ("DRAWBOX", false) => $"DRAWBOX {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
+ ("FLOWDRAWBOX", false) => $"FLOWDRAWBOX {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
("IMG", false) => $"IMG {NewContent} {NewX} {NewY} {NewWidth} {NewHeight} scale={NormalizedImageScale()}%",
("TABLE", false) => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
("CHART", false) => $"CHART {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
("TEXT", true) => $"TEXT {QuoteIfLiteral(NewContent)}{attrs}",
("TEXTBOX", true) => $"TEXTBOX {QuoteIfLiteral(NewContent)}{attrs}",
+ ("FLOWBOX", true) => $"FLOWBOX {QuoteIfLiteral(NewContent)}{attrs}",
+ ("DRAWBOX", true) => $"DRAWBOX {NewContent} w={NewWidth} h={NewHeight}{attrs}",
+ ("FLOWDRAWBOX", true) => $"FLOWDRAWBOX {NewContent} w={NewWidth} h={NewHeight}{attrs}",
("IMG", true) => $"IMG {NewContent} w={NewWidth} h={NewHeight} scale={NormalizedImageScale()}%{attrs}",
("TABLE", true) => $"TABLE {NewContent}{attrs}",
("CHART", true) => $"CHART {NewContent} h={NewHeight}{attrs}",
_ => throw new InvalidOperationException("Unbekannter Elementtyp."),
};
- LayoutSource = InsertIntoSection(LayoutSource, line, flowElement ? "content-flow" : "page-template",
- flowElement ? SelectedContentFlow : SelectedPageTemplate);
+ var parsedLayout = new LayoutParser().Parse(LayoutSource);
+ LayoutSource = parsedLayout.UsesPageTemplates
+ ? InsertIntoSection(LayoutSource, line, flowElement ? "content-flow" : "page-template",
+ flowElement ? SelectedContentFlow : SelectedPageTemplate)
+ : LayoutSource.TrimEnd() + Environment.NewLine + line + Environment.NewLine;
CanExport = false; SetStatus("Element ergänzt. Vorschau zur Prüfung aktualisieren.", false);
}
@@ -463,6 +529,12 @@ 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)}",
+ DrawBoxElement box => $"DRAWBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ + $"${box.Placeholder}{Attributes(box.Attributes)}",
+ FlowDrawBoxElement box => $"FLOWDRAWBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ + $"${box.Placeholder}{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)} "
@@ -683,11 +755,13 @@ public partial class DesignerPlaceholder : ObservableObject
PlaceholderType.Image => new ImageValue([], "image/png"),
PlaceholderType.Table => ParseTable(Sample),
PlaceholderType.Chart => ParseChart(Sample),
+ PlaceholderType.Drawing => SampleDrawing(),
_ => new TextValue(Sample),
};
public static string SampleFor(PlaceholderType type) => type switch
{ PlaceholderType.Date => DateTime.Today.ToString("yyyy-MM-dd"), PlaceholderType.Number => "42,5",
- PlaceholderType.Table => "Datum;Grund|01.09.;Krank", PlaceholderType.Chart => "Sep:2;Okt:3;Nov:1", _ => "Beispielwert" };
+ PlaceholderType.Table => "Datum;Grund|01.09.;Krank", PlaceholderType.Chart => "Sep:2;Okt:3;Nov:1",
+ PlaceholderType.Drawing => "Externe Zeichenbefehle", _ => "Beispielwert" };
private static TableValue ParseTable(string value)
{
var lines = value.Split('|', StringSplitOptions.RemoveEmptyEntries);
@@ -696,6 +770,10 @@ public partial class DesignerPlaceholder : ObservableObject
}
private static ChartValue ParseChart(string value) => new([new("Werte", value.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Select((x, i) => { var parts = x.Split(':', 2); return new ChartPoint(parts[0], parts.Length == 2 && decimal.TryParse(parts[1], CultureInfo.InvariantCulture, out var y) ? y : i + 1); }).ToList())]);
+ private static DrawingValue SampleDrawing() => new DrawingValue(
+ [new DrawRectangle(0, 0, 80, 24, "#2563EB", 0.8f, "#EFF6FF"),
+ new DrawString(4, 4, "Dynamischer Inhalt", 10, "#1E3A8A", Bold: true),
+ new MoveTo(4, 19), new LineTo(76, 19, "#93C5FD", 0.6f)], 24);
}
internal sealed class DesignerDataProvider(IReadOnlyDictionary values) : ITemplateDataProvider
diff --git a/LehrerApp.TemplateDesigner/LayoutOverlayEditor.cs b/LehrerApp.TemplateDesigner/LayoutOverlayEditor.cs
index 3e6b28b..26815ba 100644
--- a/LehrerApp.TemplateDesigner/LayoutOverlayEditor.cs
+++ b/LehrerApp.TemplateDesigner/LayoutOverlayEditor.cs
@@ -204,7 +204,8 @@ public sealed class LayoutOverlayEditor : Control
{
if (element is BackgroundElement) continue;
var keyword = element switch
- { ImageElement => "IMG", TextElement => "TEXT", TextBoxElement => "TEXTBOX",
+ { ImageElement => "IMG", TextElement => "TEXT", TextBoxElement => "TEXTBOX", FlowBoxElement => "FLOWBOX",
+ DrawBoxElement => "DRAWBOX", FlowDrawBoxElement => "FLOWDRAWBOX",
TableElement => "TABLE", ChartElement => "CHART", _ => "?" };
var (width, height, resizable) = element switch
{
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 aaac3d1..1416ac7 100644
--- a/LehrerApp.TemplateDesigner/MainWindow.axaml
+++ b/LehrerApp.TemplateDesigner/MainWindow.axaml
@@ -10,6 +10,7 @@
+
@@ -25,6 +26,8 @@