Files
LehrerApp/LehrerApp.TemplateDesigner/DesignerViewModel.cs
T
admin 11ef73ab1f
CI / build-and-test (push) Canceled after 0s
TemplateDesigner: pdf-Export
2026-08-31 23:28:08 +02:00

563 lines
32 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
{
[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 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";
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 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 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));
}
public void Reset()
{
TemplateId = "neue-vorlage"; TemplateName = "Neue Vorlage"; Description = "";
PageWidth = 210; PageHeight = 297; Unit = "mm"; LayoutSource = "PAGE 210 297 mm\n";
UseContinuationLayout = false;
ContinuationLayoutSource = "PAGE 210 297 mm\nFLOWBOX 20 20 170 257 $Brieftext size=11\n";
Placeholders.Clear(); SelectedPlaceholder = null;
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";
PreviewImage?.Dispose(); PreviewImage = null; 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."));
var firstFlows = layout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement).ToList();
if (firstFlows.Count > 1)
issues.Add(new(ValidationSeverity.Error, "Ein Layout darf höchstens eine FLOWBOX enthalten."));
if (continuationLayout is not null)
{
var continuationFlows = continuationLayout.Elements.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)
{
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.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 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);
var bytes = new QuestTemplateRenderer().RenderFirstPageToPng(template, provider);
using var stream = new MemoryStream(bytes);
PreviewImage?.Dispose(); PreviewImage = new Bitmap(stream);
SetStatus($"Vorschau von „{template.Manifest.Name}“.", false);
}
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 line = NewElementType switch
{
"TEXT" => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}",
"TEXTBOX" => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
"FLOWBOX" => $"FLOWBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
"DRAWBOX" => $"DRAWBOX {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
"FLOWDRAWBOX" => $"FLOWDRAWBOX {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
"IMG" => $"IMG {NewContent} {NewX} {NewY} {NewWidth} {NewHeight} scale={NormalizedImageScale()}%",
"TABLE" => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
"CHART" => $"CHART {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
_ => throw new InvalidOperationException("Unbekannter Elementtyp."),
};
LayoutSource = LayoutSource.TrimEnd() + Environment.NewLine + line + Environment.NewLine;
CanExport = false; SetStatus("Element ergänzt. Vorschau zur Prüfung aktualisieren.", false);
}
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);
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;
}
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 DefaultLayout = """
# Elternbrief Standard
PAGE 210 297 mm
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
TEXTBOX 20 90 170 155 $Brieftext size=11 wrap=true
TEXT 20 265 $LehrerName size=10 italic=true
""";
}
public partial class DesignerMetadata(string name, string value) : ObservableObject
{
[ObservableProperty] private string _name = name;
[ObservableProperty] private string _value = value;
}
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; }