TemplateDesigner: Redesign des TemplateSkripts

This commit is contained in:
2026-09-01 10:07:25 +02:00
parent 95e3f677e4
commit b2d0ccd30d
12 changed files with 807 additions and 55 deletions
+221 -16
View File
@@ -8,6 +8,7 @@ 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";
@@ -23,6 +24,10 @@ public partial class DesignerViewModel : ObservableObject
[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";
@@ -39,10 +44,13 @@ public partial class DesignerViewModel : ObservableObject
[ObservableProperty] private double _overlayPageWidth = 210;
[ObservableProperty] private double _overlayPageHeight = 297;
[ObservableProperty] private string _overlayPageUnit = "mm";
[ObservableProperty] private string _selectedPageTemplate = "first";
[ObservableProperty] private DesignerPreviewPage? _selectedPreviewPage;
public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"];
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
public IReadOnlyList<string> ElementTypes { get; } = ["TEXT", "TEXTBOX", "IMG", "TABLE", "CHART"];
public IReadOnlyList<string> ElementScopes { get; } = ["Seitenvorlage (fest)", "Content-Flow"];
public ObservableCollection<DesignerPlaceholder> Placeholders { get; } =
[
new("Datum", PlaceholderType.Date, true, DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
@@ -59,6 +67,9 @@ public partial class DesignerViewModel : ObservableObject
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;
@@ -70,10 +81,12 @@ public partial class DesignerViewModel : ObservableObject
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 = "PAGE 210 297 mm\n";
PageWidth = 210; PageHeight = 297; Unit = "mm"; LayoutSource = EmptyStructuredLayout;
Placeholders.Clear(); SelectedPlaceholder = null;
MetadataItems.Clear(); MetadataItems.Add(new(TemplateMetadataKeys.Language, "de-DE"));
MetadataItems.Add(new(TemplateMetadataKeys.ReportType, "letter"));
@@ -81,7 +94,7 @@ public partial class DesignerViewModel : ObservableObject
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;
ClearPreviewPages(); CanExport = false;
OverlayMode = OverlayEditorMode.Measure; OverlayCoordinates = "x= · y=";
SelectedOverlayElement = "Kein Element ausgewählt";
SetStatus("Neues Projekt angelegt.", false);
@@ -138,6 +151,8 @@ public partial class DesignerViewModel : ObservableObject
public void Load(LoadedTemplate template, string layoutSource)
{
if (!template.Layout.UsesPageTemplates)
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;
@@ -157,6 +172,36 @@ public partial class DesignerViewModel : ObservableObject
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? newId = null)
{
Load(template, layoutSource);
@@ -169,10 +214,44 @@ public partial class DesignerViewModel : ObservableObject
public void PreviewTemplate(LoadedTemplate template)
{
var provider = BuildSampleDataProvider(template.Manifest);
var bytes = new QuestTemplateRenderer().RenderFirstPageToPng(template, provider);
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);
PreviewImage?.Dispose(); PreviewImage = new Bitmap(stream);
SetStatus($"Vorschau von „{template.Manifest.Name}“.", false);
_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)
@@ -220,19 +299,84 @@ public partial class DesignerViewModel : ObservableObject
public void AddElement()
{
var attrs = string.IsNullOrWhiteSpace(NewAttributes) ? "" : " " + NewAttributes.Trim();
var line = NewElementType switch
var flowElement = NewElementScope == "Content-Flow";
var line = (NewElementType, flowElement) switch
{
"TEXT" => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}",
"TEXTBOX" => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(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}",
("TEXT", false) => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}",
("TEXTBOX", false) => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(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}",
("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."),
};
LayoutSource = LayoutSource.TrimEnd() + Environment.NewLine + line + Environment.NewLine;
LayoutSource = InsertIntoSection(LayoutSource, line, flowElement ? "content-flow" : "page-template",
flowElement ? SelectedContentFlow : SelectedPageTemplate);
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)
@@ -293,6 +437,19 @@ public partial class DesignerViewModel : ObservableObject
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();
@@ -324,6 +481,20 @@ public partial class DesignerViewModel : ObservableObject
{
var page = new LayoutParser().Parse(value);
OverlayPageWidth = page.Width; OverlayPageHeight = page.Height; OverlayPageUnit = page.Unit;
var names = page.PageTemplates.Select(x => x.Name).ToList();
if (names.Count == 0) names.Add("legacy");
var selected = names.Contains(SelectedPageTemplate, StringComparer.OrdinalIgnoreCase)
? SelectedPageTemplate : names[0];
PageTemplateNames.Clear();
foreach (var name in names) PageTemplateNames.Add(name);
SelectedPageTemplate = selected;
var flows = page.ContentFlows.Select(x => x.Name).ToList();
if (flows.Count == 0) flows.Add("body");
var selectedFlow = flows.Contains(SelectedContentFlow, StringComparer.OrdinalIgnoreCase)
? SelectedContentFlow : flows[0];
ContentFlowNames.Clear();
foreach (var flow in flows) ContentFlowNames.Add(flow);
SelectedContentFlow = selectedFlow;
}
catch (TemplateValidationException) { }
}
@@ -406,15 +577,44 @@ public partial class DesignerViewModel : ObservableObject
{ Status = text; StatusColor = error ? "#B91C1C" : "#166534"; }
private static string QuoteIfLiteral(string value) => value.StartsWith('$') ? value : $"\"{value.Replace("\"", "\\\"")}\"";
private const string DefaultLayout = """
# Elternbrief Standard
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
TEXTBOX 20 90 170 155 $Brieftext size=11 wrap=true
TEXT 20 265 $LehrerName size=10 italic=true
#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
""";
}
@@ -424,6 +624,11 @@ public partial class DesignerMetadata(string name, string value) : ObservableObj
[ObservableProperty] private string _value = value;
}
public sealed record DesignerPreviewPage(int Number, Bitmap Image)
{
public string Display => $"Dokumentseite {Number}";
}
public partial class DesignerAsset(string name, int pixelWidth, int pixelHeight, long byteCount) : ObservableObject
{
[ObservableProperty] private int _usageCount;