Merge remote-tracking branch 'origin/main'
CI / build-and-test (push) Canceled after 0s

# 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:
2026-09-01 10:15:54 +02:00
28 changed files with 1729 additions and 65 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);
}
@@ -108,6 +108,15 @@ public sealed class ProjectLifecycleTests
Assert.Empty(parsed.ContentFlows.Single().Elements); Assert.Empty(parsed.ContentFlows.Single().Elements);
} }
[Fact]
public void AktuelleAnsicht_KannAlsPdfGerendertWerden()
{
var pdf = new DesignerViewModel().RenderCurrentPdf();
Assert.True(pdf.Length > 100);
Assert.Equal("%PDF-", System.Text.Encoding.ASCII.GetString(pdf, 0, 5));
}
private static readonly byte[] OnePixelPng = Convert.FromBase64String( private static readonly byte[] OnePixelPng = Convert.FromBase64String(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="); "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=");
} }
+89 -11
View File
@@ -16,6 +16,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";
@@ -49,7 +51,8 @@ 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", "DRAWBOX", "FLOWDRAWBOX", "IMG", "TABLE", "CHART"];
public IReadOnlyList<string> ElementScopes { get; } = ["Seitenvorlage (fest)", "Content-Flow"]; public IReadOnlyList<string> ElementScopes { get; } = ["Seitenvorlage (fest)", "Content-Flow"];
public ObservableCollection<DesignerPlaceholder> Placeholders { get; } = public ObservableCollection<DesignerPlaceholder> Placeholders { get; } =
[ [
@@ -87,6 +90,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 = EmptyStructuredLayout; 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; 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"));
@@ -105,6 +110,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(),
}; };
@@ -116,21 +122,69 @@ 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."));
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)) 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 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() public DesignerPlaceholder AddPlaceholder()
{ {
var existing = Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal); 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); 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); layoutSource = MigrateLegacyLayout(layoutSource, template.Layout);
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.Layout.UsesPageTemplates
&& 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));
@@ -202,9 +259,9 @@ public partial class DesignerViewModel : ObservableObject
return string.Join('\n', result); 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"; TemplateId = newId ?? template.Manifest.Id + "-neu";
TemplateName = template.Manifest.Name + " - Neu"; TemplateName = template.Manifest.Name + " - Neu";
CanExport = false; CanExport = false;
@@ -304,18 +361,27 @@ public partial class DesignerViewModel : ObservableObject
{ {
("TEXT", false) => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}", ("TEXT", false) => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}",
("TEXTBOX", false) => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {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()}%", ("IMG", false) => $"IMG {NewContent} {NewX} {NewY} {NewWidth} {NewHeight} scale={NormalizedImageScale()}%",
("TABLE", false) => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}", ("TABLE", false) => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
("CHART", false) => $"CHART {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}", ("CHART", false) => $"CHART {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
("TEXT", true) => $"TEXT {QuoteIfLiteral(NewContent)}{attrs}", ("TEXT", true) => $"TEXT {QuoteIfLiteral(NewContent)}{attrs}",
("TEXTBOX", true) => $"TEXTBOX {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}", ("IMG", true) => $"IMG {NewContent} w={NewWidth} h={NewHeight} scale={NormalizedImageScale()}%{attrs}",
("TABLE", true) => $"TABLE {NewContent}{attrs}", ("TABLE", true) => $"TABLE {NewContent}{attrs}",
("CHART", true) => $"CHART {NewContent} h={NewHeight}{attrs}", ("CHART", true) => $"CHART {NewContent} h={NewHeight}{attrs}",
_ => throw new InvalidOperationException("Unbekannter Elementtyp."), _ => throw new InvalidOperationException("Unbekannter Elementtyp."),
}; };
LayoutSource = InsertIntoSection(LayoutSource, line, flowElement ? "content-flow" : "page-template", var parsedLayout = new LayoutParser().Parse(LayoutSource);
flowElement ? SelectedContentFlow : SelectedPageTemplate); 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); 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)}", 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)}",
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)} " 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)} "
@@ -683,11 +755,13 @@ public partial class DesignerPlaceholder : ObservableObject
PlaceholderType.Image => new ImageValue([], "image/png"), PlaceholderType.Image => new ImageValue([], "image/png"),
PlaceholderType.Table => ParseTable(Sample), PlaceholderType.Table => ParseTable(Sample),
PlaceholderType.Chart => ParseChart(Sample), PlaceholderType.Chart => ParseChart(Sample),
PlaceholderType.Drawing => SampleDrawing(),
_ => new TextValue(Sample), _ => new TextValue(Sample),
}; };
public static string SampleFor(PlaceholderType type) => type switch public static string SampleFor(PlaceholderType type) => type switch
{ PlaceholderType.Date => DateTime.Today.ToString("yyyy-MM-dd"), PlaceholderType.Number => "42,5", { 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) private static TableValue ParseTable(string value)
{ {
var lines = value.Split('|', StringSplitOptions.RemoveEmptyEntries); 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) 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())]); .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 internal sealed class DesignerDataProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
@@ -204,7 +204,8 @@ public sealed class LayoutOverlayEditor : Control
{ {
if (element is BackgroundElement) continue; if (element is BackgroundElement) continue;
var keyword = element switch 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", _ => "?" }; TableElement => "TABLE", ChartElement => "CHART", _ => "?" };
var (width, height, resizable) = element switch var (width, height, resizable) = element switch
{ {
@@ -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>
+28 -4
View File
@@ -10,6 +10,7 @@
<Separator/> <Separator/>
<MenuItem Header="Speichern" InputGesture="Ctrl+S" Click="OnSave"/> <MenuItem Header="Speichern" InputGesture="Ctrl+S" Click="OnSave"/>
<MenuItem Header="Speichern unter …" InputGesture="Ctrl+Shift+S" Click="OnSaveAs"/> <MenuItem Header="Speichern unter …" InputGesture="Ctrl+Shift+S" Click="OnSaveAs"/>
<MenuItem Header="Aktuelle Ansicht als PDF exportieren …" Click="OnExportCurrentPdf"/>
<Separator/> <Separator/>
<MenuItem Header="Ausgangsvorlage importieren …" Click="OnImportStarterTemplate"/> <MenuItem Header="Ausgangsvorlage importieren …" Click="OnImportStarterTemplate"/>
<Separator/> <Separator/>
@@ -25,6 +26,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"/>
@@ -237,17 +240,38 @@
TextWrapping="Wrap" Opacity="0.65" FontSize="11"/> 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." <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"/> 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. &quot;Seite $$curPage von $$maxPageNum&quot;)."
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> </StackPanel>
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
</TabControl> </TabControl>
<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,8,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"/>
<TextBox Grid.Row="1" Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap" <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" 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">
+71 -8
View File
@@ -107,6 +107,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; }
@@ -143,8 +159,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); }
} }
@@ -155,9 +171,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); }
@@ -168,7 +184,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);
} }
@@ -238,8 +255,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); }
@@ -253,6 +278,40 @@ public partial class MainWindow : Window
private async void OnSaveAs(object? sender, RoutedEventArgs e) => await SaveAsAsync(); 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() private async Task SaveAsAsync()
{ {
try { _viewModel.BuildLoaded(); } try { _viewModel.BuildLoaded(); }
@@ -274,7 +333,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;
@@ -317,6 +377,9 @@ public partial class MainWindow : Window
private static string EnsurePackageExtension(string path) => private static string EnsurePackageExtension(string path) =>
string.Equals(Path.GetExtension(path), TemplatePackage.Extension, StringComparison.OrdinalIgnoreCase) string.Equals(Path.GetExtension(path), TemplatePackage.Extension, StringComparison.OrdinalIgnoreCase)
? path : path + TemplatePackage.Extension; ? 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) => private void RefreshStarterTemplates(string? selectedId = null) =>
_viewModel.SetStarterTemplates(_starterTemplates.GetAll(), selectedId); _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, 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,
+117 -2
View File
@@ -19,14 +19,129 @@ 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 Systemvariablen_BrauchenKeineManifestDeklarationUndRendernMitSeitenzahlen()
{
var manifest = new TemplateManifest { Id = "system", Name = "Systemvariablen" };
var layout = new LayoutParser().Parse("""
PAGE 210 297 mm
TEXT 20 10 "Stand: $$today" size=9
TEXT 140 285 "Seite $$curPage von $$maxPageNum" size=9
FLOWBOX 20 20 170 250 "Langer Inhalt für zwei Seiten.\nLanger Inhalt für zwei Seiten."
""");
var template = new LoadedTemplate(manifest, layout, new Dictionary<string, byte[]>());
var validation = new TemplateLoader().Validate(template, new Dictionary<string, PlaceholderType>());
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
new DictionaryProvider(new Dictionary<string, PlaceholderValue>()));
Assert.True(validation.IsValid);
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
}
[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);
}
[Fact]
public void DrawBox_RendertDeklarativeExterneZeichenbefehleInFesterBox()
{
var template = new LoadedTemplate(new TemplateManifest
{
Placeholders = [new("Grafik", PlaceholderType.Drawing, true)],
}, new LayoutParser().Parse("PAGE 210 297 mm\nDRAWBOX 20 20 100 60 $Grafik"),
new Dictionary<string, byte[]>());
var drawing = new DrawingValue(
[new DrawRectangle(0, 0, 100, 60, "#1D4ED8", 1, "#EFF6FF"),
new DrawString(5, 5, "Externer Inhalt", 11, Bold: true),
new MoveTo(5, 25), new LineTo(95, 25, "#DC2626", 1.5f),
new DrawLine(5, 35, 95, 50, "#059669", 1)], 60);
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Grafik"] = drawing }));
Assert.Single(System.Text.RegularExpressions.Regex.Matches(
System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b"));
}
[Fact]
public void FlowDrawBox_PaginertHohenDeklarativenZeichenraum()
{
var template = new LoadedTemplate(new TemplateManifest
{
Placeholders = [new("Protokoll", PlaceholderType.Drawing, true)],
}, new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 257 $Protokoll"),
new Dictionary<string, byte[]>());
var drawing = new DrawingValue(
Enumerable.Range(0, 80).SelectMany(i => (DrawingCommand[])
[new DrawString(0, i * 10, $"Zeile {i + 1}", 8), new DrawLine(0, i * 10 + 9, 160, i * 10 + 9, "#CBD5E1", .3f)])
.ToList(), 800);
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Protokoll"] = drawing }));
Assert.True(System.Text.RegularExpressions.Regex.Matches(
System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b").Count >= 4);
}
[Fact]
public void FlowDrawBox_CallbackErhaeltSeitenflaecheStateUndSteuertSeitenwechsel()
{
var template = new LoadedTemplate(new TemplateManifest
{
Placeholders = [new("Bericht", PlaceholderType.Drawing, true)],
}, new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 257 $Bericht"),
new Dictionary<string, byte[]>());
var invocations = 0;
var drawing = new PagedDrawingValue(context =>
{
invocations++;
var item = context.State is int value ? value : 0;
context.Canvas.DrawRectangle(0, 0, context.Width, 20, "#1D4ED8", .5f, "#EFF6FF");
context.Canvas.DrawStringEx(0, 2, 14, context.Width, $"Untrennbarer Block {item + 1}",
DrawingTextAlignment.AlignCenter, 11, "Arial", "#1E3A8A", bold: true);
context.State = item + 1;
return context.PageNumber == 3;
}, InitialState: 0);
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Bericht"] = drawing }));
Assert.Equal(3, invocations);
Assert.Equal(3, System.Text.RegularExpressions.Regex.Matches(
System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b").Count);
} }
[Theory] [Theory]
@@ -0,0 +1,29 @@
using System.Security.Cryptography;
using QuestPDF.Drawing;
namespace LehrerApp.Templating;
/// <summary>
/// Registers application-supplied TTF/OTF font bytes for TEXT and drawing commands without
/// exposing QuestPDF types to the calling application.
/// </summary>
public static class DrawingFontRegistry
{
private static readonly object Gate = new();
private static readonly HashSet<string> Registered = new(StringComparer.Ordinal);
public static void RegisterFont(string familyName, byte[] fontData)
{
if (string.IsNullOrWhiteSpace(familyName) || familyName.Length > 80)
throw new ArgumentException("Der Schriftname fehlt oder ist zu lang.", nameof(familyName));
if (fontData.Length is 0 or > 20 * 1024 * 1024)
throw new ArgumentException("Eine Schriftdatei muss zwischen 1 Byte und 20 MB groß sein.", nameof(fontData));
var key = familyName + ":" + Convert.ToHexString(SHA256.HashData(fontData));
lock (Gate)
{
if (!Registered.Add(key)) return;
using var stream = new MemoryStream(fontData, writable: false);
FontManager.RegisterFontWithCustomName(familyName, stream);
}
}
}
+39
View File
@@ -135,6 +135,36 @@ public sealed class LayoutParser
flowBox ? 0 : Number(tokens[2]), flowBox ? 0 : Number(tokens[3]), flowBox ? 0 : Number(tokens[2]), flowBox ? 0 : Number(tokens[3]),
flowBox ? 0 : Number(tokens[4]), boxContent, boxRef.Name, boxRef.Format, flowBox ? 0 : Number(tokens[4]), boxContent, boxRef.Name, boxRef.Format,
Attributes(tokens, flowBox ? 2 : 6)); break; Attributes(tokens, flowBox ? 2 : 6)); break;
case "FLOWBOX":
var nestedFlowBox = currentFlowName is not null;
Require(tokens, nestedFlowBox ? 2 : 6);
var flowContent = tokens[nestedFlowBox ? 1 : 5];
var flowRef = Reference(flowContent);
parsedElement = new FlowBoxElement(lineNumber,
nestedFlowBox ? 0 : Number(tokens[1]), nestedFlowBox ? 0 : Number(tokens[2]),
nestedFlowBox ? 0 : Number(tokens[3]), nestedFlowBox ? 0 : Number(tokens[4]),
flowContent, flowRef.Name, flowRef.Format, Attributes(tokens, nestedFlowBox ? 2 : 6));
break;
case "DRAWBOX":
var flowDrawing = currentFlowName is not null;
Require(tokens, flowDrawing ? 2 : 6);
var drawingAttributes = Attributes(tokens, flowDrawing ? 2 : 6);
parsedElement = new DrawBoxElement(lineNumber,
flowDrawing ? 0 : Number(tokens[1]), flowDrawing ? 0 : Number(tokens[2]),
flowDrawing ? RequiredPositiveNumber(drawingAttributes, "w") : Number(tokens[3]),
flowDrawing ? RequiredPositiveNumber(drawingAttributes, "h") : Number(tokens[4]),
RequiredReference(tokens[flowDrawing ? 1 : 5]), drawingAttributes);
break;
case "FLOWDRAWBOX":
var nestedFlowDrawing = currentFlowName is not null;
Require(tokens, nestedFlowDrawing ? 2 : 6);
var flowDrawingAttributes = Attributes(tokens, nestedFlowDrawing ? 2 : 6);
parsedElement = new FlowDrawBoxElement(lineNumber,
nestedFlowDrawing ? 0 : Number(tokens[1]), nestedFlowDrawing ? 0 : Number(tokens[2]),
nestedFlowDrawing ? RequiredPositiveNumber(flowDrawingAttributes, "w") : Number(tokens[3]),
nestedFlowDrawing ? RequiredPositiveNumber(flowDrawingAttributes, "h") : Number(tokens[4]),
RequiredReference(tokens[nestedFlowDrawing ? 1 : 5]), flowDrawingAttributes);
break;
case "TABLE": case "TABLE":
var flowTable = currentFlowName is not null; var flowTable = currentFlowName is not null;
Require(tokens, flowTable ? 2 : 6); Require(tokens, flowTable ? 2 : 6);
@@ -201,6 +231,7 @@ public sealed class LayoutParser
private static (string? Name, string? Format) Reference(string value) private static (string? Name, string? Format) Reference(string value)
{ {
if (SystemVariables.IsStandalone(value)) return (null, null);
if (!value.StartsWith('$')) return (null, null); if (!value.StartsWith('$')) return (null, null);
var parts = value[1..].Split('|', 2); var parts = value[1..].Split('|', 2);
if (string.IsNullOrWhiteSpace(parts[0])) throw new FormatException("Platzhaltername fehlt."); if (string.IsNullOrWhiteSpace(parts[0])) throw new FormatException("Platzhaltername fehlt.");
@@ -216,6 +247,14 @@ public sealed class LayoutParser
private static float OptionalNumber(IReadOnlyDictionary<string, string> attributes, string name) => private static float OptionalNumber(IReadOnlyDictionary<string, string> attributes, string name) =>
attributes.TryGetValue(name, out var value) ? Number(value) : 0; attributes.TryGetValue(name, out var value) ? Number(value) : 0;
private static float RequiredPositiveNumber(IReadOnlyDictionary<string, string> attributes, string name)
{
if (!attributes.TryGetValue(name, out var raw))
throw new FormatException($"Das Element benötigt {name}=…");
var value = Number(raw);
return value > 0 ? value : throw new FormatException($"{name} muss positiv sein.");
}
public static float Percentage(string value) public static float Percentage(string value)
{ {
var normalized = value.EndsWith('%') ? value[..^1] : value; var normalized = value.EndsWith('%') ? value[..^1] : value;
+74 -2
View File
@@ -2,7 +2,7 @@ using System.Text.Json.Serialization;
namespace LehrerApp.Templating; namespace LehrerApp.Templating;
public enum PlaceholderType { Text, Multiline, Date, Number, Image, Table, Chart } public enum PlaceholderType { Text, Multiline, Date, Number, Image, Table, Chart, Drawing }
public abstract record PlaceholderValue(PlaceholderType Type); public abstract record PlaceholderValue(PlaceholderType Type);
public sealed record TextValue(string Value) : PlaceholderValue(PlaceholderType.Text); public sealed record TextValue(string Value) : PlaceholderValue(PlaceholderType.Text);
@@ -16,6 +16,67 @@ public sealed record TableValue(IReadOnlyList<string> Columns, IReadOnlyList<IRe
public sealed record ChartPoint(string X, decimal Y); public sealed record ChartPoint(string X, decimal Y);
public sealed record ChartSeries(string Label, IReadOnlyList<ChartPoint> Points); public sealed record ChartSeries(string Label, IReadOnlyList<ChartPoint> Points);
public sealed record ChartValue(IReadOnlyList<ChartSeries> Series) : PlaceholderValue(PlaceholderType.Chart); public sealed record ChartValue(IReadOnlyList<ChartSeries> Series) : PlaceholderValue(PlaceholderType.Chart);
public abstract record DrawingCommand;
public sealed record DrawString(float X, float Y, string Text, float FontSize = 11,
string Color = "#000000", bool Bold = false, bool Italic = false, string FontFamily = "") : DrawingCommand;
public enum DrawingTextAlignment { AlignLeft, AlignCenter, AlignRight }
public sealed record DrawStringEx(float X, float Y, float Height, float Width, string Text,
DrawingTextAlignment Alignment = DrawingTextAlignment.AlignLeft, float FontSize = 11,
string FontFamily = "", string Color = "#000000", bool Bold = false, bool Italic = false)
: DrawingCommand;
public sealed record MoveTo(float X, float Y) : DrawingCommand;
public sealed record LineTo(float X, float Y, string Color = "#000000", float StrokeWidth = 1) : DrawingCommand;
public sealed record DrawLine(float X1, float Y1, float X2, float Y2,
string Color = "#000000", float StrokeWidth = 1) : DrawingCommand;
public sealed record DrawRectangle(float X, float Y, float Width, float Height,
string StrokeColor = "#000000", float StrokeWidth = 1, string FillColor = "none") : DrawingCommand;
public sealed record DrawImage(float X, float Y, float Width, float Height, byte[] Data, string MimeType) : DrawingCommand;
/// <summary>Declarative drawing supplied by an external data provider. Coordinates use the template layout unit; font sizes use points.</summary>
public sealed record DrawingValue(IReadOnlyList<DrawingCommand> Commands, float ContentHeight)
: PlaceholderValue(PlaceholderType.Drawing);
/// <summary>A recorded drawing surface handed to an in-process external renderer.</summary>
public sealed class DrawingCanvas
{
private readonly List<DrawingCommand> _commands = [];
public IReadOnlyList<DrawingCommand> Commands => _commands;
public void DrawString(float x, float y, string text, float fontSize = 11, string color = "#000000",
bool bold = false, bool italic = false, string fontFamily = "") =>
_commands.Add(new LehrerApp.Templating.DrawString(x, y, text, fontSize, color, bold, italic, fontFamily));
public void DrawStringEx(float x, float y, float height, float width, string text,
DrawingTextAlignment alignment = DrawingTextAlignment.AlignLeft, float fontSize = 11,
string fontFamily = "", string color = "#000000", bool bold = false, bool italic = false) =>
_commands.Add(new LehrerApp.Templating.DrawStringEx(x, y, height, width, text, alignment,
fontSize, fontFamily, color, bold, italic));
public void MoveTo(float x, float y) => _commands.Add(new LehrerApp.Templating.MoveTo(x, y));
public void LineTo(float x, float y, string color = "#000000", float strokeWidth = 1) =>
_commands.Add(new LehrerApp.Templating.LineTo(x, y, color, strokeWidth));
public void DrawLine(float x1, float y1, float x2, float y2, string color = "#000000", float strokeWidth = 1) =>
_commands.Add(new LehrerApp.Templating.DrawLine(x1, y1, x2, y2, color, strokeWidth));
public void DrawRectangle(float x, float y, float width, float height, string strokeColor = "#000000",
float strokeWidth = 1, string fillColor = "none") =>
_commands.Add(new LehrerApp.Templating.DrawRectangle(x, y, width, height, strokeColor, strokeWidth, fillColor));
public void DrawImage(float x, float y, float width, float height, byte[] data, string mimeType) =>
_commands.Add(new LehrerApp.Templating.DrawImage(x, y, width, height, data, mimeType));
}
public sealed class DrawingPageContext(float width, float height, int pageNumber, DrawingCanvas canvas, object? state)
{
public float Width { get; } = width;
public float Height { get; } = height;
public int PageNumber { get; } = pageNumber;
public DrawingCanvas Canvas { get; } = canvas;
public object? State { get; set; } = state;
}
public delegate bool DrawingPageCallback(DrawingPageContext context);
/// <summary>
/// In-process callback drawing. The callback returns true when finished or false to request another box.
/// State is carried across callbacks. It is recorded before QuestPDF performs layout and is never executed by a package.
/// </summary>
public sealed record PagedDrawingValue(DrawingPageCallback DrawPage, object? InitialState = null,
int MaxPages = 1_000) : PlaceholderValue(PlaceholderType.Drawing);
public interface ITemplateDataProvider public interface ITemplateDataProvider
{ {
@@ -35,6 +96,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 +116,15 @@ 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 DrawBoxElement(int Line, float X, float Y, float Width, float Height,
string Placeholder, IReadOnlyDictionary<string, string> Attributes)
: TemplateElement(Line, X, Y, Width, Height, Attributes);
public sealed record FlowDrawBoxElement(int Line, float X, float Y, float Width, float Height,
string Placeholder, 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);
@@ -82,7 +153,8 @@ public sealed record TemplateLayout(float Width, float Height, string Unit,
} }
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);
+325 -8
View File
@@ -35,30 +35,100 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
} }
private static IDocument BuildDocument(LoadedTemplate template, private static IDocument BuildDocument(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values) => template.Layout.UsesPageTemplates IReadOnlyDictionary<string, PlaceholderValue> values)
{
values = PreparePagedDrawings(template, values);
return template.Layout.UsesPageTemplates
? BuildFlowDocument(template, values) ? BuildFlowDocument(template, values)
: BuildLegacyDocument(template, values); : BuildLegacyDocument(template, values);
}
private static IDocument BuildLegacyDocument(LoadedTemplate template, private static IDocument BuildLegacyDocument(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values) => Document.Create(document => IReadOnlyDictionary<string, PlaceholderValue> values)
{ {
document.Page(page => var flowBox = template.Layout.Elements.SingleOrDefault(x => x is FlowBoxElement or FlowDrawBoxElement);
return Document.Create(document => document.Page(page =>
{ {
page.Size(UnitConverter.Points(template.Layout.Width, template.Layout.Unit), page.Size(UnitConverter.Points(template.Layout.Width, template.Layout.Unit),
UnitConverter.Points(template.Layout.Height, template.Layout.Unit)); UnitConverter.Points(template.Layout.Height, template.Layout.Unit));
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
RenderLegacyFlowElement(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 IReadOnlyDictionary<string, PlaceholderValue> PreparePagedDrawings(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
var result = new Dictionary<string, PlaceholderValue>(values, StringComparer.Ordinal);
var layouts = template.ContinuationLayout is null
? new[] { template.Layout }
: new[] { template.Layout, template.ContinuationLayout };
var drawingElements = layouts.SelectMany(x => x.Elements)
.Where(x => x is DrawBoxElement or FlowDrawBoxElement).ToList();
foreach (var group in drawingElements.GroupBy(x => x switch
{ DrawBoxElement box => box.Placeholder, FlowDrawBoxElement box => box.Placeholder, _ => "" }))
{
if (values.GetValueOrDefault(group.Key) is not PagedDrawingValue) continue;
var signatures = group.Select(x => (x.GetType(), x.Width, x.Height)).Distinct().Count();
if (signatures > 1)
throw new InvalidDataException($"Der Callback-Platzhalter „{group.Key}“ wird in unterschiedlich großen oder unterschiedlichen Zeichenboxen verwendet.");
}
foreach (var element in drawingElements)
{
var placeholder = element switch
{ DrawBoxElement box => box.Placeholder, FlowDrawBoxElement box => box.Placeholder, _ => null };
if (placeholder is null || values.GetValueOrDefault(placeholder) is not PagedDrawingValue callback) continue;
if (result.GetValueOrDefault(placeholder) is RecordedDrawingValue) continue;
var pages = element is FlowDrawBoxElement
? DrawingElementRenderer.RecordPages(callback, element.Width, element.Height, callback.MaxPages)
: [DrawingElementRenderer.RecordSingle(callback, element.Width, element.Height)];
result[placeholder] = new RecordedDrawingValue(pages);
}
return result;
}
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 or FlowDrawBoxElement)))
{ {
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 RenderLegacyFlowElement(IContainer container, TemplateElement element, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
var unit = template.Layout.Unit;
container
.PaddingLeft(UnitConverter.Points(element.X, unit))
.PaddingTop(UnitConverter.Points(element.Y, unit))
.PaddingRight(UnitConverter.Points(Math.Max(0, template.Layout.Width - element.X - element.Width), unit))
.PaddingBottom(UnitConverter.Points(Math.Max(0, template.Layout.Height - element.Y - element.Height), unit))
.Element(content =>
{
if (element is FlowBoxElement box)
RenderResolvedText(content, box.Content, box.Placeholder, box.Format,
values, template.Manifest, box.Attributes);
else if (element is FlowDrawBoxElement drawing
&& values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue)
DrawingElementRenderer.RenderFlow(content, drawingValue, drawing.Width, drawing.Height, unit);
}); });
}); }
});
private static IDocument BuildFlowDocument(LoadedTemplate template, private static IDocument BuildFlowDocument(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values) IReadOnlyDictionary<string, PlaceholderValue> values)
@@ -185,6 +255,20 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
RenderResolvedText(container, box.Content, box.Placeholder, box.Format, RenderResolvedText(container, box.Content, box.Placeholder, box.Format,
values, template.Manifest, box.Attributes); values, template.Manifest, box.Attributes);
break; break;
case FlowBoxElement box:
RenderResolvedText(container, box.Content, box.Placeholder, box.Format,
values, template.Manifest, box.Attributes);
break;
case DrawBoxElement drawing when values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue:
DrawingElementRenderer.RenderFixed(
container.Width(UnitConverter.Points(drawing.Width, template.Layout.Unit))
.Height(UnitConverter.Points(drawing.Height, template.Layout.Unit)),
drawingValue, drawing.Width, drawing.Height, template.Layout.Unit);
break;
case FlowDrawBoxElement drawing when values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue:
DrawingElementRenderer.RenderFlow(container, drawingValue,
drawing.Width, drawing.Height, template.Layout.Unit);
break;
case ImageElement image: case ImageElement image:
var imageContainer = container; var imageContainer = container;
if (image.Width > 0) imageContainer = imageContainer.Width(UnitConverter.Points(image.Width, template.Layout.Unit)); if (image.Width > 0) imageContainer = imageContainer.Width(UnitConverter.Points(image.Width, template.Layout.Unit));
@@ -229,6 +313,20 @@ 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 box:
RenderResolvedText(Position(root, box, unit).Shrink(), box.Content, box.Placeholder, box.Format,
values, template.Manifest, box.Attributes);
break;
case DrawBoxElement drawing:
if (values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue)
DrawingElementRenderer.RenderFixed(Position(root, drawing, unit), drawingValue,
drawing.Width, drawing.Height, unit);
break;
case FlowDrawBoxElement drawing:
if (values.GetValueOrDefault(drawing.Placeholder) is { } flowDrawingValue)
DrawingElementRenderer.RenderFixed(Position(root, drawing, unit), flowDrawingValue,
drawing.Width, drawing.Height, unit);
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);
@@ -252,6 +350,15 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
{ {
var aligned = attributes.GetValueOrDefault("align", "left").ToLowerInvariant() switch var aligned = attributes.GetValueOrDefault("align", "left").ToLowerInvariant() switch
{ "center" => container.AlignCenter(), "right" => container.AlignRight(), _ => container.AlignLeft() }; { "center" => container.AlignCenter(), "right" => container.AlignRight(), _ => container.AlignLeft() };
if (SystemVariables.Contains(content))
{
aligned.Text(text =>
{
text.DefaultTextStyle(style => ApplyTextStyle(style, attributes));
AppendSystemVariables(text, content);
});
return;
}
var descriptor = aligned.Text(content); var descriptor = aligned.Text(content);
descriptor.FontSize(ParseFloat(attributes, "size", 11)); descriptor.FontSize(ParseFloat(attributes, "size", 11));
if (ParseBool(attributes, "bold")) descriptor.SemiBold(); if (ParseBool(attributes, "bold")) descriptor.SemiBold();
@@ -291,15 +398,42 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
{ {
var content = run.Placeholder is null ? run.Text var content = run.Placeholder is null ? run.Text
: values.TryGetValue(run.Placeholder, out var value) ? Format(value, run.Format) : ""; : values.TryGetValue(run.Placeholder, out var value) ? Format(value, run.Format) : "";
var span = text.Span(content).FontSize(fontSize); foreach (var span in AppendSystemVariables(text, content))
{
span.FontSize(fontSize);
if (globalBold || run.Bold) span.SemiBold(); if (globalBold || run.Bold) span.SemiBold();
if (globalItalic || run.Italic) span.Italic(); if (globalItalic || run.Italic) span.Italic();
if (globalUnderline || run.Underline) span.Underline(); if (globalUnderline || run.Underline) span.Underline();
if (color is not null) span.FontColor(color); if (color is not null) span.FontColor(color);
} }
}
}); });
} }
private static TextStyle ApplyTextStyle(TextStyle style, IReadOnlyDictionary<string, string> attributes)
{
style = style.FontSize(ParseFloat(attributes, "size", 11));
if (ParseBool(attributes, "bold")) style = style.SemiBold();
if (ParseBool(attributes, "italic")) style = style.Italic();
if (ParseBool(attributes, "underline")) style = style.Underline();
if (attributes.TryGetValue("color", out var color)) style = style.FontColor(color);
return style;
}
private static IEnumerable<TextSpanDescriptor> AppendSystemVariables(TextDescriptor text, string content)
{
foreach (var part in SystemVariables.Split(content))
{
yield return part.Name switch
{
SystemVariables.CurrentPage => text.CurrentPageNumber(),
SystemVariables.MaximumPageNumber => text.TotalPages(),
SystemVariables.Today => text.Span(DateTime.Today.ToString("d", CultureInfo.GetCultureInfo("de-DE"))),
_ => text.Span(part.Text),
};
}
}
private static IReadOnlyDictionary<string, string> TextAttributes(TemplateManifest manifest, string? placeholder, private static IReadOnlyDictionary<string, string> TextAttributes(TemplateManifest manifest, string? placeholder,
IReadOnlyDictionary<string, string> elementAttributes) IReadOnlyDictionary<string, string> elementAttributes)
{ {
@@ -340,6 +474,9 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
attributes.TryGetValue(key, out var raw) && bool.TryParse(raw, out var value) && value; attributes.TryGetValue(key, out var raw) && bool.TryParse(raw, out var value) && value;
} }
internal sealed record RecordedDrawingValue(IReadOnlyList<IReadOnlyList<DrawingCommand>> Pages)
: PlaceholderValue(PlaceholderType.Drawing);
public static class UnitConverter public static class UnitConverter
{ {
public static float Points(float value, string unit) => unit.ToLowerInvariant() switch public static float Points(float value, string unit) => unit.ToLowerInvariant() switch
@@ -370,6 +507,186 @@ internal static class TableElementRenderer
} }
} }
internal static class DrawingElementRenderer
{
public static void RenderFixed(IContainer container, PlaceholderValue value, float width, float height, string unit)
{
var drawing = value switch
{
DrawingValue direct => direct,
RecordedDrawingValue recorded => new DrawingValue(recorded.Pages.FirstOrDefault() ?? [], height),
_ => throw new InvalidDataException("DRAWBOX erwartet einen Drawing-Platzhalter."),
};
container.Svg(BuildSvg(drawing, width, height, 0, unit));
}
public static void RenderFlow(IContainer container, PlaceholderValue value, float width, float pageHeight, string unit)
{
var pages = value switch
{
DrawingValue direct => Slice(direct, pageHeight),
RecordedDrawingValue recorded => recorded.Pages
.Select(commands => new DrawingValue(commands, pageHeight)).ToList(),
_ => throw new InvalidDataException("FLOWDRAWBOX erwartet einen Drawing-Platzhalter."),
};
container.Column(column =>
{
for (var page = 0; page < pages.Count; page++)
{
if (page > 0) column.Item().PageBreak();
var offset = value is DrawingValue ? page * pageHeight : 0;
column.Item().Height(UnitConverter.Points(pageHeight, unit)).Svg(
BuildSvg(pages[page], width, pageHeight, offset, unit));
}
});
}
private static List<DrawingValue> Slice(DrawingValue value, float pageHeight)
{
var pageCount = Math.Max(1, (int)Math.Ceiling(Math.Max(0, value.ContentHeight) / pageHeight));
return Enumerable.Repeat(value, pageCount).ToList();
}
internal static List<IReadOnlyList<DrawingCommand>> RecordPages(PagedDrawingValue value,
float width, float height, int maxPages)
{
if (maxPages is < 1 or > 10_000)
throw new InvalidDataException("PagedDrawingValue.MaxPages muss zwischen 1 und 10000 liegen.");
var pages = new List<IReadOnlyList<DrawingCommand>>();
var state = value.InitialState;
for (var pageNumber = 1; pageNumber <= maxPages; pageNumber++)
{
var canvas = new DrawingCanvas();
var context = new DrawingPageContext(width, height, pageNumber, canvas, state);
var finished = value.DrawPage(context);
pages.Add(canvas.Commands.ToList());
state = context.State;
if (finished) return pages;
}
throw new InvalidDataException($"Der Zeichen-Callback war nach {maxPages} Seiten noch nicht beendet.");
}
internal static IReadOnlyList<DrawingCommand> RecordSingle(PagedDrawingValue value, float width, float height)
{
var canvas = new DrawingCanvas();
value.DrawPage(new DrawingPageContext(width, height, 1, canvas, value.InitialState));
return canvas.Commands.ToList();
}
private static string BuildSvg(DrawingValue value, float width, float height, float verticalOffset, string unit)
{
if (!float.IsFinite(value.ContentHeight) || value.ContentHeight < 0)
throw new InvalidDataException("DrawingValue.ContentHeight muss eine endliche, nichtnegative Zahl sein.");
if (value.Commands.Count > 100_000)
throw new InvalidDataException("DrawingValue enthält zu viele Zeichenbefehle.");
var svg = new StringBuilder();
svg.Append(CultureInfo.InvariantCulture,
$"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 {verticalOffset} {width} {height}\" overflow=\"hidden\">");
float? currentX = null, currentY = null;
foreach (var command in value.Commands)
{
switch (command)
{
case MoveTo move:
Validate(move.X, move.Y); currentX = move.X; currentY = move.Y;
break;
case LineTo line when currentX is not null && currentY is not null:
Validate(line.X, line.Y, line.StrokeWidth); Positive(line.StrokeWidth);
AppendLine(svg, currentX.Value, currentY.Value, line.X, line.Y, line.Color, line.StrokeWidth);
currentX = line.X; currentY = line.Y;
break;
case LineTo:
throw new InvalidDataException("LineTo benötigt ein vorheriges MoveTo.");
case DrawLine line:
Validate(line.X1, line.Y1, line.X2, line.Y2, line.StrokeWidth); Positive(line.StrokeWidth);
AppendLine(svg, line.X1, line.Y1, line.X2, line.Y2, line.Color, line.StrokeWidth);
break;
case DrawRectangle rectangle:
Validate(rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height, rectangle.StrokeWidth);
Positive(rectangle.Width, rectangle.Height, rectangle.StrokeWidth);
svg.Append(CultureInfo.InvariantCulture,
$"<rect x=\"{rectangle.X}\" y=\"{rectangle.Y}\" width=\"{rectangle.Width}\" height=\"{rectangle.Height}\" stroke=\"{Attribute(rectangle.StrokeColor)}\" stroke-width=\"{rectangle.StrokeWidth}\" fill=\"{Attribute(rectangle.FillColor)}\"/>");
break;
case DrawString text:
Validate(text.X, text.Y, text.FontSize);
Positive(text.FontSize);
var localFontSize = text.FontSize / UnitConverter.Points(1, unit);
svg.Append(CultureInfo.InvariantCulture,
$"<text x=\"{text.X}\" y=\"{text.Y + localFontSize}\" font-size=\"{localFontSize}\" fill=\"{Attribute(text.Color)}\" font-family=\"{FontFamily(text.FontFamily)}\" font-weight=\"{(text.Bold ? "600" : "400")}\" font-style=\"{(text.Italic ? "italic" : "normal")}\">{SecurityElement.Escape(text.Text)}</text>");
break;
case DrawStringEx text:
Validate(text.X, text.Y, text.Height, text.Width, text.FontSize);
Positive(text.Height, text.Width, text.FontSize);
AppendTextBox(svg, text, unit);
break;
case DrawImage image:
Validate(image.X, image.Y, image.Width, image.Height);
Positive(image.Width, image.Height);
if (image.MimeType is not ("image/png" or "image/jpeg"))
throw new InvalidDataException("DrawImage unterstützt nur PNG und JPEG.");
if (image.Data.Length > 20 * 1024 * 1024)
throw new InvalidDataException("Ein DrawImage-Bild darf höchstens 20 MB groß sein.");
svg.Append(CultureInfo.InvariantCulture,
$"<image x=\"{image.X}\" y=\"{image.Y}\" width=\"{image.Width}\" height=\"{image.Height}\" href=\"data:{image.MimeType};base64,{Convert.ToBase64String(image.Data)}\" preserveAspectRatio=\"xMidYMid meet\"/>");
break;
default: throw new InvalidDataException($"Unbekannter Zeichenbefehl {command.GetType().Name}.");
}
}
return svg.Append("</svg>").ToString();
}
private static void AppendLine(StringBuilder svg, float x1, float y1, float x2, float y2,
string color, float strokeWidth) => svg.Append(CultureInfo.InvariantCulture,
$"<line x1=\"{x1}\" y1=\"{y1}\" x2=\"{x2}\" y2=\"{y2}\" stroke=\"{Attribute(color)}\" stroke-width=\"{strokeWidth}\"/>");
private static string Attribute(string value)
{
if (value != "none" && !System.Text.RegularExpressions.Regex.IsMatch(value,
@"^(#[0-9a-fA-F]{3,8}|[a-zA-Z]{1,24})$"))
throw new InvalidDataException($"Ungültiger Zeichenfarbwert „{value}“.");
return value;
}
private static string FontFamily(string value)
{
if (string.IsNullOrWhiteSpace(value)) return "sans-serif";
if (!System.Text.RegularExpressions.Regex.IsMatch(value, @"^[\p{L}\p{N} _.,-]{1,80}$"))
throw new InvalidDataException($"Ungültige Schriftfamilie „{value}“.");
return SecurityElement.Escape(value) ?? "sans-serif";
}
private static void AppendTextBox(StringBuilder svg, DrawStringEx text, string unit)
{
var fontSize = text.FontSize / UnitConverter.Points(1, unit);
var (x, anchor) = text.Alignment switch
{
DrawingTextAlignment.AlignCenter => (text.X + text.Width / 2, "middle"),
DrawingTextAlignment.AlignRight => (text.X + text.Width, "end"),
_ => (text.X, "start"),
};
var clipId = "clip" + Guid.NewGuid().ToString("N");
svg.Append(CultureInfo.InvariantCulture,
$"<defs><clipPath id=\"{clipId}\"><rect x=\"{text.X}\" y=\"{text.Y}\" width=\"{text.Width}\" height=\"{text.Height}\"/></clipPath></defs>");
svg.Append(CultureInfo.InvariantCulture,
$"<text x=\"{x}\" y=\"{text.Y + fontSize}\" text-anchor=\"{anchor}\" clip-path=\"url(#{clipId})\" font-size=\"{fontSize}\" fill=\"{Attribute(text.Color)}\" font-family=\"{FontFamily(text.FontFamily)}\" font-weight=\"{(text.Bold ? "600" : "400")}\" font-style=\"{(text.Italic ? "italic" : "normal")}\">");
var lines = text.Text.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
for (var index = 0; index < lines.Length; index++)
svg.Append(CultureInfo.InvariantCulture,
$"<tspan x=\"{x}\" dy=\"{(index == 0 ? 0 : fontSize * 1.2f)}\">{SecurityElement.Escape(lines[index])}</tspan>");
svg.Append("</text>");
}
private static void Validate(params float[] values)
{
if (values.Any(x => !float.IsFinite(x))) throw new InvalidDataException("Zeichenkoordinaten müssen endlich sein.");
}
private static void Positive(params float[] values)
{
if (values.Any(x => x < 0)) throw new InvalidDataException("Zeichengrößen dürfen nicht negativ sein.");
}
}
internal static class ChartElementRenderer internal static class ChartElementRenderer
{ {
public static void Render(IContainer container, ChartValue value, string chartType, public static void Render(IContainer container, ChartValue value, string chartType,
+139
View File
@@ -1,5 +1,144 @@
# LehrerApp Templating # LehrerApp Templating
## Dynamische Zeichenflächen für externe Apps
Externe `ITemplateDataProvider` können einen Platzhalter vom Typ `Drawing` mit einem
`DrawingValue` befüllen. Dabei wird kein QuestPDF-/Skia-Canvas nach außen gegeben. Die portable
Form besteht stattdessen aus einer geprüften, serialisierbaren Befehlsliste:
```csharp
var drawing = new DrawingValue(
[
new DrawRectangle(0, 0, 160, 24, "#2563EB", 0.8f, "#EFF6FF"),
new DrawString(4, 4, "Dynamischer Bericht", 11, "#1E3A8A", Bold: true),
new MoveTo(4, 19),
new LineTo(156, 19, "#93C5FD", 0.6f),
new DrawImage(120, 2, 30, 18, pngBytes, "image/png")
],
ContentHeight: 24);
```
Koordinaten und Längen verwenden die Einheit des Layouts; `DrawString.FontSize` wird wie bei
`TEXT` in Punkt angegeben. Unterstützt werden `DrawString`, `MoveTo`, `LineTo`, `DrawLine`,
`DrawRectangle` und `DrawImage` (PNG/JPEG). Die Befehle gelangen über den normalen
`ITemplateDataProvider`, beispielsweise als `values["ExternerBericht"] = drawing`.
`DrawStringEx(x, y, height, width, ...)` ergänzt eine geclippte Textbox mit `AlignLeft`,
`AlignCenter` oder `AlignRight`. Farbe und Schriftfamilie können bei beiden Textbefehlen gesetzt
werden. Ohne Schriftangabe wird `sans-serif` verwendet. Für portable Schriften registriert die
integrierende App TTF-/OTF-Daten einmal vor dem Rendern:
```csharp
DrawingFontRegistry.RegisterFont("MeineSchulschrift", fontBytes);
canvas.DrawStringEx(0, 0, 12, context.Width, "Zentrierte Überschrift",
DrawingTextAlignment.AlignCenter, 11, "MeineSchulschrift", "#1E3A8A", bold: true);
```
Explizite Zeilenumbrüche werden berücksichtigt; Text außerhalb von `height`/`width` wird
abgeschnitten. Eine automatische Worttrennung findet in dieser elementaren Zeichenfunktion nicht
statt.
Im Layout stehen zwei Varianten zur Verfügung:
```text
DRAWBOX 20 40 170 80 $ExternerBericht
FLOWDRAWBOX 20 40 170 237 $LangesProtokoll
```
`DRAWBOX` ist ein fester, geclippter Viewport. Inhalte außerhalb seiner Breite oder Höhe werden
nicht angezeigt. `FLOWDRAWBOX` zerlegt den vertikalen Zeichenraum anhand von `ContentHeight` in
gleich hohe Seitenfenster und setzt ihn auf Folgeseiten fort. Wie bei `FLOWBOX` darf ein Layout
höchstens ein fließendes Element enthalten; bei einem eigenen Folgeseitenlayout müssen Typ,
Position und Größe übereinstimmen.
Ein Vorlagenpaket kann keinen Callback und keinen Typ aus einer fremden Assembly einschleusen.
Farben, Zahlen, Bildformate, Bildgröße und Gesamtzahl der portablen Befehle werden validiert.
Damit bleibt die paketfähige Schnittstelle deterministisch. Ein `PagedDrawingValue` ist dagegen
ein ausdrücklich vom vertrauenswürdigen In-Process-`ITemplateDataProvider` übergebener Delegate.
Für umfangreiche In-Process-Integrationen gibt es zusätzlich den klassischen seitenweisen
Callback `PagedDrawingValue`. Er wird vor QuestPDFs Layout vollständig in deklarative Seitenlisten
aufgezeichnet und daher nicht durch interne Layoutdurchläufe mehrfach ausgeführt:
```csharp
var value = new PagedDrawingValue(context =>
{
var nextRow = context.State is int row ? row : 0;
// Nur vollständige Strukturen zeichnen, die noch in die zugewiesene Box passen.
while (nextRow < rows.Count && PasstNochVollstaendig(rows[nextRow], context))
ZeichneZeile(context.Canvas, rows[nextRow++]);
context.State = nextRow;
return nextRow == rows.Count; // true = fertig, false = weitere FLOWDRAWBOX
}, InitialState: 0);
```
`DrawingPageContext` stellt `Width`, `Height`, `PageNumber`, `Canvas` und ein über alle Aufrufe
weitergereichtes `State`-Objekt bereit. So kann der Zeichner Tabellenzeilen, Diagrammgruppen oder
andere unteilbare Strukturen bewusst auf die nächste Seite verschieben. Ein `DRAWBOX`-Callback
wird genau einmal aufgerufen; bei `FLOWDRAWBOX` fordert `false` eine weitere Seite an. `MaxPages`
verhindert Endlosschleifen. Delegates funktionieren nur innerhalb desselben .NET-Prozesses;
prozessübergreifend bleibt `DrawingValue` die Übergabeform.
## 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.
## Systemvariablen
Textinhalte können drei vom Renderer bereitgestellte Variablen verwenden. Sie werden nicht im
Manifest deklariert und nicht vom `ITemplateDataProvider` geliefert:
- `$$today` aktuelles lokales Datum im deutschen Kurzformat
- `$$curPage` aktuelle Seitenzahl
- `$$maxPageNum` Gesamtzahl der Seiten
Sie können allein oder innerhalb eines Literals stehen, beispielsweise:
```text
TEXT 20 10 "Stand: $$today" size=9
TEXT 145 285 "Seite $$curPage von $$maxPageNum" size=9 align=right
```
QuestPDF löst aktuelle und gesamte Seitenzahl während der Dokumenterzeugung auf. Dafür ist kein
zusätzlicher Renderdurchlauf durch die Anwendung erforderlich. Die Variablen funktionieren auch in
konstanten Rich-Text-Platzhaltern.
## 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,
+48
View File
@@ -0,0 +1,48 @@
namespace LehrerApp.Templating;
internal static class SystemVariables
{
internal const string CurrentPage = "curPage";
internal const string MaximumPageNumber = "maxPageNum";
internal const string Today = "today";
private static readonly string[] Names = [CurrentPage, MaximumPageNumber, Today];
internal static bool IsStandalone(string value) =>
TryRead(value, 0, out var length) && length == value.Length;
internal static bool Contains(string value) => Names.Any(name =>
value.Contains("$$" + name, StringComparison.Ordinal));
internal static bool TryRead(string source, int index, out int length)
{
length = 0;
if (index < 0 || index + 2 > source.Length || source[index] != '$' || source[index + 1] != '$')
return false;
foreach (var name in Names)
{
var token = "$$" + name;
if (!source.AsSpan(index).StartsWith(token, StringComparison.Ordinal)) continue;
var end = index + token.Length;
if (end < source.Length && (char.IsLetterOrDigit(source[end]) || source[end] is '_' or '-' or '.'))
continue;
length = token.Length;
return true;
}
return false;
}
internal static IEnumerable<(string Text, string? Name)> Split(string source)
{
var literalStart = 0;
for (var index = 0; index < source.Length;)
{
if (!TryRead(source, index, out var length)) { index++; continue; }
if (index > literalStart) yield return (source[literalStart..index], null);
yield return (source.Substring(index, length), source.Substring(index + 2, length - 2));
index += length;
literalStart = index;
}
if (literalStart < source.Length) yield return (source[literalStart..], null);
}
}
+38 -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,33 @@ 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 = FlowElements(layout).ToList();
if (flowBoxes.Count > 1)
issues.Add(new(ValidationSeverity.Error, "Ein Layout darf höchstens ein fließendes Element (FLOWBOX/FLOWDRAWBOX) enthalten."));
if (continuationLayout is not null)
{
var continuationFlows = FlowElements(continuationLayout).ToList();
if (flowBoxes.Count != 1 || continuationFlows.Count != 1)
issues.Add(new(ValidationSeverity.Error, "Bei einem Folgeseiten-Layout müssen Haupt- und Folgeseite jeweils genau ein gleichartiges fließendes Element enthalten."));
else if (flowBoxes[0].GetType() != continuationFlows[0].GetType())
issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen denselben fließenden Elementtyp verwenden."));
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 +117,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 +137,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,10 +170,15 @@ 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,
DrawBoxElement d => d.Placeholder,
FlowDrawBoxElement d => d.Placeholder,
TableElement t => t.Placeholder, TableElement t => t.Placeholder,
ChartElement c => c.Placeholder, ChartElement c => c.Placeholder,
_ => null, _ => null,
}).Where(x => x is not null).Cast<string>().Distinct(StringComparer.Ordinal); }).Where(x => x is not null).Cast<string>().Distinct(StringComparer.Ordinal);
private static IEnumerable<TemplateElement> FlowElements(TemplateLayout layout) =>
layout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement);
internal static bool IsSafeRelativePath(string path) => !string.IsNullOrWhiteSpace(path) internal static bool IsSafeRelativePath(string path) => !string.IsNullOrWhiteSpace(path)
&& !Path.IsPathRooted(path) && !path.Split('/', '\\').Any(part => part == ".." || part.Length == 0); && !Path.IsPathRooted(path) && !path.Split('/', '\\').Any(part => part == ".." || part.Length == 0);
+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)
{ {
+7
View File
@@ -30,6 +30,13 @@ public static class TemplateRichText
&& source[index + 1] is '\\' or '[' or '$') && source[index + 1] is '\\' or '[' or '$')
{ literal.Append(source[index + 1]); index += 2; continue; } { literal.Append(source[index + 1]); index += 2; continue; }
if (source[index] == '$' && SystemVariables.TryRead(source, index, out var systemLength))
{
literal.Append(source, index, systemLength);
index += systemLength;
continue;
}
if (TryTag(source, index, out var tag, out var closing, out var tagLength)) if (TryTag(source, index, out var tag, out var closing, out var tagLength))
{ {
Flush(); Flush();
+36
View File
@@ -77,6 +77,42 @@ function ai_backend_fail(int $httpStatus, string $message): never
exit; exit;
} }
/**
* Dekodiert eine JSON-Antwort des LLM. Modelle setzen trotz entsprechender Anweisung gelegentlich
* Markdown-Codezäune oder einen kurzen Begleittext um das eigentliche JSON. Diese rein
* syntaktischen Zusätze sollen eine ansonsten gültige Antwort nicht unbrauchbar machen.
*/
function ai_backend_decode_json_response(string $content): ?array
{
$content = trim($content, "\xEF\xBB\xBF \t\n\r\0\x0B");
$candidates = [$content];
if (preg_match('/```(?:json)?\s*([\s\S]*?)\s*```/i', $content, $match) === 1) {
$candidates[] = trim($match[1]);
}
$firstBrace = strpos($content, '{');
$lastBrace = strrpos($content, '}');
if ($firstBrace !== false && $lastBrace !== false && $lastBrace >= $firstBrace) {
$candidates[] = substr($content, $firstBrace, $lastBrace - $firstBrace + 1);
}
foreach (array_unique($candidates) as $candidate) {
$decoded = json_decode($candidate, true);
if (is_array($decoded) && json_last_error() === JSON_ERROR_NONE) {
return $decoded;
}
}
error_log(sprintf(
'LLM JSON parse failed: %s; length=%d; sha256=%s',
json_last_error_msg(),
strlen($content),
hash('sha256', $content)
));
return null;
}
/** /**
* Ruft das konfigurierte LLM (oder den FakeProvider für lokale Tests, siehe README.md) auf und * Ruft das konfigurierte LLM (oder den FakeProvider für lokale Tests, siehe README.md) auf und
* verrechnet die echten Token-Kosten gegen das Guthaben des Nutzers. Gemeinsame Logik für jeden * verrechnet die echten Token-Kosten gegen das Guthaben des Nutzers. Gemeinsame Logik für jeden
+1 -1
View File
@@ -60,7 +60,7 @@ PROMPT;
$userContent = json_encode($body); $userContent = json_encode($body);
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent); $result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent);
$parsed = json_decode($result['content'], true); $parsed = ai_backend_decode_json_response($result['content']);
if (!is_array($parsed) || !isset($parsed['explanation'])) { if (!is_array($parsed) || !isset($parsed['explanation'])) {
ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.'); ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.');
} }
+1 -1
View File
@@ -82,7 +82,7 @@ PROMPT;
$userContent = json_encode($body); $userContent = json_encode($body);
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent); $result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent);
$parsed = json_decode($result['content'], true); $parsed = ai_backend_decode_json_response($result['content']);
if (!is_array($parsed) || !isset($parsed['procedure'])) { if (!is_array($parsed) || !isset($parsed['procedure'])) {
ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.'); ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.');
} }
+80
View File
@@ -0,0 +1,80 @@
<?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 = ai_backend_decode_json_response($result['content']);
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);
+1 -1
View File
@@ -161,7 +161,7 @@ $result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userC
// Erst NACH der Abrechnung validieren: die Token wurden real verbraucht, das wird auch dann // Erst NACH der Abrechnung validieren: die Token wurden real verbraucht, das wird auch dann
// verrechnet, wenn die KI kein valides JSON geliefert hat (siehe Planungsdokument). // verrechnet, wenn die KI kein valides JSON geliefert hat (siehe Planungsdokument).
$parsed = json_decode($result['content'], true); $parsed = ai_backend_decode_json_response($result['content']);
if (!is_array($parsed) || !isset($parsed['lessons'])) { if (!is_array($parsed) || !isset($parsed['lessons'])) {
ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.'); ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.');
} }
+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' => [