This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
<Application xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LehrerApp.TemplateDesigner.App" RequestedThemeVariant="Default">
|
||||
<Application.Styles>
|
||||
<FluentTheme/>
|
||||
<Style Selector="TextBlock.section"><Setter Property="FontWeight" Value="SemiBold"/><Setter Property="FontSize" Value="15"/></Style>
|
||||
<Style Selector="TextBlock.label"><Setter Property="Opacity" Value="0.7"/><Setter Property="FontSize" Value="12"/></Style>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
@@ -0,0 +1,16 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner;
|
||||
|
||||
public sealed class App : Application
|
||||
{
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
desktop.MainWindow = new MainWindow();
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using Avalonia.Media.Imaging;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner;
|
||||
|
||||
public partial class DesignerViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private string _templateId = "elternbrief-standard";
|
||||
[ObservableProperty] private string _templateName = "Elternbrief Standard";
|
||||
[ObservableProperty] private string _description = "Briefvorlage mit Schul-Briefkopf";
|
||||
[ObservableProperty] private decimal _pageWidth = 210;
|
||||
[ObservableProperty] private decimal _pageHeight = 297;
|
||||
[ObservableProperty] private string _unit = "mm";
|
||||
[ObservableProperty] private string _layoutSource = DefaultLayout;
|
||||
[ObservableProperty] private string _newElementType = "TEXT";
|
||||
[ObservableProperty] private string _newX = "20";
|
||||
[ObservableProperty] private string _newY = "50";
|
||||
[ObservableProperty] private string _newWidth = "170";
|
||||
[ObservableProperty] private string _newHeight = "30";
|
||||
[ObservableProperty] private string _newImageScale = "100";
|
||||
[ObservableProperty] private string _newContent = "$Brieftext";
|
||||
[ObservableProperty] private string _newAttributes = "size=11";
|
||||
[ObservableProperty] private Bitmap? _previewImage;
|
||||
[ObservableProperty] private string _status = "Bereit.";
|
||||
[ObservableProperty] private string _statusColor = "#475569";
|
||||
[ObservableProperty] private bool _canExport;
|
||||
[ObservableProperty] private DesignerPlaceholder? _selectedPlaceholder;
|
||||
[ObservableProperty] private DesignerAsset? _selectedAsset;
|
||||
[ObservableProperty] private StarterTemplateItem? _selectedStarterTemplate;
|
||||
|
||||
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 ObservableCollection<DesignerPlaceholder> Placeholders { get; } =
|
||||
[
|
||||
new("Datum", PlaceholderType.Date, true, DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
|
||||
new("Empfaenger", PlaceholderType.Text, true, "Familie Beispiel"),
|
||||
new("Anrede", PlaceholderType.Text, true, "Frau Beispiel"),
|
||||
new("Brieftext", PlaceholderType.Multiline, true, "hiermit informieren wir Sie über einen wichtigen Termin.\n\nMit freundlichen Grüßen"),
|
||||
new("LehrerName", PlaceholderType.Text, true, "M. Mustermann"),
|
||||
];
|
||||
public Dictionary<string, byte[]> Assets { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public ObservableCollection<DesignerAsset> AssetItems { get; } = [];
|
||||
public ObservableCollection<StarterTemplateItem> StarterTemplates { get; } = [];
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
TemplateId = "neue-vorlage"; TemplateName = "Neue Vorlage"; Description = "";
|
||||
PageWidth = 210; PageHeight = 297; Unit = "mm"; LayoutSource = "PAGE 210 297 mm\n";
|
||||
Placeholders.Clear(); Assets.Clear(); AssetItems.Clear(); SelectedAsset = null;
|
||||
PreviewImage = null; CanExport = false;
|
||||
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",
|
||||
Placeholders = Placeholders.Select(x => new PlaceholderDefinition(x.Name.Trim(), x.Type, x.Required)).ToList(),
|
||||
};
|
||||
|
||||
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 issues = new List<ValidationIssue>();
|
||||
var declared = manifest.Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var used in TemplateLoader.UsedPlaceholders(layout).Where(x => !declared.Contains(x)))
|
||||
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{used}“ ist nicht deklariert."));
|
||||
foreach (var path in layout.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 (issues.Count > 0) throw new TemplateValidationException(new(issues));
|
||||
return new(manifest, layout, new Dictionary<string, byte[]>(Assets));
|
||||
}
|
||||
|
||||
public ITemplateDataProvider BuildDataProvider() => new DesignerDataProvider(Placeholders.ToDictionary(
|
||||
x => x.Name.Trim(), x => x.ToValue(), StringComparer.Ordinal));
|
||||
|
||||
public void Load(LoadedTemplate template, string layoutSource)
|
||||
{
|
||||
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;
|
||||
Placeholders.Clear();
|
||||
foreach (var placeholder in template.Manifest.Placeholders)
|
||||
Placeholders.Add(new(placeholder.Name, placeholder.Type, placeholder.Required, DesignerPlaceholder.SampleFor(placeholder.Type)));
|
||||
Assets.Clear(); AssetItems.Clear();
|
||||
foreach (var asset in template.Assets) AddOrReplaceAsset(asset.Key, asset.Value, keepName: true);
|
||||
RefreshAssetUsage();
|
||||
CanExport = true; SetStatus($"„{template.Manifest.Name}“ geladen.", false);
|
||||
}
|
||||
|
||||
public void LoadAsNewProject(LoadedTemplate template, string layoutSource, string? newId = null)
|
||||
{
|
||||
Load(template, layoutSource);
|
||||
TemplateId = newId ?? template.Manifest.Id + "-neu";
|
||||
TemplateName = template.Manifest.Name + " - Neu";
|
||||
CanExport = false;
|
||||
SetStatus($"Neues unabhängiges Projekt aus „{template.Manifest.Name}“ erstellt. Bitte ID und Namen prüfen.", false);
|
||||
}
|
||||
|
||||
public void PreviewTemplate(LoadedTemplate template)
|
||||
{
|
||||
var provider = BuildSampleDataProvider(template.Manifest);
|
||||
var bytes = new QuestTemplateRenderer().RenderFirstPageToPng(template, provider);
|
||||
using var stream = new MemoryStream(bytes);
|
||||
PreviewImage?.Dispose(); PreviewImage = new Bitmap(stream);
|
||||
SetStatus($"Vorschau von „{template.Manifest.Name}“.", false);
|
||||
}
|
||||
|
||||
public void SetStarterTemplates(IEnumerable<StarterTemplateItem> templates, string? selectedId = null)
|
||||
{
|
||||
StarterTemplates.Clear();
|
||||
foreach (var template in templates) StarterTemplates.Add(template);
|
||||
SelectedStarterTemplate = StarterTemplates.FirstOrDefault(x => x.Id.Equals(selectedId, StringComparison.OrdinalIgnoreCase))
|
||||
?? StarterTemplates.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static ITemplateDataProvider BuildSampleDataProvider(TemplateManifest manifest) =>
|
||||
new DesignerDataProvider(manifest.Placeholders.ToDictionary(x => x.Name,
|
||||
x => new DesignerPlaceholder(x.Name, x.Type, x.Required, DesignerPlaceholder.SampleFor(x.Type)).ToValue(),
|
||||
StringComparer.Ordinal));
|
||||
|
||||
public void AddElement()
|
||||
{
|
||||
var attrs = string.IsNullOrWhiteSpace(NewAttributes) ? "" : " " + NewAttributes.Trim();
|
||||
var line = NewElementType switch
|
||||
{
|
||||
"TEXT" => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}",
|
||||
"TEXTBOX" => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
|
||||
"IMG" => $"IMG {NewContent} {NewX} {NewY} {NewWidth} {NewHeight} scale={NormalizedImageScale()}%",
|
||||
"TABLE" => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
|
||||
"CHART" => $"CHART {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
|
||||
_ => throw new InvalidOperationException("Unbekannter Elementtyp."),
|
||||
};
|
||||
LayoutSource = LayoutSource.TrimEnd() + Environment.NewLine + line + Environment.NewLine;
|
||||
CanExport = false; SetStatus("Element ergänzt. Vorschau zur Prüfung aktualisieren.", false);
|
||||
}
|
||||
|
||||
public DesignerAsset ImportAsset(string sourceName, byte[] bytes) => AddOrReplaceAsset(sourceName, bytes, keepName: false);
|
||||
|
||||
public DesignerAsset ImportBackground(string sourceName, byte[] bytes)
|
||||
{
|
||||
var asset = AddOrReplaceAsset(sourceName, bytes, keepName: false);
|
||||
var lines = LayoutSource.Split('\n').Where(x => !IsAssetStatement(x, "BG", null)).ToList();
|
||||
var page = lines.FindIndex(x => x.TrimStart().StartsWith("PAGE ", StringComparison.OrdinalIgnoreCase));
|
||||
lines.Insert(Math.Max(0, page + 1), $"BG {asset.Name}");
|
||||
LayoutSource = string.Join('\n', lines);
|
||||
SelectedAsset = asset; CanExport = false;
|
||||
SetStatus($"Hintergrund „{asset.Name}“ importiert. Vorschau aktualisieren.", false);
|
||||
return asset;
|
||||
}
|
||||
|
||||
public void InsertSelectedAssetAsImage()
|
||||
{
|
||||
if (SelectedAsset is null) throw new InvalidOperationException("Bitte zuerst ein Asset auswählen.");
|
||||
NewElementType = "IMG"; NewContent = SelectedAsset.Name; NewAttributes = "";
|
||||
AddElement();
|
||||
SetStatus($"„{SelectedAsset.Name}“ wurde als IMG-Element eingefügt. Vorschau aktualisieren.", false);
|
||||
}
|
||||
|
||||
public void ReplaceSelectedAsset(byte[] bytes)
|
||||
{
|
||||
if (SelectedAsset is null) throw new InvalidOperationException("Bitte zuerst ein Asset auswählen.");
|
||||
var name = SelectedAsset.Name;
|
||||
AddOrReplaceAsset(name, bytes, keepName: true);
|
||||
SelectedAsset = AssetItems.First(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
CanExport = false; SetStatus($"Asset „{name}“ wurde ersetzt. Alle Referenzen bleiben erhalten.", false);
|
||||
}
|
||||
|
||||
public void RemoveSelectedAsset()
|
||||
{
|
||||
if (SelectedAsset is null) throw new InvalidOperationException("Bitte zuerst ein Asset auswählen.");
|
||||
var selected = SelectedAsset;
|
||||
var lines = LayoutSource.Split('\n').ToList();
|
||||
var removedReferences = lines.RemoveAll(line => IsAssetStatement(line, "BG", selected.Name)
|
||||
|| IsAssetStatement(line, "IMG", selected.Name));
|
||||
LayoutSource = string.Join('\n', lines);
|
||||
Assets.Remove(selected.Name); AssetItems.Remove(selected); SelectedAsset = null;
|
||||
CanExport = false;
|
||||
SetStatus(removedReferences == 0
|
||||
? $"Asset „{selected.Name}“ wurde entfernt."
|
||||
: $"Asset „{selected.Name}“ und {removedReferences} Layout-Referenz(en) wurden entfernt.", false);
|
||||
}
|
||||
|
||||
partial void OnLayoutSourceChanged(string value) => RefreshAssetUsage();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public void SetStatus(string text, bool error)
|
||||
{ Status = text; StatusColor = error ? "#B91C1C" : "#166534"; }
|
||||
private static string QuoteIfLiteral(string value) => value.StartsWith('$') ? value : $"\"{value.Replace("\"", "\\\"")}\"";
|
||||
|
||||
private const string DefaultLayout = """
|
||||
# Elternbrief Standard
|
||||
PAGE 210 297 mm
|
||||
TEXT 20 25 "Elternbrief" size=18 bold=true color=#1E3A8A
|
||||
TEXT 20 43 $Datum|dd.MM.yyyy size=10
|
||||
TEXT 20 55 $Empfaenger size=11
|
||||
TEXT 20 75 "Sehr geehrte/r $Anrede," size=11
|
||||
TEXTBOX 20 90 170 155 $Brieftext size=11 wrap=true
|
||||
TEXT 20 265 $LehrerName size=10 italic=true
|
||||
""";
|
||||
}
|
||||
|
||||
public partial class 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;
|
||||
public DesignerPlaceholder(string name, PlaceholderType type, bool required, string sample)
|
||||
{ _name = name; _type = type; _required = required; _sample = sample; }
|
||||
|
||||
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),
|
||||
_ => 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", _ => "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 sealed class DesignerDataProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
|
||||
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="PDFtoImage" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,130 @@
|
||||
<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:LehrerApp.TemplateDesigner"
|
||||
x:Class="LehrerApp.TemplateDesigner.MainWindow" x:DataType="local:DesignerViewModel" Title="LehrerApp Vorlagen-Designer"
|
||||
Width="1320" Height="860" MinWidth="1050" MinHeight="700" WindowStartupLocation="CenterScreen">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Border Padding="18,12" Background="#172554">
|
||||
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto">
|
||||
<StackPanel><TextBlock Text="Vorlagen-Designer" Foreground="White" FontWeight="Bold" FontSize="20"/>
|
||||
<TextBlock Text="Portable PDF-Briefpakete · Schema 1" Foreground="#BFDBFE" FontSize="12"/></StackPanel>
|
||||
<Button Grid.Column="1" Content="Neues Projekt" Click="OnNew"/>
|
||||
<Button Grid.Column="3" Content="Paket öffnen …" Click="OnOpenPackage"/>
|
||||
<Button Grid.Column="5" Content="Exportieren …" Click="OnExport" IsEnabled="{Binding CanExport}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid Grid.Row="1" ColumnDefinitions="460,*,390">
|
||||
<ScrollViewer Grid.Column="0" Padding="18">
|
||||
<StackPanel Spacing="14">
|
||||
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Meine Ausgangsvorlagen" Classes="section"/>
|
||||
<Button Grid.Column="1" Content="Importieren …" Click="OnImportStarterTemplate"/></Grid>
|
||||
<ComboBox ItemsSource="{Binding StarterTemplates}" SelectedItem="{Binding SelectedStarterTemplate}"
|
||||
DisplayMemberBinding="{Binding Display}" PlaceholderText="Noch keine Ausgangsvorlage"/>
|
||||
<TextBlock Text="{Binding SelectedStarterTemplate.Description}" FontSize="12" Opacity="0.7"
|
||||
TextWrapping="Wrap"/>
|
||||
<Grid ColumnDefinitions="*,8,*,8,*" RowDefinitions="Auto,8,Auto,8,Auto">
|
||||
<Button Grid.Column="0" Content="Vorschau" Click="OnPreviewStarterTemplate"/>
|
||||
<Button Grid.Column="2" Content="Bearbeiten" Click="OnEditStarterTemplate"/>
|
||||
<Button Grid.Column="4" Content="Als neues Projekt" Click="OnUseStarterTemplate"/>
|
||||
<Button Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" Content="Aktuelles Projekt speichern/aktualisieren"
|
||||
Click="OnSaveStarterTemplate"/>
|
||||
<Button Grid.Row="2" Grid.Column="4" Content="Duplizieren" Click="OnDuplicateStarterTemplate"/>
|
||||
<Button Grid.Row="4" Grid.Column="0" Content="Exportieren …" Click="OnExportStarterTemplate"/>
|
||||
<Button Grid.Row="4" Grid.Column="2" Content="Löschen" Click="OnDeleteStarterTemplate"/>
|
||||
</Grid>
|
||||
<TextBlock Text="Ausgangsvorlagen werden lokal gespeichert. Beim Erstellen eines neuen Projekts bleiben Original und Briefkopf unverändert."
|
||||
FontSize="11" Opacity="0.6" TextWrapping="Wrap"/>
|
||||
<Separator/>
|
||||
|
||||
<TextBlock Text="Paket" Classes="section"/>
|
||||
<Grid ColumnDefinitions="*,10,*" RowDefinitions="Auto,Auto">
|
||||
<StackPanel><TextBlock Text="Stabile ID" Classes="label"/><TextBox Text="{Binding TemplateId}"/></StackPanel>
|
||||
<StackPanel Grid.Column="2"><TextBlock Text="Name" Classes="label"/><TextBox Text="{Binding TemplateName}"/></StackPanel>
|
||||
<StackPanel Grid.Row="1" Grid.ColumnSpan="3" Margin="0,8,0,0"><TextBlock Text="Beschreibung" Classes="label"/><TextBox Text="{Binding Description}"/></StackPanel>
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="*,8,*,8,*">
|
||||
<StackPanel><TextBlock Text="Breite" Classes="label"/><NumericUpDown Value="{Binding PageWidth}" Minimum="10" Maximum="2000"/></StackPanel>
|
||||
<StackPanel Grid.Column="2"><TextBlock Text="Höhe" Classes="label"/><NumericUpDown Value="{Binding PageHeight}" Minimum="10" Maximum="2000"/></StackPanel>
|
||||
<StackPanel Grid.Column="4"><TextBlock Text="Einheit" Classes="label"/><ComboBox ItemsSource="{Binding Units}" SelectedItem="{Binding Unit}"/></StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Platzhalter & Beispieldaten" Classes="section"/>
|
||||
<Button Grid.Column="1" Content="+" Click="OnAddPlaceholder"/></Grid>
|
||||
<DataGrid x:Name="PlaceholderGrid" ItemsSource="{Binding Placeholders}" SelectedItem="{Binding SelectedPlaceholder}"
|
||||
AutoGenerateColumns="False" Height="210" CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="*"/>
|
||||
<DataGridTextColumn Header="Typ" Binding="{Binding Type}" Width="105" IsReadOnly="True"/>
|
||||
<DataGridCheckBoxColumn Header="Pflicht" Binding="{Binding Required}" Width="58"/>
|
||||
<DataGridTextColumn Header="Beispiel" Binding="{Binding Sample}" Width="*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<Grid ColumnDefinitions="Auto,10,*">
|
||||
<Button Content="Markierten Platzhalter entfernen" Click="OnRemovePlaceholder"/>
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<TextBlock Text="Typ:" VerticalAlignment="Center"/>
|
||||
<ComboBox Width="130" ItemsSource="{Binding PlaceholderTypes}" SelectedItem="{Binding SelectedPlaceholder.Type}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Paket-Assets" Classes="section"/>
|
||||
<Button Grid.Column="1" Content="+ Bild importieren …" Click="OnImportAsset"/></Grid>
|
||||
<DataGrid ItemsSource="{Binding AssetItems}" SelectedItem="{Binding SelectedAsset}"
|
||||
AutoGenerateColumns="False" Height="150" IsReadOnly="True" CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Datei" Binding="{Binding Name}" Width="*"/>
|
||||
<DataGridTextColumn Header="Auflösung" Binding="{Binding Dimensions}" Width="125"/>
|
||||
<DataGridTextColumn Header="Größe" Binding="{Binding FileSize}" Width="70"/>
|
||||
<DataGridTextColumn Header="Verwendung" Binding="{Binding Usage}" Width="105"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<Grid ColumnDefinitions="*,8,*,8,*">
|
||||
<Button Grid.Column="0" Content="Als IMG einfügen" Click="OnInsertSelectedAsset"/>
|
||||
<Button Grid.Column="2" Content="Ersetzen …" Click="OnReplaceSelectedAsset"/>
|
||||
<Button Grid.Column="4" Content="Entfernen" Click="OnRemoveSelectedAsset"/>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="Element hinzufügen" Classes="section"/>
|
||||
<ComboBox ItemsSource="{Binding ElementTypes}" SelectedItem="{Binding NewElementType}"/>
|
||||
<Grid ColumnDefinitions="*,6,*,6,*,6,*">
|
||||
<StackPanel><TextBlock Text="X" Classes="label"/><TextBox Text="{Binding NewX}"/></StackPanel>
|
||||
<StackPanel Grid.Column="2"><TextBlock Text="Y" Classes="label"/><TextBox Text="{Binding NewY}"/></StackPanel>
|
||||
<StackPanel Grid.Column="4"><TextBlock Text="Breite" Classes="label"/><TextBox Text="{Binding NewWidth}"/></StackPanel>
|
||||
<StackPanel Grid.Column="6"><TextBlock Text="Höhe" Classes="label"/><TextBox Text="{Binding NewHeight}"/></StackPanel>
|
||||
</Grid>
|
||||
<StackPanel>
|
||||
<TextBlock Text="IMG-Skalierung in % (nur für IMG)" Classes="label"/>
|
||||
<TextBox Text="{Binding NewImageScale}" PlaceholderText="100"/>
|
||||
</StackPanel>
|
||||
<StackPanel><TextBlock Text="Inhalt / $Platzhalter / Assetpfad" Classes="label"/><TextBox Text="{Binding NewContent}" PlaceholderText="$Brieftext oder ein Literal"/></StackPanel>
|
||||
<StackPanel><TextBlock Text="Attribute" Classes="label"/><TextBox Text="{Binding NewAttributes}" PlaceholderText="size=11 bold=true"/></StackPanel>
|
||||
<Button Content="Element ins Layout übernehmen" Click="OnAddElement"/>
|
||||
<Button Content="PNG/JPEG als Hintergrund importieren …" Click="OnImportBackground"/>
|
||||
<Button Content="PDF als Hintergrund (300 DPI) importieren …" Click="OnImportPdfBackground"/>
|
||||
<TextBlock Text="Bekannte Einschränkung: TEXTBOX-Überlauf wird in v1 nicht automatisch auf Folgeseiten verteilt."
|
||||
TextWrapping="Wrap" Foreground="#B45309" FontSize="12"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,*" Margin="0,18">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="12,0,12,10"><TextBlock Text="Layout-DSL" Classes="section"/>
|
||||
<Button Grid.Column="1" Content="Prüfen & Vorschau" Click="OnPreview"/></Grid>
|
||||
<TextBox Grid.Row="1" Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap"
|
||||
FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto" Margin="12"/>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Column="2" Background="#E2E8F0" Padding="18">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<TextBlock Text="PDF-Vorschau" Classes="section"/>
|
||||
<Border Grid.Row="1" Margin="0,12" Background="White" BorderBrush="#94A3B8" BorderThickness="1">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
|
||||
<Image Source="{Binding PreviewImage}" Stretch="Uniform" MaxWidth="740"/>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
<StackPanel Grid.Row="2" Spacing="4"><TextBlock Text="{Binding Status}" TextWrapping="Wrap" Foreground="{Binding StatusColor}"/>
|
||||
<TextBlock Text="Die Vorschau wird mit derselben QuestPDF-Pipeline wie in LehrerApp erzeugt." FontSize="11" Opacity="0.65" TextWrapping="Wrap"/></StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,241 @@
|
||||
using System.IO.Compression;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LehrerApp.Templating;
|
||||
using PDFtoImage;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private readonly DesignerViewModel _viewModel = new();
|
||||
private readonly StarterTemplateLibrary _starterTemplates = new();
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent(); DataContext = _viewModel;
|
||||
RefreshStarterTemplates();
|
||||
}
|
||||
|
||||
private void OnNew(object? sender, RoutedEventArgs e) => _viewModel.Reset();
|
||||
private void OnAddPlaceholder(object? sender, RoutedEventArgs e) =>
|
||||
_viewModel.Placeholders.Add(new($"Feld{_viewModel.Placeholders.Count + 1}", PlaceholderType.Text, false, "Beispiel"));
|
||||
private void OnRemovePlaceholder(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (this.FindControl<DataGrid>("PlaceholderGrid")?.SelectedItem is DesignerPlaceholder selected)
|
||||
_viewModel.Placeholders.Remove(selected);
|
||||
}
|
||||
private void OnAddElement(object? sender, RoutedEventArgs e)
|
||||
{ try { _viewModel.AddElement(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
|
||||
private void OnInsertSelectedAsset(object? sender, RoutedEventArgs e)
|
||||
{ try { _viewModel.InsertSelectedAssetAsImage(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
|
||||
private void OnRemoveSelectedAsset(object? sender, RoutedEventArgs e)
|
||||
{ try { _viewModel.RemoveSelectedAsset(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
|
||||
|
||||
private async void OnImportAsset(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new()
|
||||
{ Title = "Bilder ins Vorlagenpaket importieren", AllowMultiple = true, FileTypeFilter = [ImageType()] });
|
||||
if (files.Count == 0) return;
|
||||
try
|
||||
{
|
||||
foreach (var file in files)
|
||||
_viewModel.ImportAsset(file.Name, await File.ReadAllBytesAsync(file.Path.LocalPath));
|
||||
_viewModel.CanExport = false;
|
||||
_viewModel.SetStatus(files.Count == 1
|
||||
? $"Asset „{_viewModel.SelectedAsset?.Name}“ importiert."
|
||||
: $"{files.Count} Assets importiert.", false);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Bildimport fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private async void OnReplaceSelectedAsset(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedAsset is null) { _viewModel.SetStatus("Bitte zuerst ein Asset auswählen.", true); return; }
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new()
|
||||
{ Title = $"„{_viewModel.SelectedAsset.Name}“ ersetzen", AllowMultiple = false, FileTypeFilter = [ImageType()] });
|
||||
if (files.Count == 0) return;
|
||||
try { _viewModel.ReplaceSelectedAsset(await File.ReadAllBytesAsync(files[0].Path.LocalPath)); }
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Ersetzen fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private void OnPreview(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = new QuestTemplateRenderer().RenderFirstPageToPng(_viewModel.BuildLoaded(), _viewModel.BuildDataProvider());
|
||||
using var stream = new MemoryStream(bytes);
|
||||
_viewModel.PreviewImage?.Dispose(); _viewModel.PreviewImage = new Bitmap(stream);
|
||||
_viewModel.CanExport = true; _viewModel.SetStatus("Validierung erfolgreich. Vorschau ist aktuell.", false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{ _viewModel.CanExport = false; _viewModel.SetStatus(ex.Message, true); }
|
||||
}
|
||||
|
||||
private void OnPreviewStarterTemplate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedStarterTemplate is not { } selected)
|
||||
{ _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; }
|
||||
try { var (template, _) = _starterTemplates.Load(selected); _viewModel.PreviewTemplate(template); }
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Vorschau fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private void OnEditStarterTemplate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedStarterTemplate is not { } selected)
|
||||
{ _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; }
|
||||
try
|
||||
{
|
||||
var (template, layout) = _starterTemplates.Load(selected);
|
||||
_viewModel.Load(template, layout); OnPreview(sender, e);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Öffnen fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private void OnUseStarterTemplate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedStarterTemplate is not { } selected)
|
||||
{ _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; }
|
||||
try
|
||||
{
|
||||
var (template, layout) = _starterTemplates.Load(selected);
|
||||
var projectId = _starterTemplates.CreateUniqueId(template.Manifest.Id + "-neu");
|
||||
_viewModel.LoadAsNewProject(template, layout, projectId); OnPreview(sender, e);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Klonen fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private void OnSaveStarterTemplate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
_viewModel.BuildLoaded();
|
||||
var saved = _starterTemplates.Save(_viewModel.BuildManifest(), _viewModel.LayoutSource, _viewModel.Assets);
|
||||
RefreshStarterTemplates(saved.Id);
|
||||
_viewModel.SetStatus($"Ausgangsvorlage „{saved.Name}“ lokal gespeichert/aktualisiert.", false);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Speichern fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private void OnDuplicateStarterTemplate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedStarterTemplate is not { } selected)
|
||||
{ _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; }
|
||||
try
|
||||
{
|
||||
var duplicate = _starterTemplates.Duplicate(selected);
|
||||
RefreshStarterTemplates(duplicate.Id);
|
||||
_viewModel.SetStatus($"Ausgangsvorlage als „{duplicate.Name}“ dupliziert.", false);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Duplizieren fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private async void OnImportStarterTemplate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new()
|
||||
{ Title = "Ausgangsvorlage importieren", AllowMultiple = false, FileTypeFilter = [PackageType()] });
|
||||
if (files.Count == 0) return;
|
||||
try
|
||||
{
|
||||
var imported = _starterTemplates.Import(files[0].Path.LocalPath);
|
||||
RefreshStarterTemplates(imported.Id);
|
||||
_viewModel.SetStatus($"Ausgangsvorlage „{imported.Name}“ importiert.", false);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Import fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private async void OnExportStarterTemplate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedStarterTemplate is not { } selected)
|
||||
{ _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; }
|
||||
var file = await StorageProvider.SaveFilePickerAsync(new()
|
||||
{ Title = "Ausgangsvorlage exportieren", SuggestedFileName = selected.Id + TemplatePackage.Extension,
|
||||
DefaultExtension = TemplatePackage.Extension[1..], FileTypeChoices = [PackageType()] });
|
||||
if (file is null) return;
|
||||
try
|
||||
{
|
||||
_starterTemplates.Export(selected, file.Path.LocalPath);
|
||||
_viewModel.SetStatus($"Ausgangsvorlage exportiert: {file.Name}", false);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Export fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private void OnDeleteStarterTemplate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedStarterTemplate is not { } selected)
|
||||
{ _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; }
|
||||
try
|
||||
{
|
||||
_starterTemplates.Delete(selected); RefreshStarterTemplates();
|
||||
_viewModel.SetStatus($"Ausgangsvorlage „{selected.Name}“ gelöscht.", false);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Löschen fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private async void OnOpenPackage(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new()
|
||||
{ Title = "Vorlagenpaket öffnen", AllowMultiple = false, FileTypeFilter = [PackageType()] });
|
||||
if (files.Count == 0) return;
|
||||
try
|
||||
{
|
||||
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());
|
||||
_viewModel.Load(loaded, reader.ReadToEnd()); OnPreview(sender, e);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Import fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private async void OnExport(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try { _viewModel.BuildLoaded(); }
|
||||
catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); return; }
|
||||
var file = await StorageProvider.SaveFilePickerAsync(new()
|
||||
{ Title = "Vorlagenpaket exportieren", SuggestedFileName = _viewModel.TemplateId + TemplatePackage.Extension,
|
||||
DefaultExtension = TemplatePackage.Extension[1..], FileTypeChoices = [PackageType()] });
|
||||
if (file is null) return;
|
||||
try
|
||||
{
|
||||
TemplatePackage.Create(file.Path.LocalPath, _viewModel.BuildManifest(), _viewModel.LayoutSource, _viewModel.Assets);
|
||||
_viewModel.SetStatus($"Paket exportiert: {file.Name}", false);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Export fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private async void OnImportBackground(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new()
|
||||
{ Title = "Hintergrundbild importieren", AllowMultiple = false,
|
||||
FileTypeFilter = [ImageType()] });
|
||||
if (files.Count == 0) return;
|
||||
try { _viewModel.ImportBackground(files[0].Name, await File.ReadAllBytesAsync(files[0].Path.LocalPath)); }
|
||||
catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); }
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
[SupportedOSPlatform("linux")]
|
||||
[SupportedOSPlatform("macos")]
|
||||
private async void OnImportPdfBackground(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new()
|
||||
{ Title = "PDF-Briefkopf importieren", AllowMultiple = false,
|
||||
FileTypeFilter = [new("PDF-Dateien") { Patterns = ["*.pdf"] }] });
|
||||
if (files.Count == 0) return;
|
||||
var temporary = Path.Combine(Path.GetTempPath(), $"lehrerapp-bg-{Guid.NewGuid():N}.png");
|
||||
try
|
||||
{
|
||||
await using var pdf = File.OpenRead(files[0].Path.LocalPath);
|
||||
Conversion.SavePng(temporary, pdf, page: 0, options: new RenderOptions(Dpi: 300));
|
||||
_viewModel.ImportBackground("background.png", await File.ReadAllBytesAsync(temporary));
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"PDF-Import fehlgeschlagen: {ex.Message}", true); }
|
||||
finally { if (File.Exists(temporary)) File.Delete(temporary); }
|
||||
}
|
||||
|
||||
private static FilePickerFileType ImageType() => new("PNG/JPEG-Bilder") { Patterns = ["*.png", "*.jpg", "*.jpeg"] };
|
||||
private static FilePickerFileType PackageType() => new("LehrerApp-Vorlagen") { Patterns = ["*.lavorlage"] };
|
||||
private void RefreshStarterTemplates(string? selectedId = null) =>
|
||||
_viewModel.SetStarterTemplates(_starterTemplates.GetAll(), selectedId);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<App>().UsePlatformDetect().WithInterFont().LogToTrace();
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.IO.Compression;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner;
|
||||
|
||||
public sealed record StarterTemplateItem(
|
||||
string Id, string Name, string Description, string PackagePath, DateTime UpdatedAtUtc)
|
||||
{
|
||||
public string Display => string.IsNullOrWhiteSpace(Description) ? Name : $"{Name} · {Description}";
|
||||
}
|
||||
|
||||
public sealed class StarterTemplateLibrary
|
||||
{
|
||||
private readonly string _directory;
|
||||
private readonly ITemplateLoader _loader;
|
||||
|
||||
public StarterTemplateLibrary(string? directory = null, ITemplateLoader? loader = null)
|
||||
{
|
||||
_directory = directory ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"LehrerApp", "TemplateDesigner", "starter-templates");
|
||||
Directory.CreateDirectory(_directory);
|
||||
_loader = loader ?? new TemplateLoader();
|
||||
}
|
||||
|
||||
public IReadOnlyList<StarterTemplateItem> GetAll()
|
||||
{
|
||||
var result = new List<StarterTemplateItem>();
|
||||
foreach (var path in Directory.EnumerateFiles(_directory, $"*{TemplatePackage.Extension}"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var template = _loader.LoadFromPackage(path);
|
||||
result.Add(ToItem(template.Manifest, path));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { }
|
||||
}
|
||||
return result.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
public StarterTemplateItem Save(TemplateManifest manifest, string layoutSource,
|
||||
IReadOnlyDictionary<string, byte[]> assets)
|
||||
{
|
||||
var path = Path.Combine(_directory, SafeId(manifest.Id) + TemplatePackage.Extension);
|
||||
TemplatePackage.Create(path, manifest, layoutSource, assets);
|
||||
return ToItem(manifest, path);
|
||||
}
|
||||
|
||||
public StarterTemplateItem Import(string sourcePath)
|
||||
{
|
||||
var (template, layout) = LoadPackage(sourcePath);
|
||||
return Save(template.Manifest, layout, template.Assets);
|
||||
}
|
||||
|
||||
public void Export(StarterTemplateItem item, string destinationPath)
|
||||
{
|
||||
if (!File.Exists(item.PackagePath)) throw new FileNotFoundException("Ausgangsvorlage nicht gefunden.", item.PackagePath);
|
||||
if (!string.Equals(Path.GetExtension(destinationPath), TemplatePackage.Extension, StringComparison.OrdinalIgnoreCase))
|
||||
destinationPath += TemplatePackage.Extension;
|
||||
var directory = Path.GetDirectoryName(destinationPath);
|
||||
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
File.Copy(item.PackagePath, destinationPath, overwrite: true);
|
||||
}
|
||||
|
||||
public StarterTemplateItem Duplicate(StarterTemplateItem item)
|
||||
{
|
||||
var (template, layout) = Load(item);
|
||||
var id = CreateUniqueId(template.Manifest.Id + "-kopie");
|
||||
var manifest = CopyManifest(template.Manifest, id, template.Manifest.Name + " - Kopie");
|
||||
return Save(manifest, layout, template.Assets);
|
||||
}
|
||||
|
||||
public (LoadedTemplate Template, string LayoutSource) Load(StarterTemplateItem item) =>
|
||||
LoadPackage(item.PackagePath);
|
||||
|
||||
public void Delete(StarterTemplateItem item)
|
||||
{
|
||||
if (File.Exists(item.PackagePath)) File.Delete(item.PackagePath);
|
||||
}
|
||||
|
||||
private (LoadedTemplate Template, string LayoutSource) LoadPackage(string path)
|
||||
{
|
||||
var template = _loader.LoadFromPackage(path);
|
||||
using var archive = ZipFile.OpenRead(path);
|
||||
var layoutEntry = archive.Entries.FirstOrDefault(x => x.FullName.Equals(
|
||||
template.Manifest.LayoutFile.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidDataException($"Layoutdatei „{template.Manifest.LayoutFile}“ fehlt.");
|
||||
using var reader = new StreamReader(layoutEntry.Open());
|
||||
return (template, reader.ReadToEnd());
|
||||
}
|
||||
|
||||
public string CreateUniqueId(string baseId)
|
||||
{
|
||||
var existing = GetAll().Select(x => x.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
if (!existing.Contains(baseId)) return baseId;
|
||||
for (var index = 2; ; index++)
|
||||
{
|
||||
var candidate = $"{baseId}-{index}";
|
||||
if (!existing.Contains(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
private static TemplateManifest CopyManifest(TemplateManifest source, string id, string name) => new()
|
||||
{
|
||||
SchemaVersion = source.SchemaVersion,
|
||||
Id = id,
|
||||
Name = name,
|
||||
Description = source.Description,
|
||||
PageSize = new(source.PageSize.Width, source.PageSize.Height, source.PageSize.Unit),
|
||||
LayoutFile = source.LayoutFile,
|
||||
Placeholders = source.Placeholders.Select(x => new PlaceholderDefinition(x.Name, x.Type, x.Required)).ToList(),
|
||||
};
|
||||
|
||||
private static StarterTemplateItem ToItem(TemplateManifest manifest, string path) =>
|
||||
new(manifest.Id, manifest.Name, manifest.Description, path, File.GetLastWriteTimeUtc(path));
|
||||
|
||||
private static string SafeId(string id)
|
||||
{
|
||||
var safe = string.Concat(id.Select(c => char.IsAsciiLetterOrDigit(c) || c == '-' ? c : '-')).Trim('-');
|
||||
return safe.Length > 0 ? safe : throw new InvalidDataException("Die Vorlagen-ID ist ungültig.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user