Add visual template coordinate editor

This commit is contained in:
2026-08-30 19:29:06 +02:00
parent 2f2761caf8
commit 3f71f7f371
7 changed files with 483 additions and 10 deletions
@@ -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);
}
}
@@ -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<string> Units { get; } = ["mm", "cm", "pt", "in"];
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
@@ -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<string, string> attributes) => attributes.Count == 0
? "" : " " + string.Join(' ', attributes.Select(x => $"{x.Key}={x.Value}"));
private static string FormatNumber(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
public void SetStatus(string text, bool error)
{ Status = text; StatusColor = error ? "#B91C1C" : "#166534"; }
private static string QuoteIfLiteral(string value) => value.StartsWith('$') ? value : $"\"{value.Replace("\"", "\\\"")}\"";
@@ -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<Bitmap?> PreviewImageProperty =
AvaloniaProperty.Register<LayoutOverlayEditor, Bitmap?>(nameof(PreviewImage));
public static readonly StyledProperty<string> LayoutSourceProperty =
AvaloniaProperty.Register<LayoutOverlayEditor, string>(nameof(LayoutSource), "");
public static readonly StyledProperty<double> PageWidthProperty =
AvaloniaProperty.Register<LayoutOverlayEditor, double>(nameof(PageWidth), 210);
public static readonly StyledProperty<double> PageHeightProperty =
AvaloniaProperty.Register<LayoutOverlayEditor, double>(nameof(PageHeight), 297);
public static readonly StyledProperty<string> PageUnitProperty =
AvaloniaProperty.Register<LayoutOverlayEditor, string>(nameof(PageUnit), "mm");
public static readonly StyledProperty<OverlayEditorMode> ModeProperty =
AvaloniaProperty.Register<LayoutOverlayEditor, OverlayEditorMode>(nameof(Mode), OverlayEditorMode.Measure);
public static readonly StyledProperty<bool> SnapToGridProperty =
AvaloniaProperty.Register<LayoutOverlayEditor, bool>(nameof(SnapToGrid), true);
public static readonly StyledProperty<double> GridSizeProperty =
AvaloniaProperty.Register<LayoutOverlayEditor, double>(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<OverlayItem> _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<OverlayMeasurement>? MeasurementCompleted;
public event EventHandler<OverlayElementGeometry>? ElementGeometryChanged;
public event EventHandler<string>? CursorCoordinatesChanged;
public event EventHandler<string>? ElementSelected;
static LayoutOverlayEditor()
{
AffectsRender<LayoutOverlayEditor>(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<string, string> 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));
}
+32 -8
View File
@@ -114,15 +114,39 @@
</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>
<Grid RowDefinitions="Auto,Auto,*,Auto">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="Visueller Layout-Editor" Classes="section"/>
<TextBlock Grid.Column="1" Text="{Binding OverlayCoordinates}" FontFamily="Monospace"
FontSize="11" VerticalAlignment="Center"/>
</Grid>
<StackPanel Grid.Row="1" Spacing="7" Margin="0,10,0,0">
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Content="Koordinaten messen" Click="OnMeasureOverlayMode"/>
<Button Grid.Column="2" Content="Elemente verschieben" Click="OnEditOverlayMode"/>
</Grid>
<Grid ColumnDefinitions="Auto,8,90,*">
<CheckBox Content="Rasterfang" IsChecked="{Binding SnapOverlayToGrid}" VerticalAlignment="Center"/>
<NumericUpDown Grid.Column="2" Value="{Binding OverlayGridSize}" Minimum="0.1" Maximum="50"
Increment="0.5" FormatString="0.##"/>
<TextBlock Grid.Column="3" Margin="8,0,0,0" Text="{Binding OverlayPageUnit}" VerticalAlignment="Center"/>
</Grid>
<TextBlock Text="{Binding SelectedOverlayElement}" FontSize="11" Opacity="0.7"/>
</StackPanel>
<Border Grid.Row="2" Margin="0,12" Background="#CBD5E1" BorderBrush="#94A3B8" BorderThickness="1">
<local:LayoutOverlayEditor PreviewImage="{Binding PreviewImage}"
LayoutSource="{Binding LayoutSource, Mode=TwoWay}"
PageWidth="{Binding OverlayPageWidth}" PageHeight="{Binding OverlayPageHeight}"
PageUnit="{Binding OverlayPageUnit}" Mode="{Binding OverlayMode}"
SnapToGrid="{Binding SnapOverlayToGrid}" GridSize="{Binding OverlayGridSize}"
MeasurementCompleted="OnOverlayMeasurementCompleted"
ElementGeometryChanged="OnOverlayElementGeometryChanged"
CursorCoordinatesChanged="OnOverlayCursorCoordinatesChanged"
ElementSelected="OnOverlayElementSelected"/>
</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>
<StackPanel Grid.Row="3" Spacing="4"><TextBlock Text="{Binding Status}" TextWrapping="Wrap" Foreground="{Binding StatusColor}"/>
<TextBlock Text="Messen: klicken oder Bereich aufziehen. Bearbeiten: Rahmen ziehen, unten rechts skalieren; Pfeiltasten verschieben."
FontSize="11" Opacity="0.65" TextWrapping="Wrap"/></StackPanel>
</Grid>
</Border>
</Grid>
@@ -29,6 +29,21 @@ public partial class MainWindow : Window
}
private void OnAddElement(object? sender, RoutedEventArgs e)
{ try { _viewModel.AddElement(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
private void OnMeasureOverlayMode(object? sender, RoutedEventArgs e)
{ _viewModel.OverlayMode = OverlayEditorMode.Measure; _viewModel.SelectedOverlayElement = "Messmodus aktiv"; }
private void OnEditOverlayMode(object? sender, RoutedEventArgs e)
{ _viewModel.OverlayMode = OverlayEditorMode.Edit; _viewModel.SelectedOverlayElement = "Elementmodus aktiv"; }
private void OnOverlayMeasurementCompleted(object? sender, OverlayMeasurement measurement) =>
_viewModel.ApplyMeasurement(measurement);
private void OnOverlayCursorCoordinatesChanged(object? sender, string coordinates) =>
_viewModel.OverlayCoordinates = coordinates;
private void OnOverlayElementSelected(object? sender, string element) =>
_viewModel.SelectedOverlayElement = element;
private void OnOverlayElementGeometryChanged(object? sender, OverlayElementGeometry geometry)
{
try { _viewModel.ApplyElementGeometry(geometry); OnPreview(sender, new RoutedEventArgs()); }
catch (Exception ex) { _viewModel.SetStatus($"Elementänderung fehlgeschlagen: {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)
+1 -1
View File
@@ -99,7 +99,7 @@ public sealed class LayoutParser
private static float Number(string value) => float.TryParse(value, NumberStyles.Float,
CultureInfo.InvariantCulture, out var result) ? result : throw new FormatException($"„{value}“ ist keine Zahl.");
internal static float Percentage(string value)
public static float Percentage(string value)
{
var normalized = value.EndsWith('%') ? value[..^1] : value;
var result = Number(normalized);
+17
View File
@@ -25,3 +25,20 @@ ist weiterhin ein normales `.lavorlage`-Paket und kann deshalb importiert oder e
Die Bibliothek liegt unter `LehrerApp/TemplateDesigner/starter-templates` im plattformspezifischen
Anwendungsdatenverzeichnis und wird nicht in das LehrerApp-Repository oder Release eingebettet.
## Visueller Koordinateneditor
Die QuestPDF-Vorschau dient gleichzeitig als maßstabsgetreue Zeichenfläche. Der Designer bildet
das tatsächlich sichtbare Seitenrechteck unabhängig von Zoom und freien Rändern auf die `PAGE`-
Koordinaten ab:
```text
dslX = (mausX - seitenrandLinks) / angezeigteSeitenbreite * pageWidth
dslY = (mausY - seitenrandOben) / angezeigteSeitenhöhe * pageHeight
```
Im Messmodus übernimmt ein Klick `x/y`; ein aufgezogener Bereich übernimmt zusätzlich Breite und
Höhe ins Elementformular. Im Bearbeitungsmodus lassen sich vorhandene Elemente verschieben und -
außer einzeiligem `TEXT` - am rechten unteren Anfasser skalieren. Rasterfang und Pfeiltasten sind
für Feinkorrekturen verfügbar. Änderungen werden in die ursprüngliche DSL-Zeile zurückgeschrieben,
wobei Inhalte, Platzhalter, Formatangaben und Attribute erhalten bleiben.