Add designer file menu and package metadata
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-08-30 20:49:13 +02:00
parent 23abe7af63
commit 6468596bf3
11 changed files with 363 additions and 20 deletions
@@ -0,0 +1,47 @@
using LehrerApp.TemplateDesigner;
using Xunit;
namespace LehrerApp.TemplateDesigner.Tests;
public sealed class ProjectLifecycleTests
{
[Fact]
public void Reset_ErzeugtEinVollstaendigLeeresUnabhaengigesProjekt()
{
var viewModel = new DesignerViewModel();
viewModel.ImportAsset("logo.png", OnePixelPng);
viewModel.Placeholders.Add(new("Test", LehrerApp.Templating.PlaceholderType.Text, false, "Wert"));
viewModel.NewElementType = "IMG";
viewModel.NewContent = "logo.png";
viewModel.Reset();
Assert.Equal("neue-vorlage", viewModel.TemplateId);
Assert.Equal("PAGE 210 297 mm\n", viewModel.LayoutSource);
Assert.Empty(viewModel.Placeholders);
Assert.Empty(viewModel.Assets);
Assert.Empty(viewModel.AssetItems);
Assert.Equal("de-DE", viewModel.MetadataItems.Single(x => x.Name == "language").Value);
Assert.Equal("letter", viewModel.MetadataItems.Single(x => x.Name == "report-type").Value);
Assert.Null(viewModel.SelectedAsset);
Assert.Null(viewModel.PreviewImage);
Assert.Equal("TEXT", viewModel.NewElementType);
Assert.Equal("$Brieftext", viewModel.NewContent);
Assert.False(viewModel.CanExport);
}
[Fact]
public void FreieMetadaten_WerdenInDasManifestUebernommen()
{
var viewModel = new DesignerViewModel();
viewModel.MetadataItems.Add(new("school-year", "2026/27"));
var manifest = viewModel.BuildManifest();
Assert.Equal("de-DE", manifest.Metadata["language"]);
Assert.Equal("2026/27", manifest.Metadata["school-year"]);
}
private static readonly byte[] OnePixelPng = Convert.FromBase64String(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=");
}
@@ -28,6 +28,7 @@ public partial class DesignerViewModel : ObservableObject
[ObservableProperty] private string _statusColor = "#475569"; [ObservableProperty] private string _statusColor = "#475569";
[ObservableProperty] private bool _canExport; [ObservableProperty] private bool _canExport;
[ObservableProperty] private DesignerPlaceholder? _selectedPlaceholder; [ObservableProperty] private DesignerPlaceholder? _selectedPlaceholder;
[ObservableProperty] private DesignerMetadata? _selectedMetadata;
[ObservableProperty] private DesignerAsset? _selectedAsset; [ObservableProperty] private DesignerAsset? _selectedAsset;
[ObservableProperty] private StarterTemplateItem? _selectedStarterTemplate; [ObservableProperty] private StarterTemplateItem? _selectedStarterTemplate;
[ObservableProperty] private OverlayEditorMode _overlayMode = OverlayEditorMode.Measure; [ObservableProperty] private OverlayEditorMode _overlayMode = OverlayEditorMode.Measure;
@@ -50,6 +51,11 @@ public partial class DesignerViewModel : ObservableObject
new("Brieftext", PlaceholderType.Multiline, true, "hiermit informieren wir Sie über einen wichtigen Termin.\n\nMit freundlichen Grüßen"), 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("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 Dictionary<string, byte[]> Assets { get; } = new(StringComparer.OrdinalIgnoreCase);
public ObservableCollection<DesignerAsset> AssetItems { get; } = []; public ObservableCollection<DesignerAsset> AssetItems { get; } = [];
public ObservableCollection<StarterTemplateItem> StarterTemplates { get; } = []; public ObservableCollection<StarterTemplateItem> StarterTemplates { get; } = [];
@@ -58,8 +64,16 @@ public partial class DesignerViewModel : ObservableObject
{ {
TemplateId = "neue-vorlage"; TemplateName = "Neue Vorlage"; Description = ""; 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 = "PAGE 210 297 mm\n";
Placeholders.Clear(); Assets.Clear(); AssetItems.Clear(); SelectedAsset = null; Placeholders.Clear(); SelectedPlaceholder = null;
PreviewImage = null; CanExport = false; 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); SetStatus("Neues Projekt angelegt.", false);
} }
@@ -68,6 +82,7 @@ public partial class DesignerViewModel : ObservableObject
SchemaVersion = TemplateLoader.CurrentSchemaVersion, SchemaVersion = TemplateLoader.CurrentSchemaVersion,
Id = TemplateId.Trim(), Name = TemplateName.Trim(), Description = Description.Trim(), Id = TemplateId.Trim(), Name = TemplateName.Trim(), Description = Description.Trim(),
PageSize = new((float)PageWidth, (float)PageHeight, Unit), LayoutFile = "layout.tpl", PageSize = new((float)PageWidth, (float)PageHeight, Unit), LayoutFile = "layout.tpl",
Metadata = BuildMetadata(),
Placeholders = Placeholders.Select(x => new PlaceholderDefinition(x.Name.Trim(), x.Type, x.Required)).ToList(), Placeholders = Placeholders.Select(x => new PlaceholderDefinition(x.Name.Trim(), x.Type, x.Required)).ToList(),
}; };
@@ -96,6 +111,10 @@ public partial class DesignerViewModel : ObservableObject
TemplateId = template.Manifest.Id; TemplateName = template.Manifest.Name; Description = template.Manifest.Description; TemplateId = template.Manifest.Id; TemplateName = template.Manifest.Name; Description = template.Manifest.Description;
PageWidth = (decimal)template.Manifest.PageSize.Width; PageHeight = (decimal)template.Manifest.PageSize.Height; PageWidth = (decimal)template.Manifest.PageSize.Width; PageHeight = (decimal)template.Manifest.PageSize.Height;
Unit = template.Manifest.PageSize.Unit; LayoutSource = layoutSource; Unit = template.Manifest.PageSize.Unit; LayoutSource = layoutSource;
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(); Placeholders.Clear();
foreach (var placeholder in template.Manifest.Placeholders) foreach (var placeholder in template.Manifest.Placeholders)
Placeholders.Add(new(placeholder.Name, placeholder.Type, placeholder.Required, DesignerPlaceholder.SampleFor(placeholder.Type))); Placeholders.Add(new(placeholder.Name, placeholder.Type, placeholder.Required, DesignerPlaceholder.SampleFor(placeholder.Type)));
@@ -131,6 +150,19 @@ public partial class DesignerViewModel : ObservableObject
?? StarterTemplates.FirstOrDefault(); ?? 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 static ITemplateDataProvider BuildSampleDataProvider(TemplateManifest manifest) => private static ITemplateDataProvider BuildSampleDataProvider(TemplateManifest manifest) =>
new DesignerDataProvider(manifest.Placeholders.ToDictionary(x => x.Name, new DesignerDataProvider(manifest.Placeholders.ToDictionary(x => x.Name,
x => new DesignerPlaceholder(x.Name, x.Type, x.Required, DesignerPlaceholder.SampleFor(x.Type)).ToValue(), x => new DesignerPlaceholder(x.Name, x.Type, x.Required, DesignerPlaceholder.SampleFor(x.Type)).ToValue(),
@@ -337,6 +369,12 @@ public partial class DesignerViewModel : ObservableObject
"""; """;
} }
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 public partial class DesignerAsset(string name, int pixelWidth, int pixelHeight, long byteCount) : ObservableObject
{ {
[ObservableProperty] private int _usageCount; [ObservableProperty] private int _usageCount;
+51 -7
View File
@@ -2,17 +2,46 @@
xmlns:local="clr-namespace:LehrerApp.TemplateDesigner" xmlns:local="clr-namespace:LehrerApp.TemplateDesigner"
x:Class="LehrerApp.TemplateDesigner.MainWindow" x:DataType="local:DesignerViewModel" Title="LehrerApp Vorlagen-Designer" x:Class="LehrerApp.TemplateDesigner.MainWindow" x:DataType="local:DesignerViewModel" Title="LehrerApp Vorlagen-Designer"
Width="1320" Height="860" MinWidth="1050" MinHeight="700" WindowStartupLocation="CenterScreen"> Width="1320" Height="860" MinWidth="1050" MinHeight="700" WindowStartupLocation="CenterScreen">
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,Auto,*">
<Border Padding="18,12" Background="#172554"> <Menu>
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto"> <MenuItem Header="_Datei">
<MenuItem Header="Neu (leeres Projekt)" InputGesture="Ctrl+N" Click="OnNew"/>
<MenuItem Header="Öffnen …" InputGesture="Ctrl+O" Click="OnOpenPackage"/>
<Separator/>
<MenuItem Header="Speichern" InputGesture="Ctrl+S" Click="OnSave"/>
<MenuItem Header="Speichern unter …" InputGesture="Ctrl+Shift+S" Click="OnSaveAs"/>
<Separator/>
<MenuItem Header="Ausgangsvorlage importieren …" Click="OnImportStarterTemplate"/>
<Separator/>
<MenuItem Header="Beenden" Click="OnExit"/>
</MenuItem>
<MenuItem Header="_Vorlage">
<MenuItem Header="Prüfen und Vorschau" InputGesture="F5" Click="OnPreview"/>
<MenuItem Header="Aktuelles Projekt als Ausgangsvorlage speichern" Click="OnSaveStarterTemplate"/>
<Separator/>
<MenuItem Header="Ausgewählte Ausgangsvorlage bearbeiten" Click="OnEditStarterTemplate"/>
<MenuItem Header="Ausgewählte Ausgangsvorlage als neues Projekt" Click="OnUseStarterTemplate"/>
<MenuItem Header="Ausgewählte Ausgangsvorlage duplizieren" Click="OnDuplicateStarterTemplate"/>
<MenuItem Header="Ausgewählte Ausgangsvorlage exportieren …" Click="OnExportStarterTemplate"/>
</MenuItem>
<MenuItem Header="_Einfügen">
<MenuItem Header="Bild-Asset …" Click="OnImportAsset"/>
<Separator/>
<MenuItem Header="PNG/JPEG als Hintergrund …" Click="OnImportBackground"/>
<MenuItem Header="PDF als Hintergrund …" Click="OnImportPdfBackground"/>
</MenuItem>
</Menu>
<Border Grid.Row="1" Padding="18,12" Background="#172554">
<Grid ColumnDefinitions="*,Auto">
<StackPanel><TextBlock Text="Vorlagen-Designer" Foreground="White" FontWeight="Bold" FontSize="20"/> <StackPanel><TextBlock Text="Vorlagen-Designer" Foreground="White" FontWeight="Bold" FontSize="20"/>
<TextBlock Text="Portable PDF-Briefpakete · Schema 1" Foreground="#BFDBFE" FontSize="12"/></StackPanel> <TextBlock Text="Portable PDF-Briefpakete · Schema 1" Foreground="#BFDBFE" FontSize="12"/></StackPanel>
<Button Grid.Column="1" Content="Neues Projekt" Click="OnNew"/> <StackPanel Grid.Column="1" HorizontalAlignment="Right">
<Button Grid.Column="3" Content="Paket öffnen …" Click="OnOpenPackage"/> <TextBlock x:Name="DocumentNameText" Foreground="White" FontWeight="SemiBold" HorizontalAlignment="Right"/>
<Button Grid.Column="5" Content="Exportieren …" Click="OnExport" IsEnabled="{Binding CanExport}"/> <TextBlock x:Name="DocumentPathText" Foreground="#BFDBFE" FontSize="11" HorizontalAlignment="Right"/>
</StackPanel>
</Grid> </Grid>
</Border> </Border>
<Grid Grid.Row="1" ColumnDefinitions="460,*,390"> <Grid Grid.Row="2" ColumnDefinitions="460,*,390">
<ScrollViewer Grid.Column="0" Padding="18"> <ScrollViewer Grid.Column="0" Padding="18">
<StackPanel Spacing="14"> <StackPanel Spacing="14">
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Meine Ausgangsvorlagen" Classes="section"/> <Grid ColumnDefinitions="*,Auto"><TextBlock Text="Meine Ausgangsvorlagen" Classes="section"/>
@@ -47,6 +76,21 @@
<StackPanel Grid.Column="4"><TextBlock Text="Einheit" Classes="label"/><ComboBox ItemsSource="{Binding Units}" SelectedItem="{Binding Unit}"/></StackPanel> <StackPanel Grid.Column="4"><TextBlock Text="Einheit" Classes="label"/><ComboBox ItemsSource="{Binding Units}" SelectedItem="{Binding Unit}"/></StackPanel>
</Grid> </Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Freie Metadaten" Classes="section"/>
<Button Grid.Column="1" Content="" Click="OnAddMetadata"/></Grid>
<DataGrid ItemsSource="{Binding MetadataItems}" SelectedItem="{Binding SelectedMetadata}"
AutoGenerateColumns="False" Height="125" CanUserResizeColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="*"/>
<DataGridTextColumn Header="Wert" Binding="{Binding Value}" Width="1.4*"/>
</DataGrid.Columns>
</DataGrid>
<Grid ColumnDefinitions="Auto,*">
<Button Content="Markierten Eintrag entfernen" Click="OnRemoveMetadata"/>
<TextBlock Grid.Column="1" Margin="10,0,0,0" Text="z. B. language=de-DE · report-type=letter"
FontSize="11" Opacity="0.6" VerticalAlignment="Center" TextWrapping="Wrap"/>
</Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Platzhalter &amp; Beispieldaten" Classes="section"/> <Grid ColumnDefinitions="*,Auto"><TextBlock Text="Platzhalter &amp; Beispieldaten" Classes="section"/>
<Button Grid.Column="1" Content="" Click="OnAddPlaceholder"/></Grid> <Button Grid.Column="1" Content="" Click="OnAddPlaceholder"/></Grid>
<DataGrid x:Name="PlaceholderGrid" ItemsSource="{Binding Placeholders}" SelectedItem="{Binding SelectedPlaceholder}" <DataGrid x:Name="PlaceholderGrid" ItemsSource="{Binding Placeholders}" SelectedItem="{Binding SelectedPlaceholder}"
+61 -10
View File
@@ -13,15 +13,32 @@ public partial class MainWindow : Window
{ {
private readonly DesignerViewModel _viewModel = new(); private readonly DesignerViewModel _viewModel = new();
private readonly StarterTemplateLibrary _starterTemplates = new(); private readonly StarterTemplateLibrary _starterTemplates = new();
private string? _currentPackagePath;
public MainWindow() public MainWindow()
{ {
InitializeComponent(); DataContext = _viewModel; InitializeComponent(); DataContext = _viewModel;
RefreshStarterTemplates(); RefreshStarterTemplates();
UpdateDocumentTitle();
} }
private void OnNew(object? sender, RoutedEventArgs e) => _viewModel.Reset(); private void OnNew(object? sender, RoutedEventArgs e)
{
_viewModel.Reset(); _currentPackagePath = null; UpdateDocumentTitle();
}
private void OnExit(object? sender, RoutedEventArgs e) => Close();
private void OnAddPlaceholder(object? sender, RoutedEventArgs e) => private void OnAddPlaceholder(object? sender, RoutedEventArgs e) =>
_viewModel.Placeholders.Add(new($"Feld{_viewModel.Placeholders.Count + 1}", PlaceholderType.Text, false, "Beispiel")); _viewModel.Placeholders.Add(new($"Feld{_viewModel.Placeholders.Count + 1}", PlaceholderType.Text, false, "Beispiel"));
private void OnAddMetadata(object? sender, RoutedEventArgs e)
{
var item = new DesignerMetadata($"custom-{_viewModel.MetadataItems.Count + 1}", "");
_viewModel.MetadataItems.Add(item); _viewModel.SelectedMetadata = item; _viewModel.CanExport = false;
}
private void OnRemoveMetadata(object? sender, RoutedEventArgs e)
{
if (_viewModel.SelectedMetadata is not { } selected) return;
_viewModel.MetadataItems.Remove(selected); _viewModel.SelectedMetadata = null; _viewModel.CanExport = false;
}
private void OnRemovePlaceholder(object? sender, RoutedEventArgs e) private void OnRemovePlaceholder(object? sender, RoutedEventArgs e)
{ {
if (this.FindControl<DataGrid>("PlaceholderGrid")?.SelectedItem is DesignerPlaceholder selected) if (this.FindControl<DataGrid>("PlaceholderGrid")?.SelectedItem is DesignerPlaceholder selected)
@@ -104,7 +121,7 @@ public partial class MainWindow : Window
try try
{ {
var (template, layout) = _starterTemplates.Load(selected); var (template, layout) = _starterTemplates.Load(selected);
_viewModel.Load(template, layout); OnPreview(sender, e); _viewModel.Load(template, layout); _currentPackagePath = null; UpdateDocumentTitle(); OnPreview(sender, e);
} }
catch (Exception ex) { _viewModel.SetStatus($"Öffnen fehlgeschlagen: {ex.Message}", true); } catch (Exception ex) { _viewModel.SetStatus($"Öffnen fehlgeschlagen: {ex.Message}", true); }
} }
@@ -117,7 +134,8 @@ public partial class MainWindow : Window
{ {
var (template, layout) = _starterTemplates.Load(selected); var (template, layout) = _starterTemplates.Load(selected);
var projectId = _starterTemplates.CreateUniqueId(template.Manifest.Id + "-neu"); var projectId = _starterTemplates.CreateUniqueId(template.Manifest.Id + "-neu");
_viewModel.LoadAsNewProject(template, layout, projectId); OnPreview(sender, e); _viewModel.LoadAsNewProject(template, layout, projectId);
_currentPackagePath = null; UpdateDocumentTitle(); OnPreview(sender, e);
} }
catch (Exception ex) { _viewModel.SetStatus($"Klonen fehlgeschlagen: {ex.Message}", true); } catch (Exception ex) { _viewModel.SetStatus($"Klonen fehlgeschlagen: {ex.Message}", true); }
} }
@@ -198,25 +216,47 @@ public partial class MainWindow : Window
{ {
var path = files[0].Path.LocalPath; var loaded = new TemplateLoader().LoadFromPackage(path); var path = files[0].Path.LocalPath; var loaded = new TemplateLoader().LoadFromPackage(path);
using var archive = ZipFile.OpenRead(path); using var reader = new StreamReader(archive.GetEntry(loaded.Manifest.LayoutFile)!.Open()); using var archive = ZipFile.OpenRead(path); using var reader = new StreamReader(archive.GetEntry(loaded.Manifest.LayoutFile)!.Open());
_viewModel.Load(loaded, reader.ReadToEnd()); OnPreview(sender, e); _viewModel.Load(loaded, reader.ReadToEnd()); _currentPackagePath = Path.GetFullPath(path);
UpdateDocumentTitle(); OnPreview(sender, e);
} }
catch (Exception ex) { _viewModel.SetStatus($"Import fehlgeschlagen: {ex.Message}", true); } catch (Exception ex) { _viewModel.SetStatus($"Öffnen fehlgeschlagen: {ex.Message}", true); }
} }
private async void OnExport(object? sender, RoutedEventArgs e) private async void OnSave(object? sender, RoutedEventArgs e)
{
if (_currentPackagePath is null) { await SaveAsAsync(); return; }
SaveTo(_currentPackagePath);
}
private async void OnSaveAs(object? sender, RoutedEventArgs e) => await SaveAsAsync();
private async Task SaveAsAsync()
{ {
try { _viewModel.BuildLoaded(); } try { _viewModel.BuildLoaded(); }
catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); return; } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); return; }
var file = await StorageProvider.SaveFilePickerAsync(new() var file = await StorageProvider.SaveFilePickerAsync(new()
{ Title = "Vorlagenpaket exportieren", SuggestedFileName = _viewModel.TemplateId + TemplatePackage.Extension, { Title = "Vorlagenpaket speichern unter", SuggestedFileName = _viewModel.TemplateId + TemplatePackage.Extension,
DefaultExtension = TemplatePackage.Extension[1..], FileTypeChoices = [PackageType()] }); DefaultExtension = TemplatePackage.Extension[1..], FileTypeChoices = [PackageType()] });
if (file is null) return; if (file is null) return;
var destination = EnsurePackageExtension(file.Path.LocalPath);
if (SaveTo(destination))
{
_currentPackagePath = Path.GetFullPath(destination);
UpdateDocumentTitle();
}
}
private bool SaveTo(string path)
{
try try
{ {
TemplatePackage.Create(file.Path.LocalPath, _viewModel.BuildManifest(), _viewModel.LayoutSource, _viewModel.Assets); _viewModel.BuildLoaded();
_viewModel.SetStatus($"Paket exportiert: {file.Name}", false); TemplatePackage.Create(path, _viewModel.BuildManifest(), _viewModel.LayoutSource, _viewModel.Assets);
_viewModel.CanExport = true;
_viewModel.SetStatus($"Gespeichert: {Path.GetFileName(path)}", false);
return true;
} }
catch (Exception ex) { _viewModel.SetStatus($"Export fehlgeschlagen: {ex.Message}", true); } catch (Exception ex) { _viewModel.SetStatus($"Speichern fehlgeschlagen: {ex.Message}", true); return false; }
} }
private async void OnImportBackground(object? sender, RoutedEventArgs e) private async void OnImportBackground(object? sender, RoutedEventArgs e)
@@ -251,6 +291,17 @@ public partial class MainWindow : Window
private static FilePickerFileType ImageType() => new("PNG/JPEG-Bilder") { Patterns = ["*.png", "*.jpg", "*.jpeg"] }; private static FilePickerFileType ImageType() => new("PNG/JPEG-Bilder") { Patterns = ["*.png", "*.jpg", "*.jpeg"] };
private static FilePickerFileType PackageType() => new("LehrerApp-Vorlagen") { Patterns = ["*.lavorlage"] }; private static FilePickerFileType PackageType() => new("LehrerApp-Vorlagen") { Patterns = ["*.lavorlage"] };
private static string EnsurePackageExtension(string path) =>
string.Equals(Path.GetExtension(path), TemplatePackage.Extension, StringComparison.OrdinalIgnoreCase)
? path : path + TemplatePackage.Extension;
private void RefreshStarterTemplates(string? selectedId = null) => private void RefreshStarterTemplates(string? selectedId = null) =>
_viewModel.SetStarterTemplates(_starterTemplates.GetAll(), selectedId); _viewModel.SetStarterTemplates(_starterTemplates.GetAll(), selectedId);
private void UpdateDocumentTitle()
{
var fileName = _currentPackagePath is null ? "Unbenannt" : Path.GetFileName(_currentPackagePath);
Title = $"{fileName} LehrerApp Vorlagen-Designer";
DocumentNameText.Text = fileName;
DocumentPathText.Text = _currentPackagePath ?? "Noch nicht gespeichert";
}
} }
@@ -108,6 +108,8 @@ public sealed class StarterTemplateLibrary
Description = source.Description, Description = source.Description,
PageSize = new(source.PageSize.Width, source.PageSize.Height, source.PageSize.Unit), PageSize = new(source.PageSize.Width, source.PageSize.Height, source.PageSize.Unit),
LayoutFile = source.LayoutFile, LayoutFile = source.LayoutFile,
MetadataFile = source.MetadataFile,
Metadata = new(source.Metadata, StringComparer.OrdinalIgnoreCase),
Placeholders = source.Placeholders.Select(x => new PlaceholderDefinition(x.Name, x.Type, x.Required)).ToList(), Placeholders = source.Placeholders.Select(x => new PlaceholderDefinition(x.Name, x.Type, x.Required)).ToList(),
}; };
@@ -0,0 +1,59 @@
using System.IO.Compression;
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Templating.Tests;
public sealed class TemplateMetadataTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"template-metadata-{Guid.NewGuid():N}");
public TemplateMetadataTests() => Directory.CreateDirectory(_directory);
[Fact]
public void Paket_SchreibtLesbareMetadatendateiUndLaedtWerteInsManifest()
{
var path = Path.Combine(_directory, "metadata.lavorlage");
var manifest = new TemplateManifest
{
Id = "metadata", Name = "Metadaten",
Metadata = new(StringComparer.OrdinalIgnoreCase)
{
["language"] = "de-DE",
["report-type"] = "parent-letter",
["filter"] = "class=7a",
},
};
TemplatePackage.Create(path, manifest, "PAGE 210 297 mm", new Dictionary<string, byte[]>());
using (var archive = ZipFile.OpenRead(path))
using (var reader = new StreamReader(archive.GetEntry("metadata.txt")!.Open()))
{
var text = reader.ReadToEnd();
Assert.Contains("language=de-DE", text);
Assert.Contains("report-type=parent-letter", text);
}
var loaded = new TemplateLoader().LoadFromPackage(path);
Assert.Equal("de-DE", loaded.Manifest.Metadata["language"]);
Assert.Equal("parent-letter", loaded.Manifest.Metadata["REPORT-TYPE"]);
Assert.Equal("class=7a", loaded.Manifest.Metadata["filter"]);
Assert.DoesNotContain("metadata.txt", loaded.Assets.Keys);
}
[Fact]
public void Textformat_IgnoriertKommentareUndLehntDoppelteSchluesselAb()
{
var parsed = TemplateMetadataText.Parse("# Kommentar\nlanguage = de-DE\nreport-type=letter\n");
Assert.Equal("de-DE", parsed["language"]);
var exception = Assert.Throws<InvalidDataException>(() =>
TemplateMetadataText.Parse("language=de-DE\nLANGUAGE=en-US"));
Assert.Contains("mehrfach", exception.Message);
}
public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, true);
}
}
+5
View File
@@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace LehrerApp.Templating; namespace LehrerApp.Templating;
public enum PlaceholderType { Text, Multiline, Date, Number, Image, Table, Chart } public enum PlaceholderType { Text, Multiline, Date, Number, Image, Table, Chart }
@@ -31,6 +33,9 @@ public sealed class TemplateManifest
public string Description { get; set; } = ""; public string Description { get; set; } = "";
public PageSizeDefinition PageSize { get; set; } = new(210, 297); public PageSizeDefinition PageSize { get; set; } = new(210, 297);
public string LayoutFile { get; set; } = "layout.tpl"; public string LayoutFile { get; set; } = "layout.tpl";
public string MetadataFile { get; set; } = TemplateMetadataText.FileName;
[JsonIgnore]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public List<PlaceholderDefinition> Placeholders { get; set; } = []; public List<PlaceholderDefinition> Placeholders { get; set; } = [];
} }
+18
View File
@@ -1,5 +1,23 @@
# LehrerApp Templating # LehrerApp Templating
## Freie Paketmetadaten
Jedes neu gespeicherte `.lavorlage`-Paket enthält eine lesbare `metadata.txt`. Pro Zeile steht ein
frei wählbares Schlüssel-Wert-Paar; leere Zeilen und mit `#` beginnende Kommentare werden beim
Einlesen ignoriert:
```text
language=de-DE
report-type=parent-letter
school-year=2026/27
```
Werte dürfen ein weiteres `=` enthalten. Schlüssel sind ohne Beachtung der Groß-/Kleinschreibung
eindeutig und Werte bleiben einzeilig. Der Loader stellt sie der Anwendung direkt über
`loadedTemplate.Manifest.Metadata` zur Verfügung. Bestehende Pakete ohne `metadata.txt` werden
weiterhin mit einer leeren Metadatensammlung geladen. Die empfohlenen Standardschlüssel stehen
zusätzlich als `TemplateMetadataKeys.Language` und `TemplateMetadataKeys.ReportType` bereit.
## Bildskalierung ## Bildskalierung
`IMG` unterstützt neben dem festen Begrenzungsrahmen eine optionale prozentuale Skalierung: `IMG` unterstützt neben dem festen Begrenzungsrahmen eine optionale prozentuale Skalierung:
+17 -1
View File
@@ -57,11 +57,26 @@ public sealed class TemplateLoader(TemplateLimits? limits = null,
if (manifest.SchemaVersion != CurrentSchemaVersion) if (manifest.SchemaVersion != CurrentSchemaVersion)
throw Error($"schemaVersion {manifest.SchemaVersion} wird nicht unterstützt."); throw Error($"schemaVersion {manifest.SchemaVersion} wird nicht unterstützt.");
if (!IsSafeRelativePath(manifest.LayoutFile)) throw Error("layoutFile enthält einen unsicheren Pfad."); if (!IsSafeRelativePath(manifest.LayoutFile)) throw Error("layoutFile enthält einen unsicheren Pfad.");
if (!IsSafeRelativePath(manifest.MetadataFile)) throw Error("metadataFile enthält einen unsicheren Pfad.");
if (Normalize(manifest.MetadataFile).Equals(Normalize(manifest.LayoutFile), StringComparison.OrdinalIgnoreCase))
throw Error("metadataFile und layoutFile dürfen nicht identisch sein.");
if (!entries.TryGetValue(Normalize(manifest.LayoutFile), out var layoutEntry)) if (!entries.TryGetValue(Normalize(manifest.LayoutFile), out var layoutEntry))
throw Error($"Layoutdatei „{manifest.LayoutFile}“ fehlt."); throw Error($"Layoutdatei „{manifest.LayoutFile}“ fehlt.");
string layoutSource; string layoutSource;
using (var reader = new StreamReader(layoutEntry.Open())) layoutSource = reader.ReadToEnd(); using (var reader = new StreamReader(layoutEntry.Open())) layoutSource = reader.ReadToEnd();
var metadataPath = Normalize(manifest.MetadataFile);
manifest.Metadata = new(StringComparer.OrdinalIgnoreCase);
if (entries.TryGetValue(metadataPath, out var metadataEntry))
{
if (metadataEntry.Length > 1024 * 1024) throw Error("metadata.txt überschreitet das Größenlimit.");
try
{
using var reader = new StreamReader(metadataEntry.Open());
manifest.Metadata = TemplateMetadataText.Parse(reader.ReadToEnd());
}
catch (InvalidDataException ex) { throw Error($"{manifest.MetadataFile} ist ungültig: {ex.Message}"); }
}
var layout = new LayoutParser().Parse(layoutSource); var layout = new LayoutParser().Parse(layoutSource);
var referencedAssets = layout.Elements.Select(AssetPath).Where(x => x is not null).Cast<string>() var referencedAssets = layout.Elements.Select(AssetPath).Where(x => x is not null).Cast<string>()
.Select(Normalize).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); .Select(Normalize).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
@@ -76,7 +91,8 @@ public sealed class TemplateLoader(TemplateLimits? limits = null,
var layoutPath = Normalize(manifest.LayoutFile); var layoutPath = Normalize(manifest.LayoutFile);
foreach (var (path, asset) in entries.Where(x => foreach (var (path, asset) in entries.Where(x =>
!x.Key.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) !x.Key.Equals("manifest.json", StringComparison.OrdinalIgnoreCase)
&& !x.Key.Equals(layoutPath, StringComparison.OrdinalIgnoreCase))) && !x.Key.Equals(layoutPath, StringComparison.OrdinalIgnoreCase)
&& !x.Key.Equals(metadataPath, StringComparison.OrdinalIgnoreCase)))
{ {
if (asset.Length > _limits.MaxAssetBytes) if (asset.Length > _limits.MaxAssetBytes)
{ issues.Add(new(ValidationSeverity.Error, $"Bild „{path}“ überschreitet das Größenlimit.")); continue; } { issues.Add(new(ValidationSeverity.Error, $"Bild „{path}“ überschreitet das Größenlimit.")); continue; }
@@ -0,0 +1,53 @@
namespace LehrerApp.Templating;
public static class TemplateMetadataKeys
{
public const string Language = "language";
public const string ReportType = "report-type";
}
public static class TemplateMetadataText
{
public const string FileName = "metadata.txt";
public static Dictionary<string, string> Parse(string source)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var lines = source.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
for (var index = 0; index < lines.Length; index++)
{
var line = lines[index].Trim();
if (line.Length == 0 || line.StartsWith('#')) continue;
var separator = line.IndexOf('=');
if (separator < 1)
throw new InvalidDataException($"Metadatenzeile {index + 1} muss Schlüssel=Wert enthalten.");
var key = line[..separator].Trim();
var value = line[(separator + 1)..].Trim();
Validate(key, value);
if (!result.TryAdd(key, value))
throw new InvalidDataException($"Metadatenschlüssel „{key}“ ist mehrfach vorhanden.");
}
return result;
}
public static string Serialize(IReadOnlyDictionary<string, string> metadata)
{
if (metadata.Count == 0) return "";
var lines = new List<string>(metadata.Count);
foreach (var (key, value) in metadata.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase))
{
Validate(key, value);
lines.Add($"{key.Trim()}={value.Trim()}");
}
return string.Join('\n', lines) + "\n";
}
private static void Validate(string key, string value)
{
if (string.IsNullOrWhiteSpace(key)) throw new InvalidDataException("Ein Metadatenschlüssel darf nicht leer sein.");
if (key.Contains('=') || key.Contains('\n') || key.Contains('\r'))
throw new InvalidDataException($"Metadatenschlüssel „{key}“ enthält ein unzulässiges Zeichen.");
if (value.Contains('\n') || value.Contains('\r'))
throw new InvalidDataException($"Metadatenwert für „{key}“ darf nur eine Zeile umfassen.");
}
}
+10
View File
@@ -10,6 +10,13 @@ public static class TemplatePackage
public static void Create(string outputPath, TemplateManifest manifest, string layoutSource, public static void Create(string outputPath, TemplateManifest manifest, string layoutSource,
IReadOnlyDictionary<string, byte[]> assets) IReadOnlyDictionary<string, byte[]> assets)
{ {
if (!TemplateLoader.IsSafeRelativePath(manifest.MetadataFile))
throw new InvalidDataException("metadataFile enthält einen unsicheren Pfad.");
if (TemplateLoader.Normalize(manifest.MetadataFile).Equals(
TemplateLoader.Normalize(manifest.LayoutFile), StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("metadataFile und layoutFile dürfen nicht identisch sein.");
var reservedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "manifest.json", TemplateLoader.Normalize(manifest.LayoutFile), TemplateLoader.Normalize(manifest.MetadataFile) };
if (!string.Equals(Path.GetExtension(outputPath), Extension, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(Path.GetExtension(outputPath), Extension, StringComparison.OrdinalIgnoreCase))
outputPath += Extension; outputPath += Extension;
var directory = Path.GetDirectoryName(outputPath); var directory = Path.GetDirectoryName(outputPath);
@@ -21,10 +28,13 @@ public static class TemplatePackage
{ {
WriteText(archive, "manifest.json", JsonSerializer.Serialize(manifest, TemplateLoader.JsonOptions)); WriteText(archive, "manifest.json", JsonSerializer.Serialize(manifest, TemplateLoader.JsonOptions));
WriteText(archive, manifest.LayoutFile, layoutSource); WriteText(archive, manifest.LayoutFile, layoutSource);
WriteText(archive, manifest.MetadataFile, TemplateMetadataText.Serialize(manifest.Metadata));
foreach (var asset in assets) foreach (var asset in assets)
{ {
if (!TemplateLoader.IsSafeRelativePath(asset.Key)) if (!TemplateLoader.IsSafeRelativePath(asset.Key))
throw new InvalidDataException($"Unsicherer Assetpfad „{asset.Key}“."); throw new InvalidDataException($"Unsicherer Assetpfad „{asset.Key}“.");
if (reservedPaths.Contains(TemplateLoader.Normalize(asset.Key)))
throw new InvalidDataException($"Assetpfad „{asset.Key}“ ist für Paketdaten reserviert.");
var entry = archive.CreateEntry(TemplateLoader.Normalize(asset.Key), CompressionLevel.Optimal); var entry = archive.CreateEntry(TemplateLoader.Normalize(asset.Key), CompressionLevel.Optimal);
using var stream = entry.Open(); stream.Write(asset.Value); using var stream = entry.Open(); stream.Write(asset.Value);
} }