Files
LehrerApp/LehrerApp.TemplateDesigner/DesignerViewModel.cs
T
admin ab1feba0f0
CI / build-and-test (push) Canceled after 0s
Merge remote-tracking branch 'origin/main'
# Conflicts:
#	LehrerApp.TemplateDesigner.Tests/ProjectLifecycleTests.cs
#	LehrerApp.TemplateDesigner/DesignerViewModel.cs
#	LehrerApp.TemplateDesigner/MainWindow.axaml
#	LehrerApp.Templating/LayoutParser.cs
#	LehrerApp.Templating/QuestTemplateRenderer.cs
2026-09-01 10:15:54 +02:00

781 lines
43 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.ObjectModel;
using System.Globalization;
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Templating;
namespace LehrerApp.TemplateDesigner;
public partial class DesignerViewModel : ObservableObject
{
private Bitmap? _pageTemplatePreview;
[ObservableProperty] private string _templateId = "elternbrief-standard";
[ObservableProperty] private string _templateName = "Elternbrief Standard";
[ObservableProperty] private string _description = "Briefvorlage mit Schul-Briefkopf";
[ObservableProperty] private decimal _pageWidth = 210;
[ObservableProperty] private decimal _pageHeight = 297;
[ObservableProperty] private string _unit = "mm";
[ObservableProperty] private string _layoutSource = DefaultLayout;
[ObservableProperty] private bool _useContinuationLayout;
[ObservableProperty] private string _continuationLayoutSource = "PAGE 210 297 mm\nFLOWBOX 20 20 170 257 $Brieftext size=11\n";
[ObservableProperty] private string _newElementType = "TEXT";
[ObservableProperty] private string _newX = "20";
[ObservableProperty] private string _newY = "50";
[ObservableProperty] private string _newWidth = "170";
[ObservableProperty] private string _newHeight = "30";
[ObservableProperty] private string _newImageScale = "100";
[ObservableProperty] private string _newContent = "$Brieftext";
[ObservableProperty] private string _newAttributes = "size=11";
[ObservableProperty] private string _newElementScope = "Seitenvorlage (fest)";
[ObservableProperty] private string _selectedContentFlow = "body";
[ObservableProperty] private string _newPageTemplateName = "continuation";
[ObservableProperty] private string _newFlowName = "body";
[ObservableProperty] private Bitmap? _previewImage;
[ObservableProperty] private string _status = "Bereit.";
[ObservableProperty] private string _statusColor = "#475569";
[ObservableProperty] private bool _canExport;
[ObservableProperty] private DesignerPlaceholder? _selectedPlaceholder;
[ObservableProperty] private DesignerMetadata? _selectedMetadata;
[ObservableProperty] private DesignerAsset? _selectedAsset;
[ObservableProperty] private StarterTemplateItem? _selectedStarterTemplate;
[ObservableProperty] private OverlayEditorMode _overlayMode = OverlayEditorMode.Measure;
[ObservableProperty] private bool _snapOverlayToGrid = true;
[ObservableProperty] private double _overlayGridSize = 1;
[ObservableProperty] private string _overlayCoordinates = "x= · y=";
[ObservableProperty] private string _selectedOverlayElement = "Kein Element ausgewählt";
[ObservableProperty] private double _overlayPageWidth = 210;
[ObservableProperty] private double _overlayPageHeight = 297;
[ObservableProperty] private string _overlayPageUnit = "mm";
[ObservableProperty] private string _selectedPageTemplate = "first";
[ObservableProperty] private DesignerPreviewPage? _selectedPreviewPage;
public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"];
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
public IReadOnlyList<string> ElementTypes { get; } =
["TEXT", "TEXTBOX", "FLOWBOX", "DRAWBOX", "FLOWDRAWBOX", "IMG", "TABLE", "CHART"];
public IReadOnlyList<string> ElementScopes { get; } = ["Seitenvorlage (fest)", "Content-Flow"];
public ObservableCollection<DesignerPlaceholder> Placeholders { get; } =
[
new("Datum", PlaceholderType.Date, true, DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
new("Empfaenger", PlaceholderType.Text, true, "Familie Beispiel"),
new("Anrede", PlaceholderType.Text, true, "Frau Beispiel"),
new("Brieftext", PlaceholderType.Multiline, true, "hiermit informieren wir Sie über einen wichtigen Termin.\n\nMit freundlichen Grüßen"),
new("LehrerName", PlaceholderType.Text, true, "M. Mustermann"),
];
public ObservableCollection<DesignerMetadata> MetadataItems { get; } =
[
new(TemplateMetadataKeys.Language, "de-DE"),
new(TemplateMetadataKeys.ReportType, "letter"),
];
public Dictionary<string, byte[]> Assets { get; } = new(StringComparer.OrdinalIgnoreCase);
public ObservableCollection<DesignerAsset> AssetItems { get; } = [];
public ObservableCollection<StarterTemplateItem> StarterTemplates { get; } = [];
public ObservableCollection<string> PageTemplateNames { get; } = ["first"];
public ObservableCollection<string> ContentFlowNames { get; } = ["body"];
public ObservableCollection<DesignerPreviewPage> PreviewPages { get; } = [];
public bool HasSelectedPlaceholder => SelectedPlaceholder is not null;
public bool HasNoSelectedPlaceholder => SelectedPlaceholder is null;
public DesignerViewModel() => SelectedPlaceholder = Placeholders.FirstOrDefault();
partial void OnSelectedPlaceholderChanged(DesignerPlaceholder? value)
{
OnPropertyChanged(nameof(HasSelectedPlaceholder));
OnPropertyChanged(nameof(HasNoSelectedPlaceholder));
}
partial void OnSelectedPreviewPageChanged(DesignerPreviewPage? value) => PreviewImage = value?.Image;
public void Reset()
{
TemplateId = "neue-vorlage"; TemplateName = "Neue Vorlage"; Description = "";
PageWidth = 210; PageHeight = 297; Unit = "mm"; LayoutSource = EmptyStructuredLayout;
UseContinuationLayout = false;
ContinuationLayoutSource = "PAGE 210 297 mm\nFLOWBOX 20 20 170 257 $Brieftext size=11\n";
Placeholders.Clear(); SelectedPlaceholder = null;
MetadataItems.Clear(); MetadataItems.Add(new(TemplateMetadataKeys.Language, "de-DE"));
MetadataItems.Add(new(TemplateMetadataKeys.ReportType, "letter"));
SelectedMetadata = null;
Assets.Clear(); AssetItems.Clear(); SelectedAsset = null;
NewElementType = "TEXT"; NewX = "20"; NewY = "50"; NewWidth = "170"; NewHeight = "30";
NewImageScale = "100"; NewContent = "$Brieftext"; NewAttributes = "size=11";
ClearPreviewPages(); CanExport = false;
OverlayMode = OverlayEditorMode.Measure; OverlayCoordinates = "x= · y=";
SelectedOverlayElement = "Kein Element ausgewählt";
SetStatus("Neues Projekt angelegt.", false);
}
public TemplateManifest BuildManifest() => new()
{
SchemaVersion = TemplateLoader.CurrentSchemaVersion,
Id = TemplateId.Trim(), Name = TemplateName.Trim(), Description = Description.Trim(),
PageSize = new((float)PageWidth, (float)PageHeight, Unit), LayoutFile = "layout.tpl",
ContinuationLayoutFile = UseContinuationLayout ? "continuation.tpl" : null,
Metadata = BuildMetadata(),
Placeholders = BuildPlaceholders(),
};
public LoadedTemplate BuildLoaded()
{
var manifest = BuildManifest();
if (string.IsNullOrWhiteSpace(manifest.Id) || manifest.Id.Any(c => !(char.IsAsciiLetterOrDigit(c) || c == '-')))
throw new InvalidDataException("Die ID darf nur ASCII-Buchstaben, Ziffern und Bindestriche enthalten.");
if (string.IsNullOrWhiteSpace(manifest.Name)) throw new InvalidDataException("Der Vorlagenname fehlt.");
var layout = new LayoutParser().Parse(LayoutSource);
var continuationLayout = UseContinuationLayout ? new LayoutParser().Parse(ContinuationLayoutSource) : null;
var issues = new List<ValidationIssue>();
var layouts = continuationLayout is null ? new[] { layout } : new[] { layout, continuationLayout };
var declared = manifest.Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal);
foreach (var used in layouts.SelectMany(TemplateLoader.UsedPlaceholders).Distinct(StringComparer.Ordinal)
.Where(x => !declared.Contains(x)))
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{used}“ ist nicht deklariert."));
foreach (var path in layouts.SelectMany(x => x.Elements).Select(x => x switch { BackgroundElement b => b.Path, ImageElement i => i.Path, _ => null }).Where(x => x is not null))
if (!Assets.ContainsKey(path!)) issues.Add(new(ValidationSeverity.Error, $"Asset „{path}“ fehlt."));
if (layout.UsesPageTemplates && continuationLayout is not null)
issues.Add(new(ValidationSeverity.Error,
"Layoutformat 3 enthält Folgeseiten als page-template und kann nicht zusätzlich continuation.tpl verwenden."));
if (!layout.UsesPageTemplates)
{
var firstFlows = layout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement).ToList();
if (firstFlows.Count > 1)
issues.Add(new(ValidationSeverity.Error, "Ein Legacy-Layout darf höchstens eine FLOWBOX enthalten."));
if (continuationLayout is not null)
{
var continuationFlows = continuationLayout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement).ToList();
if (firstFlows.Count != 1 || continuationFlows.Count != 1)
issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen jeweils genau ein fließendes Element enthalten."));
else if (firstFlows[0].GetType() != continuationFlows[0].GetType())
issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen denselben fließenden Elementtyp verwenden."));
else if (firstFlows[0].X != continuationFlows[0].X || firstFlows[0].Y != continuationFlows[0].Y
|| firstFlows[0].Width != continuationFlows[0].Width || firstFlows[0].Height != continuationFlows[0].Height)
issues.Add(new(ValidationSeverity.Error, "Die FLOWBOX muss auf Haupt- und Folgeseiten dieselbe Position und Größe haben."));
}
}
foreach (var issue in TemplateDataResolver.ValidateConstants(manifest))
issues.Add(new(ValidationSeverity.Error, issue));
if (issues.Count > 0) throw new TemplateValidationException(new(issues));
return new(manifest, layout, new Dictionary<string, byte[]>(Assets), ContinuationLayout: continuationLayout);
}
public ITemplateDataProvider BuildDataProvider() => new DesignerDataProvider(Placeholders.ToDictionary(
x => x.Name.Trim(), x => x.ToValue(), StringComparer.Ordinal));
public byte[] RenderCurrentPdf() =>
new QuestTemplateRenderer().RenderToPdf(BuildLoaded(), BuildDataProvider());
public void ApplyPdfImport(PdfImportResult import)
{
TemplateId = import.Manifest.Id; TemplateName = import.Manifest.Name; Description = import.Manifest.Description;
PageWidth = (decimal)import.Manifest.PageSize.Width; PageHeight = (decimal)import.Manifest.PageSize.Height;
Unit = import.Manifest.PageSize.Unit; LayoutSource = import.LayoutSource;
UseContinuationLayout = false;
Placeholders.Clear();
foreach (var definition in import.Manifest.Placeholders)
{
var candidate = import.Candidates.FirstOrDefault(x => x.Name.Equals(definition.Name, StringComparison.Ordinal));
Placeholders.Add(new(definition.Name, definition.Type, definition.Required,
candidate?.OriginalText ?? DesignerPlaceholder.SampleFor(definition.Type)));
}
SelectedPlaceholder = Placeholders.FirstOrDefault();
Assets.Clear(); AssetItems.Clear();
foreach (var asset in import.Assets) AddOrReplaceAsset(asset.Key, asset.Value, keepName: true);
MetadataItems.Clear();
foreach (var metadata in import.Manifest.Metadata) MetadataItems.Add(new(metadata.Key, metadata.Value));
CanExport = false;
SetStatus("PDF-Import übernommen. Bitte Vorschau, Platzhalter und Layout vor dem Speichern prüfen.", false);
}
public DesignerPlaceholder AddPlaceholder()
{
var existing = Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal);
var index = Placeholders.Count + 1;
while (existing.Contains($"Feld{index}")) index++;
var placeholder = new DesignerPlaceholder($"Feld{index}", PlaceholderType.Text, false, "Beispiel");
Placeholders.Add(placeholder); SelectedPlaceholder = placeholder; CanExport = false;
SetStatus($"Platzhalter „{placeholder.Name}“ angelegt. Details können jetzt bearbeitet werden.", false);
return placeholder;
}
public void RemoveSelectedPlaceholder()
{
if (SelectedPlaceholder is not { } selected) return;
Placeholders.Remove(selected); SelectedPlaceholder = null; CanExport = false;
SetStatus($"Platzhalter „{selected.Name}“ entfernt.", false);
}
public void Load(LoadedTemplate template, string layoutSource, string? continuationLayoutSource = null)
{
if (!template.Layout.UsesPageTemplates && continuationLayoutSource is null)
layoutSource = MigrateLegacyLayout(layoutSource, template.Layout);
TemplateId = template.Manifest.Id; TemplateName = template.Manifest.Name; Description = template.Manifest.Description;
PageWidth = (decimal)template.Manifest.PageSize.Width; PageHeight = (decimal)template.Manifest.PageSize.Height;
Unit = template.Manifest.PageSize.Unit; LayoutSource = layoutSource;
UseContinuationLayout = !template.Layout.UsesPageTemplates
&& template.Manifest.ContinuationLayoutFile is not null;
ContinuationLayoutSource = continuationLayoutSource ?? "PAGE 210 297 mm\n";
MetadataItems.Clear();
foreach (var item in template.Manifest.Metadata.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase))
MetadataItems.Add(new(item.Key, item.Value));
SelectedMetadata = null;
Placeholders.Clear();
foreach (var placeholder in template.Manifest.Placeholders)
Placeholders.Add(new(placeholder.Name, placeholder.Type, placeholder.Required,
placeholder.IsConstant ? placeholder.ConstantValue ?? "" : DesignerPlaceholder.SampleFor(placeholder.Type),
placeholder.IsConstant, placeholder.Bold, placeholder.Italic, placeholder.Underline));
SelectedPlaceholder = Placeholders.FirstOrDefault();
Assets.Clear(); AssetItems.Clear();
foreach (var asset in template.Assets) AddOrReplaceAsset(asset.Key, asset.Value, keepName: true);
RefreshAssetUsage();
CanExport = true; SetStatus($"„{template.Manifest.Name}“ geladen.", false);
}
public static string MigrateLegacyLayout(string source, TemplateLayout layout)
{
if (layout.UsesPageTemplates) return source;
var statements = source.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n')
.Where(line =>
{
var trimmed = line.Trim();
return trimmed.Length > 0 && !trimmed.StartsWith('#')
&& !trimmed.StartsWith("PAGE ", StringComparison.OrdinalIgnoreCase);
}).Select(line => line.Trim()).ToList();
var margin = layout.Unit.Equals("mm", StringComparison.OrdinalIgnoreCase) ? 20f : layout.Width * 0.08f;
var x = FormatNumber(margin); var y = FormatNumber(margin);
var width = FormatNumber(Math.Max(1, layout.Width - margin * 2));
var height = FormatNumber(Math.Max(1, layout.Height - margin * 2));
var result = new List<string>
{
$"PAGE {FormatNumber(layout.Width)} {FormatNumber(layout.Height)} {layout.Unit}",
"#pragma format-version 3", "", "#pragma page-template first",
};
result.AddRange(statements);
result.AddRange([
$"#pragma flow-slot body x={x} y={y} w={width} h={height}",
"#pragma end-page-template", "", "#pragma page-template continuation",
$"#pragma flow-slot body x={x} y={y} w={width} h={height}",
"#pragma end-page-template", "", "#pragma content-flow body",
"#pragma end-content-flow", "",
]);
return string.Join('\n', result);
}
public void LoadAsNewProject(LoadedTemplate template, string layoutSource, string? continuationLayoutSource = null, string? newId = null)
{
Load(template, layoutSource, continuationLayoutSource);
TemplateId = newId ?? template.Manifest.Id + "-neu";
TemplateName = template.Manifest.Name + " - Neu";
CanExport = false;
SetStatus($"Neues unabhängiges Projekt aus „{template.Manifest.Name}“ erstellt. Bitte ID und Namen prüfen.", false);
}
public void PreviewTemplate(LoadedTemplate template)
{
var provider = BuildSampleDataProvider(template.Manifest);
RenderPreviewPages(template, provider);
SetStatus($"Vorschau von „{template.Manifest.Name}“ mit {PreviewPages.Count} Seite(n).", false);
}
public void RenderPreviewPages(LoadedTemplate template, ITemplateDataProvider provider)
{
var pages = new QuestTemplateRenderer().RenderPagesToPng(template, provider);
ClearPreviewPages();
for (var index = 0; index < pages.Count; index++)
{
using var stream = new MemoryStream(pages[index]);
PreviewPages.Add(new(index + 1, new Bitmap(stream)));
}
SelectedPreviewPage = PreviewPages.FirstOrDefault();
}
public void PreviewSelectedPageTemplateCanvas()
{
var loaded = BuildLoaded();
var pageTemplate = loaded.Layout.PageTemplates.FirstOrDefault(x =>
x.Name.Equals(SelectedPageTemplate, StringComparison.OrdinalIgnoreCase));
if (pageTemplate is null) return;
var canvasLayout = new TemplateLayout(loaded.Layout.Width, loaded.Layout.Height, loaded.Layout.Unit,
pageTemplate.Elements);
var canvasTemplate = new LoadedTemplate(loaded.Manifest, canvasLayout, loaded.Assets, loaded.SourceName);
var bytes = new QuestTemplateRenderer().RenderFirstPageToPng(canvasTemplate, BuildDataProvider());
using var stream = new MemoryStream(bytes);
_pageTemplatePreview?.Dispose(); _pageTemplatePreview = new Bitmap(stream);
SelectedPreviewPage = null; PreviewImage = _pageTemplatePreview;
SetStatus($"Bearbeitungsansicht des Seitentyps „{SelectedPageTemplate}“.", false);
}
private void ClearPreviewPages()
{
_pageTemplatePreview?.Dispose(); _pageTemplatePreview = null;
PreviewImage = null; SelectedPreviewPage = null;
foreach (var page in PreviewPages) page.Image.Dispose();
PreviewPages.Clear();
}
public void SetStarterTemplates(IEnumerable<StarterTemplateItem> templates, string? selectedId = null)
{
StarterTemplates.Clear();
foreach (var template in templates) StarterTemplates.Add(template);
SelectedStarterTemplate = StarterTemplates.FirstOrDefault(x => x.Id.Equals(selectedId, StringComparison.OrdinalIgnoreCase))
?? StarterTemplates.FirstOrDefault();
}
private Dictionary<string, string> BuildMetadata()
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in MetadataItems)
{
var key = item.Name.Trim(); var value = item.Value.Trim();
if (!result.TryAdd(key, value))
throw new InvalidDataException($"Metadatenschlüssel „{key}“ ist mehrfach vorhanden.");
}
TemplateMetadataText.Serialize(result);
return result;
}
private List<PlaceholderDefinition> BuildPlaceholders()
{
var names = new HashSet<string>(StringComparer.Ordinal);
var result = new List<PlaceholderDefinition>(Placeholders.Count);
foreach (var placeholder in Placeholders)
{
var name = placeholder.Name.Trim();
if (name.Length == 0) throw new InvalidDataException("Ein Platzhaltername darf nicht leer sein.");
if (!names.Add(name)) throw new InvalidDataException($"Platzhalter „{name}“ ist mehrfach definiert.");
result.Add(new(name, placeholder.Type, placeholder.Required, placeholder.IsConstant,
placeholder.IsConstant ? placeholder.Sample : null,
placeholder.Bold, placeholder.Italic, placeholder.Underline));
}
return result;
}
private static ITemplateDataProvider BuildSampleDataProvider(TemplateManifest manifest) =>
new DesignerDataProvider(manifest.Placeholders.ToDictionary(x => x.Name,
x => new DesignerPlaceholder(x.Name, x.Type, x.Required, DesignerPlaceholder.SampleFor(x.Type)).ToValue(),
StringComparer.Ordinal));
public void AddElement()
{
var attrs = string.IsNullOrWhiteSpace(NewAttributes) ? "" : " " + NewAttributes.Trim();
var flowElement = NewElementScope == "Content-Flow";
var line = (NewElementType, flowElement) switch
{
("TEXT", false) => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}",
("TEXTBOX", false) => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
("FLOWBOX", false) => $"FLOWBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
("DRAWBOX", false) => $"DRAWBOX {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
("FLOWDRAWBOX", false) => $"FLOWDRAWBOX {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
("IMG", false) => $"IMG {NewContent} {NewX} {NewY} {NewWidth} {NewHeight} scale={NormalizedImageScale()}%",
("TABLE", false) => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
("CHART", false) => $"CHART {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
("TEXT", true) => $"TEXT {QuoteIfLiteral(NewContent)}{attrs}",
("TEXTBOX", true) => $"TEXTBOX {QuoteIfLiteral(NewContent)}{attrs}",
("FLOWBOX", true) => $"FLOWBOX {QuoteIfLiteral(NewContent)}{attrs}",
("DRAWBOX", true) => $"DRAWBOX {NewContent} w={NewWidth} h={NewHeight}{attrs}",
("FLOWDRAWBOX", true) => $"FLOWDRAWBOX {NewContent} w={NewWidth} h={NewHeight}{attrs}",
("IMG", true) => $"IMG {NewContent} w={NewWidth} h={NewHeight} scale={NormalizedImageScale()}%{attrs}",
("TABLE", true) => $"TABLE {NewContent}{attrs}",
("CHART", true) => $"CHART {NewContent} h={NewHeight}{attrs}",
_ => throw new InvalidOperationException("Unbekannter Elementtyp."),
};
var parsedLayout = new LayoutParser().Parse(LayoutSource);
LayoutSource = parsedLayout.UsesPageTemplates
? InsertIntoSection(LayoutSource, line, flowElement ? "content-flow" : "page-template",
flowElement ? SelectedContentFlow : SelectedPageTemplate)
: LayoutSource.TrimEnd() + Environment.NewLine + line + Environment.NewLine;
CanExport = false; SetStatus("Element ergänzt. Vorschau zur Prüfung aktualisieren.", false);
}
public void AddPageTemplate()
{
var name = ValidateSectionName(NewPageTemplateName, "Seitentyp");
if (PageTemplateNames.Contains(name, StringComparer.OrdinalIgnoreCase))
throw new InvalidDataException($"Seitentyp „{name}“ existiert bereits.");
var block = $"#pragma page-template {name}\n#pragma end-page-template\n";
var marker = LayoutSource.IndexOf("#pragma content-flow", StringComparison.OrdinalIgnoreCase);
LayoutSource = marker < 0 ? LayoutSource.TrimEnd() + "\n\n" + block
: LayoutSource.Insert(marker, block + "\n");
SelectedPageTemplate = name; CanExport = false;
SetStatus($"Seitentyp „{name}“ angelegt.", false);
}
public void AddContentFlow()
{
var name = ValidateSectionName(NewFlowName, "Flow");
if (ContentFlowNames.Contains(name, StringComparer.OrdinalIgnoreCase))
throw new InvalidDataException($"Content-Flow „{name}“ existiert bereits.");
LayoutSource = LayoutSource.TrimEnd() + $"\n\n#pragma content-flow {name}\n#pragma end-content-flow\n";
SelectedContentFlow = name; CanExport = false;
SetStatus($"Content-Flow „{name}“ angelegt. Lege nun gleichnamige Slots auf den Seitentypen an.", false);
}
public void AddFlowSlot()
{
var name = ValidateSectionName(NewFlowName, "Flow-Slot");
var line = $"#pragma flow-slot {name} x={NewX} y={NewY} w={NewWidth} h={NewHeight}";
LayoutSource = InsertIntoSection(LayoutSource, line, "page-template", SelectedPageTemplate);
CanExport = false;
SetStatus($"Flow-Slot „{name}“ auf „{SelectedPageTemplate}“ angelegt.", false);
}
private static string ValidateSectionName(string value, string label)
{
var name = value.Trim();
if (name.Length == 0 || name.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '-' or '_')))
throw new InvalidDataException($"{label}-Namen dürfen nur Buchstaben, Ziffern, - und _ enthalten.");
return name;
}
private static string InsertIntoSection(string source, string line, string section, string name)
{
var lines = source.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList();
var start = lines.FindIndex(x =>
{
var tokens = x.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
return tokens.Length >= 3 && tokens[0].Equals("#pragma", StringComparison.OrdinalIgnoreCase)
&& tokens[1].Equals(section, StringComparison.OrdinalIgnoreCase)
&& tokens[2].Equals(name, StringComparison.OrdinalIgnoreCase);
});
if (start < 0) throw new InvalidDataException($"{section} „{name}“ wurde nicht gefunden.");
var endDirective = section == "content-flow" ? "end-content-flow" : "end-page-template";
var end = lines.FindIndex(start + 1, x => x.Trim().Equals($"#pragma {endDirective}", StringComparison.OrdinalIgnoreCase));
if (end < 0) throw new InvalidDataException($"{section} „{name}“ ist nicht geschlossen.");
lines.Insert(end, line);
return string.Join('\n', lines);
}
public DesignerAsset ImportAsset(string sourceName, byte[] bytes) => AddOrReplaceAsset(sourceName, bytes, keepName: false);
public DesignerAsset ImportBackground(string sourceName, byte[] bytes)
{
var asset = AddOrReplaceAsset(sourceName, bytes, keepName: false);
var lines = LayoutSource.Split('\n').Where(x => !IsAssetStatement(x, "BG", null)).ToList();
var page = lines.FindIndex(x => x.TrimStart().StartsWith("PAGE ", StringComparison.OrdinalIgnoreCase));
lines.Insert(Math.Max(0, page + 1), $"BG {asset.Name}");
LayoutSource = string.Join('\n', lines);
SelectedAsset = asset; CanExport = false;
SetStatus($"Hintergrund „{asset.Name}“ importiert. Vorschau aktualisieren.", false);
return asset;
}
public void InsertSelectedAssetAsImage()
{
if (SelectedAsset is null) throw new InvalidOperationException("Bitte zuerst ein Asset auswählen.");
NewElementType = "IMG"; NewContent = SelectedAsset.Name; NewAttributes = "";
AddElement();
SetStatus($"„{SelectedAsset.Name}“ wurde als IMG-Element eingefügt. Vorschau aktualisieren.", false);
}
public void ReplaceSelectedAsset(byte[] bytes)
{
if (SelectedAsset is null) throw new InvalidOperationException("Bitte zuerst ein Asset auswählen.");
var name = SelectedAsset.Name;
AddOrReplaceAsset(name, bytes, keepName: true);
SelectedAsset = AssetItems.First(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
CanExport = false; SetStatus($"Asset „{name}“ wurde ersetzt. Alle Referenzen bleiben erhalten.", false);
}
public void RemoveSelectedAsset()
{
if (SelectedAsset is null) throw new InvalidOperationException("Bitte zuerst ein Asset auswählen.");
var selected = SelectedAsset;
var lines = LayoutSource.Split('\n').ToList();
var removedReferences = lines.RemoveAll(line => IsAssetStatement(line, "BG", selected.Name)
|| IsAssetStatement(line, "IMG", selected.Name));
LayoutSource = string.Join('\n', lines);
Assets.Remove(selected.Name); AssetItems.Remove(selected); SelectedAsset = null;
CanExport = false;
SetStatus(removedReferences == 0
? $"Asset „{selected.Name}“ wurde entfernt."
: $"Asset „{selected.Name}“ und {removedReferences} Layout-Referenz(en) wurden entfernt.", false);
}
public void ApplyMeasurement(OverlayMeasurement measurement)
{
NewX = FormatNumber(measurement.X); NewY = FormatNumber(measurement.Y);
if (measurement.HasArea)
{
NewWidth = FormatNumber(measurement.Width); NewHeight = FormatNumber(measurement.Height);
SetStatus($"Bereich übernommen: x={NewX}, y={NewY}, b={NewWidth}, h={NewHeight} {OverlayPageUnit}.", false);
}
else SetStatus($"Koordinate übernommen: x={NewX}, y={NewY} {OverlayPageUnit}.", false);
}
public void ApplyElementGeometry(OverlayElementGeometry geometry)
{
var layout = new LayoutParser().Parse(LayoutSource);
if (geometry.Keyword == "FLOW")
{
var slot = layout.PageTemplates.SelectMany(x => x.FlowSlots)
.FirstOrDefault(x => x.Line == geometry.Line)
?? throw new InvalidDataException($"Flow-Slot in Zeile {geometry.Line} wurde nicht gefunden.");
var slotLines = LayoutSource.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList();
slotLines[geometry.Line - 1] = $"#pragma flow-slot {slot.Name} x={FormatNumber(geometry.X)} "
+ $"y={FormatNumber(geometry.Y)} w={FormatNumber(geometry.Width)} h={FormatNumber(geometry.Height)}";
LayoutSource = string.Join('\n', slotLines); CanExport = false;
SelectedOverlayElement = $"Flow {slot.Name} · Zeile {geometry.Line}";
SetStatus($"Flow-Slot „{slot.Name}“ verschoben/skaliert.", false);
return;
}
var element = layout.Elements.FirstOrDefault(x => x.Line == geometry.Line)
?? throw new InvalidDataException($"Element in Zeile {geometry.Line} wurde nicht gefunden.");
var lines = LayoutSource.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList();
var index = geometry.Line - 1;
if (index < 0 || index >= lines.Count) throw new InvalidDataException("Elementzeile liegt außerhalb des Layouts.");
var x = FormatNumber(geometry.X); var y = FormatNumber(geometry.Y);
var width = geometry.Width; var height = geometry.Height;
lines[index] = element switch
{
ImageElement image => BuildImageLine(image, x, y, width, height),
TextElement text => $"TEXT {x} {y} {Content(text.Content, text.Placeholder, text.Format)}{Attributes(text.Attributes)}",
TextBoxElement box => $"TEXTBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"{Content(box.Content, box.Placeholder, box.Format)}{Attributes(box.Attributes)}",
FlowBoxElement box => $"FLOWBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"{Content(box.Content, box.Placeholder, box.Format)}{Attributes(box.Attributes)}",
DrawBoxElement box => $"DRAWBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"${box.Placeholder}{Attributes(box.Attributes)}",
FlowDrawBoxElement box => $"FLOWDRAWBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"${box.Placeholder}{Attributes(box.Attributes)}",
TableElement table => $"TABLE {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"${table.Placeholder}{Attributes(table.Attributes)}",
ChartElement chart => $"CHART {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"${chart.Placeholder}{Attributes(chart.Attributes)}",
_ => lines[index],
};
LayoutSource = string.Join('\n', lines); CanExport = false;
SelectedOverlayElement = $"{geometry.Keyword} · Zeile {geometry.Line}";
SetStatus($"{geometry.Keyword} verschoben/skalisiert. PDF-Vorschau wird aktualisiert.", false);
}
partial void OnLayoutSourceChanged(string value)
{
RefreshAssetUsage();
try
{
var page = new LayoutParser().Parse(value);
OverlayPageWidth = page.Width; OverlayPageHeight = page.Height; OverlayPageUnit = page.Unit;
var names = page.PageTemplates.Select(x => x.Name).ToList();
if (names.Count == 0) names.Add("legacy");
var selected = names.Contains(SelectedPageTemplate, StringComparer.OrdinalIgnoreCase)
? SelectedPageTemplate : names[0];
PageTemplateNames.Clear();
foreach (var name in names) PageTemplateNames.Add(name);
SelectedPageTemplate = selected;
var flows = page.ContentFlows.Select(x => x.Name).ToList();
if (flows.Count == 0) flows.Add("body");
var selectedFlow = flows.Contains(SelectedContentFlow, StringComparer.OrdinalIgnoreCase)
? SelectedContentFlow : flows[0];
ContentFlowNames.Clear();
foreach (var flow in flows) ContentFlowNames.Add(flow);
SelectedContentFlow = selectedFlow;
}
catch (TemplateValidationException) { }
}
private DesignerAsset AddOrReplaceAsset(string sourceName, byte[] bytes, bool keepName)
{
var safeName = SafeAssetName(sourceName);
if (!keepName) safeName = UniqueAssetName(safeName);
var metadata = ImageInspector.Inspect(bytes);
Assets[safeName] = bytes;
var existing = AssetItems.FirstOrDefault(x => x.Name.Equals(safeName, StringComparison.OrdinalIgnoreCase));
if (existing is not null) AssetItems.Remove(existing);
var item = new DesignerAsset(safeName, metadata.Width, metadata.Height, bytes.LongLength);
AssetItems.Add(item); RefreshAssetUsage(); SelectedAsset = item;
return item;
}
private string UniqueAssetName(string name)
{
if (!Assets.ContainsKey(name)) return name;
var extension = Path.GetExtension(name); var stem = Path.GetFileNameWithoutExtension(name);
for (var index = 2; ; index++)
{
var candidate = $"{stem}-{index}{extension}";
if (!Assets.ContainsKey(candidate)) return candidate;
}
}
private static string SafeAssetName(string sourceName)
{
var fileName = Path.GetFileName(sourceName);
var safe = string.Concat(fileName.Select(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_' ? c : '-'));
if (string.IsNullOrWhiteSpace(safe)) throw new InvalidDataException("Der Asset-Dateiname ist ungültig.");
return safe;
}
private void RefreshAssetUsage()
{
foreach (var asset in AssetItems)
{
var count = LayoutSource.Split('\n').Count(line => IsAssetStatement(line, "BG", asset.Name)
|| IsAssetStatement(line, "IMG", asset.Name));
asset.SetUsage(count);
}
}
private static bool IsAssetStatement(string line, string keyword, string? name)
{
var tokens = line.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
return tokens.Length >= 2 && tokens[0].Equals(keyword, StringComparison.OrdinalIgnoreCase)
&& (name is null || tokens[1].Equals(name, StringComparison.OrdinalIgnoreCase));
}
private string NormalizedImageScale()
{
var raw = NewImageScale.Trim().TrimEnd('%').Replace(',', '.');
if (!float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
&& !float.TryParse(raw, NumberStyles.Float, CultureInfo.CurrentCulture, out value))
throw new InvalidDataException("Die IMG-Skalierung muss eine Zahl sein.");
if (value <= 0 || value > 1000)
throw new InvalidDataException("Die IMG-Skalierung muss zwischen 0 und 1000 Prozent liegen.");
return value.ToString("0.###", CultureInfo.InvariantCulture);
}
private static string BuildImageLine(ImageElement image, string x, string y, double effectiveWidth, double effectiveHeight)
{
var scale = image.Attributes.TryGetValue("scale", out var raw) ? LayoutParser.Percentage(raw) : 1f;
return $"IMG {image.Path} {x} {y} {FormatNumber(effectiveWidth / scale)} "
+ $"{FormatNumber(effectiveHeight / scale)}{Attributes(image.Attributes)}";
}
private static string Content(string original, string? placeholder, string? format) => placeholder is null
? $"\"{original.Replace("\"", "\\\"")}\""
: $"${placeholder}{(format is null ? "" : "|" + format)}";
private static string Attributes(IReadOnlyDictionary<string, string> attributes) => attributes.Count == 0
? "" : " " + string.Join(' ', attributes.Select(x => $"{x.Key}={x.Value}"));
private static string FormatNumber(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
public void SetStatus(string text, bool error)
{ Status = text; StatusColor = error ? "#B91C1C" : "#166534"; }
private static string QuoteIfLiteral(string value) => value.StartsWith('$') ? value : $"\"{value.Replace("\"", "\\\"")}\"";
private const string EmptyStructuredLayout = """
PAGE 210 297 mm
#pragma format-version 3
#pragma page-template first
#pragma flow-slot body x=20 y=30 w=170 h=247
#pragma end-page-template
#pragma page-template continuation
#pragma flow-slot body x=20 y=20 w=170 h=257
#pragma end-page-template
#pragma content-flow body
#pragma end-content-flow
""";
private const string DefaultLayout = """
# Elternbrief Standard · Format 3
PAGE 210 297 mm
#pragma format-version 3
#pragma page-template first
TEXT 20 25 "Elternbrief" size=18 bold=true color=#1E3A8A
TEXT 20 43 $Datum|dd.MM.yyyy size=10
TEXT 20 55 $Empfaenger size=11
TEXT 20 75 "Sehr geehrte/r $Anrede," size=11
#pragma flow-slot body x=20 y=90 w=170 h=175
#pragma end-page-template
#pragma page-template continuation
#pragma flow-slot body x=20 y=25 w=170 h=240
#pragma end-page-template
#pragma content-flow body
TEXTBOX $Brieftext size=11 overflow=continue
TEXT "Mit freundlichen Grüßen" size=10 gap=8 keep-with-next=true
TEXT $LehrerName size=10 italic=true gap=4
#pragma end-content-flow
""";
}
public partial class DesignerMetadata(string name, string value) : ObservableObject
{
[ObservableProperty] private string _name = name;
[ObservableProperty] private string _value = value;
}
public sealed record DesignerPreviewPage(int Number, Bitmap Image)
{
public string Display => $"Dokumentseite {Number}";
}
public partial class DesignerAsset(string name, int pixelWidth, int pixelHeight, long byteCount) : ObservableObject
{
[ObservableProperty] private int _usageCount;
public string Name { get; } = name;
public int PixelWidth { get; } = pixelWidth;
public int PixelHeight { get; } = pixelHeight;
public long ByteCount { get; } = byteCount;
public string Dimensions => $"{PixelWidth} × {PixelHeight} px";
public string FileSize => ByteCount >= 1024 * 1024
? $"{ByteCount / (1024d * 1024d):0.0} MB"
: $"{Math.Max(1, ByteCount / 1024d):0} KB";
public string Usage => UsageCount == 0 ? "nicht verwendet" : UsageCount == 1 ? "1 Referenz" : $"{UsageCount} Referenzen";
public void SetUsage(int count) { UsageCount = count; OnPropertyChanged(nameof(Usage)); }
}
public partial class DesignerPlaceholder : ObservableObject
{
[ObservableProperty] private string _name;
[ObservableProperty] private PlaceholderType _type;
[ObservableProperty] private bool _required;
[ObservableProperty] private string _sample;
[ObservableProperty] private bool _isConstant;
[ObservableProperty] private bool _bold;
[ObservableProperty] private bool _italic;
[ObservableProperty] private bool _underline;
public bool SupportsConstantValue => Type is PlaceholderType.Text or PlaceholderType.Multiline
or PlaceholderType.Date or PlaceholderType.Number;
public bool SupportsRichText => IsConstant && Type is PlaceholderType.Text or PlaceholderType.Multiline;
public DesignerPlaceholder(string name, PlaceholderType type, bool required, string sample,
bool isConstant = false, bool bold = false, bool italic = false, bool underline = false)
{
_name = name; _type = type; _required = required; _sample = sample;
_isConstant = isConstant; _bold = bold; _italic = italic; _underline = underline;
}
partial void OnTypeChanged(PlaceholderType value)
{
OnPropertyChanged(nameof(SupportsConstantValue));
OnPropertyChanged(nameof(SupportsRichText));
if (!SupportsConstantValue) IsConstant = false;
}
partial void OnIsConstantChanged(bool value) => OnPropertyChanged(nameof(SupportsRichText));
public PlaceholderValue ToValue() => Type switch
{
PlaceholderType.Text => new TextValue(Sample),
PlaceholderType.Multiline => new MultilineValue(Sample),
PlaceholderType.Date => new DateValue(DateOnly.TryParse(Sample, CultureInfo.InvariantCulture, out var date) ? date : DateOnly.FromDateTime(DateTime.Today)),
PlaceholderType.Number => new NumberValue(decimal.TryParse(Sample, NumberStyles.Number, CultureInfo.InvariantCulture, out var number) ? number : 0),
PlaceholderType.Image => new ImageValue([], "image/png"),
PlaceholderType.Table => ParseTable(Sample),
PlaceholderType.Chart => ParseChart(Sample),
PlaceholderType.Drawing => SampleDrawing(),
_ => new TextValue(Sample),
};
public static string SampleFor(PlaceholderType type) => type switch
{ PlaceholderType.Date => DateTime.Today.ToString("yyyy-MM-dd"), PlaceholderType.Number => "42,5",
PlaceholderType.Table => "Datum;Grund|01.09.;Krank", PlaceholderType.Chart => "Sep:2;Okt:3;Nov:1",
PlaceholderType.Drawing => "Externe Zeichenbefehle", _ => "Beispielwert" };
private static TableValue ParseTable(string value)
{
var lines = value.Split('|', StringSplitOptions.RemoveEmptyEntries);
var columns = (lines.FirstOrDefault() ?? "Spalte").Split(';');
return new(columns, lines.Skip(1).Select(x => (IReadOnlyList<string>)x.Split(';')).ToList());
}
private static ChartValue ParseChart(string value) => new([new("Werte", value.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Select((x, i) => { var parts = x.Split(':', 2); return new ChartPoint(parts[0], parts.Length == 2 && decimal.TryParse(parts[1], CultureInfo.InvariantCulture, out var y) ? y : i + 1); }).ToList())]);
private static DrawingValue SampleDrawing() => new DrawingValue(
[new DrawRectangle(0, 0, 80, 24, "#2563EB", 0.8f, "#EFF6FF"),
new DrawString(4, 4, "Dynamischer Inhalt", 10, "#1E3A8A", Bold: true),
new MoveTo(4, 19), new LineTo(76, 19, "#93C5FD", 0.6f)], 24);
}
internal sealed class DesignerDataProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }