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 Units { get; } = ["mm", "cm", "pt", "in"]; public IReadOnlyList PlaceholderTypes { get; } = Enum.GetValues(); public IReadOnlyList ElementTypes { get; } = ["TEXT", "TEXTBOX", "FLOWBOX", "DRAWBOX", "FLOWDRAWBOX", "IMG", "TABLE", "CHART"]; public ObservableCollection 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 MetadataItems { get; } = [ new(TemplateMetadataKeys.Language, "de-DE"), new(TemplateMetadataKeys.ReportType, "letter"), ]; public Dictionary Assets { get; } = new(StringComparer.OrdinalIgnoreCase); public ObservableCollection AssetItems { get; } = []; public ObservableCollection 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(); 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(Assets), ContinuationLayout: continuationLayout); } public ITemplateDataProvider BuildDataProvider() => new DesignerDataProvider(Placeholders.ToDictionary( x => x.Name.Trim(), x => x.ToValue(), StringComparer.Ordinal)); 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 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 BuildMetadata() { var result = new Dictionary(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 BuildPlaceholders() { var names = new HashSet(StringComparer.Ordinal); var result = new List(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 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)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 values) : ITemplateDataProvider { public IReadOnlyDictionary GetValues() => values; }