# Conflicts: # LehrerApp.TemplateDesigner.Tests/ProjectLifecycleTests.cs # LehrerApp.TemplateDesigner/DesignerViewModel.cs # LehrerApp.TemplateDesigner/MainWindow.axaml # LehrerApp.Templating/LayoutParser.cs # LehrerApp.Templating/QuestTemplateRenderer.cs
This commit is contained in:
@@ -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<string> Units { get; } = ["mm", "cm", "pt", "in"];
|
||||
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
|
||||
public IReadOnlyList<string> ElementTypes { get; } = ["TEXT", "TEXTBOX", "IMG", "TABLE", "CHART"];
|
||||
public IReadOnlyList<string> ElementTypes { get; } =
|
||||
["TEXT", "TEXTBOX", "FLOWBOX", "DRAWBOX", "FLOWDRAWBOX", "IMG", "TABLE", "CHART"];
|
||||
public IReadOnlyList<string> ElementScopes { get; } = ["Seitenvorlage (fest)", "Content-Flow"];
|
||||
public ObservableCollection<DesignerPlaceholder> 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<ValidationIssue>();
|
||||
var layouts = continuationLayout is null ? new[] { layout } : new[] { layout, continuationLayout };
|
||||
var declared = manifest.Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var used in TemplateLoader.UsedPlaceholders(layout).Where(x => !declared.Contains(x)))
|
||||
foreach (var used in layouts.SelectMany(TemplateLoader.UsedPlaceholders).Distinct(StringComparer.Ordinal)
|
||||
.Where(x => !declared.Contains(x)))
|
||||
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{used}“ ist nicht deklariert."));
|
||||
foreach (var path in layout.Elements.Select(x => x switch { BackgroundElement b => b.Path, ImageElement i => i.Path, _ => null }).Where(x => x is not null))
|
||||
foreach (var path in layouts.SelectMany(x => x.Elements).Select(x => x switch { BackgroundElement b => b.Path, ImageElement i => i.Path, _ => null }).Where(x => x is not null))
|
||||
if (!Assets.ContainsKey(path!)) issues.Add(new(ValidationSeverity.Error, $"Asset „{path}“ fehlt."));
|
||||
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<string, byte[]>(Assets));
|
||||
return new(manifest, layout, new Dictionary<string, byte[]>(Assets), ContinuationLayout: continuationLayout);
|
||||
}
|
||||
|
||||
public ITemplateDataProvider BuildDataProvider() => new DesignerDataProvider(Placeholders.ToDictionary(
|
||||
x => x.Name.Trim(), x => x.ToValue(), StringComparer.Ordinal));
|
||||
|
||||
public 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<string, PlaceholderValue> values) : ITemplateDataProvider
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="PDFtoImage" />
|
||||
<PackageReference Include="PdfPig" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<Separator/>
|
||||
<MenuItem Header="Speichern" InputGesture="Ctrl+S" Click="OnSave"/>
|
||||
<MenuItem Header="Speichern unter …" InputGesture="Ctrl+Shift+S" Click="OnSaveAs"/>
|
||||
<MenuItem Header="Aktuelle Ansicht als PDF exportieren …" Click="OnExportCurrentPdf"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Ausgangsvorlage importieren …" Click="OnImportStarterTemplate"/>
|
||||
<Separator/>
|
||||
@@ -25,6 +26,8 @@
|
||||
<MenuItem Header="Ausgewählte Ausgangsvorlage exportieren …" Click="OnExportStarterTemplate"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Einfügen">
|
||||
<MenuItem Header="PDF als Vorlage importieren …" Click="OnImportPdfTemplate"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Bild-Asset …" Click="OnImportAsset"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="PNG/JPEG als Hintergrund …" Click="OnImportBackground"/>
|
||||
@@ -237,17 +240,38 @@
|
||||
TextWrapping="Wrap" Opacity="0.65" FontSize="11"/>
|
||||
<TextBlock Text="Elemente im Content-Flow verwenden Reihenfolge, gap und keep-with-next; ihre X/Y-Koordinaten werden nicht benötigt."
|
||||
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
|
||||
<TextBlock Text="FLOWBOX verteilt Text automatisch über beliebig viele Seiten. TEXTBOX bleibt ein fester Bereich."
|
||||
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
|
||||
<TextBlock Text="Systemvariablen ohne Deklaration: $$today, $$curPage und $$maxPageNum (z. B. "Seite $$curPage von $$maxPageNum")."
|
||||
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
|
||||
<TextBlock Text="DRAWBOX und FLOWDRAWBOX nehmen deklarative Zeichenbefehle (Text, Linien, Rechtecke, Bilder) aus einer externen App entgegen."
|
||||
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,*" Margin="0,18">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="12,0,12,10"><TextBlock Text="Layout-DSL" Classes="section"/>
|
||||
<Button Grid.Column="1" Content="Prüfen & Vorschau" Click="OnPreview"/></Grid>
|
||||
<TextBox Grid.Row="1" Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap"
|
||||
FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto" Margin="12"/>
|
||||
<Grid ColumnDefinitions="*,Auto,8,Auto" Margin="12,0,12,10"><TextBlock Text="Layout-DSL" Classes="section"/>
|
||||
<Button Grid.Column="1" Content="Prüfen & Vorschau" Click="OnPreview"/>
|
||||
<Button Grid.Column="3" Content="Als PDF exportieren …" Click="OnExportCurrentPdf"/></Grid>
|
||||
<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"
|
||||
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>
|
||||
|
||||
<Border Grid.Column="2" Background="#E2E8F0" Padding="18">
|
||||
|
||||
@@ -107,6 +107,22 @@ public partial class MainWindow : Window
|
||||
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)
|
||||
{
|
||||
if (_viewModel.SelectedAsset is null) { _viewModel.SetStatus("Bitte zuerst ein Asset auswählen.", true); return; }
|
||||
@@ -143,8 +159,8 @@ public partial class MainWindow : Window
|
||||
{ _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; }
|
||||
try
|
||||
{
|
||||
var (template, layout) = _starterTemplates.Load(selected);
|
||||
_viewModel.Load(template, layout); _currentPackagePath = null; UpdateDocumentTitle(); OnPreview(sender, e);
|
||||
var (template, layout, continuationLayout) = _starterTemplates.LoadWithContinuation(selected);
|
||||
_viewModel.Load(template, layout, continuationLayout); _currentPackagePath = null; UpdateDocumentTitle(); OnPreview(sender, e);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Öffnen fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
@@ -155,9 +171,9 @@ public partial class MainWindow : Window
|
||||
{ _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; }
|
||||
try
|
||||
{
|
||||
var (template, layout) = _starterTemplates.Load(selected);
|
||||
var (template, layout, continuationLayout) = _starterTemplates.LoadWithContinuation(selected);
|
||||
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);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Klonen fehlgeschlagen: {ex.Message}", true); }
|
||||
@@ -168,7 +184,8 @@ public partial class MainWindow : Window
|
||||
try
|
||||
{
|
||||
_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);
|
||||
_viewModel.SetStatus($"Ausgangsvorlage „{saved.Name}“ lokal gespeichert/aktualisiert.", false);
|
||||
}
|
||||
@@ -238,8 +255,16 @@ public partial class MainWindow : Window
|
||||
try
|
||||
{
|
||||
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());
|
||||
_viewModel.Load(loaded, reader.ReadToEnd()); _currentPackagePath = Path.GetFullPath(path);
|
||||
using var archive = ZipFile.OpenRead(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);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Öffnen fehlgeschlagen: {ex.Message}", true); }
|
||||
@@ -253,6 +278,40 @@ public partial class MainWindow : Window
|
||||
|
||||
private async void OnSaveAs(object? sender, RoutedEventArgs e) => await SaveAsAsync();
|
||||
|
||||
private async void OnExportCurrentPdf(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
byte[] pdf;
|
||||
try
|
||||
{
|
||||
pdf = _viewModel.RenderCurrentPdf();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_viewModel.SetStatus($"PDF-Export fehlgeschlagen: {ex.Message}", true);
|
||||
return;
|
||||
}
|
||||
|
||||
var file = await StorageProvider.SaveFilePickerAsync(new()
|
||||
{
|
||||
Title = "Aktuelle Vorlagenansicht als PDF exportieren",
|
||||
SuggestedFileName = _viewModel.TemplateId + "-vorschau.pdf",
|
||||
DefaultExtension = "pdf",
|
||||
FileTypeChoices = [new("PDF-Dateien") { Patterns = ["*.pdf"] }],
|
||||
});
|
||||
if (file is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var destination = EnsurePdfExtension(file.Path.LocalPath);
|
||||
await File.WriteAllBytesAsync(destination, pdf);
|
||||
_viewModel.SetStatus($"PDF-Vorschau exportiert: {Path.GetFileName(destination)}", false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_viewModel.SetStatus($"PDF-Export fehlgeschlagen: {ex.Message}", true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsAsync()
|
||||
{
|
||||
try { _viewModel.BuildLoaded(); }
|
||||
@@ -274,7 +333,8 @@ public partial class MainWindow : Window
|
||||
try
|
||||
{
|
||||
_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.SetStatus($"Gespeichert: {Path.GetFileName(path)}", false);
|
||||
return true;
|
||||
@@ -317,6 +377,9 @@ public partial class MainWindow : Window
|
||||
private static string EnsurePackageExtension(string path) =>
|
||||
string.Equals(Path.GetExtension(path), TemplatePackage.Extension, StringComparison.OrdinalIgnoreCase)
|
||||
? path : path + TemplatePackage.Extension;
|
||||
private static string EnsurePdfExtension(string path) =>
|
||||
string.Equals(Path.GetExtension(path), ".pdf", StringComparison.OrdinalIgnoreCase)
|
||||
? path : path + ".pdf";
|
||||
private void RefreshStarterTemplates(string? selectedId = null) =>
|
||||
_viewModel.SetStarterTemplates(_starterTemplates.GetAll(), selectedId);
|
||||
|
||||
|
||||
@@ -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,
|
||||
IReadOnlyDictionary<string, byte[]> assets)
|
||||
IReadOnlyDictionary<string, byte[]> assets, string? continuationLayoutSource = null)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
public StarterTemplateItem Import(string sourcePath)
|
||||
{
|
||||
var (template, layout) = LoadPackage(sourcePath);
|
||||
return Save(template.Manifest, layout, template.Assets);
|
||||
var (template, layout, continuationLayout) = LoadPackage(sourcePath);
|
||||
return Save(template.Manifest, layout, template.Assets, continuationLayout);
|
||||
}
|
||||
|
||||
public void Export(StarterTemplateItem item, string destinationPath)
|
||||
@@ -64,13 +64,19 @@ public sealed class StarterTemplateLibrary
|
||||
|
||||
public StarterTemplateItem Duplicate(StarterTemplateItem item)
|
||||
{
|
||||
var (template, layout) = Load(item);
|
||||
var (template, layout, continuationLayout) = LoadWithContinuation(item);
|
||||
var id = CreateUniqueId(template.Manifest.Id + "-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);
|
||||
|
||||
public void Delete(StarterTemplateItem item)
|
||||
@@ -78,15 +84,24 @@ public sealed class StarterTemplateLibrary
|
||||
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);
|
||||
using var archive = ZipFile.OpenRead(path);
|
||||
var layoutEntry = archive.Entries.FirstOrDefault(x => x.FullName.Equals(
|
||||
template.Manifest.LayoutFile.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidDataException($"Layoutdatei „{template.Manifest.LayoutFile}“ fehlt.");
|
||||
using var reader = new StreamReader(layoutEntry.Open());
|
||||
return (template, reader.ReadToEnd());
|
||||
string layoutSource;
|
||||
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)
|
||||
@@ -108,6 +123,7 @@ public sealed class StarterTemplateLibrary
|
||||
Description = source.Description,
|
||||
PageSize = new(source.PageSize.Width, source.PageSize.Height, source.PageSize.Unit),
|
||||
LayoutFile = source.LayoutFile,
|
||||
ContinuationLayoutFile = source.ContinuationLayoutFile,
|
||||
MetadataFile = source.MetadataFile,
|
||||
Metadata = new(source.Metadata, StringComparer.OrdinalIgnoreCase),
|
||||
Placeholders = source.Placeholders.Select(x => new PlaceholderDefinition(x.Name, x.Type, x.Required,
|
||||
|
||||
Reference in New Issue
Block a user