using System; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Shapes; using System.Windows.Threading; using KillerPDF.Services; namespace KillerPDF { /// /// Modal "Transform" window. Renders the current page on its own canvas (so the main view's mode is /// irrelevant) and lets the user rotate (quarter turns + fine deskew) and scale it, with the controls in /// a right-hand sidebar (the mirror of Print Preview). Apply hands the chosen angle / scale / page-mode /// back to the caller, which rasterizes at full resolution. Draggable corner handles are the next step. /// internal sealed class TransformWindow : Window { public bool Applied { get; private set; } public double Angle { get; private set; } // total = quarter turns + fine public double Scale { get; private set; } = 1.0; public bool FixedPage { get; private set; } // true = keep page size (margins); false = resize page public bool FlipH { get; private set; } public bool FlipV { get; private set; } // #174: source levels (black point, white point, midtone gamma). 0/255/1.0 = untouched. public int LevelBlack { get; private set; } public int LevelWhite { get; private set; } = 255; public double LevelGamma { get; private set; } = 1.0; public Point[] PerspectiveCorners { get; private set; } = [new(0, 0), new(1, 0), new(1, 1), new(0, 1)]; private readonly BitmapSource _src; private readonly Image _preview = new() { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(24), Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, BlurRadius = 14, ShadowDepth = 3, Direction = 270, Opacity = 0.45 } }; private readonly Border _previewArea = null!; private readonly double _srcW; private readonly double _srcH; private readonly double _pageWpt; private readonly double _pageHpt; private readonly TextBlock _sizeReadout = null!; private int _quarter; // 0..3 quarter turns clockwise private double _fine; // fine deskew, degrees private double _scale = 1.0; private bool _fixedPage; private readonly TextBlock _rotReadout = null!; private readonly TextBlock _scaleReadout = null!; private readonly Slider _rotSlider = null!; private readonly Slider _scaleSlider = null!; private Slider _lvlBlack = null!, _lvlWhite = null!, _lvlGamma = null!; // #174 private readonly RadioButton _resizeRadio = null!; private bool _flipH; private bool _flipV; private readonly CheckBox _flipHCheck = null!; private readonly CheckBox _flipVCheck = null!; private readonly Canvas _lineCanvas = null!; private readonly Line _alignLine = null!; private readonly CheckBox _deskewCheck = null!; private readonly TextBlock _lineCoords = null!; private bool _drawingLine; private Point _lineStart; private Point _startPagePt; private readonly DispatcherTimer _previewTimer = null!; private readonly Canvas _perspectiveCanvas = null!; private readonly Polygon _perspectiveOutline = null!; private readonly Ellipse[] _perspectiveHandles = new Ellipse[4]; private readonly CheckBox _perspectiveCheck = null!; private int _dragPerspective = -1; private static SolidColorBrush R(string key) => (SolidColorBrush)Application.Current.Resources[key]; private static string S(string key) => Application.Current.TryFindResource(key) as string ?? key; public TransformWindow(Window owner, BitmapSource src, double pageWpt, double pageHpt) { _src = src; _srcW = src.PixelWidth; _srcH = src.PixelHeight; _pageWpt = pageWpt; _pageHpt = pageHpt; Title = "KillerPDF - " + S("Str_Tf_Suffix"); Width = 980; Height = 720; MinWidth = 640; MinHeight = 460; DialogChrome.Configure(this, owner, resizable: true); var darkSlider = owner?.TryFindResource("DarkSlider") as Style; var themeRadio = owner?.TryFindResource("ThemeRadio") as Style; // Coalesce rapid slider changes: the heavy compose (especially scaling a page up, which makes a // big bitmap) only runs ~25x/sec on the latest value, so dragging stays smooth instead of queuing // a backlog of full re-renders. _previewTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(40) }; _previewTimer.Tick += (_, _2) => { _previewTimer.Stop(); UpdatePreview(); }; var root = new DockPanel(); // ---- Right sidebar (transparent so it blends with the dark title bar, like Print Preview) ---- var sidebar = new Border { Width = 288, Background = Brushes.Transparent, Padding = new Thickness(16, 8, 16, 14) }; DockPanel.SetDock(sidebar, Dock.Right); var side = new DockPanel(); // Bottom: a "Reset all" text link on its own line (translations like "Tout reinitialiser" are // long), with Cancel / Apply right-aligned beneath it - so nothing crowds or clips. var bottom = new StackPanel { Margin = new Thickness(0, 10, 0, 0) }; var resetAll = new TextBlock { Text = S("Str_Tf_ResetAll"), FontFamily = UiKit.UiFont, FontSize = 12, Foreground = R("MutedTextBrush"), Cursor = Cursors.Hand, VerticalAlignment = VerticalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Left }; resetAll.MouseEnter += (_, _2) => resetAll.Foreground = R("PrimaryBrush"); resetAll.MouseLeave += (_, _2) => resetAll.Foreground = R("MutedTextBrush"); resetAll.MouseLeftButtonUp += (_, _2) => { _quarter = 0; _rotSlider.Value = 0; _scaleSlider.Value = 100; _resizeRadio.IsChecked = true; _flipHCheck.IsChecked = false; _flipVCheck.IsChecked = false; ResetPerspective(); ResetLevels(); // #174 }; bottom.Children.Add(resetAll); var actionRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 8, 0, 0) }; var cancelBtn = UiKit.Make(S("Str_Tf_Cancel"), false); cancelBtn.Margin = new Thickness(0, 0, 8, 0); cancelBtn.Click += (_, _2) => { Applied = false; Close(); }; cancelBtn.IsCancel = true; // Esc actionRow.Children.Add(cancelBtn); var applyBtn = UiKit.Make(S("Str_Tf_Apply"), true); applyBtn.Click += (_, _2) => CommitAndClose(); applyBtn.IsDefault = true; // Enter actionRow.Children.Add(applyBtn); bottom.Children.Add(actionRow); DockPanel.SetDock(bottom, Dock.Bottom); side.Children.Add(bottom); var stack = new StackPanel(); int rotateStart = stack.Children.Count; // Quarter-turn buttons. var turnRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 6) }; var turnL = UiKit.Make("↺ 90°", false); turnL.Margin = new Thickness(0, 0, 6, 0); turnL.Click += (_, _2) => { _quarter = (_quarter + 3) % 4; UpdatePreview(); }; var turnR = UiKit.Make("90° ↻", false); turnR.Click += (_, _2) => { _quarter = (_quarter + 1) % 4; UpdatePreview(); }; turnRow.Children.Add(turnL); turnRow.Children.Add(turnR); stack.Children.Add(turnRow); _rotSlider = new Slider { Minimum = -45, Maximum = 45, Value = 0, TickFrequency = 1, SmallChange = 0.1, LargeChange = 1, Margin = new Thickness(0, 2, 0, 2) }; if (darkSlider != null) _rotSlider.Style = darkSlider; _rotSlider.ValueChanged += (_, ev) => { _fine = Math.Round(ev.NewValue, 1); if (_rotReadout != null) _rotReadout.Text = $"{Total:0.0}°"; SchedulePreview(); }; stack.Children.Add(_rotSlider); stack.Children.Add(ValueRow(S("Str_Tf_Angle"), "0.0°", out _rotReadout, out var rotReset)); rotReset.Click += (_, _2) => { _quarter = 0; _rotSlider.Value = 0; UpdatePreview(); }; WrapSection(stack, rotateStart, S("Str_Tf_Rotate"), expanded: true); stack.Children.Add(Divider()); int scaleStart = stack.Children.Count; _scaleSlider = new Slider { Minimum = 25, Maximum = 200, Value = 100, TickFrequency = 5, SmallChange = 1, LargeChange = 10, Margin = new Thickness(0, 2, 0, 2) }; if (darkSlider != null) _scaleSlider.Style = darkSlider; _scaleSlider.ValueChanged += (_, ev) => { _scale = Math.Round(ev.NewValue) / 100.0; _scaleReadout.Text = $"{ev.NewValue:0}%"; SchedulePreview(); }; stack.Children.Add(_scaleSlider); stack.Children.Add(ValueRow(S("Str_Tf_Size"), "100%", out _scaleReadout, out var scaleReset)); scaleReset.Click += (_, _2) => _scaleSlider.Value = 100; stack.Children.Add(new TextBlock { Text = S("Str_Tf_WhenScaling"), Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont, FontSize = 11, Margin = new Thickness(0, 10, 0, 4) }); _resizeRadio = MakeRadio(S("Str_Tf_ResizePage"), true, themeRadio); var fixedRadio = MakeRadio(S("Str_Tf_KeepSize"), false, themeRadio); _resizeRadio.Checked += (_, _2) => { _fixedPage = false; UpdatePreview(); }; fixedRadio.Checked += (_, _2) => { _fixedPage = true; UpdatePreview(); }; stack.Children.Add(_resizeRadio); stack.Children.Add(fixedRadio); // Live output dimensions, so scale changes (including above 100%, where the preview clamps to // fit) are always legible as a number even when the page can't grow on screen. _sizeReadout = new TextBlock { Foreground = R("MutedTextBrush"), FontFamily = UiKit.MonoFont, FontSize = 11, Margin = new Thickness(0, 8, 0, 0) }; stack.Children.Add(_sizeReadout); WrapSection(stack, scaleStart, S("Str_Tf_Scale"), expanded: false); stack.Children.Add(Divider()); int flipStart = stack.Children.Count; _flipHCheck = MakeCheck(S("Str_Tf_FlipH")); _flipHCheck.Checked += (_, _2) => { _flipH = true; UpdatePreview(); }; _flipHCheck.Unchecked += (_, _2) => { _flipH = false; UpdatePreview(); }; stack.Children.Add(_flipHCheck); _flipVCheck = MakeCheck(S("Str_Tf_FlipV")); _flipVCheck.Checked += (_, _2) => { _flipV = true; UpdatePreview(); }; _flipVCheck.Unchecked += (_, _2) => { _flipV = false; UpdatePreview(); }; stack.Children.Add(_flipVCheck); WrapSection(stack, flipStart, S("Str_Tf_Flip"), expanded: false); stack.Children.Add(Divider()); int skewStart = stack.Children.Count; _deskewCheck = MakeCheck(S("Str_Tf_LevelLine")); _deskewCheck.Checked += (_, _2) => { _lineCanvas.IsHitTestVisible = true; }; _deskewCheck.Unchecked += (_, _2) => { _lineCanvas.IsHitTestVisible = false; _alignLine.Visibility = Visibility.Collapsed; _lineCoords.Text = ""; }; stack.Children.Add(_deskewCheck); stack.Children.Add(new TextBlock { Text = S("Str_Tf_SkewHint"), Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont, FontSize = 10, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 4, 0, 0) }); // Live cursor coordinates (page points), so the user can place the line precisely on the small // preview. Start point on press, end point as they drag. _lineCoords = new TextBlock { Text = "", Foreground = R("MutedTextBrush"), FontFamily = UiKit.MonoFont, FontSize = 11, LineHeight = 16, Margin = new Thickness(0, 6, 0, 0), Padding = new Thickness(3) }; stack.Children.Add(_lineCoords); WrapSection(stack, skewStart, S("Str_Tf_Skew"), expanded: false); stack.Children.Add(Divider()); int perspectiveStart = stack.Children.Count; _perspectiveCheck = MakeCheck(S("Str_Tf_CorrectPerspective")); _perspectiveCheck.Checked += (_, _2) => { _deskewCheck.IsChecked = false; _perspectiveCanvas.Visibility = Visibility.Visible; _perspectiveCanvas.IsHitTestVisible = true; UpdatePerspectiveOverlay(); }; _perspectiveCheck.Unchecked += (_, _2) => { _perspectiveCanvas.Visibility = Visibility.Collapsed; _perspectiveCanvas.IsHitTestVisible = false; }; stack.Children.Add(_perspectiveCheck); stack.Children.Add(new TextBlock { Text = S("Str_Tf_PerspectiveHint"), Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont, FontSize = 10, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 4, 0, 0) }); var resetPerspective = UiKit.Make(S("Str_Tf_ResetCorners"), false); resetPerspective.Margin = new Thickness(0, 7, 0, 0); resetPerspective.HorizontalAlignment = HorizontalAlignment.Left; resetPerspective.Click += (_, _2) => ResetPerspective(); stack.Children.Add(resetPerspective); WrapSection(stack, perspectiveStart, S("Str_Tf_Perspective"), expanded: false); // #174: LEVELS - FineReader-style source levels for rescuing pale scans. Black point, // white point, and a midtone gamma; live in the preview, baked on Apply like every // other correction here. stack.Children.Add(Divider()); int levelsStart = stack.Children.Count; stack.Children.Add(SliderLabel(S("Str_Tf_LevelsBlack"))); _lvlBlack = new Slider { Minimum = 0, Maximum = 200, Value = 0, TickFrequency = 5, SmallChange = 1, LargeChange = 10, Margin = new Thickness(0, 2, 0, 2) }; if (darkSlider != null) _lvlBlack.Style = darkSlider; _lvlBlack.ValueChanged += (_, ev) => { LevelBlack = (int)Math.Round(ev.NewValue); SchedulePreview(); }; stack.Children.Add(_lvlBlack); stack.Children.Add(SliderLabel(S("Str_Tf_LevelsWhite"))); _lvlWhite = new Slider { Minimum = 55, Maximum = 255, Value = 255, TickFrequency = 5, SmallChange = 1, LargeChange = 10, Margin = new Thickness(0, 2, 0, 2) }; if (darkSlider != null) _lvlWhite.Style = darkSlider; _lvlWhite.ValueChanged += (_, ev) => { LevelWhite = (int)Math.Round(ev.NewValue); SchedulePreview(); }; stack.Children.Add(_lvlWhite); stack.Children.Add(SliderLabel(S("Str_Tf_LevelsGamma"))); _lvlGamma = new Slider { Minimum = 0.2, Maximum = 2.5, Value = 1.0, TickFrequency = 0.05, SmallChange = 0.05, LargeChange = 0.2, Margin = new Thickness(0, 2, 0, 2) }; if (darkSlider != null) _lvlGamma.Style = darkSlider; _lvlGamma.ValueChanged += (_, ev) => { LevelGamma = Math.Round(ev.NewValue, 2); SchedulePreview(); }; stack.Children.Add(_lvlGamma); var levelsReset = UiKit.Make(S("Str_Tf_Reset"), false); levelsReset.Margin = new Thickness(0, 7, 0, 0); levelsReset.HorizontalAlignment = HorizontalAlignment.Left; levelsReset.Click += (_, _2) => ResetLevels(); stack.Children.Add(levelsReset); WrapSection(stack, levelsStart, S("Str_Tf_Levels"), expanded: false); side.Children.Add(new ScrollViewer { Content = stack, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, }); sidebar.Child = side; root.Children.Add(sidebar); // ---- Preview area: a documentbg box (1px frame, margin, rounded) with grain in the margins and // the page (sized to its true relative scale, with a drop shadow) centered on top. ---- var previewWrap = new Border { BorderThickness = new Thickness(1), CornerRadius = UiKit.RadControl, Margin = new Thickness(8, 4, 8, 12), ClipToBounds = true }; previewWrap.SetResourceReference(Border.BackgroundProperty, "BgCanvas"); previewWrap.SetResourceReference(Border.BorderBrushProperty, "PaneBorderBrush"); var previewGrid = new Grid(); var pgGrain = (owner as MainWindow)?.GrainTexture; if (pgGrain != null) { double pop = Application.Current.Resources["GrainOpacity"] is double pgo ? pgo : 0.05; previewGrid.Children.Add(new Border { IsHitTestVisible = false, Opacity = pop, Background = new ImageBrush(pgGrain) { TileMode = TileMode.Tile, ViewportUnits = BrushMappingMode.Absolute, Viewport = new Rect(0, 0, 256, 256), Stretch = Stretch.None } }); } RenderOptions.SetBitmapScalingMode(_preview, BitmapScalingMode.HighQuality); _preview.Source = _src; previewGrid.Children.Add(_preview); // Alignment-line overlay: when "Draw a level line" is on, the user drags a reference line across // the page and the page rotates so that line becomes level. Hit-testing is off until enabled, so // it never interferes with the rest of the preview. _lineCanvas = new Canvas { Background = Brushes.Transparent, IsHitTestVisible = false, Cursor = Cursors.Cross }; _alignLine = new Line { Stroke = R("PrimaryBrush"), StrokeThickness = 2, StrokeDashArray = [4, 3], Visibility = Visibility.Collapsed, Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.White, BlurRadius = 3, ShadowDepth = 0, Opacity = 0.8 } }; _lineCanvas.Children.Add(_alignLine); _lineCanvas.MouseLeftButtonDown += LineCanvas_Down; _lineCanvas.MouseMove += LineCanvas_Move; _lineCanvas.MouseLeftButtonUp += LineCanvas_Up; previewGrid.Children.Add(_lineCanvas); _perspectiveCanvas = new Canvas { Background = Brushes.Transparent, Visibility = Visibility.Collapsed, IsHitTestVisible = false, Cursor = Cursors.Cross, }; _perspectiveOutline = new Polygon { Stroke = R("PrimaryBrush"), StrokeThickness = 2, StrokeDashArray = [5, 3], Fill = new SolidColorBrush(Color.FromArgb(24, 30, 165, 76)), IsHitTestVisible = false, }; _perspectiveCanvas.Children.Add(_perspectiveOutline); for (int i = 0; i < 4; i++) { var handle = new Ellipse { Width = 18, Height = 18, Fill = R("PrimaryBrush"), Stroke = Brushes.White, StrokeThickness = 2, Cursor = Cursors.SizeAll, Tag = i, }; handle.PreviewMouseLeftButtonDown += PerspectiveHandle_Down; _perspectiveHandles[i] = handle; _perspectiveCanvas.Children.Add(handle); } _perspectiveCanvas.AddHandler(Mouse.PreviewMouseMoveEvent, new MouseEventHandler(PerspectiveCanvas_Move), true); _perspectiveCanvas.AddHandler(Mouse.PreviewMouseUpEvent, new MouseButtonEventHandler(PerspectiveCanvas_Up), true); _perspectiveCanvas.LostMouseCapture += (_, _2) => _dragPerspective = -1; previewGrid.Children.Add(_perspectiveCanvas); previewWrap.Child = previewGrid; _previewArea = previewWrap; previewWrap.SizeChanged += (_, _2) => SizePreviewImage(); // Family shadow under the content pane, like the main window (flat on 98SE). root.Children.Add(UiKit.PaneWithShadow(previewWrap)); Content = DialogChrome.Frame(this, Owner, "KillerPDF - " + S("Str_Tf_Suffix"), () => { Applied = false; Close(); }, root); UpdatePreview(); // populate the output-size readout at the original dimensions // Esc-to-close is wired by DialogChrome.Frame; Enter commits. KeyDown += (_, e) => { if (e.Key == Key.Enter) CommitAndClose(); }; } private double Total => _quarter * 90 + _fine; private void CommitAndClose() { Applied = true; Angle = Total; Scale = _scale; FixedPage = _fixedPage; FlipH = _flipH; FlipV = _flipV; PerspectiveCorners = PerspectiveCorners.ToArray(); Close(); } private void ResetPerspective() { PerspectiveCorners = [new(0, 0), new(1, 0), new(1, 1), new(0, 1)]; UpdatePerspectiveOverlay(); } private Rect PreviewBoundsOnPerspectiveCanvas() { if (_preview.ActualWidth <= 0 || _preview.ActualHeight <= 0) return Rect.Empty; Point origin = _preview.TranslatePoint(new Point(0, 0), _perspectiveCanvas); return new Rect(origin.X, origin.Y, _preview.ActualWidth, _preview.ActualHeight); } private void UpdatePerspectiveOverlay() { if (_perspectiveCanvas == null || _perspectiveOutline == null) return; Rect bounds = PreviewBoundsOnPerspectiveCanvas(); if (bounds.IsEmpty) return; var points = new PointCollection(); for (int i = 0; i < 4; i++) { Point p = new(bounds.Left + PerspectiveCorners[i].X * bounds.Width, bounds.Top + PerspectiveCorners[i].Y * bounds.Height); points.Add(p); Canvas.SetLeft(_perspectiveHandles[i], p.X - 9); Canvas.SetTop(_perspectiveHandles[i], p.Y - 9); } _perspectiveOutline.Points = points; } private void PerspectiveHandle_Down(object sender, MouseButtonEventArgs e) { if (sender is not Ellipse { Tag: int index }) return; _dragPerspective = index; Mouse.Capture(_perspectiveCanvas, CaptureMode.SubTree); e.Handled = true; } private void PerspectiveCanvas_Move(object sender, MouseEventArgs e) { if (_dragPerspective < 0 || e.LeftButton != MouseButtonState.Pressed) return; Rect bounds = PreviewBoundsOnPerspectiveCanvas(); if (bounds.IsEmpty) return; Point p = e.GetPosition(_perspectiveCanvas); PerspectiveCorners[_dragPerspective] = new Point( Math.Max(0, Math.Min(1, (p.X - bounds.Left) / bounds.Width)), Math.Max(0, Math.Min(1, (p.Y - bounds.Top) / bounds.Height))); UpdatePerspectiveOverlay(); } private void PerspectiveCanvas_Up(object sender, MouseButtonEventArgs e) { _dragPerspective = -1; if (ReferenceEquals(Mouse.Captured, _perspectiveCanvas)) Mouse.Capture(null); e.Handled = true; } // ---- Alignment-line deskew: drag a line, release, and the page rotates to make that line level. ---- // Maps a point in the preview image to page coordinates in points (clamped to the page). private Point PreviewToPagePts(Point pInPreview) { double w = _preview.ActualWidth, h = _preview.ActualHeight; double fx = w > 0 ? Math.Max(0, Math.Min(1, pInPreview.X / w)) : 0; double fy = h > 0 ? Math.Max(0, Math.Min(1, pInPreview.Y / h)) : 0; return new Point(fx * _pageWpt, fy * _pageHpt); } private void ShowLineCoords(Point endPage) => _lineCoords.Text = $"Start {_startPagePt.X:0}, {_startPagePt.Y:0} pt\nEnd {endPage.X:0}, {endPage.Y:0} pt"; private void LineCanvas_Down(object sender, MouseButtonEventArgs e) { _drawingLine = true; _lineStart = e.GetPosition(_lineCanvas); _alignLine.X1 = _alignLine.X2 = _lineStart.X; _alignLine.Y1 = _alignLine.Y2 = _lineStart.Y; _alignLine.Visibility = Visibility.Visible; _startPagePt = PreviewToPagePts(e.GetPosition(_preview)); ShowLineCoords(_startPagePt); _lineCanvas.CaptureMouse(); } private void LineCanvas_Move(object sender, MouseEventArgs e) { if (!_drawingLine) return; var p = e.GetPosition(_lineCanvas); _alignLine.X2 = p.X; _alignLine.Y2 = p.Y; ShowLineCoords(PreviewToPagePts(e.GetPosition(_preview))); } private void LineCanvas_Up(object sender, MouseButtonEventArgs e) { if (!_drawingLine) return; _drawingLine = false; _lineCanvas.ReleaseMouseCapture(); double dx = _alignLine.X2 - _alignLine.X1; double dy = _alignLine.Y2 - _alignLine.Y1; _alignLine.Visibility = Visibility.Collapsed; if (dx * dx + dy * dy < 100) return; // ignore an accidental tap // Screen angle of the line (clockwise positive, since Y is down). Normalize to an undirected // (-90, 90], then snap to the nearest axis so a near-vertical drag deskews to vertical. double a = Math.Atan2(dy, dx) * 180.0 / Math.PI; a %= 180.0; if (a > 90.0) a -= 180.0; else if (a < -90.0) a += 180.0; if (a > 45.0) a -= 90.0; else if (a < -45.0) a += 90.0; // Rotate by -a (on top of the current fine angle) to level the line; the slider drives _fine. double newFine = Math.Max(-45.0, Math.Min(45.0, _fine - a)); _rotSlider.Value = Math.Round(newFine, 1); } // Throttles the heavy preview compose so slider dragging stays smooth (see the timer in the ctor). private void SchedulePreview() { _previewTimer.Stop(); _previewTimer.Start(); } // #174 helpers, shared with the full-resolution Apply in Rotate.cs. internal static bool LevelsIdentity(int black, int white, double gamma) => black <= 0 && white >= 255 && Math.Abs(gamma - 1.0) < 0.01; /// Levels pass: remaps [black..white] to [0..255] through a midtone gamma, /// per RGB channel, alpha untouched. Identity settings return the source unchanged. internal static BitmapSource ApplyLevels(BitmapSource src, int black, int white, double gamma) { if (LevelsIdentity(black, white, gamma)) return src; var conv = new FormatConvertedBitmap(src, PixelFormats.Bgra32, null, 0); int w = conv.PixelWidth, h = conv.PixelHeight, stride = w * 4; var px = new byte[stride * h]; conv.CopyPixels(px, stride, 0); var lut = new byte[256]; double lo = black, hi = Math.Max(black + 1, white), invG = 1.0 / Math.Max(0.05, gamma); for (int i = 0; i < 256; i++) { double t = (i - lo) / (hi - lo); t = t < 0 ? 0 : t > 1 ? 1 : t; lut[i] = (byte)Math.Round(Math.Pow(t, invG) * 255); } for (int i = 0; i < px.Length; i += 4) { px[i] = lut[px[i]]; px[i + 1] = lut[px[i + 1]]; px[i + 2] = lut[px[i + 2]]; } var bmp = BitmapSource.Create(w, h, conv.DpiX, conv.DpiY, PixelFormats.Bgra32, null, px, stride); bmp.Freeze(); return bmp; } private void ResetLevels() { _lvlBlack.Value = 0; _lvlWhite.Value = 255; _lvlGamma.Value = 1.0; } private TextBlock SliderLabel(string text) => new() { Text = text, Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont, FontSize = 10, Margin = new Thickness(0, 6, 0, 0), }; private void UpdatePreview() { double total = Total; if (_rotReadout != null) _rotReadout.Text = $"{total:0.0}°"; _preview.Source = (total == 0 && _scale == 1.0 && !_flipH && !_flipV) ? _src : MainWindow.ComposeTransform(_src, total, _scale, _fixedPage, _flipH, _flipV); // #174: levels ride on top of whatever geometry the preview shows. if (_preview.Source is BitmapSource lvlSrc && !LevelsIdentity(LevelBlack, LevelWhite, LevelGamma)) _preview.Source = ApplyLevels(lvlSrc, LevelBlack, LevelWhite, LevelGamma); if (_sizeReadout != null && _preview.Source is BitmapSource b && _srcW > 0 && _pageWpt > 0) { double outWin = b.PixelWidth * (_pageWpt / _srcW) / 72.0; double outHin = b.PixelHeight * (_pageHpt / _srcH) / 72.0; _sizeReadout.Text = string.Format(S("Str_Tf_Output"), outWin.ToString("0.0"), outHin.ToString("0.0")); } SizePreviewImage(); } // Sizes the page to its TRUE relative scale within the preview box, so "Resize the whole page" makes // the page visibly shrink (rather than refit to the same size), and rotation visibly grows it. // Clamps so the page never overflows the box. private void SizePreviewImage() { if (_previewArea is null || _preview.Source is not BitmapSource bmp || _srcW <= 0 || _srcH <= 0) return; const double m = 36; // breathing room inside the box double areaW = Math.Max(1, _previewArea.ActualWidth - m); double areaH = Math.Max(1, _previewArea.ActualHeight - m); double baseFit = Math.Min(areaW / _srcW, areaH / _srcH); // scale that fits the original page double dispW = bmp.PixelWidth * baseFit; double dispH = bmp.PixelHeight * baseFit; double clamp = Math.Min(1.0, Math.Min(areaW / dispW, areaH / dispH)); // never overflow the box _preview.Width = dispW * clamp; _preview.Height = dispH * clamp; Dispatcher.BeginInvoke(new Action(UpdatePerspectiveOverlay), DispatcherPriority.Loaded); } private TextBlock SectionHeader(string text) => new() { Text = text, Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont, FontSize = 10, FontWeight = FontWeights.SemiBold, Margin = new Thickness(0, 6, 0, 4) }; private void WrapSection(StackPanel host, int start, string title, bool expanded) { var children = host.Children.Cast().Skip(start).ToList(); while (host.Children.Count > start) host.Children.RemoveAt(start); var body = new StackPanel { Visibility = expanded ? Visibility.Visible : Visibility.Collapsed }; foreach (var child in children) body.Children.Add(child); var chevron = new TextBlock { Text = expanded ? "▾" : "▸", Width = 16, FontSize = 12, Foreground = R("MutedTextBrush"), VerticalAlignment = VerticalAlignment.Center, }; var label = SectionHeader(title); label.Margin = new Thickness(0); var row = new StackPanel { Orientation = Orientation.Horizontal }; row.Children.Add(chevron); row.Children.Add(label); var header = new Border { Background = Brushes.Transparent, Cursor = Cursors.Hand, Padding = new Thickness(0, 5, 0, 5), Child = row, }; header.MouseLeftButtonUp += (_, _2) => { bool open = body.Visibility != Visibility.Visible; body.Visibility = open ? Visibility.Visible : Visibility.Collapsed; chevron.Text = open ? "▾" : "▸"; }; host.Children.Add(header); host.Children.Add(body); } private Border Divider() { var b = new Border { Height = 1, Margin = new Thickness(0, 14, 0, 12) }; b.SetResourceReference(Border.BackgroundProperty, "CardBorderBrush"); return b; } private DockPanel ValueRow(string label, string value, out TextBlock valueBlock, out Button reset) { var row = new DockPanel { Margin = new Thickness(0, 2, 0, 0) }; reset = UiKit.Make(S("Str_Tf_Reset"), false); reset.Padding = new Thickness(8, 1, 8, 1); reset.FontSize = 11; DockPanel.SetDock(reset, Dock.Right); row.Children.Add(reset); valueBlock = new TextBlock { Text = value, Foreground = R("TextBrush"), FontFamily = UiKit.MonoFont, FontSize = 12, VerticalAlignment = VerticalAlignment.Center, TextAlignment = TextAlignment.Right, Margin = new Thickness(0, 0, 8, 0) }; DockPanel.SetDock(valueBlock, Dock.Right); row.Children.Add(valueBlock); row.Children.Add(new TextBlock { Text = label, Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont, FontSize = 11, VerticalAlignment = VerticalAlignment.Center }); return row; } private RadioButton MakeRadio(string text, bool isChecked, Style? style) { var rb = new RadioButton { Content = new TextBlock { Text = text, TextWrapping = TextWrapping.Wrap, VerticalAlignment = VerticalAlignment.Center }, IsChecked = isChecked, Foreground = R("TextBrush"), FontFamily = UiKit.UiFont, FontSize = 12, Margin = new Thickness(0, 3, 0, 0) }; if (style != null) rb.Style = style; return rb; } private CheckBox MakeCheck(string text) { var cb = UiKit.CheckBox(text); cb.Margin = new Thickness(0, 3, 0, 0); return cb; } } }