using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using Docnet.Core;
using Docnet.Core.Models;
using Microsoft.Win32;
using PdfSharpCore.Drawing;
using PdfSharpCore.Pdf;
using PdfSharpCore.Pdf.IO;
using MmdPdf.Services;
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
namespace MmdPdf.Controls
{
// Annotation rendering - draws the per-page annotation overlays (text, cover, highlight, ink,
// signature, image). Moved from Shell/Annotations.cs; the namespace and class line are the only
// changes. Window members spelled bare here resolve through PdfViewer.Bridge.cs.
public partial class PdfViewer
{
private static double MeasureTextBoxHeight(string text, double width, double fontSize)
{
double inner = Math.Max(1, width - 4); // minus left+right padding (2 + 2)
var ft = new FormattedText(
string.IsNullOrEmpty(text) ? " " : text,
System.Globalization.CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
new Typeface("Segoe UI"), Math.Max(1, fontSize), Brushes.Black, 1.0)
{ MaxTextWidth = inner };
return Math.Ceiling(ft.Height) + 4; // plus top + bottom padding
}
private void RenderTextAnnotation(TextAnnotation ta)
{
if (!IsFinitePositive(ta.Width) || !IsFinitePositive(ta.Height)) return;
// A fixed W x H box: optional fill background, text wrapped to the width and clipped to the
// height (so a free-form-resized box behaves like an image/crop frame).
FontFamily famsel;
try { famsel = new FontFamily(string.IsNullOrEmpty(ta.FontName) ? "Segoe UI" : ta.FontName); }
catch { famsel = UiKit.UiFont; }
var tb = new TextBlock
{
Text = ta.Content,
Foreground = new SolidColorBrush(ta.GetColor()),
FontFamily = famsel,
FontWeight = ta.Bold ? FontWeights.Bold : FontWeights.Normal,
FontStyle = ta.Italic ? FontStyles.Italic : FontStyles.Normal,
TextDecorations = BuildDecorations(ta.Underline, ta.Strike),
FontSize = ta.FontSize,
Padding = new Thickness(2),
TextWrapping = TextWrapping.Wrap,
VerticalAlignment = VerticalAlignment.Top
};
// Crisp glyphs: pixel-snapped layout + grayscale AA (ClearType can't subpixel on the
// transparent overlay, and the default left the placed text looking aliased).
TextOptions.SetTextFormattingMode(tb, TextFormattingMode.Display);
TextOptions.SetTextRenderingMode(tb, TextRenderingMode.Grayscale);
var box = new Border
{
Width = Math.Max(1, ta.Width),
Height = Math.Max(1, ta.Height),
Background = ta.HasFill ? new SolidColorBrush(ta.GetFill()) : Brushes.Transparent,
ClipToBounds = true,
IsHitTestVisible = false,
Child = tb
};
Canvas.SetLeft(box, ta.Position.X);
Canvas.SetTop(box, ta.Position.Y);
_activeCanvas.Children.Add(box);
}
// HighlightEraseGeometry (shared by on-screen rendering and PDF export) lives in
// Services/PdfBurn.cs with the rest of the burn-to-document core.
internal void RenderAllAnnotations(int pageIndex)
{
// Resolve this page's annotation surface from the unified per-page overlay map, which
// every multi-page view populates; fall back to the single-page canvas. View-mode
// independent on purpose so the tools behave identically in all four modes.
_activeCanvas = CanvasForPage(pageIndex);
_activeCanvas.Children.Clear();
RenderStamps(pageIndex); // stamp layer (page numbers / watermark) sits beneath annotations
if (_annotations.TryGetValue(pageIndex, out var annotList))
foreach (var annot in annotList)
{
switch (annot)
{
case TextAnnotation ta:
RenderTextAnnotation(ta);
break;
case CoverAnnotation cov:
if (!IsFinitePositive(cov.Bounds.Width) || !IsFinitePositive(cov.Bounds.Height)) continue;
var covRect = new Rectangle
{
Fill = new SolidColorBrush(cov.GetColor()),
Width = cov.Bounds.Width, Height = cov.Bounds.Height
};
// While being typed into, dash-outline the cover so it's visible behind the live
// text box. Otherwise just the opaque fill - its outline only appears on selection
// (drawn as selection chrome), so a deselected cover stays clean. Screen-only; the
// flattened/saved PDF draws just the fill.
if (ReferenceEquals(cov, _pendingCover))
{
covRect.Stroke = DarkerAccentBrush();
covRect.StrokeThickness = 1;
covRect.StrokeDashArray = [4, 3];
}
Canvas.SetLeft(covRect, cov.Bounds.X);
Canvas.SetTop(covRect, cov.Bounds.Y);
_activeCanvas.Children.Add(covRect);
break;
case HighlightAnnotation ha:
if (PdfBurn.HighlightEraseGeometry(ha) is { } hgeo)
{
// Carved highlight: rectangle minus the eraser strokes, one anti-aliased fill.
_activeCanvas.Children.Add(new System.Windows.Shapes.Path
{ Fill = new SolidColorBrush(ha.GetColor()), Data = hgeo, IsHitTestVisible = false });
}
else
{
var hr = ha.DrawRect();
if (!IsFinitePositive(hr.Width) || !IsFinitePositive(hr.Height)) continue;
var rect = new Rectangle
{
Fill = new SolidColorBrush(ha.GetColor()),
Width = hr.Width,
Height = hr.Height
};
Canvas.SetLeft(rect, hr.X);
Canvas.SetTop(rect, hr.Y);
_activeCanvas.Children.Add(rect);
}
break;
case InkAnnotation ia:
if (ia.Points.Count < 2) continue;
if (ia.HasFill)
{
// Filled shape (#127 Phase 3): closed outline - one Polygon, filled + stroked.
var pg = new Polygon
{
Stroke = new SolidColorBrush(ia.GetColor()),
StrokeThickness = ia.StrokeWidth,
StrokeLineJoin = PenLineJoin.Round,
Fill = new SolidColorBrush(ia.GetFillColor())
};
foreach (var pt in ia.Points) pg.Points.Add(pt);
_activeCanvas.Children.Add(pg);
break;
}
var poly = new Polyline
{
Stroke = new SolidColorBrush(ia.GetColor()),
StrokeThickness = ia.StrokeWidth,
StrokeLineJoin = PenLineJoin.Round,
StrokeStartLineCap = PenLineCap.Round,
StrokeEndLineCap = PenLineCap.Round
};
foreach (var pt in ia.Points) poly.Points.Add(pt);
_activeCanvas.Children.Add(poly);
break;
case SignatureAnnotation sa:
if (sa.ImageData is not null)
{
// Image-based signature (decoded once, then cached on the annotation)
var bmp = GetAnnotationBitmap(sa, sa.ImageData);
if (bmp != null && TryGetPlacedSize(sa, out double sigWidth, out double sigHeight))
{
var imgCtrl = new System.Windows.Controls.Image
{
Source = bmp,
Width = sigWidth,
Height = sigHeight,
Stretch = System.Windows.Media.Stretch.Uniform,
IsHitTestVisible = false
};
Canvas.SetLeft(imgCtrl, sa.Position.X);
Canvas.SetTop(imgCtrl, sa.Position.Y);
_activeCanvas.Children.Add(imgCtrl);
}
}
else
{
foreach (var stroke in sa.Strokes)
{
if (stroke.Count < 2) continue;
var sigPoly = new Polyline
{
Stroke = Brushes.Black,
StrokeThickness = sa.StrokeWidth * sa.Scale,
StrokeLineJoin = PenLineJoin.Round,
StrokeStartLineCap = PenLineCap.Round,
StrokeEndLineCap = PenLineCap.Round
};
foreach (var pt in stroke)
sigPoly.Points.Add(new Point(
sa.Position.X + pt.X * sa.Scale,
sa.Position.Y + pt.Y * sa.Scale));
_activeCanvas.Children.Add(sigPoly);
}
}
break;
case ImageAnnotation ia:
var iaBmp = GetAnnotationBitmap(ia, ia.ImageData);
if (iaBmp != null && TryGetPlacedSize(ia, out double imageWidth, out double imageHeight))
{
var iaCtrl = new System.Windows.Controls.Image
{
Source = iaBmp,
Width = imageWidth,
Height = imageHeight,
Stretch = System.Windows.Media.Stretch.Uniform,
IsHitTestVisible = false
};
Canvas.SetLeft(iaCtrl, ia.Position.X);
Canvas.SetTop(iaCtrl, ia.Position.Y);
_activeCanvas.Children.Add(iaCtrl);
}
break;
}
}
// Re-add form field overlays - RenderAllAnnotations clears the canvas so they must be restored.
if (_renderDims.TryGetValue(pageIndex, out var dims))
RenderFormFields(pageIndex, dims.w, dims.h);
// Search highlights live on this same canvas and were wiped by the clear above; repaint
// them last so they sit on top and survive every re-render and continuous scroll.
ApplySearchHighlights(pageIndex, _activeCanvas);
// Flowing text selection quads (#127) live here too - same deal, repaint last.
ApplyTextSelectionQuads(pageIndex, _activeCanvas);
}
// WPF rejects NaN and infinity for FrameworkElement dimensions. Keep malformed persisted
// annotations from taking down the whole viewer while their source data is being repaired.
private static bool TryGetPlacedSize(PlacedAnnotation annot, out double width, out double height)
{
width = annot.SourceWidth * annot.Scale;
height = annot.SourceHeight * annot.Scale;
return IsFinitePositive(width) && IsFinitePositive(height);
}
private static bool IsFinite(double value)
=> !double.IsNaN(value) && !double.IsInfinity(value);
private static bool IsFinitePositive(double value)
=> IsFinite(value) && value > 0;
// Decode a placed annotation's Base64 image once and cache the frozen result on the annotation,
// so repeated renders (e.g. every mousemove of a resize-drag) reuse it instead of re-decoding.
private static System.Windows.Media.Imaging.BitmapSource? GetAnnotationBitmap(PlacedAnnotation a, string? data)
{
if (a.CachedBitmap != null) return a.CachedBitmap;
if (string.IsNullOrEmpty(data)) return null;
try
{
var bmp = new System.Windows.Media.Imaging.BitmapImage();
bmp.BeginInit();
bmp.StreamSource = new System.IO.MemoryStream(Convert.FromBase64String(data));
bmp.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
bmp.EndInit();
bmp.Freeze();
a.CachedBitmap = bmp;
return bmp;
}
catch { return null; }
}
internal void ClearSelection()
{
if (_selectionBorder is not null)
{
(_selectionBorder.Parent as Canvas)?.Children.Remove(_selectionBorder);
_selectionBorder = null;
}
foreach (var hd in _resizeHandles)
(hd.Parent as Canvas)?.Children.Remove(hd);
_resizeHandles.Clear();
if (_pairedCoverOutline is not null)
{
(_pairedCoverOutline.Parent as Canvas)?.Children.Remove(_pairedCoverOutline);
_pairedCoverOutline = null;
}
ClearMultiSelection();
_isResizingSig = false;
_resizeSigAnnot = null;
_resizeTextAnnot = null;
_resizeHlAnnot = null;
_resizeInkAnnot = null;
_resizeInkOrigPoints = null;
_isDraggingAnnot = false;
_dragAnnot = null;
_dragGroupOrig.Clear();
_selectedAnnotation = null;
// If the text bar was opened for a now-cleared text-box selection (not because the Text tool
// is active), close it again.
if (_currentTool != EditTool.Text && _annotBarTool == EditTool.Text)
HideTextSettings();
// Likewise the draw bar: if it was opened to edit a selected highlight / line / ink
// annotation (the active tool isn't a draw-family tool), close it when the selection clears.
if (_currentTool is not (EditTool.Draw or EditTool.Highlight or EditTool.Strikethrough or EditTool.Underline)
&& _annotBarTool is EditTool.Draw or EditTool.Highlight or EditTool.Strikethrough or EditTool.Underline)
HideDrawSettings();
}
// ---- Shift+click multi-selection (Select tool) -------------------------------------------
/// Removes every shift-selection outline and empties the multi-selection set.
private void ClearMultiSelection()
{
foreach (var o in _selectionOutlines)
(o.Parent as Canvas)?.Children.Remove(o);
_selectionOutlines.Clear();
_selectedSet.Clear();
}
/// The overlay canvas that hosts a given page's annotations. Reads the unified page
/// map (primary included); falls back to the primary canvas only for a page with no tile.
private Canvas CanvasForPage(int pageIndex)
=> _pages.TryGetValue(pageIndex, out var c) ? c : _annotationCanvas;
/// The overlay for a page that is actually on screen right now, or null when the page
/// has no live tile (so callers don't paint a page's content onto the wrong canvas). The
/// unified map includes the primary, so there's no primary special case here anymore.
private Canvas? VisibleCanvasForPage(int pageIndex)
=> _pages.TryGetValue(pageIndex, out var c) ? c : null;
/// Every page overlay currently in the visual tree (primary + per-page tiles).
private IEnumerable