860 lines
47 KiB
C#
860 lines
47 KiB
C#
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;
|
||
[ObservableProperty] private SpecialContentItem? _selectedSpecialContent;
|
||
[ObservableProperty] private bool _isLegacyContinuationSupported = true;
|
||
|
||
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 IReadOnlyList<SpecialContentItem> SpecialContents { get; } = SpecialContentCatalog.Items;
|
||
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"),
|
||
new("Student.AttendanceCalendar", PlaceholderType.Drawing, false, ""),
|
||
new("Student.AbsenceDays", PlaceholderType.Drawing, false, ""),
|
||
];
|
||
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();
|
||
SelectedSpecialContent = SpecialContents.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;
|
||
}
|
||
|
||
/// <summary>Übernimmt einen lesbar benannten LehrerApp-Sonderinhalt in das normale
|
||
/// Elementformular und legt seine Drawing-Deklaration an, falls sie noch fehlt.</summary>
|
||
public void PrepareSelectedSpecialContent()
|
||
{
|
||
if (SelectedSpecialContent is not { } special)
|
||
throw new InvalidOperationException("Bitte zuerst einen Sonderinhalt auswählen.");
|
||
var placeholder = Placeholders.FirstOrDefault(p => p.Name == special.PlaceholderName);
|
||
if (placeholder is not null && placeholder.Type != PlaceholderType.Drawing)
|
||
throw new InvalidDataException(
|
||
$"Der vorhandene Platzhalter „{special.PlaceholderName}“ hat nicht den Typ Drawing.");
|
||
if (placeholder is null)
|
||
{
|
||
placeholder = new DesignerPlaceholder(special.PlaceholderName, PlaceholderType.Drawing,
|
||
false, special.Description);
|
||
Placeholders.Add(placeholder);
|
||
}
|
||
SelectedPlaceholder = placeholder;
|
||
NewElementType = special.RecommendedElementType;
|
||
NewContent = "$" + special.PlaceholderName;
|
||
NewWidth = special.RecommendedWidth;
|
||
NewHeight = special.RecommendedHeight;
|
||
NewAttributes = "";
|
||
CanExport = false;
|
||
SetStatus($"„{special.DisplayName}“ vorbereitet. Position und Größe prüfen, dann ins Layout übernehmen.", false);
|
||
}
|
||
|
||
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);
|
||
IsLegacyContinuationSupported = !page.UsesPageTemplates;
|
||
if (page.UsesPageTemplates) UseContinuationLayout = false;
|
||
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 sealed record SpecialContentItem(string DisplayName, string PlaceholderName, string Description,
|
||
string RecommendedElementType, string RecommendedWidth, string RecommendedHeight);
|
||
|
||
public static class SpecialContentCatalog
|
||
{
|
||
public static IReadOnlyList<SpecialContentItem> Items { get; } =
|
||
[
|
||
new("Anwesenheitskalender", "Student.AttendanceCalendar",
|
||
"Monatskalender mit farbigen Anwesenheitsmarkern; Zeitraum und Größe werden beim Erstellen des Briefs gewählt.",
|
||
"DRAWBOX", "170", "90"),
|
||
new("Fehltage nach Datum", "Student.AbsenceDays",
|
||
"Chronologische Liste mit Datum, Fehlzeit oder ganzem Fehltag sowie Entschuldigungsstatus.",
|
||
"FLOWDRAWBOX", "170", "140"),
|
||
];
|
||
|
||
public static DrawingValue SampleDrawing(string placeholderName) => placeholderName switch
|
||
{
|
||
"Student.AttendanceCalendar" => new DrawingValue(
|
||
[
|
||
new DrawString(2, 2, "Anwesenheit · Erika Beispiel", 10, "#1F2937", Bold: true),
|
||
new DrawRectangle(2, 14, 22, 12, "#D1D5DB", .4f, "#FFFFFF"),
|
||
new DrawString(9, 16, "12", 7, "#374151"),
|
||
new DrawRectangle(26, 14, 22, 12, "#C62828", .4f, "#C62828"),
|
||
new DrawString(34, 16, "U", 7, "#FFFFFF", Bold: true),
|
||
new DrawRectangle(50, 14, 22, 12, "#2E7D32", .4f, "#2E7D32"),
|
||
new DrawString(58, 16, "E", 7, "#FFFFFF", Bold: true),
|
||
], 28),
|
||
"Student.AbsenceDays" => new DrawingValue(
|
||
[
|
||
new DrawString(2, 2, "Fehltage · Erika Beispiel", 10, "#1F2937", Bold: true),
|
||
new DrawRectangle(2, 14, 166, 11, "#CBD5E1", .4f, "#F3F4F6"),
|
||
new DrawString(4, 15, "Datum", 7, "#374151", Bold: true),
|
||
new DrawString(42, 15, "Umfang", 7, "#374151", Bold: true),
|
||
new DrawString(116, 15, "Status", 7, "#374151", Bold: true),
|
||
new DrawString(4, 27, "03.09.2026", 7, "#374151"),
|
||
new DrawString(42, 27, "Ganzer Fehltag", 7, "#374151"),
|
||
new DrawString(116, 27, "Entschuldigt", 7, "#2E7D32"),
|
||
], 38),
|
||
_ => DesignerPlaceholder.GenericSampleDrawing(),
|
||
};
|
||
}
|
||
|
||
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 => SpecialContentCatalog.SampleDrawing(Name),
|
||
_ => 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())]);
|
||
internal static DrawingValue GenericSampleDrawing() => 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; }
|