# 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:
@@ -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=");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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. "Seite $$curPage von $$maxPageNum")."
|
||||||
|
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
|
||||||
|
<TextBlock Text="DRAWBOX und FLOWDRAWBOX nehmen deklarative Zeichenbefehle (Text, Linien, Rechtecke, Bilder) aus einer externen App entgegen."
|
||||||
|
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
|
||||||
</StackPanel>
|
</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 & Vorschau" Click="OnPreview"/></Grid>
|
<Button Grid.Column="1" Content="Prüfen & 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>
|
||||||
FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top"
|
<TabControl Grid.Row="1" Margin="12">
|
||||||
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto" Margin="12"/>
|
<TabItem Header="Seite 1">
|
||||||
|
<TextBox Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap"
|
||||||
|
FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top"
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
||||||
|
</TabItem>
|
||||||
|
<TabItem Header="Folgeseiten">
|
||||||
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
<CheckBox Margin="8" Content="Eigenes Layout für Seite 2 und alle weiteren Seiten im Paket speichern"
|
||||||
|
IsChecked="{Binding UseContinuationLayout}"/>
|
||||||
|
<TextBox Grid.Row="1" Text="{Binding ContinuationLayoutSource}" IsEnabled="{Binding UseContinuationLayout}"
|
||||||
|
AcceptsReturn="True" TextWrapping="NoWrap" FontFamily="Menlo,Consolas,monospace" FontSize="13"
|
||||||
|
VerticalContentAlignment="Top" ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
||||||
|
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
||||||
|
</Grid>
|
||||||
|
</TabItem>
|
||||||
|
</TabControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Border Grid.Column="2" Background="#E2E8F0" Padding="18">
|
<Border Grid.Column="2" Background="#E2E8F0" Padding="18">
|
||||||
|
|||||||
@@ -107,6 +107,22 @@ public partial class MainWindow : Window
|
|||||||
catch (Exception ex) { _viewModel.SetStatus($"Bildimport fehlgeschlagen: {ex.Message}", true); }
|
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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
? BuildFlowDocument(template, values)
|
{
|
||||||
: BuildLegacyDocument(template, values);
|
values = PreparePagedDrawings(template, values);
|
||||||
|
return template.Layout.UsesPageTemplates
|
||||||
|
? BuildFlowDocument(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 =>
|
||||||
{
|
{
|
||||||
layers.PrimaryLayer().Width(UnitConverter.Points(template.Layout.Width, template.Layout.Unit))
|
if (flowBox is null)
|
||||||
.Height(UnitConverter.Points(template.Layout.Height, template.Layout.Unit)).Background(Colors.White);
|
layers.PrimaryLayer().Width(UnitConverter.Points(template.Layout.Width, template.Layout.Unit))
|
||||||
foreach (var element in template.Layout.Elements)
|
.Height(UnitConverter.Points(template.Layout.Height, template.Layout.Unit)).Background(Colors.White);
|
||||||
{
|
else
|
||||||
var current = element;
|
RenderLegacyFlowElement(layers.PrimaryLayer(), flowBox, template, values);
|
||||||
layers.Layer().Element(container => RenderElement(container, current, 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;
|
||||||
|
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))
|
||||||
if (globalBold || run.Bold) span.SemiBold();
|
{
|
||||||
if (globalItalic || run.Italic) span.Italic();
|
span.FontSize(fontSize);
|
||||||
if (globalUnderline || run.Underline) span.Underline();
|
if (globalBold || run.Bold) span.SemiBold();
|
||||||
if (color is not null) span.FontColor(color);
|
if (globalItalic || run.Italic) span.Italic();
|
||||||
|
if (globalUnderline || run.Underline) span.Underline();
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' => [
|
||||||
|
|||||||
Reference in New Issue
Block a user