vendor: import KillerPDF 1.7.5 source (GPL-3.0) as the base for MMD PDF

This commit is contained in:
2026-08-27 06:58:22 +02:00
commit 532485a830
577 changed files with 149058 additions and 0 deletions
+221
View File
@@ -0,0 +1,221 @@
using System;
using System.Windows;
using System.Windows.Media;
namespace KillerPDF
{
public enum EditTool { Select, Text, Highlight, Strikethrough, Underline, Draw, Signature, Image, Crop, Line, Rotate, Shape }
/// <summary>Sub-mode of the Shapes tool (#127 Phase 3): drag a rectangle or ellipse, or click
/// out a free-form polygon vertex by vertex.</summary>
public enum ShapeKind { Rectangle, Ellipse, Polygon }
/// <summary>How a HighlightAnnotation paints over its bounds.</summary>
public enum HighlightStyle { Fill, Strikethrough, Underline }
public abstract class PageAnnotation
{
public int PageIndex { get; set; }
// Links a text-edit cover to its replacement text (same non-empty id on both). A cover with a
// PairId renders dashed (it's "paired"); when the partner text is deleted the cover's PairId is
// cleared and it renders as a solid box. Empty for everything else.
public string PairId { get; set; } = "";
// Groups arbitrary annotations so they select and move together (same non-empty id on every
// member). Independent of PairId. Empty when the annotation isn't grouped.
public string GroupId { get; set; } = "";
}
/// <summary>
/// Base class for placed/resizable annotations (signature, image).
/// Carries the shared position, scale, and source-dimension properties used by the resize handle.
/// </summary>
public abstract class PlacedAnnotation : PageAnnotation
{
public Point Position { get; set; }
public double Scale { get; set; } = 0.5;
public double SourceWidth { get; set; } = 400;
public double SourceHeight { get; set; } = 150;
// Runtime-only cache of the decoded image (for image signatures / placed images). Held in
// memory so a resize-drag doesn't re-decode the Base64 on every mouse tick. Not serialized;
// the immutable ImageData stays the source of truth.
public System.Windows.Media.Imaging.BitmapSource? CachedBitmap;
}
public class TextAnnotation : PageAnnotation
{
public Point Position { get; set; }
public string Content { get; set; } = "";
public double FontSize { get; set; } = 14;
// Typeface and style. FontName is a font-family name (any installed system font). Bold/Italic/Strike
// apply to the whole box. Defaults keep text placed before these existed rendering as plain Segoe UI.
public string FontName { get; set; } = "Segoe UI";
public bool Bold { get; set; }
public bool Italic { get; set; }
public bool Strike { get; set; }
public bool Underline { get; set; }
public byte ColorR { get; set; } = 0;
public byte ColorG { get; set; } = 0;
public byte ColorB { get; set; } = 0;
public byte ColorA { get; set; } = 255;
// Box geometry. Width is fixed (text wraps to it); Height auto-grows to fit the wrapped text.
public double Width { get; set; } = 200;
public double Height { get; set; } = 28;
// Optional background fill (the "whiteout"/highlight behind the text). BgA == 0 means no fill.
public byte BgR { get; set; } = 255;
public byte BgG { get; set; } = 255;
public byte BgB { get; set; } = 255;
public byte BgA { get; set; } = 0;
public Color GetColor() => Color.FromArgb(ColorA, ColorR, ColorG, ColorB);
public void SetColor(Color c) { ColorR = c.R; ColorG = c.G; ColorB = c.B; ColorA = c.A; }
public Color GetFill() => Color.FromArgb(BgA, BgR, BgG, BgB);
public void SetFill(Color c) { BgR = c.R; BgG = c.G; BgB = c.B; BgA = c.A; }
public bool HasFill => BgA > 0;
}
public class InkAnnotation : PageAnnotation
{
public List<Point> Points { get; set; } = [];
public double StrokeWidth { get; set; } = 2;
public byte ColorR { get; set; } = 255;
public byte ColorG { get; set; } = 0;
public byte ColorB { get; set; } = 0;
public byte ColorA { get; set; } = 255;
public Color GetColor() => Color.FromArgb(ColorA, ColorR, ColorG, ColorB);
public void SetColor(Color c) { ColorR = c.R; ColorG = c.G; ColorB = c.B; ColorA = c.A; }
// Shapes (#127 Phase 3): a non-zero alpha fills the region enclosed by the stroke (the
// Shapes tool commits closed outlines - the last point repeats the first). Plain ink
// strokes and lines leave FillA = 0 and render exactly as before.
public byte FillR { get; set; }
public byte FillG { get; set; }
public byte FillB { get; set; }
public byte FillA { get; set; }
public bool HasFill => FillA > 0;
public Color GetFillColor() => Color.FromArgb(FillA, FillR, FillG, FillB);
public void SetFillColor(Color c) { FillR = c.R; FillG = c.G; FillB = c.B; FillA = c.A; }
}
// One brush-eraser pass over a highlight: a stroke (canvas-space points) of the given radius. The
// highlight renders as its rectangle MINUS the union of these widened strokes - one anti-aliased
// geometry, so the erased edges are smooth curves, not blocky steps or seamed strips.
public sealed class HighlightErase
{
public System.Collections.Generic.List<Point> Points { get; set; } = [];
public double Radius { get; set; }
}
public class HighlightAnnotation : PageAnnotation
{
public Rect Bounds { get; set; }
// Brush-eraser passes carved out of this highlight (null = untouched solid rect). Only Fill-style
// highlights are ever carved.
public System.Collections.Generic.List<HighlightErase>? Erases { get; set; }
public HighlightStyle Style { get; set; } = HighlightStyle.Fill;
public byte ColorR { get; set; } = 255;
public byte ColorG { get; set; } = 255;
public byte ColorB { get; set; } = 0;
public byte ColorA { get; set; } = 80;
public Color GetColor() => Color.FromArgb(ColorA, ColorR, ColorG, ColorB);
public virtual void SetColor(Color c) { ColorR = c.R; ColorG = c.G; ColorB = c.B; ColorA = c.A; }
/// <summary>
/// The actual rectangle painted for this annotation. Fill uses the whole bounds;
/// strikethrough is a thin band at the vertical center; underline sits at the bottom.
/// </summary>
public Rect DrawRect()
{
double t = Math.Max(2.0, Bounds.Height * 0.10);
switch (Style)
{
case HighlightStyle.Strikethrough:
return new Rect(Bounds.X, Bounds.Y + Bounds.Height / 2 - t / 2, Bounds.Width, t);
case HighlightStyle.Underline:
return new Rect(Bounds.X, Bounds.Y + Bounds.Height - t, Bounds.Width, t);
default:
return Bounds;
}
}
}
/// <summary>
/// An opaque filled rectangle that covers ("erases") existing PDF content - the background half
/// of a text edit. Subclasses HighlightAnnotation so it inherits all rect plumbing (render, drag,
/// corner-resize, hit-test, export) for free; the only differences are an opaque default fill and
/// a SetColor that can never go translucent (a see-through cover would let the old text ghost
/// through, the exact bug this feature exists to avoid). The paired replacement text is a normal
/// TextAnnotation placed on top, so it is independently editable, movable, and recolorable.
/// </summary>
public class CoverAnnotation : HighlightAnnotation
{
public CoverAnnotation()
{
ColorR = 255; ColorG = 255; ColorB = 255; ColorA = 255; // opaque white by default
Style = HighlightStyle.Fill;
}
/// <summary>Recolor the cover but keep it fully opaque - drop any alpha the caller passed.</summary>
public override void SetColor(Color c) => base.SetColor(Color.FromArgb(255, c.R, c.G, c.B));
}
/// <summary>
/// A signature placed on a PDF page: either ink strokes or an imported image.
/// </summary>
public class SignatureAnnotation : PlacedAnnotation
{
public List<List<Point>> Strokes { get; set; } = [];
/// <summary>Pen thickness (DIPs at source scale); multiplied by Scale when rendered.</summary>
public double StrokeWidth { get; set; } = 2.5;
/// <summary>Base-64 encoded PNG. Non-null = image sig; null = drawn strokes.</summary>
public string? ImageData { get; set; }
}
/// <summary>
/// An image placed on a PDF page as a resizable annotation.
/// </summary>
public class ImageAnnotation : PlacedAnnotation
{
/// <summary>Base-64 encoded image bytes (PNG, JPG, BMP, etc.).</summary>
public string ImageData { get; set; } = "";
}
/// <summary>
/// A point that can be serialized to JSON (WPF Point doesn't serialize well).
/// </summary>
public class SerializablePoint
{
public double X { get; set; }
public double Y { get; set; }
}
/// <summary>
/// A saved signature stored in the user's AppData for reuse.
/// </summary>
/// <summary>Distinguishes a full signature from a (smaller) initials stamp. Default is Signature
/// so signatures saved before this field existed still deserialize correctly.</summary>
public enum SignatureKind { Signature, Initials }
public class SavedSignature
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Name { get; set; } = "Signature";
/// <summary>Whether this is a full signature or an initials stamp. Drives which popup section
/// it appears in and the default placement scale.</summary>
public SignatureKind Kind { get; set; } = SignatureKind.Signature;
/// <summary>Pen thickness the signature was drawn with (DIPs at CanvasWidth/Height scale).</summary>
public double StrokeWidth { get; set; } = 2.5;
public List<List<SerializablePoint>> Strokes { get; set; } = [];
public double CanvasWidth { get; set; } = 400;
public double CanvasHeight { get; set; } = 150;
/// <summary>Base-64 encoded PNG for imported image signatures. Null = drawn strokes.</summary>
public string? ImageData { get; set; }
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace KillerPDF
{
/// <summary>
/// One link rectangle on a page, in render-dim coordinates.
///
/// Used by the tiled views (continuous, grid, two-page), where a per-link overlay would swallow
/// the click without its own handler ever firing - so clicks and the hover cursor are resolved
/// by bounds-testing these rects instead. That makes them the source of truth for links outside
/// single-page view.
///
/// TOP-LEVEL, not nested. Links.cs lives in the viewer control while ContextMenu.cs lives on the
/// window and also bounds-tests these rects to build the right-click menu, so neither class can
/// own the type.
/// </summary>
internal readonly record struct LinkInfo(
double Cx, double Cy, double Cw, double Ch, object Tag, string Tip, int AnnotIndex);
}
+133
View File
@@ -0,0 +1,133 @@
using System;
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Windows;
using System.Windows.Media.Imaging;
using Docnet.Core;
using Docnet.Core.Models;
namespace KillerPDF
{
/// <summary>
/// ViewModel for a single page thumbnail in the sidebar PageList.
/// Thumbnail is loaded lazily on a background thread; the UI binds to
/// the <see cref="Thumbnail"/> property and updates via PropertyChanged.
/// </summary>
internal sealed class PageThumbnailVm(int pageIndex, string filePath, int rotation = 0) : INotifyPropertyChanged
{
// Limit concurrent pdfium doc-reader opens to avoid contention
private static readonly SemaphoreSlim _loadSem = new(2, 2);
private BitmapSource? _thumb;
private bool _loadRequested;
public int PageIndex { get; } = pageIndex;
public string Label => string.Format(
Application.Current?.TryFindResource("Str_PageLabel") as string ?? "Page {0}", PageIndex + 1);
private readonly string _filePath = filePath;
private readonly int _rotation = ((rotation % 360) + 360) % 360; // degrees: 0, 90, 180, 270
public BitmapSource? Thumbnail => _thumb;
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>Called when the ListBox item becomes visible (via binding getter trigger).</summary>
public void RequestLoad()
{
if (_loadRequested) return;
_loadRequested = true;
System.Threading.Tasks.Task.Run(LoadAsync);
}
/// <summary>
/// Seed the thumbnail before ItemsSource is set - no dispatch needed because
/// no binding exists yet. Used to carry old thumbnails across a RefreshPageList
/// call so the list never flashes blank.
/// </summary>
internal void SetThumbnailDirect(BitmapSource src) => _thumb = src;
/// <summary>Called by RefreshPageList's bulk background loader.</summary>
internal void SetThumbnail(BitmapSource src)
{
// A background load can finish after the app has begun shutting down (or between
// tab switches), when Application.Current is briefly null. The UI is going away in
// that case, so just drop the update instead of throwing.
var app = Application.Current;
if (app == null) return;
app.Dispatcher.BeginInvoke(new Action(() =>
{
_thumb = src;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Thumbnail)));
}));
}
private async System.Threading.Tasks.Task LoadAsync()
{
await _loadSem.WaitAsync().ConfigureAwait(false);
try
{
var src = BuildThumb(_filePath, PageIndex, _rotation);
if (src != null) SetThumbnail(src);
}
catch { /* thumbnail not critical */ }
finally { _loadSem.Release(); }
}
internal static BitmapSource? BuildThumb(string filePath, int pageIndex, int rotation = 0)
{
try
{
// Render thumbnails at a higher resolution than they're usually shown so the page list
// stays crisp when the sidebar is dragged wider (thumbnails scale to the sidebar width).
// 288px covers the ~240px the page can show at the widest sidebar; raise for sharper,
// lower to save memory (each loaded thumbnail is kept in RAM).
using var docReader = DocLib.Instance.GetDocReader(filePath, new PageDimensions(288, 576));
using var pr = docReader.GetPageReader(pageIndex);
int tw = pr.GetPageWidth();
int th = pr.GetPageHeight();
var raw = KillerPDF.Services.PdfiumInterop.RenderPageWithAnnotations(filePath, pageIndex, tw, th)
?? pr.GetImage(); // #141
if (tw <= 0 || th <= 0 || raw == null || raw.Length < tw * th * 4)
return null;
// Apply in-memory rotation (temp file stores /Rotate=0; _pageRotations holds true angle)
if (rotation != 0)
(raw, tw, th) = Services.BitmapHelpers.RotateBitmap(raw, tw, th, rotation);
return EncodeToBitmapSource(raw, tw, th);
}
catch { return null; }
}
/// <summary>
/// Encode already-decoded BGRA pixels (with rotation applied) to a frozen BitmapFrame.
/// Called by the RefreshPageList bulk loader which manages its own doc reader.
/// </summary>
internal static BitmapSource? BuildThumbFromRaw(byte[] bgra, int width, int height)
=> EncodeToBitmapSource(bgra, width, height);
/// <summary>
/// Encode raw BGRA (pdfium) → PNG → frozen BitmapFrame entirely on the calling thread.
/// GDI+ Format32bppArgb is BGRA in memory, matching pdfium output exactly.
/// </summary>
private static BitmapSource? EncodeToBitmapSource(byte[] bgra, int width, int height)
{
var pin = GCHandle.Alloc(bgra, GCHandleType.Pinned);
try
{
using var bmp = new System.Drawing.Bitmap(
width, height, width * 4,
System.Drawing.Imaging.PixelFormat.Format32bppArgb,
pin.AddrOfPinnedObject());
using var ms = new MemoryStream();
bmp.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
ms.Position = 0;
var src = BitmapFrame.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
src.Freeze();
return src;
}
finally { pin.Free(); }
}
}
}
+55
View File
@@ -0,0 +1,55 @@
using System.Windows.Media;
namespace KillerPDF
{
internal enum StampKind { PageNumber, Watermark }
// The full configuration produced/edited by the Stamp window. One spec can drive page numbers,
// a watermark, or both, each over its own page range. A spec is the unit that gets re-opened when
// the user double-clicks a placed stamp, so it carries everything needed to recreate the stamps.
internal sealed class StampSpec
{
// ---- Page numbers ----
public bool NumbersEnabled;
public int StartNumber = 1;
public string Format = "{n}"; // {n} = this page's number, {N} = total
public int NumPosH = 1; // 0 left, 1 center, 2 right
public int NumPosV = 2; // 0 top, 1 middle, 2 bottom
public double NumFontPt = 12;
public Color NumColor = Colors.Black;
public string NumRange = ""; // "" = all pages; else "1-3,5"
public bool NumMirror; // flip left/right each page so numbers sit on the outer edge
public double NumCustomX = 0.5; // used when NumPosH == -1 (Custom): center as a fraction of page
public double NumCustomY = 0.92;
// ---- Watermark ----
public bool WmEnabled;
public bool WmIsImage; // false = text, true = image
// Localized default (falls back to DRAFT); resolved at model construction on the UI thread.
public string WmText = System.Windows.Application.Current?.TryFindResource("Str_Stamp_DefaultText") as string ?? "DRAFT";
public string WmFont = "Segoe UI";
public double WmFontPt = 64;
public Color WmColor = Color.FromRgb(0x88, 0x88, 0x88);
public double WmOpacity = 0.25; // 0..1
public double WmAngle = 45; // degrees, counter-clockwise
public int WmPosH = 1; // 0 left, 1 center, 2 right
public int WmPosV = 1; // 0 top, 1 middle, 2 bottom
public string? WmImagePath; // source image when WmIsImage
public double WmScale = 1.0; // multiplier on the natural placement size
public string WmRange = ""; // "" = all pages
public double WmCustomX = 0.5; // used when WmPosH == -1 (Custom): center as a fraction of page
public double WmCustomY = 0.5;
public StampSpec Clone() => (StampSpec)MemberwiseClone();
}
// A single placed stamp on one page. It points back at the spec that created it so a double-click
// on the page can re-open the Stamp window with the original settings. The concrete text/position is
// derived from Spec + page geometry at render/burn time, so nothing here needs the resolved layout.
internal sealed class StampInstance
{
public int PageIndex;
public StampKind Kind;
public StampSpec Spec = null!;
}
}
+28
View File
@@ -0,0 +1,28 @@
using System.Collections.Generic;
namespace KillerPDF
{
// The undo stack's entry type. Each entry is either an annotation removal or a full document
// snapshot; AnnotationGroup removes a specific set in one step (a text edit = cover + text).
//
// TOP-LEVEL, not nested in MainWindow. The code that pushes undo entries - Annotations.cs and
// TextEditing.cs - lives in KillerPDF.Controls, where a type nested in MainWindow only spells
// as MainWindow.UndoEntry; that would mean qualifying roughly 30 call sites for no gain. As
// top-level types in KillerPDF they resolve unqualified from the child namespace too.
//
// This also retires the CS0052 chain that made them internal in the first place: DocumentSession
// had to be internal for the render cache, its UndoStack field is Stack<UndoEntry>, and a field
// cannot be more accessible than its type.
internal enum UndoKind { Annotation, Document, StampBatch, ClearAnnotations, AnnotationGroup, PageSnapshot }
internal readonly record struct UndoEntry(
UndoKind Kind,
int PageIdx = -1,
byte[]? DocBytes = null,
bool WasDirty = false,
int[]? Pages = null,
PageAnnotation? Annot = null,
Dictionary<int, List<PageAnnotation>>? AnnotSnapshot = null,
List<PageAnnotation>? AnnotGroup = null);
}
+20
View File
@@ -0,0 +1,20 @@
namespace KillerPDF
{
// How a document view lays its pages out, and how it fits them to the viewport.
//
// TOP-LEVEL, not nested in MainWindow. The viewer is a UserControl in KillerPDF.Controls, and
// from there a type nested in MainWindow only spells as MainWindow.ViewMode - which would mean
// qualifying 91 references for no gain. As top-level types in KillerPDF they resolve
// unqualified from KillerPDF.Controls too (a namespace declaration puts its parent namespaces
// in scope), so every call site compiles untouched.
//
// internal, not public: nothing outside the assembly has any business with either.
/// <summary>Page layout for a document view. RenderPage is Single/TwoPage/Grid only and is
/// guarded to no-op in Continuous - see the render pipeline's notes on why the two pipelines
/// cannot be mixed.</summary>
internal enum ViewMode { Single, Continuous, TwoPage, Grid }
/// <summary>Automatic fit applied on resize, or None when the user has set a zoom.</summary>
internal enum FitMode { None, Width, Page }
}
+118
View File
@@ -0,0 +1,118 @@
using System.Collections.Generic;
using System.Windows.Controls;
namespace KillerPDF
{
/// <summary>
/// Everything ONE document view owns. Split pane needs two of these; each PdfViewer control
/// owns one, and the window reads the active one back through its `_view` property, so the ~500
/// call sites behind the window's forwarding properties are untouched.
///
/// This is deliberately the PER-VIEW cut, not the per-document one. Per-document state
/// (annotations, undo, form values, search hits) already travels in DocumentSession, which
/// tab switching swaps by reference - see the comment above _annotations in
/// MainWindow.xaml.cs. A second pane needs its own live visual maps and its own view mode
/// and zoom; it does NOT need a second copy of the per-document machinery, because each
/// pane will simply own its own set of sessions.
///
/// TOP-LEVEL, not nested in MainWindow. The viewer lives in KillerPDF.Controls and cannot own a
/// type nested in the window without every reference spelling out MainWindow.ViewerState.
/// ViewMode and FitMode live in Models/ViewTypes.cs for the same reason.
/// </summary>
internal sealed class ViewerState
{
/// <summary>Unified page -> overlay map covering EVERY rendered page, the primary
/// included. The single source of truth the canvas accessors read from.</summary>
public readonly Dictionary<int, Canvas> Pages = [];
/// <summary>Per-page overlay canvases for the multi-page tile systems (continuous
/// overlays, or grid / two-page secondaries). Holds only secondary tiles and is driven
/// by the tile-recycling machinery.</summary>
public readonly Dictionary<int, Canvas> ContinuousCanvases = [];
/// <summary>The page this view is showing (0-based; -1 = no document).
///
/// This exists because reading the SIDEBAR's selected thumbnail,
/// `PageList.SelectedIndex` (118 times across 24 files), works with one pane and cannot
/// work with two - there is one sidebar and two current pages, so a viewer inside the
/// control has nothing to ask.
///
/// This is the storage; the sidebar FOLLOWS it. Kept in sync in exactly two places,
/// which between them cover every write:
/// - PageList_SelectionChanged (PageSelection.cs) mirrors the sidebar back into here,
/// unconditionally and before its own >= 0 guard, so clearing the list to -1 (tab
/// close, document close) is mirrored too.
/// - SyncCurrentPageTo (Viewport.cs), which detaches that handler to avoid re-entry
/// and so would otherwise slip past the mirror.
/// Everything that sets PageList.SelectedIndex directly still routes through the
/// handler, so those need no change.
///
/// The 118 call sites are deliberately NOT repointed at this field: the render pipeline
/// switches over to reading it as it moves into the control.</summary>
public int CurrentPage = -1;
/// <summary>Current view mode for this view.</summary>
public ViewMode Mode = ViewMode.Continuous;
/// <summary>Mode a fade is transitioning to, if one is in flight. Reads that need the
/// destination rather than the current mode use `Pending ?? Mode` - the fade takes
/// ~90ms and Mode lags behind it, which is what made wheel-cycling need several
/// notches before it was fixed.</summary>
public ViewMode? Pending;
// ── Zoom / fit ──────────────────────────────────────────────────────────────────
public double ZoomLevel = 1.0;
/// <summary>Zoom the current bitmaps were rasterized at, so the re-sharpen pass knows
/// whether what is on screen is still crisp enough.</summary>
public double LastRenderZoom = 1.0;
/// <summary>Primary (spread-left) page currently rasterized.</summary>
public int RenderedPrimaryPage = -1;
public FitMode Fit = FitMode.None;
// ── In-flight render work ───────────────────────────────────────────────────────
// Each view cancels and reschedules its own rendering, so two panes must not share
// these - one pane's mode switch would otherwise cancel the other's render.
public System.Windows.Threading.DispatcherTimer? RerenderTimer;
public System.Threading.CancellationTokenSource? SecondaryRenderCts;
public System.Threading.CancellationTokenSource? ContinuousRenderCts;
/// <summary>#85 visible-page re-sharpen.</summary>
public System.Threading.CancellationTokenSource? ContinuousSharpenCts;
// ── Continuous-view bookkeeping ─────────────────────────────────────────────────
/// <summary>Slots currently holding a hi-res bitmap.</summary>
public readonly HashSet<int> ContinuousSharpPages = [];
/// <summary>Budget those slots were sharpened at.</summary>
public int ContinuousSharpW;
public readonly List<double> ContinuousTops = [];
/// <summary>Page to scroll to once its grid tile streams in (-1 = none).</summary>
public int GridScrollToPage = -1;
/// <summary>Re-scroll here once its true height is known.</summary>
public int ContinuousScrollTarget = -1;
public double ContinuousPageW;
// ── Gesture routing ─────────────────────────────────────────────────────────────
/// <summary>The page surface a pointer gesture started on, captured on mouse-down.
/// Kept separate from the active canvas because RenderAllAnnotations reuses that as its
/// render target, and in Grid view tiles stream in asynchronously and re-point it
/// mid-gesture - which committed annotations to the wrong page.</summary>
public Canvas? GestureCanvas;
public int GesturePage = -1;
// ── Visual hosts ────────────────────────────────────────────────────────────────
// References only - the window still creates and owns the actual elements. Today
// ContinuousPanel / PageContentPanel / PageContentGrid come from FindName in the
// window ctor and are the ONE window's XAML; AnnotationCanvas / PageImage are the
// code-built primary tile (Viewport.BuildPrimaryTile) and ActiveCanvas is re-pointed
// on mouse-down. Holding them here is what lets the next stage hand each viewer its
// own tile tree without touching any of the ~250 call sites that use them.
public StackPanel ContinuousPanel = null!;
public WrapPanel PageContentPanel = null!;
public Grid PageContentGrid = null!;
/// <summary>The hardcoded primary tile's overlay, shown in Single/Grid/TwoPage.</summary>
public Canvas AnnotationCanvas = null!;
public Image PageImage = null!;
/// <summary>Active annotation surface. Single view: always AnnotationCanvas.
/// Continuous: set on mouse-down to the clicked page's overlay.</summary>
public Canvas ActiveCanvas = null!;
}
}