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 MmdPdf.Services;
namespace MmdPdf
{
///
/// 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 = "MmdPdf - " + 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, "MmdPdf - " + 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;
}
}
}