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
+1
View File
@@ -20,6 +20,7 @@
<!-- PDF-Erzeugung: Desktop-Exporte und die unabhängige Templating-Library. --> <!-- PDF-Erzeugung: Desktop-Exporte und die unabhängige Templating-Library. -->
<PackageVersion Include="QuestPDF" Version="2025.7.0" /> <PackageVersion Include="QuestPDF" Version="2025.7.0" />
<PackageVersion Include="PDFtoImage" Version="5.4.0" /> <PackageVersion Include="PDFtoImage" Version="5.4.0" />
<PackageVersion Include="PdfPig" Version="0.1.13" />
<!-- API --> <!-- API -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
@@ -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<string, byte[]>(),
[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);
}
@@ -15,6 +15,8 @@ public partial class DesignerViewModel : ObservableObject
[ObservableProperty] private decimal _pageHeight = 297; [ObservableProperty] private decimal _pageHeight = 297;
[ObservableProperty] private string _unit = "mm"; [ObservableProperty] private string _unit = "mm";
[ObservableProperty] private string _layoutSource = DefaultLayout; [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 _newElementType = "TEXT";
[ObservableProperty] private string _newX = "20"; [ObservableProperty] private string _newX = "20";
[ObservableProperty] private string _newY = "50"; [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<string> Units { get; } = ["mm", "cm", "pt", "in"];
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>(); 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; } = public ObservableCollection<DesignerPlaceholder> Placeholders { get; } =
[ [
new("Datum", PlaceholderType.Date, true, DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), 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 = ""; TemplateId = "neue-vorlage"; TemplateName = "Neue Vorlage"; Description = "";
PageWidth = 210; PageHeight = 297; Unit = "mm"; LayoutSource = "PAGE 210 297 mm\n"; 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; Placeholders.Clear(); SelectedPlaceholder = null;
MetadataItems.Clear(); MetadataItems.Add(new(TemplateMetadataKeys.Language, "de-DE")); MetadataItems.Clear(); MetadataItems.Add(new(TemplateMetadataKeys.Language, "de-DE"));
MetadataItems.Add(new(TemplateMetadataKeys.ReportType, "letter")); MetadataItems.Add(new(TemplateMetadataKeys.ReportType, "letter"));
@@ -92,6 +96,7 @@ public partial class DesignerViewModel : ObservableObject
SchemaVersion = TemplateLoader.CurrentSchemaVersion, SchemaVersion = TemplateLoader.CurrentSchemaVersion,
Id = TemplateId.Trim(), Name = TemplateName.Trim(), Description = Description.Trim(), Id = TemplateId.Trim(), Name = TemplateName.Trim(), Description = Description.Trim(),
PageSize = new((float)PageWidth, (float)PageHeight, Unit), LayoutFile = "layout.tpl", PageSize = new((float)PageWidth, (float)PageHeight, Unit), LayoutFile = "layout.tpl",
ContinuationLayoutFile = UseContinuationLayout ? "continuation.tpl" : null,
Metadata = BuildMetadata(), Metadata = BuildMetadata(),
Placeholders = BuildPlaceholders(), Placeholders = BuildPlaceholders(),
}; };
@@ -103,21 +108,58 @@ public partial class DesignerViewModel : ObservableObject
throw new InvalidDataException("Die ID darf nur ASCII-Buchstaben, Ziffern und Bindestriche enthalten."); throw new InvalidDataException("Die ID darf nur ASCII-Buchstaben, Ziffern und Bindestriche enthalten.");
if (string.IsNullOrWhiteSpace(manifest.Name)) throw new InvalidDataException("Der Vorlagenname fehlt."); if (string.IsNullOrWhiteSpace(manifest.Name)) throw new InvalidDataException("Der Vorlagenname fehlt.");
var layout = new LayoutParser().Parse(LayoutSource); var layout = new LayoutParser().Parse(LayoutSource);
var continuationLayout = UseContinuationLayout ? new LayoutParser().Parse(ContinuationLayoutSource) : null;
var issues = new List<ValidationIssue>(); 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); 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.")); 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 (!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)) foreach (var issue in TemplateDataResolver.ValidateConstants(manifest))
issues.Add(new(ValidationSeverity.Error, issue)); issues.Add(new(ValidationSeverity.Error, issue));
if (issues.Count > 0) throw new TemplateValidationException(new(issues)); 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( public ITemplateDataProvider BuildDataProvider() => new DesignerDataProvider(Placeholders.ToDictionary(
x => x.Name.Trim(), x => x.ToValue(), StringComparer.Ordinal)); 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() public DesignerPlaceholder AddPlaceholder()
{ {
var existing = Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal); 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); 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; TemplateId = template.Manifest.Id; TemplateName = template.Manifest.Name; Description = template.Manifest.Description;
PageWidth = (decimal)template.Manifest.PageSize.Width; PageHeight = (decimal)template.Manifest.PageSize.Height; PageWidth = (decimal)template.Manifest.PageSize.Width; PageHeight = (decimal)template.Manifest.PageSize.Height;
Unit = template.Manifest.PageSize.Unit; LayoutSource = layoutSource; Unit = template.Manifest.PageSize.Unit; LayoutSource = layoutSource;
UseContinuationLayout = template.Manifest.ContinuationLayoutFile is not null;
ContinuationLayoutSource = continuationLayoutSource ?? "PAGE 210 297 mm\n";
MetadataItems.Clear(); MetadataItems.Clear();
foreach (var item in template.Manifest.Metadata.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase)) foreach (var item in template.Manifest.Metadata.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase))
MetadataItems.Add(new(item.Key, item.Value)); MetadataItems.Add(new(item.Key, item.Value));
@@ -157,9 +201,9 @@ public partial class DesignerViewModel : ObservableObject
CanExport = true; SetStatus($"„{template.Manifest.Name}“ geladen.", false); 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"; TemplateId = newId ?? template.Manifest.Id + "-neu";
TemplateName = template.Manifest.Name + " - Neu"; TemplateName = template.Manifest.Name + " - Neu";
CanExport = false; CanExport = false;
@@ -224,6 +268,7 @@ public partial class DesignerViewModel : ObservableObject
{ {
"TEXT" => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}", "TEXT" => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}",
"TEXTBOX" => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {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()}%", "IMG" => $"IMG {NewContent} {NewX} {NewY} {NewWidth} {NewHeight} scale={NormalizedImageScale()}%",
"TABLE" => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}", "TABLE" => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
"CHART" => $"CHART {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)}", TextElement text => $"TEXT {x} {y} {Content(text.Content, text.Placeholder, text.Format)}{Attributes(text.Attributes)}",
TextBoxElement box => $"TEXTBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} " TextBoxElement box => $"TEXTBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"{Content(box.Content, box.Placeholder, box.Format)}{Attributes(box.Attributes)}", + $"{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)} " TableElement table => $"TABLE {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"${table.Placeholder}{Attributes(table.Attributes)}", + $"${table.Placeholder}{Attributes(table.Attributes)}",
ChartElement chart => $"CHART {x} {y} {FormatNumber(width)} {FormatNumber(height)} " ChartElement chart => $"CHART {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
@@ -12,5 +12,6 @@
<PackageReference Include="Avalonia.Controls.DataGrid" /> <PackageReference Include="Avalonia.Controls.DataGrid" />
<PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="PDFtoImage" /> <PackageReference Include="PDFtoImage" />
<PackageReference Include="PdfPig" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+20 -4
View File
@@ -25,6 +25,8 @@
<MenuItem Header="Ausgewählte Ausgangsvorlage exportieren …" Click="OnExportStarterTemplate"/> <MenuItem Header="Ausgewählte Ausgangsvorlage exportieren …" Click="OnExportStarterTemplate"/>
</MenuItem> </MenuItem>
<MenuItem Header="_Einfügen"> <MenuItem Header="_Einfügen">
<MenuItem Header="PDF als Vorlage importieren …" Click="OnImportPdfTemplate"/>
<Separator/>
<MenuItem Header="Bild-Asset …" Click="OnImportAsset"/> <MenuItem Header="Bild-Asset …" Click="OnImportAsset"/>
<Separator/> <Separator/>
<MenuItem Header="PNG/JPEG als Hintergrund …" Click="OnImportBackground"/> <MenuItem Header="PNG/JPEG als Hintergrund …" Click="OnImportBackground"/>
@@ -192,8 +194,8 @@
<Button Content="Element ins Layout übernehmen" Click="OnAddElement"/> <Button Content="Element ins Layout übernehmen" Click="OnAddElement"/>
<TextBlock Text="Tipp: Koordinaten können rechts im Messmodus direkt aus der Vorschau übernommen werden." <TextBlock Text="Tipp: Koordinaten können rechts im Messmodus direkt aus der Vorschau übernommen werden."
TextWrapping="Wrap" Opacity="0.65" FontSize="11"/> TextWrapping="Wrap" Opacity="0.65" FontSize="11"/>
<TextBlock Text="Bekannte Einschränkung: TEXTBOX-Überlauf wird in v1 nicht automatisch auf Folgeseiten verteilt." <TextBlock Text="FLOWBOX verteilt Text automatisch über beliebig viele Seiten. TEXTBOX bleibt ein fester Bereich."
TextWrapping="Wrap" Foreground="#B45309" FontSize="12"/> TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
@@ -202,9 +204,23 @@
<Grid Grid.Column="1" RowDefinitions="Auto,*" Margin="0,18"> <Grid Grid.Column="1" RowDefinitions="Auto,*" Margin="0,18">
<Grid ColumnDefinitions="*,Auto" Margin="12,0,12,10"><TextBlock Text="Layout-DSL" Classes="section"/> <Grid ColumnDefinitions="*,Auto" Margin="12,0,12,10"><TextBlock Text="Layout-DSL" Classes="section"/>
<Button Grid.Column="1" Content="Prüfen &amp; Vorschau" Click="OnPreview"/></Grid> <Button Grid.Column="1" Content="Prüfen &amp; Vorschau" Click="OnPreview"/></Grid>
<TextBox Grid.Row="1" Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap" <TabControl Grid.Row="1" Margin="12">
<TabItem Header="Seite 1">
<TextBox Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap"
FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top" FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top"
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto" Margin="12"/> ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
</TabItem>
<TabItem Header="Folgeseiten">
<Grid RowDefinitions="Auto,*">
<CheckBox Margin="8" Content="Eigenes Layout für Seite 2 und alle weiteren Seiten im Paket speichern"
IsChecked="{Binding UseContinuationLayout}"/>
<TextBox Grid.Row="1" Text="{Binding ContinuationLayoutSource}" IsEnabled="{Binding UseContinuationLayout}"
AcceptsReturn="True" TextWrapping="NoWrap" FontFamily="Menlo,Consolas,monospace" FontSize="13"
VerticalContentAlignment="Top" ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
</Grid>
</TabItem>
</TabControl>
</Grid> </Grid>
<Border Grid.Column="2" Background="#E2E8F0" Padding="18"> <Border Grid.Column="2" Background="#E2E8F0" Padding="18">
+34 -8
View File
@@ -95,6 +95,22 @@ public partial class MainWindow : Window
catch (Exception ex) { _viewModel.SetStatus($"Bildimport fehlgeschlagen: {ex.Message}", true); } catch (Exception ex) { _viewModel.SetStatus($"Bildimport fehlgeschlagen: {ex.Message}", true); }
} }
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macos")]
private async void OnImportPdfTemplate(object? sender, RoutedEventArgs e)
{
var dialog = new PdfImportDialog();
var accepted = await dialog.ShowDialog<bool>(this);
if (!accepted || dialog.Result is null) return;
try
{
_viewModel.ApplyPdfImport(dialog.Result);
_currentPackagePath = null; UpdateDocumentTitle(); OnPreview(sender, e);
}
catch (Exception ex) { _viewModel.SetStatus($"PDF-Vorschlag konnte nicht übernommen werden: {ex.Message}", true); }
}
private async void OnReplaceSelectedAsset(object? sender, RoutedEventArgs e) private async void OnReplaceSelectedAsset(object? sender, RoutedEventArgs e)
{ {
if (_viewModel.SelectedAsset is null) { _viewModel.SetStatus("Bitte zuerst ein Asset auswählen.", true); return; } if (_viewModel.SelectedAsset is null) { _viewModel.SetStatus("Bitte zuerst ein Asset auswählen.", true); return; }
@@ -132,8 +148,8 @@ public partial class MainWindow : Window
{ _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; } { _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; }
try try
{ {
var (template, layout) = _starterTemplates.Load(selected); var (template, layout, continuationLayout) = _starterTemplates.LoadWithContinuation(selected);
_viewModel.Load(template, layout); _currentPackagePath = null; UpdateDocumentTitle(); OnPreview(sender, e); _viewModel.Load(template, layout, continuationLayout); _currentPackagePath = null; UpdateDocumentTitle(); OnPreview(sender, e);
} }
catch (Exception ex) { _viewModel.SetStatus($"Öffnen fehlgeschlagen: {ex.Message}", true); } catch (Exception ex) { _viewModel.SetStatus($"Öffnen fehlgeschlagen: {ex.Message}", true); }
} }
@@ -144,9 +160,9 @@ public partial class MainWindow : Window
{ _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; } { _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; }
try try
{ {
var (template, layout) = _starterTemplates.Load(selected); var (template, layout, continuationLayout) = _starterTemplates.LoadWithContinuation(selected);
var projectId = _starterTemplates.CreateUniqueId(template.Manifest.Id + "-neu"); var projectId = _starterTemplates.CreateUniqueId(template.Manifest.Id + "-neu");
_viewModel.LoadAsNewProject(template, layout, projectId); _viewModel.LoadAsNewProject(template, layout, continuationLayout, projectId);
_currentPackagePath = null; UpdateDocumentTitle(); OnPreview(sender, e); _currentPackagePath = null; UpdateDocumentTitle(); OnPreview(sender, e);
} }
catch (Exception ex) { _viewModel.SetStatus($"Klonen fehlgeschlagen: {ex.Message}", true); } catch (Exception ex) { _viewModel.SetStatus($"Klonen fehlgeschlagen: {ex.Message}", true); }
@@ -157,7 +173,8 @@ public partial class MainWindow : Window
try try
{ {
_viewModel.BuildLoaded(); _viewModel.BuildLoaded();
var saved = _starterTemplates.Save(_viewModel.BuildManifest(), _viewModel.LayoutSource, _viewModel.Assets); var saved = _starterTemplates.Save(_viewModel.BuildManifest(), _viewModel.LayoutSource, _viewModel.Assets,
_viewModel.UseContinuationLayout ? _viewModel.ContinuationLayoutSource : null);
RefreshStarterTemplates(saved.Id); RefreshStarterTemplates(saved.Id);
_viewModel.SetStatus($"Ausgangsvorlage „{saved.Name}“ lokal gespeichert/aktualisiert.", false); _viewModel.SetStatus($"Ausgangsvorlage „{saved.Name}“ lokal gespeichert/aktualisiert.", false);
} }
@@ -227,8 +244,16 @@ public partial class MainWindow : Window
try try
{ {
var path = files[0].Path.LocalPath; var loaded = new TemplateLoader().LoadFromPackage(path); var path = files[0].Path.LocalPath; var loaded = new TemplateLoader().LoadFromPackage(path);
using var archive = ZipFile.OpenRead(path); using var reader = new StreamReader(archive.GetEntry(loaded.Manifest.LayoutFile)!.Open()); using var archive = ZipFile.OpenRead(path);
_viewModel.Load(loaded, reader.ReadToEnd()); _currentPackagePath = Path.GetFullPath(path); string layoutSource;
using (var reader = new StreamReader(archive.GetEntry(loaded.Manifest.LayoutFile)!.Open())) layoutSource = reader.ReadToEnd();
string? continuationLayoutSource = null;
if (loaded.Manifest.ContinuationLayoutFile is { } continuationPath)
{
using var reader = new StreamReader(archive.GetEntry(continuationPath)!.Open());
continuationLayoutSource = reader.ReadToEnd();
}
_viewModel.Load(loaded, layoutSource, continuationLayoutSource); _currentPackagePath = Path.GetFullPath(path);
UpdateDocumentTitle(); OnPreview(sender, e); UpdateDocumentTitle(); OnPreview(sender, e);
} }
catch (Exception ex) { _viewModel.SetStatus($"Öffnen fehlgeschlagen: {ex.Message}", true); } catch (Exception ex) { _viewModel.SetStatus($"Öffnen fehlgeschlagen: {ex.Message}", true); }
@@ -263,7 +288,8 @@ public partial class MainWindow : Window
try try
{ {
_viewModel.BuildLoaded(); _viewModel.BuildLoaded();
TemplatePackage.Create(path, _viewModel.BuildManifest(), _viewModel.LayoutSource, _viewModel.Assets); TemplatePackage.Create(path, _viewModel.BuildManifest(), _viewModel.LayoutSource, _viewModel.Assets,
_viewModel.UseContinuationLayout ? _viewModel.ContinuationLayoutSource : null);
_viewModel.CanExport = true; _viewModel.CanExport = true;
_viewModel.SetStatus($"Gespeichert: {Path.GetFileName(path)}", false); _viewModel.SetStatus($"Gespeichert: {Path.GetFileName(path)}", false);
return true; return true;
@@ -0,0 +1,53 @@
<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:LehrerApp.TemplateDesigner"
x:Class="LehrerApp.TemplateDesigner.PdfImportDialog" x:DataType="local:PdfImportDialogViewModel" Title="PDF als Vorlage importieren"
Width="920" Height="720" MinWidth="760" MinHeight="600" WindowStartupLocation="CenterOwner">
<Grid Margin="18" RowDefinitions="Auto,Auto,Auto,Auto,*,Auto" RowSpacing="12">
<TextBlock FontSize="20" FontWeight="Bold" Text="PDF-Import mit geometrischer Analyse"/>
<TextBlock Grid.Row="1" TextWrapping="Wrap" Opacity="0.75"
Text="Koordinaten werden lokal aus dem PDF gelesen. Die optionale KI ordnet nur Namen und Datentypen zu; sie erhält weder PDF noch Bilder."/>
<Grid Grid.Row="2" ColumnDefinitions="Auto,*,Auto" RowDefinitions="Auto,8,Auto">
<TextBlock VerticalAlignment="Center" Text="Ausgefülltes Beispiel:"/>
<TextBox Grid.Column="1" Margin="10,0" Text="{Binding ExamplePath}" IsReadOnly="True"/>
<Button Grid.Column="2" Content="Auswählen …" Click="OnChooseExample"/>
<TextBlock Grid.Row="2" VerticalAlignment="Center" Text="Leeres Template (optional):"/>
<TextBox Grid.Row="2" Grid.Column="1" Margin="10,0" Text="{Binding TemplatePath}" IsReadOnly="True"/>
<Button Grid.Row="2" Grid.Column="2" Content="Auswählen …" Click="OnChooseTemplate"/>
</Grid>
<StackPanel Grid.Row="3" Spacing="8">
<CheckBox Content="KI-Backend für semantische Klassifikation verwenden" IsChecked="{Binding UseAi}"/>
<Grid ColumnDefinitions="*,10,*" IsEnabled="{Binding UseAi}">
<TextBox PlaceholderText="Benutzername" Text="{Binding Username}"/>
<TextBox Grid.Column="2" PlaceholderText="Passwort (wird nicht gespeichert)" PasswordChar="●" Text="{Binding Password}"/>
</Grid>
<Button HorizontalAlignment="Left" Content="Analysieren" Click="OnAnalyze" IsEnabled="{Binding CanAnalyze}"/>
<TextBlock Text="{Binding Status}" TextWrapping="Wrap"/>
</StackPanel>
<DataGrid Grid.Row="4" ItemsSource="{Binding Candidates}" AutoGenerateColumns="False" CanUserResizeColumns="True">
<DataGrid.Columns>
<DataGridCheckBoxColumn Header="Übernehmen" Binding="{Binding Include}" Width="85"/>
<DataGridTextColumn Header="Erkannter Inhalt" Binding="{Binding OriginalText}" IsReadOnly="True" Width="2*"/>
<DataGridTextColumn Header="Platzhalter" Binding="{Binding Name}" Width="*"/>
<DataGridTemplateColumn Header="Typ" Width="130">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="local:PdfImportCandidate">
<ComboBox ItemsSource="{Binding AvailableTypes}" SelectedItem="{Binding Type}"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Konfidenz" Width="90">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="local:PdfImportCandidate">
<TextBlock Text="{Binding ConfidenceLabel}" Foreground="{Binding ConfidenceColor}" FontWeight="SemiBold"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<Grid Grid.Row="5" ColumnDefinitions="*,Auto,10,Auto">
<TextBlock VerticalAlignment="Center" Text="Rot/niedrig und gelb/mittel bitte besonders sorgfältig prüfen." Opacity="0.7"/>
<Button Grid.Column="1" Content="Abbrechen" Click="OnCancel"/>
<Button Grid.Column="3" Content="Geprüften Vorschlag übernehmen" Click="OnAccept" IsEnabled="{Binding HasResult}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,89 @@
using System.Collections.ObjectModel;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Templating;
using System.Runtime.Versioning;
namespace LehrerApp.TemplateDesigner;
public partial class PdfImportDialogViewModel : ObservableObject
{
[ObservableProperty] private string _examplePath = "";
[ObservableProperty] private string _templatePath = "";
[ObservableProperty] private bool _useAi = true;
[ObservableProperty] private string _username = "";
[ObservableProperty] private string _password = "";
[ObservableProperty] private string _status = "Bitte mindestens ein ausgefülltes Beispiel-PDF auswählen.";
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private bool _hasResult;
public ObservableCollection<PdfImportCandidate> Candidates { get; } = [];
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } =
[PlaceholderType.Text, PlaceholderType.Multiline, PlaceholderType.Date, PlaceholderType.Number];
public bool CanAnalyze => !IsBusy && File.Exists(ExamplePath) && (!UseAi
|| (!string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password)));
partial void OnExamplePathChanged(string value) => OnPropertyChanged(nameof(CanAnalyze));
partial void OnUseAiChanged(bool value) => OnPropertyChanged(nameof(CanAnalyze));
partial void OnUsernameChanged(string value) => OnPropertyChanged(nameof(CanAnalyze));
partial void OnPasswordChanged(string value) => OnPropertyChanged(nameof(CanAnalyze));
partial void OnIsBusyChanged(bool value) => OnPropertyChanged(nameof(CanAnalyze));
}
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macos")]
public partial class PdfImportDialog : Window
{
private readonly PdfImportDialogViewModel _viewModel = new();
private PdfImportResult? _result;
public PdfImportResult? Result { get; private set; }
public PdfImportDialog() { InitializeComponent(); DataContext = _viewModel; }
private async void OnChooseExample(object? sender, RoutedEventArgs e)
{
var file = await ChoosePdf("Ausgefülltes Beispiel-PDF auswählen");
if (file is not null) _viewModel.ExamplePath = file;
}
private async void OnChooseTemplate(object? sender, RoutedEventArgs e)
{
var file = await ChoosePdf("Leeres Template-PDF auswählen");
if (file is not null) _viewModel.TemplatePath = file;
}
private async Task<string?> ChoosePdf(string title)
{
var files = await StorageProvider.OpenFilePickerAsync(new()
{ Title = title, AllowMultiple = false, FileTypeFilter = [new("PDF-Dateien") { Patterns = ["*.pdf"] }] });
return files.Count == 0 ? null : files[0].Path.LocalPath;
}
private async void OnAnalyze(object? sender, RoutedEventArgs e)
{
_viewModel.IsBusy = true; _viewModel.HasResult = false; _viewModel.Status = "PDF wird lokal analysiert …";
try
{
_result = await new PdfImportPipeline().BuildAsync(_viewModel.ExamplePath,
string.IsNullOrWhiteSpace(_viewModel.TemplatePath) ? null : _viewModel.TemplatePath,
_viewModel.UseAi ? _viewModel.Username : null, _viewModel.UseAi ? _viewModel.Password : null);
_viewModel.Candidates.Clear();
foreach (var candidate in _result.Candidates) _viewModel.Candidates.Add(candidate);
_viewModel.HasResult = true;
_viewModel.Status = $"{_viewModel.Candidates.Count} variable Textbereiche erkannt. Bitte Zuordnung prüfen und bestätigen.";
}
catch (Exception ex) { _viewModel.Status = $"Import fehlgeschlagen: {ex.Message}"; }
finally { _viewModel.IsBusy = false; }
}
private void OnAccept(object? sender, RoutedEventArgs e)
{
if (_result is null) return;
var document = new PdfImportPipeline().Extract(_viewModel.ExamplePath);
Result = new PdfImportPipeline().BuildResult(_viewModel.ExamplePath, document, _viewModel.Candidates);
Close(true);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -0,0 +1,294 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Runtime.Versioning;
using LehrerApp.Templating;
using PDFtoImage;
using UglyToad.PdfPig;
namespace LehrerApp.TemplateDesigner;
public enum PdfImportConfidence { Low, Medium, High }
public sealed record PdfTextBlock(string Id, int PageNumber, double X, double Y, double Width, double Height,
double FontSize, string FontName, bool Bold, bool Italic, string Text);
public sealed record PdfImportPage(int PageNumber, double Width, double Height, IReadOnlyList<PdfTextBlock> TextBlocks);
public sealed record PdfImportDocument(IReadOnlyList<PdfImportPage> Pages);
public sealed class PdfImportCandidate
{
public required string Id { get; init; }
public required List<string> BlockIds { get; set; }
public required string OriginalText { get; init; }
public required string Name { get; set; }
public PlaceholderType Type { get; set; } = PlaceholderType.Text;
public PdfImportConfidence Confidence { get; set; }
public bool Include { get; set; } = true;
public string ConfidenceLabel => Confidence switch
{ PdfImportConfidence.High => "Hoch", PdfImportConfidence.Medium => "Mittel", _ => "Niedrig" };
public string ConfidenceColor => Confidence switch
{ PdfImportConfidence.High => "#15803D", PdfImportConfidence.Medium => "#A16207", _ => "#B91C1C" };
public IReadOnlyList<PlaceholderType> AvailableTypes { get; } =
[PlaceholderType.Text, PlaceholderType.Multiline, PlaceholderType.Date, PlaceholderType.Number];
}
public sealed record PdfImportResult(string LayoutSource, TemplateManifest Manifest,
IReadOnlyDictionary<string, byte[]> Assets, IReadOnlyList<PdfImportCandidate> Candidates);
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macos")]
public sealed class PdfImportPipeline
{
private const double PositionTolerance = 2;
public PdfImportDocument Extract(string path)
{
using var document = PdfDocument.Open(path);
var pages = new List<PdfImportPage>();
foreach (var page in document.GetPages())
{
var words = page.GetWords().OrderByDescending(x => x.BoundingBox.Top).ThenBy(x => x.BoundingBox.Left).ToList();
var lines = new List<List<UglyToad.PdfPig.Content.Word>>();
foreach (var word in words)
{
var line = lines.FirstOrDefault(x => Math.Abs(x[0].BoundingBox.Bottom - word.BoundingBox.Bottom)
<= Math.Max(1.5, word.BoundingBox.Height * .35));
if (line is null) lines.Add([word]); else line.Add(word);
}
var blocks = lines.Select((line, index) =>
{
var ordered = line.OrderBy(x => x.BoundingBox.Left).ToList();
var left = ordered.Min(x => x.BoundingBox.Left); var right = ordered.Max(x => x.BoundingBox.Right);
var bottom = ordered.Min(x => x.BoundingBox.Bottom); var top = ordered.Max(x => x.BoundingBox.Top);
var letters = ordered.SelectMany(x => x.Letters).ToList();
var font = letters.FirstOrDefault()?.FontName ?? "";
return new PdfTextBlock($"p{page.Number}-t{index + 1}", page.Number,
left, page.Height - top, right - left, top - bottom,
letters.Count == 0 ? top - bottom : letters.Average(x => x.FontSize), font,
font.Contains("Bold", StringComparison.OrdinalIgnoreCase),
font.Contains("Italic", StringComparison.OrdinalIgnoreCase) || font.Contains("Oblique", StringComparison.OrdinalIgnoreCase),
string.Join(' ', ordered.Select(x => x.Text)));
}).Where(x => !string.IsNullOrWhiteSpace(x.Text)).ToList();
pages.Add(new(page.Number, page.Width, page.Height, blocks));
}
return new(pages);
}
public List<PdfImportCandidate> FindCandidates(PdfImportDocument example, PdfImportDocument? blankTemplate)
{
var result = new List<PdfImportCandidate>();
foreach (var page in example.Pages)
{
var templateBlocks = blankTemplate?.Pages.FirstOrDefault(x => x.PageNumber == page.PageNumber)?.TextBlocks ?? [];
foreach (var block in page.TextBlocks)
{
var same = templateBlocks.Any(other => Near(block, other) && other.Text.Equals(block.Text, StringComparison.Ordinal));
if (same) continue;
var confidence = blankTemplate is not null ? PdfImportConfidence.High : HeuristicConfidence(block, page);
result.Add(new PdfImportCandidate
{
Id = block.Id, BlockIds = [block.Id], OriginalText = block.Text,
Name = SuggestedName(block.Text, block, page), Type = SuggestedType(block.Text), Confidence = confidence,
});
}
}
return result;
}
public async Task<PdfImportResult> BuildAsync(string examplePath, string? templatePath,
string? username, string? password, CancellationToken cancellationToken = default)
{
var example = Extract(examplePath);
var blank = templatePath is null ? null : Extract(templatePath);
EnsureCompatible(example, blank);
if (example.Pages.Count > 1)
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend als Folgeseitenlayout ergänzt werden.");
var candidates = FindCandidates(example, blank);
if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password))
{
var client = new PdfImportAiClient(new HttpClient { BaseAddress = new Uri("https://backapi.science-teaching.de/") });
var classifications = await client.ClassifyAsync(username, password, example, candidates, cancellationToken);
ApplyClassifications(candidates, classifications);
}
return BuildResult(templatePath ?? examplePath, example, candidates);
}
public PdfImportResult BuildResult(string backgroundPdfPath, PdfImportDocument document,
IReadOnlyList<PdfImportCandidate> candidates)
{
if (document.Pages.Count == 0) throw new InvalidDataException("Das PDF enthält keine Seiten.");
if (document.Pages.Count > 1)
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend als Folgeseitenlayout ergänzt werden.");
var first = document.Pages[0];
var assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
{
["pdf-import-background.png"] = RenderPage(backgroundPdfPath),
["pdf-import-mask.png"] = WhitePixelPng,
};
var lines = new List<string>
{
$"PAGE {N(first.Width)} {N(first.Height)} pt",
"BG pdf-import-background.png",
};
var definitions = new List<PlaceholderDefinition>();
var usedNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var candidate in candidates.Where(x => x.Include))
{
var blocks = candidate.BlockIds.Select(id => first.TextBlocks.FirstOrDefault(x => x.Id == id))
.Where(x => x is not null).Cast<PdfTextBlock>().ToList();
if (blocks.Count == 0) continue;
var name = UniqueName(SanitizeName(candidate.Name), usedNames);
var x = blocks.Min(b => b.X); var y = blocks.Min(b => b.Y);
var right = blocks.Max(b => b.X + b.Width); var bottom = blocks.Max(b => b.Y + b.Height);
var padding = Math.Max(1, blocks.Average(b => b.FontSize) * .18);
lines.Add($"IMG pdf-import-mask.png {N(x - padding)} {N(y - padding)} {N(right - x + 2 * padding)} {N(bottom - y + 2 * padding)}");
var attrs = $"size={N(blocks.Average(b => b.FontSize))}"
+ (blocks.Any(b => b.Bold) ? " bold=true" : "") + (blocks.Any(b => b.Italic) ? " italic=true" : "");
var multiline = candidate.Type == PlaceholderType.Multiline || blocks.Count > 1 || candidate.OriginalText.Contains('\n');
lines.Add(multiline
? $"TEXTBOX {N(x)} {N(y)} {N(Math.Max(20, right - x))} {N(Math.Max(bottom - y, blocks.Average(b => b.FontSize) * 2.5))} ${name} {attrs}"
: $"TEXT {N(x)} {N(y)} ${name} {attrs}");
definitions.Add(new(name, multiline ? PlaceholderType.Multiline : candidate.Type, false));
candidate.Name = name;
}
var manifest = new TemplateManifest
{
Id = "pdf-import", Name = "PDF-Import", Description = "Automatisch aus einem PDF rekonstruiert",
PageSize = new((float)first.Width, (float)first.Height, "pt"), Placeholders = definitions,
Metadata = new(StringComparer.OrdinalIgnoreCase) { [TemplateMetadataKeys.Language] = "de-DE" },
};
return new(string.Join(Environment.NewLine, lines) + Environment.NewLine, manifest, assets, candidates);
}
private static void ApplyClassifications(List<PdfImportCandidate> candidates,
IReadOnlyList<PdfAiClassification> classifications)
{
foreach (var classification in classifications)
{
var candidate = candidates.FirstOrDefault(x => x.Id == classification.Id);
if (candidate is null) continue;
candidate.Name = classification.Name;
candidate.Type = classification.Type;
candidate.Confidence = classification.Confidence;
if (classification.BlockIds.Count > 0) candidate.BlockIds = classification.BlockIds;
}
}
private static bool Near(PdfTextBlock a, PdfTextBlock b) => Math.Abs(a.X - b.X) <= PositionTolerance
&& Math.Abs(a.Y - b.Y) <= PositionTolerance && Math.Abs(a.Width - b.Width) <= Math.Max(PositionTolerance, a.Width * .08);
private static PdfImportConfidence HeuristicConfidence(PdfTextBlock block, PdfImportPage page)
{
if (SuggestedType(block.Text) is PlaceholderType.Date or PlaceholderType.Number) return PdfImportConfidence.High;
if (block.Y < page.Height * .42 && block.X < page.Width * .6) return PdfImportConfidence.Medium;
return PdfImportConfidence.Low;
}
private static PlaceholderType SuggestedType(string text)
{
if (System.Text.RegularExpressions.Regex.IsMatch(text, @"\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b")) return PlaceholderType.Date;
if (decimal.TryParse(text, NumberStyles.Number, CultureInfo.GetCultureInfo("de-DE"), out _)) return PlaceholderType.Number;
return text.Length > 100 ? PlaceholderType.Multiline : PlaceholderType.Text;
}
private static string SuggestedName(string text, PdfTextBlock block, PdfImportPage page)
{
if (SuggestedType(text) == PlaceholderType.Date) return "Datum";
if (System.Text.RegularExpressions.Regex.IsMatch(text, @"^\d{5}\s+")) return "PlzOrt";
if (text.StartsWith("Betreff", StringComparison.OrdinalIgnoreCase)) return "Betreff";
if (block.Y < page.Height * .42 && block.X < page.Width * .6) return "Adresse";
return "Feld";
}
private static string SanitizeName(string name)
{
var safe = string.Concat(name.Trim().Where(char.IsLetterOrDigit));
return string.IsNullOrEmpty(safe) ? "Feld" : safe;
}
private static string UniqueName(string name, HashSet<string> used)
{
if (used.Add(name)) return name;
for (var i = 2; ; i++) if (used.Add(name + i)) return name + i;
}
private static string N(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
private static void EnsureCompatible(PdfImportDocument example, PdfImportDocument? blank)
{
if (blank is null) return;
if (example.Pages.Count != blank.Pages.Count) throw new InvalidDataException("Template und Beispiel haben unterschiedlich viele Seiten.");
for (var i = 0; i < example.Pages.Count; i++)
if (Math.Abs(example.Pages[i].Width - blank.Pages[i].Width) > PositionTolerance
|| Math.Abs(example.Pages[i].Height - blank.Pages[i].Height) > PositionTolerance)
throw new InvalidDataException("Template und Beispiel haben unterschiedliche Seitengrößen.");
}
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macos")]
private static byte[] RenderPage(string path)
{
var temporary = Path.Combine(Path.GetTempPath(), $"lehrerapp-pdf-import-{Guid.NewGuid():N}.png");
try
{
using var pdf = File.OpenRead(path);
Conversion.SavePng(temporary, pdf, page: 0, options: new RenderOptions(Dpi: 300));
return File.ReadAllBytes(temporary);
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
private static readonly byte[] WhitePixelPng = Convert.FromBase64String(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nJkAAAAASUVORK5CYII=");
}
public sealed record PdfAiClassification(string Id, List<string> BlockIds, string Name,
PlaceholderType Type, PdfImportConfidence Confidence);
internal sealed class PdfImportAiClient(HttpClient http)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
public async Task<IReadOnlyList<PdfAiClassification>> ClassifyAsync(string username, string password,
PdfImportDocument document, IReadOnlyList<PdfImportCandidate> candidates, CancellationToken cancellationToken)
{
HttpResponseMessage login;
try { login = await http.PostAsJsonAsync("login.php", new { username, password }, JsonOptions, cancellationToken); }
catch (HttpRequestException ex) { throw new InvalidOperationException("Der KI-Dienst ist nicht erreichbar.", ex); }
if (login.StatusCode == HttpStatusCode.Unauthorized) throw new InvalidOperationException("Benutzername oder Passwort ist falsch.");
login.EnsureSuccessStatusCode();
var token = (await login.Content.ReadFromJsonAsync<LoginResult>(JsonOptions, cancellationToken))?.Token
?? throw new InvalidOperationException("Das KI-Backend lieferte kein Token.");
using var request = new HttpRequestMessage(HttpMethod.Post, "pdf-template.php")
{
Content = JsonContent.Create(new { document, candidates = candidates.Select(x => new
{ x.Id, x.BlockIds, x.OriginalText, suggestedName = x.Name, x.Type, x.Confidence }) }, options: JsonOptions),
};
request.Headers.Authorization = new("Bearer", token);
using var response = await http.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.PaymentRequired) throw new InvalidOperationException("Nicht genügend KI-Guthaben.");
if (response.StatusCode == HttpStatusCode.Unauthorized) throw new InvalidOperationException("Die KI-Anmeldung ist abgelaufen.");
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadFromJsonAsync<ErrorResult>(JsonOptions, cancellationToken);
throw new InvalidOperationException(error?.Error ?? "Die KI-Klassifikation ist fehlgeschlagen.");
}
var result = await response.Content.ReadFromJsonAsync<ClassificationResult>(JsonOptions, cancellationToken);
return result?.Classifications ?? throw new InvalidOperationException("Die KI-Antwort ist unvollständig.");
}
private sealed record LoginResult(string Token);
private sealed record ErrorResult(string Error);
private sealed record ClassificationResult(List<PdfAiClassification> Classifications);
}
@@ -39,17 +39,17 @@ public sealed class StarterTemplateLibrary
} }
public StarterTemplateItem Save(TemplateManifest manifest, string layoutSource, public StarterTemplateItem Save(TemplateManifest manifest, string layoutSource,
IReadOnlyDictionary<string, byte[]> assets) IReadOnlyDictionary<string, byte[]> assets, string? continuationLayoutSource = null)
{ {
var path = Path.Combine(_directory, SafeId(manifest.Id) + TemplatePackage.Extension); var path = Path.Combine(_directory, SafeId(manifest.Id) + TemplatePackage.Extension);
TemplatePackage.Create(path, manifest, layoutSource, assets); TemplatePackage.Create(path, manifest, layoutSource, assets, continuationLayoutSource);
return ToItem(manifest, path); return ToItem(manifest, path);
} }
public StarterTemplateItem Import(string sourcePath) public StarterTemplateItem Import(string sourcePath)
{ {
var (template, layout) = LoadPackage(sourcePath); var (template, layout, continuationLayout) = LoadPackage(sourcePath);
return Save(template.Manifest, layout, template.Assets); return Save(template.Manifest, layout, template.Assets, continuationLayout);
} }
public void Export(StarterTemplateItem item, string destinationPath) public void Export(StarterTemplateItem item, string destinationPath)
@@ -64,13 +64,19 @@ public sealed class StarterTemplateLibrary
public StarterTemplateItem Duplicate(StarterTemplateItem item) public StarterTemplateItem Duplicate(StarterTemplateItem item)
{ {
var (template, layout) = Load(item); var (template, layout, continuationLayout) = LoadWithContinuation(item);
var id = CreateUniqueId(template.Manifest.Id + "-kopie"); var id = CreateUniqueId(template.Manifest.Id + "-kopie");
var manifest = CopyManifest(template.Manifest, id, template.Manifest.Name + " - Kopie"); var manifest = CopyManifest(template.Manifest, id, template.Manifest.Name + " - Kopie");
return Save(manifest, layout, template.Assets); return Save(manifest, layout, template.Assets, continuationLayout);
} }
public (LoadedTemplate Template, string LayoutSource) Load(StarterTemplateItem item) => public (LoadedTemplate Template, string LayoutSource) Load(StarterTemplateItem item)
{
var (template, layout, _) = LoadPackage(item.PackagePath);
return (template, layout);
}
public (LoadedTemplate Template, string LayoutSource, string? ContinuationLayoutSource) LoadWithContinuation(StarterTemplateItem item) =>
LoadPackage(item.PackagePath); LoadPackage(item.PackagePath);
public void Delete(StarterTemplateItem item) public void Delete(StarterTemplateItem item)
@@ -78,15 +84,24 @@ public sealed class StarterTemplateLibrary
if (File.Exists(item.PackagePath)) File.Delete(item.PackagePath); if (File.Exists(item.PackagePath)) File.Delete(item.PackagePath);
} }
private (LoadedTemplate Template, string LayoutSource) LoadPackage(string path) private (LoadedTemplate Template, string LayoutSource, string? ContinuationLayoutSource) LoadPackage(string path)
{ {
var template = _loader.LoadFromPackage(path); var template = _loader.LoadFromPackage(path);
using var archive = ZipFile.OpenRead(path); using var archive = ZipFile.OpenRead(path);
var layoutEntry = archive.Entries.FirstOrDefault(x => x.FullName.Equals( var layoutEntry = archive.Entries.FirstOrDefault(x => x.FullName.Equals(
template.Manifest.LayoutFile.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase)) template.Manifest.LayoutFile.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase))
?? throw new InvalidDataException($"Layoutdatei „{template.Manifest.LayoutFile}“ fehlt."); ?? throw new InvalidDataException($"Layoutdatei „{template.Manifest.LayoutFile}“ fehlt.");
using var reader = new StreamReader(layoutEntry.Open()); string layoutSource;
return (template, reader.ReadToEnd()); using (var reader = new StreamReader(layoutEntry.Open())) layoutSource = reader.ReadToEnd();
string? continuationLayoutSource = null;
if (template.Manifest.ContinuationLayoutFile is { } continuationPath)
{
var continuationEntry = archive.Entries.First(x => x.FullName.Equals(
continuationPath.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase));
using var reader = new StreamReader(continuationEntry.Open());
continuationLayoutSource = reader.ReadToEnd();
}
return (template, layoutSource, continuationLayoutSource);
} }
public string CreateUniqueId(string baseId) public string CreateUniqueId(string baseId)
@@ -108,6 +123,7 @@ public sealed class StarterTemplateLibrary
Description = source.Description, Description = source.Description,
PageSize = new(source.PageSize.Width, source.PageSize.Height, source.PageSize.Unit), PageSize = new(source.PageSize.Width, source.PageSize.Height, source.PageSize.Unit),
LayoutFile = source.LayoutFile, LayoutFile = source.LayoutFile,
ContinuationLayoutFile = source.ContinuationLayoutFile,
MetadataFile = source.MetadataFile, MetadataFile = source.MetadataFile,
Metadata = new(source.Metadata, StringComparer.OrdinalIgnoreCase), Metadata = new(source.Metadata, StringComparer.OrdinalIgnoreCase),
Placeholders = source.Placeholders.Select(x => new PlaceholderDefinition(x.Name, x.Type, x.Required, Placeholders = source.Placeholders.Select(x => new PlaceholderDefinition(x.Name, x.Type, x.Required,
+28 -2
View File
@@ -19,14 +19,40 @@ public sealed class TemplatingTests : IDisposable
IMG logo.png 15 15 30 12 scale=50% IMG logo.png 15 15 30 12 scale=50%
TEXT 20 45 $Datum|dd.MM.yyyy size=10 TEXT 20 45 $Datum|dd.MM.yyyy size=10
TEXTBOX 20 90 170 120 $Brieftext wrap=true TEXTBOX 20 90 170 120 $Brieftext wrap=true
FLOWBOX 20 20 170 250 $Brieftext size=11
TABLE 20 215 170 40 $Zeilen size=9 TABLE 20 215 170 40 $Zeilen size=9
CHART 20 260 170 25 $Werte type=line CHART 20 260 170 25 $Werte type=line
"""); """);
Assert.Equal(6, layout.Elements.Count); Assert.Equal(7, layout.Elements.Count);
Assert.Equal("50%", Assert.IsType<ImageElement>(layout.Elements[1]).Attributes["scale"]); Assert.Equal("50%", Assert.IsType<ImageElement>(layout.Elements[1]).Attributes["scale"]);
Assert.Equal("dd.MM.yyyy", Assert.IsType<TextElement>(layout.Elements[2]).Format); Assert.Equal("dd.MM.yyyy", Assert.IsType<TextElement>(layout.Elements[2]).Format);
Assert.Equal("line", Assert.IsType<ChartElement>(layout.Elements[5]).ChartType); Assert.IsType<FlowBoxElement>(layout.Elements[4]);
Assert.Equal("line", Assert.IsType<ChartElement>(layout.Elements[6]).ChartType);
}
[Fact]
public void FlowBox_FliesstAufFolgeseitenUndPaketEnthaeltFolgeseitenLayout()
{
var path = Path.Combine(_directory, "flow.lavorlage");
var manifest = new TemplateManifest
{
Id = "flow", Name = "Fließtext", ContinuationLayoutFile = "continuation.tpl",
Placeholders = [new("Text", PlaceholderType.Multiline, true)],
};
var first = "PAGE 210 297 mm\nTEXT 20 10 \"Erste Seite\" size=14\nFLOWBOX 20 25 170 252 $Text size=11";
var continuation = "PAGE 210 297 mm\nTEXT 20 10 \"Folgeseite\" size=10\nFLOWBOX 20 25 170 252 $Text size=11";
TemplatePackage.Create(path, manifest, first, new Dictionary<string, byte[]>(), continuation);
var loaded = new TemplateLoader().LoadFromPackage(path);
var longText = string.Join('\n', Enumerable.Repeat(
"Ein langer Klassenbucheintrag mit ausreichend Inhalt für den automatischen Seitenumbruch.", 250));
var pdf = new QuestTemplateRenderer().RenderToPdf(loaded,
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Text"] = new MultilineValue(longText) }));
var source = System.Text.Encoding.ASCII.GetString(pdf);
Assert.NotNull(loaded.ContinuationLayout);
Assert.True(System.Text.RegularExpressions.Regex.Matches(source, @"/Type\s*/Page\b").Count > 1);
} }
[Theory] [Theory]
+6
View File
@@ -58,6 +58,12 @@ public sealed class LayoutParser
elements.Add(new TextBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]), elements.Add(new TextBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
Number(tokens[3]), Number(tokens[4]), tokens[5], boxRef.Name, boxRef.Format, Number(tokens[3]), Number(tokens[4]), tokens[5], boxRef.Name, boxRef.Format,
Attributes(tokens, 6))); break; 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": case "TABLE":
Require(tokens, 6); Require(tokens, 6);
elements.Add(new TableElement(lineNumber, Number(tokens[1]), Number(tokens[2]), 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 string Description { get; set; } = "";
public PageSizeDefinition PageSize { get; set; } = new(210, 297); public PageSizeDefinition PageSize { get; set; } = new(210, 297);
public string LayoutFile { get; set; } = "layout.tpl"; public string LayoutFile { get; set; } = "layout.tpl";
public string? ContinuationLayoutFile { get; set; }
public string MetadataFile { get; set; } = TemplateMetadataText.FileName; public string MetadataFile { get; set; } = TemplateMetadataText.FileName;
[JsonIgnore] [JsonIgnore]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.OrdinalIgnoreCase); 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, public sealed record TextBoxElement(int Line, float X, float Y, float Width, float Height,
string Content, string? Placeholder, string? Format, IReadOnlyDictionary<string, string> Attributes) string Content, string? Placeholder, string? Format, IReadOnlyDictionary<string, string> Attributes)
: TemplateElement(Line, X, Y, Width, Height, 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, public sealed record TableElement(int Line, float X, float Y, float Width, float Height,
string Placeholder, IReadOnlyDictionary<string, string> Attributes) string Placeholder, IReadOnlyDictionary<string, string> Attributes)
: TemplateElement(Line, X, Y, Width, Height, 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); IReadOnlyList<TemplateElement> Elements);
public sealed record LoadedTemplate(TemplateManifest Manifest, TemplateLayout Layout, 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 enum ValidationSeverity { Warning, Error }
public sealed record ValidationIssue(ValidationSeverity Severity, string Message, int? Line = null); public sealed record ValidationIssue(ValidationSeverity Severity, string Message, int? Line = null);
+35 -5
View File
@@ -32,6 +32,7 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
private static IDocument BuildDocument(LoadedTemplate template, private static IDocument BuildDocument(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values) => Document.Create(document => IReadOnlyDictionary<string, PlaceholderValue> values) => Document.Create(document =>
{ {
var flowBox = template.Layout.Elements.OfType<FlowBoxElement>().SingleOrDefault();
document.Page(page => document.Page(page =>
{ {
page.Size(UnitConverter.Points(template.Layout.Width, template.Layout.Unit), page.Size(UnitConverter.Points(template.Layout.Width, template.Layout.Unit),
@@ -39,16 +40,43 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
page.Margin(0); page.Margin(0);
page.Content().Layers(layers => page.Content().Layers(layers =>
{ {
if (flowBox is null)
layers.PrimaryLayer().Width(UnitConverter.Points(template.Layout.Width, template.Layout.Unit)) layers.PrimaryLayer().Width(UnitConverter.Points(template.Layout.Width, template.Layout.Unit))
.Height(UnitConverter.Points(template.Layout.Height, template.Layout.Unit)).Background(Colors.White); .Height(UnitConverter.Points(template.Layout.Height, template.Layout.Unit)).Background(Colors.White);
foreach (var element in template.Layout.Elements) 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; var current = element;
layers.Layer().Element(container => RenderElement(container, current, template, values)); 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, private static void RenderElement(IContainer root, TemplateElement element, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values) 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, RenderResolvedText(Position(root, box, unit).Shrink(), box.Content, box.Placeholder, box.Format,
values, template.Manifest, box.Attributes); values, template.Manifest, box.Attributes);
break; break;
case FlowBoxElement:
break;
case TableElement table: case TableElement table:
if (values.GetValueOrDefault(table.Placeholder) is TableValue tableValue) if (values.GetValueOrDefault(table.Placeholder) is TableValue tableValue)
TableElementRenderer.Render(Position(root, table, unit), tableValue, table.Attributes); TableElementRenderer.Render(Position(root, table, unit), tableValue, table.Attributes);
+38
View File
@@ -1,5 +1,43 @@
# LehrerApp Templating # 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 ## Konstante Platzhalter und Hervorhebung
Ein Platzhalter kann seinen Wert vollständig im Vorlagenpaket tragen. `IsConstant=true` bewirkt, 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."); throw Error($"schemaVersion {manifest.SchemaVersion} wird nicht unterstützt.");
if (!IsSafeRelativePath(manifest.LayoutFile)) throw Error("layoutFile enthält einen unsicheren Pfad."); 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 (!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)) if (Normalize(manifest.MetadataFile).Equals(Normalize(manifest.LayoutFile), StringComparison.OrdinalIgnoreCase))
throw Error("metadataFile und layoutFile dürfen nicht identisch sein."); throw Error("metadataFile und layoutFile dürfen nicht identisch sein.");
if (!entries.TryGetValue(Normalize(manifest.LayoutFile), out var layoutEntry)) 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}"); } catch (InvalidDataException ex) { throw Error($"{manifest.MetadataFile} ist ungültig: {ex.Message}"); }
} }
var layout = new LayoutParser().Parse(layoutSource); 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(); .Select(Normalize).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
foreach (var path in referencedAssets) 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 assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
var layoutPath = Normalize(manifest.LayoutFile); var layoutPath = Normalize(manifest.LayoutFile);
var continuationLayoutPath = manifest.ContinuationLayoutFile is null ? null : Normalize(manifest.ContinuationLayoutFile);
foreach (var (path, asset) in entries.Where(x => foreach (var (path, asset) in entries.Where(x =>
!x.Key.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) !x.Key.Equals("manifest.json", StringComparison.OrdinalIgnoreCase)
&& !x.Key.Equals(layoutPath, StringComparison.OrdinalIgnoreCase) && !x.Key.Equals(layoutPath, StringComparison.OrdinalIgnoreCase)
&& !x.Key.Equals(continuationLayoutPath, StringComparison.OrdinalIgnoreCase)
&& !x.Key.Equals(metadataPath, StringComparison.OrdinalIgnoreCase))) && !x.Key.Equals(metadataPath, StringComparison.OrdinalIgnoreCase)))
{ {
if (asset.Length > _limits.MaxAssetBytes) 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); 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.")); 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)) 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.")); issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{duplicate.Key}“ ist mehrfach deklariert."));
foreach (var issue in TemplateDataResolver.ValidateConstants(manifest)) foreach (var issue in TemplateDataResolver.ValidateConstants(manifest))
issues.Add(new(ValidationSeverity.Error, issue)); issues.Add(new(ValidationSeverity.Error, issue));
if (issues.Count > 0) throw new TemplateValidationException(new(issues)); 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) catch (InvalidDataException ex)
{ throw Error($"Paket ist kein lesbares ZIP-Archiv: {ex.Message}"); } { 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, TextElement { Placeholder: { } p } => p,
TextBoxElement { Placeholder: { } p } => p, TextBoxElement { Placeholder: { } p } => p,
FlowBoxElement { Placeholder: { } p } => p,
TableElement t => t.Placeholder, TableElement t => t.Placeholder,
ChartElement c => c.Placeholder, ChartElement c => c.Placeholder,
_ => null, _ => null,
+11 -1
View File
@@ -8,7 +8,7 @@ public static class TemplatePackage
public const string Extension = ".lavorlage"; public const string Extension = ".lavorlage";
public static void Create(string outputPath, TemplateManifest manifest, string layoutSource, 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)) if (!TemplateLoader.IsSafeRelativePath(manifest.MetadataFile))
throw new InvalidDataException("metadataFile enthält einen unsicheren Pfad."); 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."); throw new InvalidDataException("metadataFile und layoutFile dürfen nicht identisch sein.");
var reservedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase) var reservedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "manifest.json", TemplateLoader.Normalize(manifest.LayoutFile), TemplateLoader.Normalize(manifest.MetadataFile) }; { "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)) if (!string.Equals(Path.GetExtension(outputPath), Extension, StringComparison.OrdinalIgnoreCase))
outputPath += Extension; outputPath += Extension;
var directory = Path.GetDirectoryName(outputPath); 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.json", JsonSerializer.Serialize(manifest, TemplateLoader.JsonOptions));
WriteText(archive, manifest.LayoutFile, layoutSource); WriteText(archive, manifest.LayoutFile, layoutSource);
if (manifest.ContinuationLayoutFile is { } continuationFile)
WriteText(archive, continuationFile, continuationLayoutSource!);
WriteText(archive, manifest.MetadataFile, TemplateMetadataText.Serialize(manifest.Metadata)); WriteText(archive, manifest.MetadataFile, TemplateMetadataText.Serialize(manifest.Metadata));
foreach (var asset in assets) foreach (var asset in assets)
{ {
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
$pdo = ai_backend_db($config);
$user = ai_backend_authenticate($pdo);
if ((float) $user['balance_usd'] <= 0) {
ai_backend_fail(402, 'Kein Guthaben mehr vorhanden.');
}
$body = json_decode(file_get_contents('php://input'), true);
if (!is_array($body) || !isset($body['document']['pages']) || !is_array($body['candidates'] ?? null)) {
ai_backend_fail(400, 'Dokument oder Kandidaten fehlen.');
}
if (count($body['candidates']) > 500) {
ai_backend_fail(413, 'Zu viele Textkandidaten in einer Anfrage.');
}
$systemPrompt = <<<'PROMPT'
Du klassifizierst Textblöcke aus deutschen Schul- und Verwaltungsdokumenten für einen
Vorlageneditor. Die Geometrie wurde deterministisch aus einem PDF extrahiert und darf von dir
niemals geändert oder ergänzt werden. Du erhältst Seiten, Textblöcke und eine Liste von Kandidaten.
Für jeden Kandidaten:
- vergib einen kurzen stabilen deutschen Placeholder-Namen nur aus Buchstaben und Ziffern,
- wähle type ausschließlich aus text, multiline, date, number,
- wähle confidence ausschließlich aus high, medium, low,
- darfst du benachbarte, logisch zusammengehörige Zeilen gruppieren. Dann enthält blockIds alle
Original-IDs der Gruppe. Jede übernommene ID muss exakt aus der Eingabe stammen.
- typische Namen sind Anrede, Empfaenger, Adresse, PlzOrt, Datum, Betreff, Aktenzeichen, Brieftext.
Antworte AUSSCHLIESSLICH mit gültigem JSON in diesem Schema:
{
"classifications": [
{
"id": "<id eines Kandidaten>",
"blockIds": ["<unveränderte Original-ID>"],
"name": "<PlaceholderName>",
"type": "text|multiline|date|number",
"confidence": "high|medium|low"
}
]
}
PROMPT;
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt,
json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$parsed = json_decode($result['content'], true);
if (!is_array($parsed) || !is_array($parsed['classifications'] ?? null)) {
ai_backend_fail(502, 'Die KI hat kein gültiges Klassifikations-JSON geliefert.');
}
$knownIds = [];
foreach ($body['document']['pages'] as $page) {
foreach (($page['textBlocks'] ?? []) as $block) {
if (isset($block['id'])) $knownIds[(string) $block['id']] = true;
}
}
$candidateIds = array_fill_keys(array_map(fn($x) => (string) ($x['id'] ?? ''), $body['candidates']), true);
$allowedTypes = ['text', 'multiline', 'date', 'number'];
$allowedConfidence = ['high', 'medium', 'low'];
foreach ($parsed['classifications'] as $classification) {
if (!isset($candidateIds[(string) ($classification['id'] ?? '')])
|| !in_array($classification['type'] ?? '', $allowedTypes, true)
|| !in_array($classification['confidence'] ?? '', $allowedConfidence, true)
|| !preg_match('/^[\pL\pN]+$/u', (string) ($classification['name'] ?? ''))
|| !is_array($classification['blockIds'] ?? null)) {
ai_backend_fail(502, 'Die KI-Klassifikation enthält ungültige Werte.');
}
foreach ($classification['blockIds'] as $blockId) {
if (!isset($knownIds[(string) $blockId])) {
ai_backend_fail(502, 'Die KI-Klassifikation referenziert einen unbekannten Textblock.');
}
}
}
echo json_encode(['classifications' => $parsed['classifications']], JSON_UNESCAPED_UNICODE);
+15
View File
@@ -13,6 +13,21 @@ class FakeProvider implements ProviderInterface
{ {
public function sendMessage(string $systemPrompt, string $userContent, int $maxTokens): array public function sendMessage(string $systemPrompt, string $userContent, int $maxTokens): array
{ {
if (str_contains($systemPrompt, 'Placeholder-Namen')) {
$input = json_decode($userContent, true);
$classifications = array_map(static fn(array $candidate): array => [
'id' => $candidate['id'],
'blockIds' => $candidate['blockIds'] ?? [$candidate['id']],
'name' => $candidate['suggestedName'] ?? 'Feld',
'type' => $candidate['type'] ?? 'text',
'confidence' => $candidate['confidence'] ?? 'low',
], $input['candidates'] ?? []);
return [
'content' => json_encode(['classifications' => $classifications]),
'inputTokens' => 42, 'outputTokens' => 17,
'cacheCreationInputTokens' => 0, 'cacheReadInputTokens' => 0,
];
}
return [ return [
'content' => json_encode([ 'content' => json_encode([
'lessons' => [ 'lessons' => [