From 3f71f7f371b70a3551f62ac0a6d1944ab0c6141a Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Sun, 30 Aug 2026 19:29:06 +0200 Subject: [PATCH] Add visual template coordinate editor --- .../OverlayEditorTests.cs | 73 +++++ .../DesignerViewModel.cs | 70 ++++- .../LayoutOverlayEditor.cs | 276 ++++++++++++++++++ LehrerApp.TemplateDesigner/MainWindow.axaml | 40 ++- .../MainWindow.axaml.cs | 15 + LehrerApp.Templating/LayoutParser.cs | 2 +- LehrerApp.Templating/README.md | 17 ++ 7 files changed, 483 insertions(+), 10 deletions(-) create mode 100644 LehrerApp.TemplateDesigner.Tests/OverlayEditorTests.cs create mode 100644 LehrerApp.TemplateDesigner/LayoutOverlayEditor.cs diff --git a/LehrerApp.TemplateDesigner.Tests/OverlayEditorTests.cs b/LehrerApp.TemplateDesigner.Tests/OverlayEditorTests.cs new file mode 100644 index 0000000..9aea98b --- /dev/null +++ b/LehrerApp.TemplateDesigner.Tests/OverlayEditorTests.cs @@ -0,0 +1,73 @@ +using Avalonia; +using LehrerApp.TemplateDesigner; +using Xunit; + +namespace LehrerApp.TemplateDesigner.Tests; + +public sealed class OverlayEditorTests +{ + [Fact] + public void Koordinatenabbildung_BeruecksichtigtLetterboxUndIstUmkehrbar() + { + var displayedPage = OverlayCoordinateMapper.PageRect(new Size(1000, 1000), 210, 297); + var dslRect = new Rect(20, 30, 80, 45); + + var controlRect = OverlayCoordinateMapper.ToControl(dslRect, displayedPage, 210, 297); + var mappedTopLeft = OverlayCoordinateMapper.ToDsl(controlRect.TopLeft, displayedPage, 210, 297); + var mappedBottomRight = OverlayCoordinateMapper.ToDsl(controlRect.BottomRight, displayedPage, 210, 297); + + Assert.Equal(1000, displayedPage.Height, 6); + Assert.True(displayedPage.X > 140); + Assert.Equal(dslRect.X, mappedTopLeft.X, 6); + Assert.Equal(dslRect.Y, mappedTopLeft.Y, 6); + Assert.Equal(dslRect.Right, mappedBottomRight.X, 6); + Assert.Equal(dslRect.Bottom, mappedBottomRight.Y, 6); + } + + [Fact] + public void Messbereich_WirdInElementformularUebernommen() + { + var viewModel = new DesignerViewModel(); + + viewModel.ApplyMeasurement(new(12.5, 34.25, 80, 22.5, true)); + + Assert.Equal("12.5", viewModel.NewX); + Assert.Equal("34.25", viewModel.NewY); + Assert.Equal("80", viewModel.NewWidth); + Assert.Equal("22.5", viewModel.NewHeight); + } + + [Fact] + public void TextVerschieben_ErhaeltInhaltUndAttribute() + { + var viewModel = new DesignerViewModel + { LayoutSource = "PAGE 210 297 mm\nTEXT 20 30 \"Hallo Welt\" size=10 bold=true" }; + + viewModel.ApplyElementGeometry(new(2, "TEXT", 25.5, 40, 70, 5)); + + Assert.Contains("TEXT 25.5 40 \"Hallo Welt\" size=10 bold=true", viewModel.LayoutSource); + } + + [Fact] + public void SkaliertesBildResize_RechnetEffektivenRahmenZurueckUndErhaeltScale() + { + var viewModel = new DesignerViewModel + { LayoutSource = "PAGE 210 297 mm\nIMG logo.png 10 15 30 12 scale=50%" }; + + viewModel.ApplyElementGeometry(new(2, "IMG", 20, 25, 30, 12)); + + Assert.Contains("IMG logo.png 20 25 60 24 scale=50%", viewModel.LayoutSource); + } + + [Fact] + public void DslSeitengroesse_SteuertOverlayAuchBeiAbweichendemManifestformular() + { + var viewModel = new DesignerViewModel { PageWidth = 210, PageHeight = 297 }; + + viewModel.LayoutSource = "PAGE 200 200 pt"; + + Assert.Equal(200, viewModel.OverlayPageWidth); + Assert.Equal(200, viewModel.OverlayPageHeight); + Assert.Equal("pt", viewModel.OverlayPageUnit); + } +} diff --git a/LehrerApp.TemplateDesigner/DesignerViewModel.cs b/LehrerApp.TemplateDesigner/DesignerViewModel.cs index fcb2374..57edaa8 100644 --- a/LehrerApp.TemplateDesigner/DesignerViewModel.cs +++ b/LehrerApp.TemplateDesigner/DesignerViewModel.cs @@ -30,6 +30,14 @@ public partial class DesignerViewModel : ObservableObject [ObservableProperty] private DesignerPlaceholder? _selectedPlaceholder; [ObservableProperty] private DesignerAsset? _selectedAsset; [ObservableProperty] private StarterTemplateItem? _selectedStarterTemplate; + [ObservableProperty] private OverlayEditorMode _overlayMode = OverlayEditorMode.Measure; + [ObservableProperty] private bool _snapOverlayToGrid = true; + [ObservableProperty] private double _overlayGridSize = 1; + [ObservableProperty] private string _overlayCoordinates = "x=– · y=–"; + [ObservableProperty] private string _selectedOverlayElement = "Kein Element ausgewählt"; + [ObservableProperty] private double _overlayPageWidth = 210; + [ObservableProperty] private double _overlayPageHeight = 297; + [ObservableProperty] private string _overlayPageUnit = "mm"; public IReadOnlyList Units { get; } = ["mm", "cm", "pt", "in"]; public IReadOnlyList PlaceholderTypes { get; } = Enum.GetValues(); @@ -190,7 +198,54 @@ public partial class DesignerViewModel : ObservableObject : $"Asset „{selected.Name}“ und {removedReferences} Layout-Referenz(en) wurden entfernt.", false); } - partial void OnLayoutSourceChanged(string value) => RefreshAssetUsage(); + public void ApplyMeasurement(OverlayMeasurement measurement) + { + NewX = FormatNumber(measurement.X); NewY = FormatNumber(measurement.Y); + if (measurement.HasArea) + { + NewWidth = FormatNumber(measurement.Width); NewHeight = FormatNumber(measurement.Height); + SetStatus($"Bereich übernommen: x={NewX}, y={NewY}, b={NewWidth}, h={NewHeight} {OverlayPageUnit}.", false); + } + else SetStatus($"Koordinate übernommen: x={NewX}, y={NewY} {OverlayPageUnit}.", false); + } + + public void ApplyElementGeometry(OverlayElementGeometry geometry) + { + var layout = new LayoutParser().Parse(LayoutSource); + var element = layout.Elements.FirstOrDefault(x => x.Line == geometry.Line) + ?? throw new InvalidDataException($"Element in Zeile {geometry.Line} wurde nicht gefunden."); + var lines = LayoutSource.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList(); + var index = geometry.Line - 1; + if (index < 0 || index >= lines.Count) throw new InvalidDataException("Elementzeile liegt außerhalb des Layouts."); + var x = FormatNumber(geometry.X); var y = FormatNumber(geometry.Y); + var width = geometry.Width; var height = geometry.Height; + lines[index] = element switch + { + ImageElement image => BuildImageLine(image, x, y, width, height), + TextElement text => $"TEXT {x} {y} {Content(text.Content, text.Placeholder, text.Format)}{Attributes(text.Attributes)}", + TextBoxElement box => $"TEXTBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} " + + $"{Content(box.Content, box.Placeholder, box.Format)}{Attributes(box.Attributes)}", + TableElement table => $"TABLE {x} {y} {FormatNumber(width)} {FormatNumber(height)} " + + $"${table.Placeholder}{Attributes(table.Attributes)}", + ChartElement chart => $"CHART {x} {y} {FormatNumber(width)} {FormatNumber(height)} " + + $"${chart.Placeholder}{Attributes(chart.Attributes)}", + _ => lines[index], + }; + LayoutSource = string.Join('\n', lines); CanExport = false; + SelectedOverlayElement = $"{geometry.Keyword} · Zeile {geometry.Line}"; + SetStatus($"{geometry.Keyword} verschoben/skalisiert. PDF-Vorschau wird aktualisiert.", false); + } + + partial void OnLayoutSourceChanged(string value) + { + RefreshAssetUsage(); + try + { + var page = new LayoutParser().Parse(value); + OverlayPageWidth = page.Width; OverlayPageHeight = page.Height; OverlayPageUnit = page.Unit; + } + catch (TemplateValidationException) { } + } private DesignerAsset AddOrReplaceAsset(string sourceName, byte[] bytes, bool keepName) { @@ -253,6 +308,19 @@ public partial class DesignerViewModel : ObservableObject return value.ToString("0.###", CultureInfo.InvariantCulture); } + private static string BuildImageLine(ImageElement image, string x, string y, double effectiveWidth, double effectiveHeight) + { + var scale = image.Attributes.TryGetValue("scale", out var raw) ? LayoutParser.Percentage(raw) : 1f; + return $"IMG {image.Path} {x} {y} {FormatNumber(effectiveWidth / scale)} " + + $"{FormatNumber(effectiveHeight / scale)}{Attributes(image.Attributes)}"; + } + private static string Content(string original, string? placeholder, string? format) => placeholder is null + ? $"\"{original.Replace("\"", "\\\"")}\"" + : $"${placeholder}{(format is null ? "" : "|" + format)}"; + private static string Attributes(IReadOnlyDictionary attributes) => attributes.Count == 0 + ? "" : " " + string.Join(' ', attributes.Select(x => $"{x.Key}={x.Value}")); + private static string FormatNumber(double value) => value.ToString("0.###", CultureInfo.InvariantCulture); + public void SetStatus(string text, bool error) { Status = text; StatusColor = error ? "#B91C1C" : "#166534"; } private static string QuoteIfLiteral(string value) => value.StartsWith('$') ? value : $"\"{value.Replace("\"", "\\\"")}\""; diff --git a/LehrerApp.TemplateDesigner/LayoutOverlayEditor.cs b/LehrerApp.TemplateDesigner/LayoutOverlayEditor.cs new file mode 100644 index 0000000..1ca8a5a --- /dev/null +++ b/LehrerApp.TemplateDesigner/LayoutOverlayEditor.cs @@ -0,0 +1,276 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using LehrerApp.Templating; + +namespace LehrerApp.TemplateDesigner; + +public enum OverlayEditorMode { Measure, Edit } + +public sealed record OverlayMeasurement(double X, double Y, double Width, double Height, bool HasArea); +public sealed record OverlayElementGeometry(int Line, string Keyword, double X, double Y, double Width, double Height); + +public sealed class LayoutOverlayEditor : Control +{ + public static readonly StyledProperty PreviewImageProperty = + AvaloniaProperty.Register(nameof(PreviewImage)); + public static readonly StyledProperty LayoutSourceProperty = + AvaloniaProperty.Register(nameof(LayoutSource), ""); + public static readonly StyledProperty PageWidthProperty = + AvaloniaProperty.Register(nameof(PageWidth), 210); + public static readonly StyledProperty PageHeightProperty = + AvaloniaProperty.Register(nameof(PageHeight), 297); + public static readonly StyledProperty PageUnitProperty = + AvaloniaProperty.Register(nameof(PageUnit), "mm"); + public static readonly StyledProperty ModeProperty = + AvaloniaProperty.Register(nameof(Mode), OverlayEditorMode.Measure); + public static readonly StyledProperty SnapToGridProperty = + AvaloniaProperty.Register(nameof(SnapToGrid), true); + public static readonly StyledProperty GridSizeProperty = + AvaloniaProperty.Register(nameof(GridSize), 1); + + private readonly Pen _normalPen = new(new SolidColorBrush(Color.Parse("#2563EB")), 1.5); + private readonly Pen _selectedPen = new(new SolidColorBrush(Color.Parse("#DC2626")), 2.5); + private readonly Pen _measurePen = new(new SolidColorBrush(Color.Parse("#D97706")), 2); + private readonly Pen _gridPen = new(new SolidColorBrush(Color.FromArgb(55, 37, 99, 235)), 1); + private readonly IBrush _normalFill = new SolidColorBrush(Color.FromArgb(30, 37, 99, 235)); + private readonly IBrush _selectedFill = new SolidColorBrush(Color.FromArgb(35, 220, 38, 38)); + private readonly IBrush _handleFill = new SolidColorBrush(Color.Parse("#DC2626")); + private readonly List _items = []; + private OverlayItem? _selected; + private Point? _pointerStartDsl; + private Rect? _workingRectDsl; + private DragKind _dragKind; + + public Bitmap? PreviewImage { get => GetValue(PreviewImageProperty); set => SetValue(PreviewImageProperty, value); } + public string LayoutSource { get => GetValue(LayoutSourceProperty); set => SetValue(LayoutSourceProperty, value); } + public double PageWidth { get => GetValue(PageWidthProperty); set => SetValue(PageWidthProperty, value); } + public double PageHeight { get => GetValue(PageHeightProperty); set => SetValue(PageHeightProperty, value); } + public string PageUnit { get => GetValue(PageUnitProperty); set => SetValue(PageUnitProperty, value); } + public OverlayEditorMode Mode { get => GetValue(ModeProperty); set => SetValue(ModeProperty, value); } + public bool SnapToGrid { get => GetValue(SnapToGridProperty); set => SetValue(SnapToGridProperty, value); } + public double GridSize { get => GetValue(GridSizeProperty); set => SetValue(GridSizeProperty, value); } + + public event EventHandler? MeasurementCompleted; + public event EventHandler? ElementGeometryChanged; + public event EventHandler? CursorCoordinatesChanged; + public event EventHandler? ElementSelected; + + static LayoutOverlayEditor() + { + AffectsRender(PreviewImageProperty, LayoutSourceProperty, PageWidthProperty, + PageHeightProperty, PageUnitProperty, ModeProperty, SnapToGridProperty, GridSizeProperty); + } + + public LayoutOverlayEditor() { Focusable = true; ClipToBounds = true; } + + public override void Render(DrawingContext context) + { + base.Render(context); + var page = PageRect(); + context.FillRectangle(Brushes.White, page); + if (PreviewImage is { } image) + context.DrawImage(image, new Rect(image.Size), page); + DrawGrid(context, page); + RefreshItems(); + foreach (var item in _items) + { + var geometry = ReferenceEquals(item, _selected) && _workingRectDsl is { } working ? working : item.Rect; + var rect = ToControl(geometry, page); + var selected = ReferenceEquals(item, _selected); + context.DrawRectangle(selected ? _selectedFill : _normalFill, selected ? _selectedPen : _normalPen, + rect, 2, 2); + if (selected && item.Resizable) + context.FillRectangle(_handleFill, new Rect(rect.Right - 6, rect.Bottom - 6, 12, 12), 2); + } + if (Mode == OverlayEditorMode.Measure && _workingRectDsl is { } measurement) + context.DrawRectangle(null, _measurePen, ToControl(measurement, page), 2, 2); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + var point = e.GetPosition(this); + if (!PageRect().Contains(point)) return; + Focus(); e.Pointer.Capture(this); + var dsl = Snap(ToDsl(point)); + _pointerStartDsl = dsl; + if (Mode == OverlayEditorMode.Measure) + { + _selected = null; _dragKind = DragKind.Measure; + _workingRectDsl = new Rect(dsl, dsl); InvalidateVisual(); return; + } + + RefreshItems(); + _selected = _items.LastOrDefault(item => ToControl(item.Rect, PageRect()).Inflate(4).Contains(point)); + if (_selected is null) { _workingRectDsl = null; InvalidateVisual(); return; } + _workingRectDsl = _selected.Rect; + var selectedControl = ToControl(_selected.Rect, PageRect()); + _dragKind = _selected.Resizable && new Point(selectedControl.Right, selectedControl.Bottom).Distance(point) <= 14 + ? DragKind.Resize : DragKind.Move; + ElementSelected?.Invoke(this, $"{_selected.Keyword} · Zeile {_selected.Line}"); + InvalidateVisual(); + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + var point = e.GetPosition(this); var page = PageRect(); + if (page.Contains(point)) + { + var coordinate = ToDsl(point); + CursorCoordinatesChanged?.Invoke(this, + $"x={coordinate.X:0.##} · y={coordinate.Y:0.##} {PageUnit}"); + } + if (_pointerStartDsl is not { } start || _dragKind == DragKind.None) return; + var current = Snap(ToDsl(Clamp(point, page))); + if (_dragKind == DragKind.Measure) + _workingRectDsl = Normalize(start, current); + else if (_selected is not null) + { + var source = _selected.Rect; + if (_dragKind == DragKind.Move) + { + var x = Math.Clamp(source.X + current.X - start.X, 0, Math.Max(0, PageWidth - source.Width)); + var y = Math.Clamp(source.Y + current.Y - start.Y, 0, Math.Max(0, PageHeight - source.Height)); + _workingRectDsl = new Rect(x, y, source.Width, source.Height); + } + else + { + var width = Math.Max(GridStep(), source.Width + current.X - start.X); + var height = Math.Max(GridStep(), source.Height + current.Y - start.Y); + _workingRectDsl = new Rect(source.X, source.Y, + Math.Min(width, PageWidth - source.X), Math.Min(height, PageHeight - source.Y)); + } + } + InvalidateVisual(); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); e.Pointer.Capture(null); + if (_workingRectDsl is { } result) + { + if (_dragKind == DragKind.Measure) + { + var hasArea = result.Width >= GridStep() / 2 && result.Height >= GridStep() / 2; + MeasurementCompleted?.Invoke(this, new(result.X, result.Y, result.Width, result.Height, hasArea)); + } + else if (_selected is not null) + ElementGeometryChanged?.Invoke(this, new(_selected.Line, _selected.Keyword, + result.X, result.Y, result.Width, result.Height)); + } + _pointerStartDsl = null; _dragKind = DragKind.None; + if (Mode == OverlayEditorMode.Edit) _workingRectDsl = null; + InvalidateVisual(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (Mode != OverlayEditorMode.Edit || _selected is null) return; + var step = GridStep(); var dx = 0d; var dy = 0d; + switch (e.Key) + { case Key.Left: dx = -step; break; case Key.Right: dx = step; break; + case Key.Up: dy = -step; break; case Key.Down: dy = step; break; default: return; } + var source = _selected.Rect; + var rect = new Rect(Math.Clamp(source.X + dx, 0, Math.Max(0, PageWidth - source.Width)), + Math.Clamp(source.Y + dy, 0, Math.Max(0, PageHeight - source.Height)), source.Width, source.Height); + ElementGeometryChanged?.Invoke(this, new(_selected.Line, _selected.Keyword, rect.X, rect.Y, rect.Width, rect.Height)); + e.Handled = true; + } + + private void RefreshItems() + { + _items.Clear(); + try + { + var layout = new LayoutParser().Parse(LayoutSource); + foreach (var element in layout.Elements) + { + if (element is BackgroundElement) continue; + var keyword = element switch + { ImageElement => "IMG", TextElement => "TEXT", TextBoxElement => "TEXTBOX", + TableElement => "TABLE", ChartElement => "CHART", _ => "?" }; + var (width, height, resizable) = element switch + { + TextElement text => (Math.Min(70d, Math.Max(5, PageWidth - text.X)), + Math.Max(3, PointsToUnit(ParseSize(text.Attributes) * 1.8)), false), + ImageElement image => (image.Width * ImageScale(image), image.Height * ImageScale(image), true), + _ => ((double)element.Width, element.Height, true), + }; + _items.Add(new(element.Line, keyword, new Rect(element.X, element.Y, width, height), resizable)); + } + if (_selected is not null) + _selected = _items.FirstOrDefault(x => x.Line == _selected.Line); + } + catch (TemplateValidationException) { _selected = null; } + } + + private void DrawGrid(DrawingContext context, Rect page) + { + if (!SnapToGrid || GridSize <= 0) return; + var xStep = page.Width * GridSize / PageWidth; var yStep = page.Height * GridSize / PageHeight; + if (xStep < 5 || yStep < 5) return; + for (var x = page.X + xStep; x < page.Right; x += xStep) + context.DrawLine(_gridPen, new(x, page.Y), new(x, page.Bottom)); + for (var y = page.Y + yStep; y < page.Bottom; y += yStep) + context.DrawLine(_gridPen, new(page.X, y), new(page.Right, y)); + } + + private Rect PageRect() + { + return OverlayCoordinateMapper.PageRect(Bounds.Size, PageWidth, PageHeight); + } + private Rect ToControl(Rect rect, Rect page) => OverlayCoordinateMapper.ToControl(rect, page, PageWidth, PageHeight); + private Point ToDsl(Point point) => OverlayCoordinateMapper.ToDsl(point, PageRect(), PageWidth, PageHeight); + private Point Snap(Point point) + { + if (!SnapToGrid) return point; + var step = GridStep(); return new(Math.Round(point.X / step) * step, Math.Round(point.Y / step) * step); + } + private double GridStep() => SnapToGrid && GridSize > 0 ? GridSize : 0.5; + private static Rect Normalize(Point first, Point second) => new(Math.Min(first.X, second.X), Math.Min(first.Y, second.Y), + Math.Abs(second.X - first.X), Math.Abs(second.Y - first.Y)); + private static Point Clamp(Point point, Rect rect) => new(Math.Clamp(point.X, rect.X, rect.Right), Math.Clamp(point.Y, rect.Y, rect.Bottom)); + private static float ParseSize(IReadOnlyDictionary attributes) => + attributes.TryGetValue("size", out var raw) && float.TryParse(raw, + System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var size) ? size : 11; + private static float ImageScale(ImageElement image) => image.Attributes.TryGetValue("scale", out var raw) + ? LayoutParser.Percentage(raw) : 1; + private double PointsToUnit(double points) => PageUnit.ToLowerInvariant() switch + { "mm" => points * 25.4 / 72, "cm" => points * 2.54 / 72, "in" => points / 72, _ => points }; + + private sealed record OverlayItem(int Line, string Keyword, Rect Rect, bool Resizable); + private enum DragKind { None, Measure, Move, Resize } +} + +public static class OverlayCoordinateMapper +{ + public static Rect PageRect(Size viewport, double pageWidth, double pageHeight) + { + if (pageWidth <= 0 || pageHeight <= 0 || viewport.Width <= 0 || viewport.Height <= 0) + return new Rect(viewport); + var scale = Math.Min(viewport.Width / pageWidth, viewport.Height / pageHeight); + var width = pageWidth * scale; var height = pageHeight * scale; + return new((viewport.Width - width) / 2, (viewport.Height - height) / 2, width, height); + } + + public static Point ToDsl(Point point, Rect displayedPage, double pageWidth, double pageHeight) => + new((point.X - displayedPage.X) / displayedPage.Width * pageWidth, + (point.Y - displayedPage.Y) / displayedPage.Height * pageHeight); + + public static Rect ToControl(Rect dslRect, Rect displayedPage, double pageWidth, double pageHeight) => + new(displayedPage.X + dslRect.X / pageWidth * displayedPage.Width, + displayedPage.Y + dslRect.Y / pageHeight * displayedPage.Height, + dslRect.Width / pageWidth * displayedPage.Width, + dslRect.Height / pageHeight * displayedPage.Height); +} + +internal static class PointDistanceExtensions +{ + public static double Distance(this Point point, Point other) => + Math.Sqrt(Math.Pow(point.X - other.X, 2) + Math.Pow(point.Y - other.Y, 2)); +} diff --git a/LehrerApp.TemplateDesigner/MainWindow.axaml b/LehrerApp.TemplateDesigner/MainWindow.axaml index 6434205..d899b25 100644 --- a/LehrerApp.TemplateDesigner/MainWindow.axaml +++ b/LehrerApp.TemplateDesigner/MainWindow.axaml @@ -114,15 +114,39 @@ - - - - - - + + + + + + + +