- Services/PdfRedactText.cs: strip text whose origin falls inside a CoverAnnotation from the page content stream at save time, hooked into PdfBurn.DrawAnnotationsIntoDoc. Fixes edited values staying recoverable by text extraction. - Themes/MMD.xaml replaces all thirteen themes; picker and accent strip removed; no dark mode. - Rename KillerPDF -> MMD PDF across code, resources, packaging and locale strings; new icon. - Remove the upstream author credit and the in-app install button.
676 lines
33 KiB
C#
676 lines
33 KiB
C#
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
|
|
{
|
|
// Moved from Shell/Links.cs; the namespace and class line are the only changes. Window members
|
|
// spelled bare here resolve through PdfViewer.Bridge.cs.
|
|
public partial class PdfViewer
|
|
{
|
|
// ============================================================
|
|
// PDF Link Annotation Overlays
|
|
// ============================================================
|
|
|
|
// LinkInfo lives in Models/LinkTypes.cs, not here - ContextMenu.cs is on the window and also
|
|
// reads the link rects, so the type cannot be nested in whichever class owns them.
|
|
|
|
// Per-page link rects for the tiled views (continuous / grid / two-page), keyed by page index.
|
|
// Clicks and the hover cursor are resolved by bounds-testing these in Canvas_MouseLeftButtonDown
|
|
// and Canvas_MouseMove: a per-link overlay swallows the click in the tiled layout but its own
|
|
// handler never fires, so no visual overlay is created - these rects are the source of truth.
|
|
private readonly Dictionary<int, List<LinkInfo>> _continuousLinks = [];
|
|
|
|
/// <summary>The link-rect map, for the window side. ContextMenu.cs bounds-tests it to build
|
|
/// the right-click menu and FileOperations.cs clears it on document change; both live on the
|
|
/// window while Links.cs lives here.</summary>
|
|
internal Dictionary<int, List<LinkInfo>> ContinuousLinks => _continuousLinks;
|
|
|
|
/// <summary>Hit-slop around a link rect, shared with ContextMenu.cs so the menu targets the
|
|
/// same links the click and hover paths do.</summary>
|
|
internal const double LinkHitPadShared = LinkHitPad;
|
|
|
|
// Small hit-slop (render-dim units) added around a link rect for click / hover / right-click
|
|
// hit-testing so thin one-line link strips are easy to hit without over-reaching neighbors.
|
|
// Applied identically in single-page (grows the overlay in RenderPageLinks) and tiled views
|
|
// (bounds-checks) so both feel the same.
|
|
private const double LinkHitPad = 5;
|
|
|
|
// Persisted opt-IN for the click-safety confirmation prompt, surfaced as the
|
|
// "Confirm before opening links" toggle on the About card footer.
|
|
//
|
|
// Positive sense and default OFF: links keep opening immediately unless you ask for the
|
|
// prompt. ONE key, deliberately - a hardcoded master switch plus an inverted
|
|
// "SkipLinkConfirm" opt-out can disagree with each other. The dialog's "Don't ask again" is
|
|
// the same switch as the checkbox.
|
|
internal const string ConfirmLinksSetting = "ConfirmLinks";
|
|
|
|
// Confirms before opening an external link in the browser, unless the user opted out. Returns true
|
|
// to proceed. Internal go-to-page links never call this.
|
|
private bool ConfirmOpenLink(string url)
|
|
{
|
|
if (App.GetSetting(ConfirmLinksSetting) != "1") return true;
|
|
var (result, dontAsk) = KillerDialog.ShowWithCheckbox(
|
|
Host!.Window,
|
|
$"{Loc("Str_LinkConfirmBody")}\n\n{url}",
|
|
Loc("Str_LinkDontAsk"),
|
|
Loc("Str_LinkConfirmTitle"),
|
|
MessageBoxButton.OKCancel);
|
|
if (result != MessageBoxResult.OK) return false;
|
|
// "Don't ask again" IS the toggle, so turn it off rather than setting a second key the
|
|
// About checkbox knows nothing about - that is how the two could drift apart before.
|
|
if (dontAsk)
|
|
{
|
|
App.SetSetting(ConfirmLinksSetting, "0");
|
|
if (LinkConfirmCheck != null) LinkConfirmCheck.IsChecked = false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Schemes we will hand to the OS shell when a PDF link is clicked. A PDF can embed ANY URI, and
|
|
// Process.Start(UseShellExecute=true) would happily launch file:// paths, UNC shares, javascript:,
|
|
// or registered protocol handlers (ms-msdt:/search-ms: - real malware vectors). Anything outside
|
|
// this allow-list is refused. http/https = web links; mailto = email links.
|
|
private static readonly HashSet<string> AllowedLinkSchemes =
|
|
new(StringComparer.OrdinalIgnoreCase) { "http", "https", "mailto" };
|
|
|
|
// True only for an absolute URI in an allowed scheme. Rejects scheme-less / relative URIs (a bare
|
|
// "www.example.com" is a Tier 2 follow-up), plus file:, javascript:, and custom protocol handlers.
|
|
private static bool IsAllowedLinkUri(string url) =>
|
|
Uri.TryCreate(url, UriKind.Absolute, out var uri) && AllowedLinkSchemes.Contains(uri.Scheme);
|
|
|
|
// A PDF can store a scheme-less link like "www.example.com" or "example.com/page". Treat a domain-
|
|
// shaped target as https so it still opens; anything with an explicit scheme, a backslash (UNC/path),
|
|
// or whitespace is left untouched (and thus refused by IsAllowedLinkUri unless it's http/https/mailto).
|
|
private static string NormalizeLinkUri(string raw)
|
|
{
|
|
raw = raw.Trim();
|
|
if (raw.Length == 0) return raw;
|
|
if (raw.Contains('\\') || raw.Contains(' ')) return raw; // Windows path / UNC / junk - don't touch
|
|
if (raw.Contains("://")) return raw; // already scheme://...
|
|
int colon = raw.IndexOf(':');
|
|
int slash = raw.IndexOf('/');
|
|
if (colon >= 0 && (slash < 0 || colon < slash)) return raw; // "scheme:" (mailto:, file:, C:) - don't touch
|
|
string host = slash >= 0 ? raw[..slash] : raw; // host part before any path
|
|
return host.Contains('.') ? "https://" + raw : raw; // dotted host => assume https
|
|
}
|
|
|
|
// Maps a PDF rectangle (points, origin bottom-left, already min/max-normalized) to a canvas-space
|
|
// rectangle (pixels, origin top-left) for a page rendered at bitmapW x bitmapH. Shared by the
|
|
// PdfSharpCore and PDFium link readers so the two stay pixel-identical.
|
|
private static (double x, double y, double w, double h) PdfRectToCanvas(
|
|
double rx1, double ry1, double rx2, double ry2,
|
|
double pageWidthPt, double pageHeightPt, int bitmapW, int bitmapH)
|
|
{
|
|
double x = rx1 / pageWidthPt * bitmapW;
|
|
double y = (pageHeightPt - ry2) / pageHeightPt * bitmapH;
|
|
double w = (rx2 - rx1) / pageWidthPt * bitmapW;
|
|
double h = (ry2 - ry1) / pageHeightPt * bitmapH;
|
|
return (x, y, w, h);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Follows a resolved link target: an int page index navigates within the document; a string URI
|
|
/// is scheme-checked, confirmed, then opened via the shell. Single choke point for both the
|
|
/// single-page (_linkOverlays) and tiled (_continuousLinks) click paths, so the safety checks
|
|
/// can't be bypassed by one route and a failed open is always reported instead of silent.
|
|
/// </summary>
|
|
private void FollowLinkTarget(object? target)
|
|
{
|
|
if (target is int pageIndex)
|
|
{
|
|
if (_doc != null && pageIndex >= 0 && pageIndex < _doc.PageCount)
|
|
{
|
|
RecordNavJump(); // Alt+Left retraces the link hop
|
|
_currentPage = pageIndex;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (target is not string raw || string.IsNullOrWhiteSpace(raw)) return;
|
|
|
|
// Scheme-less but domain-shaped targets (e.g. "www.example.com") become https:// here.
|
|
string url = NormalizeLinkUri(raw);
|
|
if (!IsAllowedLinkUri(url))
|
|
{
|
|
SetStatus($"{Loc("Str_LinkBlocked")} {raw}");
|
|
return;
|
|
}
|
|
|
|
if (!ConfirmOpenLink(url)) return;
|
|
|
|
try
|
|
{
|
|
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
System.Diagnostics.Debug.WriteLine($"Open link failed: {ex}");
|
|
SetStatus(Loc("Str_LinkOpenFailed"));
|
|
}
|
|
}
|
|
|
|
// Builds the right-click actions for a link onto `menu`: Open Link (via the safe FollowLinkTarget
|
|
// path), Copy Link Address / Copy Email Address, and - only for PdfSharpCore-sourced links
|
|
// (annotIndex >= 0) - Remove Link from PDF. Shared by the single-page overlay menu and the tiled-
|
|
// view canvas menu so both views offer the same actions.
|
|
private void AddLinkMenuItems(ContextMenu menu, object target, int annotIndex, int pageIndex)
|
|
{
|
|
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_OpenLink"), (_, _) => FollowLinkTarget(target), glyph: ""));
|
|
if (target is string uri)
|
|
{
|
|
if (uri.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase))
|
|
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_CopyEmail"), (_, _) => TrySetClipboard(uri["mailto:".Length..]), "Ctrl+C", ""));
|
|
else
|
|
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_CopyLink"), (_, _) => TrySetClipboard(uri), "Ctrl+C", ""));
|
|
}
|
|
if (annotIndex >= 0)
|
|
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_RemoveLink"), (_, _) => RemoveLinkAnnotation(pageIndex, annotIndex), "Delete", ""));
|
|
}
|
|
|
|
// Clipboard COM calls throw when another app is holding the clipboard open; swallow so a copy
|
|
// never crashes the app (the worst case is the copy silently not happening).
|
|
private static void TrySetClipboard(string text)
|
|
{
|
|
try { Clipboard.SetText(text); } catch { }
|
|
}
|
|
|
|
// Status-bar hover feedback: shows the hovered link's target, restoring the prior status on exit.
|
|
private string? _preHoverStatus;
|
|
private void ShowLinkHoverStatus(string? target)
|
|
{
|
|
if (target != null)
|
|
{
|
|
_preHoverStatus ??= StatusText.Text;
|
|
StatusText.Text = target;
|
|
}
|
|
else if (_preHoverStatus != null)
|
|
{
|
|
StatusText.Text = _preHoverStatus;
|
|
_preHoverStatus = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Carries the link target (page index or URI string) plus the annotation's location in
|
|
/// the PDF so the overlay can be used to remove the native annotation on demand.
|
|
/// </summary>
|
|
private sealed class LinkAnnotInfo(object target, int pageIndex, int annotIndex)
|
|
{
|
|
public object Target { get; } = target; // int pageIndex or string URI
|
|
public int PageIndex { get; } = pageIndex; // 0-based page in _doc
|
|
public int AnnotIndex { get; } = annotIndex; // index inside page /Annots array
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses all link annotations from a PDF page and converts them to canvas-space
|
|
/// rectangles. Works for both primary and secondary page renders.
|
|
/// </summary>
|
|
private List<LinkInfo> GetPageLinks(int pageIndex, int bitmapW, int bitmapH)
|
|
{
|
|
var links = new List<LinkInfo>();
|
|
if (_doc is null) return links;
|
|
try
|
|
{
|
|
var pdfPage = _doc.Pages[pageIndex];
|
|
var annotsArr = pdfPage.Elements.GetArray("/Annots");
|
|
if (annotsArr is null || annotsArr.Elements.Count == 0) return links;
|
|
|
|
double pageWidthPt = pdfPage.Width.Point;
|
|
double pageHeightPt = pdfPage.Height.Point;
|
|
if (pageWidthPt <= 0) pageWidthPt = 595.28;
|
|
if (pageHeightPt <= 0) pageHeightPt = 841.89;
|
|
|
|
for (int i = 0; i < annotsArr.Elements.Count; i++)
|
|
{
|
|
PdfItem? elem = annotsArr.Elements[i];
|
|
PdfDictionary? ann = elem as PdfDictionary ?? DerefItem(elem) as PdfDictionary;
|
|
if (ann is null) continue;
|
|
|
|
var subtype = ann.Elements["/Subtype"]?.ToString() ?? "";
|
|
if (!subtype.Contains("Link")) continue;
|
|
|
|
var rectArr = ann.Elements.GetArray("/Rect");
|
|
if (rectArr is null || rectArr.Elements.Count < 4) continue;
|
|
double rx1 = rectArr.Elements.GetReal(0);
|
|
double ry1 = rectArr.Elements.GetReal(1);
|
|
double rx2 = rectArr.Elements.GetReal(2);
|
|
double ry2 = rectArr.Elements.GetReal(3);
|
|
if (rx1 > rx2) (rx1, rx2) = (rx2, rx1);
|
|
if (ry1 > ry2) (ry1, ry2) = (ry2, ry1);
|
|
|
|
var (cx, cy, cw, ch) = PdfRectToCanvas(rx1, ry1, rx2, ry2, pageWidthPt, pageHeightPt, bitmapW, bitmapH);
|
|
if (cw < 1 || ch < 1) continue;
|
|
|
|
int? targetPage = null;
|
|
string? uri = null;
|
|
|
|
var actionDict = ann.Elements.GetDictionary("/A");
|
|
if (actionDict != null)
|
|
{
|
|
var s = actionDict.Elements["/S"]?.ToString() ?? "";
|
|
if (s.Contains("GoTo"))
|
|
targetPage = ResolveDest(actionDict.Elements["/D"]);
|
|
else if (s.Contains("URI"))
|
|
uri = actionDict.Elements.GetString("/URI");
|
|
}
|
|
else
|
|
{
|
|
targetPage = ResolveDest(ann.Elements["/Dest"]);
|
|
}
|
|
|
|
if (targetPage is null && uri is null) continue;
|
|
|
|
object tag = targetPage.HasValue ? (object)targetPage.Value : uri!;
|
|
string tip = targetPage.HasValue ? $"Go to page {targetPage.Value + 1}" : uri!;
|
|
links.Add(new LinkInfo(cx, cy, cw, ch, tag, tip, i));
|
|
}
|
|
}
|
|
catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"GetPageLinks (PdfSharpCore): {ex}"); }
|
|
|
|
// PdfSharpCore cannot dereference link annotations stored in object streams (common in
|
|
// linearized / PDF 1.5+ files): it sees the /Annots references but resolves them to null,
|
|
// yielding zero links. PDFium reads object streams natively, so when PdfSharpCore found no
|
|
// links here, fall back to it. The early "no /Annots" return above means this only runs on
|
|
// pages that actually declare annotations, so link-free pages never pay the PDFium cost.
|
|
if (links.Count == 0)
|
|
{
|
|
try
|
|
{
|
|
var viaPdfium = GetPageLinksViaPdfium(pageIndex, bitmapW, bitmapH);
|
|
if (viaPdfium.Count > 0) return viaPdfium;
|
|
}
|
|
catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"GetPageLinks (PDFium fallback): {ex}"); }
|
|
}
|
|
return links;
|
|
}
|
|
|
|
// ============================================================
|
|
// PDFium link extraction (fallback for object-stream PDFs)
|
|
//
|
|
// PdfSharpCore silently drops link annotations stored in object streams (linearized /
|
|
// PDF 1.5+). PDFium - already shipped with Docnet and used elsewhere for security
|
|
// stripping - resolves them natively via Services/PdfiumInterop.cs.
|
|
// ============================================================
|
|
|
|
private const int PDFACTION_GOTO = 1;
|
|
private const int PDFACTION_URI = 3;
|
|
|
|
// ALL direct PDFium P/Invoke (the link + page-size entry points included) lives in
|
|
// Services/PdfiumInterop.cs - one class, one lock (Docnet's), auditable discipline.
|
|
|
|
// Cached PDFium document handle for link extraction. Object-stream PDFs take the PDFium fallback
|
|
// on every annotated page; without this we'd FPDF_LoadDocument (re-parse the whole file) once per
|
|
// page during a render sweep. Keyed by path so it self-heals when the working file changes
|
|
// (SaveTempAndReload swaps in a new temp). NOTE: on a plain open _currentFile IS the user's real
|
|
// file (it is only a temp copy after a page edit or repair), so holding this open blocks saving
|
|
// over that file - every save-over path calls CloseLinkPdfiumDoc() first (#129). Only touched from
|
|
// UI-thread render paths (RenderPageLinks / AddSecondaryPageLinks), so no locking is needed.
|
|
private IntPtr _linkPdfiumDoc = IntPtr.Zero;
|
|
private string? _linkPdfiumDocPath;
|
|
|
|
/// <summary>Returns the cached PDFium handle for the current file, (re)opening it if the file
|
|
/// changed or it isn't open yet. Returns IntPtr.Zero if there is no file or the load fails.</summary>
|
|
private IntPtr EnsureLinkPdfiumDoc()
|
|
{
|
|
if (_currentFile is null) { CloseLinkPdfiumDoc(); return IntPtr.Zero; }
|
|
if (_linkPdfiumDoc != IntPtr.Zero && _linkPdfiumDocPath == _currentFile)
|
|
return _linkPdfiumDoc;
|
|
|
|
CloseLinkPdfiumDoc();
|
|
try { _ = DocLib.Instance; } catch { } // force Docnet to init PDFium before direct pdfium.dll calls
|
|
IntPtr doc = PdfiumInterop.FPDF_LoadDocument(_currentFile, null);
|
|
if (doc != IntPtr.Zero)
|
|
{
|
|
_linkPdfiumDoc = doc;
|
|
_linkPdfiumDocPath = _currentFile;
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
/// <summary>Closes the cached PDFium link handle if open. Called when the document changes or
|
|
/// closes; the path check in EnsureLinkPdfiumDoc is the backstop for anything not closed here.</summary>
|
|
private void CloseLinkPdfiumDoc()
|
|
{
|
|
if (_linkPdfiumDoc != IntPtr.Zero)
|
|
{
|
|
try { PdfiumInterop.FPDF_CloseDocument(_linkPdfiumDoc); } catch { }
|
|
_linkPdfiumDoc = IntPtr.Zero;
|
|
}
|
|
_linkPdfiumDocPath = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads a page's link annotations via PDFium (handles object-stream PDFs that PdfSharpCore
|
|
/// cannot). Returns the same canvas-space LinkInfo list as GetPageLinks, with AnnotIndex = -1
|
|
/// because the native annotation isn't addressable through PdfSharpCore's /Annots array - so
|
|
/// "Remove Link from PDF" is not offered for these.
|
|
/// </summary>
|
|
private List<LinkInfo> GetPageLinksViaPdfium(int pageIndex, int bitmapW, int bitmapH)
|
|
{
|
|
var links = new List<LinkInfo>();
|
|
|
|
// Reuse the PDFium handle cached per document (EnsureLinkPdfiumDoc) instead of reloading the
|
|
// whole file on every annotated page - object-stream PDFs take this path on every page, so a
|
|
// per-call FPDF_LoadDocument would re-parse the file once per page during a render sweep. The
|
|
// page itself is still loaded/closed per call; only the document handle is shared.
|
|
IntPtr doc = EnsureLinkPdfiumDoc();
|
|
if (doc == IntPtr.Zero) return links;
|
|
|
|
IntPtr page = PdfiumInterop.FPDF_LoadPage(doc, pageIndex);
|
|
if (page == IntPtr.Zero) return links;
|
|
try
|
|
{
|
|
double pageWidthPt = PdfiumInterop.FPDF_GetPageWidth(page);
|
|
double pageHeightPt = PdfiumInterop.FPDF_GetPageHeight(page);
|
|
if (pageWidthPt <= 0) pageWidthPt = 595.28;
|
|
if (pageHeightPt <= 0) pageHeightPt = 841.89;
|
|
|
|
int startPos = 0;
|
|
while (PdfiumInterop.FPDFLink_Enumerate(page, ref startPos, out IntPtr link))
|
|
{
|
|
if (!PdfiumInterop.FPDFLink_GetAnnotRect(link, out PdfiumInterop.FS_RECTF r)) continue;
|
|
|
|
// PDFium may report top/bottom in either order; normalize to min/max so the
|
|
// mapping matches GetPageLinks (PDF origin is bottom-left, y up).
|
|
double rx1 = Math.Min(r.left, r.right);
|
|
double rx2 = Math.Max(r.left, r.right);
|
|
double ry1 = Math.Min(r.top, r.bottom);
|
|
double ry2 = Math.Max(r.top, r.bottom);
|
|
|
|
var (cx, cy, cw, ch) = PdfRectToCanvas(rx1, ry1, rx2, ry2, pageWidthPt, pageHeightPt, bitmapW, bitmapH);
|
|
if (cw < 1 || ch < 1) continue;
|
|
|
|
int? targetPage = null;
|
|
string? uri = null;
|
|
|
|
IntPtr dest = PdfiumInterop.FPDFLink_GetDest(doc, link);
|
|
if (dest != IntPtr.Zero)
|
|
{
|
|
int t = PdfiumInterop.FPDFDest_GetDestPageIndex(doc, dest);
|
|
if (t >= 0) targetPage = t;
|
|
}
|
|
else
|
|
{
|
|
IntPtr action = PdfiumInterop.FPDFLink_GetAction(link);
|
|
if (action != IntPtr.Zero)
|
|
{
|
|
uint at = PdfiumInterop.FPDFAction_GetType(action);
|
|
if (at == PDFACTION_URI)
|
|
{
|
|
uint len = PdfiumInterop.FPDFAction_GetURIPath(doc, action, null, 0);
|
|
if (len > 1)
|
|
{
|
|
var buf = new byte[len];
|
|
PdfiumInterop.FPDFAction_GetURIPath(doc, action, buf, len);
|
|
uri = System.Text.Encoding.UTF8.GetString(buf, 0, (int)len - 1);
|
|
}
|
|
}
|
|
else if (at == PDFACTION_GOTO)
|
|
{
|
|
IntPtr d2 = PdfiumInterop.FPDFAction_GetDest(doc, action);
|
|
if (d2 != IntPtr.Zero)
|
|
{
|
|
int t = PdfiumInterop.FPDFDest_GetDestPageIndex(doc, d2);
|
|
if (t >= 0) targetPage = t;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (targetPage is null && string.IsNullOrEmpty(uri)) continue;
|
|
|
|
object tag = targetPage.HasValue ? (object)targetPage.Value : uri!;
|
|
string tip = targetPage.HasValue ? $"Go to page {targetPage.Value + 1}" : uri!;
|
|
links.Add(new LinkInfo(cx, cy, cw, ch, tag, tip, -1));
|
|
}
|
|
}
|
|
finally { PdfiumInterop.FPDF_ClosePage(page); }
|
|
return links;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders link overlays for the primary page onto the annotation canvas.
|
|
/// Uses a manual bounds-check in Canvas_MouseLeftButtonDown for hit detection
|
|
/// (transparent Canvas children are unreliable for WPF hit-testing alone).
|
|
/// </summary>
|
|
internal void RenderPageLinks(int pageIndex, int bitmapW, int bitmapH)
|
|
{
|
|
if (_doc is null || _currentFile is null) return;
|
|
|
|
var links = GetPageLinks(pageIndex, bitmapW, bitmapH);
|
|
foreach (var lnk in links)
|
|
{
|
|
var info = new LinkAnnotInfo(lnk.Tag, pageIndex, lnk.AnnotIndex);
|
|
// Grow the overlay by LinkHitPad on every side so the hand cursor, right-click menu, and the
|
|
// click bounds-check all share the padded hit area the tiled views use - thin one-line link
|
|
// strips are easy to hit in single-page view too.
|
|
var overlay = new Canvas
|
|
{
|
|
Width = lnk.Cw + LinkHitPad * 2,
|
|
Height = lnk.Ch + LinkHitPad * 2,
|
|
Background = Brushes.Transparent,
|
|
Cursor = Cursors.Hand,
|
|
ToolTip = lnk.Tip,
|
|
Tag = info,
|
|
IsHitTestVisible = true,
|
|
};
|
|
Canvas.SetLeft(overlay, lnk.Cx - LinkHitPad);
|
|
Canvas.SetTop(overlay, lnk.Cy - LinkHitPad);
|
|
|
|
// Right-click menu: same actions as the tiled-view canvas menu, from the shared builder.
|
|
var cm = new ContextMenu();
|
|
if (TryFindResource(typeof(ContextMenu)) is Style menuStyle) cm.Style = menuStyle;
|
|
TextOptions.SetTextFormattingMode(cm, TextFormattingMode.Display);
|
|
TextOptions.SetTextRenderingMode(cm, TextRenderingMode.Grayscale);
|
|
AddLinkMenuItems(cm, lnk.Tag, lnk.AnnotIndex, pageIndex);
|
|
if (cm.Items.Count > 0) overlay.ContextMenu = cm;
|
|
|
|
_annotationCanvas.Children.Add(overlay);
|
|
_linkOverlays.Add(overlay);
|
|
}
|
|
|
|
if (links.Count > 0)
|
|
SetStatus(string.Format(Loc("Str_PageOfLinks"), pageIndex + 1, _doc.PageCount, links.Count));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes a native PDF link annotation from the page /Annots array and persists the change.
|
|
/// Called from the "Remove Link from PDF" context-menu item on link overlays.
|
|
/// </summary>
|
|
private void RemoveLinkAnnotation(int pageIndex, int annotIndex)
|
|
{
|
|
if (_doc is null || pageIndex >= _doc.PageCount || annotIndex < 0) return;
|
|
try
|
|
{
|
|
var pdfPage = _doc.Pages[pageIndex];
|
|
var annotsArr = pdfPage.Elements.GetArray("/Annots");
|
|
if (annotsArr is null || annotIndex >= annotsArr.Elements.Count) return;
|
|
|
|
// Neutralize the annotation object before removing the /Annots reference.
|
|
// If PdfSharpCore writes the orphaned indirect object to the output file,
|
|
// aggressive PDF viewers that scan cross-reference tables directly (rather
|
|
// than following /Annots) would still trigger the link without this step.
|
|
PdfItem? elem = annotsArr.Elements[annotIndex];
|
|
PdfDictionary? ann = elem as PdfDictionary ?? DerefItem(elem) as PdfDictionary;
|
|
if (ann != null)
|
|
{
|
|
ann.Elements.Remove("/A");
|
|
ann.Elements.Remove("/Dest");
|
|
ann.Elements.Remove("/Subtype");
|
|
}
|
|
|
|
annotsArr.Elements.RemoveAt(annotIndex);
|
|
MarkDirty();
|
|
SaveTempAndReload();
|
|
// Refresh the current page view so the overlay disappears.
|
|
int sel = _currentPage;
|
|
_currentPage = -1;
|
|
_currentPage = sel;
|
|
SetStatus(Loc("Str_LinkRemoved"));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
KillerDialog.Show(Host!.Window, $"{Loc("Str_LinkRemoveFailed")}\n{ex.Message}", "MmdPdf",
|
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
|
}
|
|
}
|
|
|
|
// StripLinkAnnotationBorders lives in Services/PdfScrub.cs (MmdPdfUI refactor), beside
|
|
// the other pre-save scrubs it always runs with.
|
|
|
|
/// <summary>
|
|
/// Records a page's link rectangles for the tiled views (continuous, grid, two-page). No
|
|
/// clickable overlay is created: in the tiled layout a per-link overlay swallows the click
|
|
/// but its own handler never fires, so clicks and the hover cursor are resolved by bounds-
|
|
/// testing these rects in Canvas_MouseLeftButtonDown and Canvas_MouseMove instead.
|
|
/// </summary>
|
|
internal void AddSecondaryPageLinks(int pageIndex, int bitmapW, int bitmapH)
|
|
{
|
|
_continuousLinks[pageIndex] = GetPageLinks(pageIndex, bitmapW, bitmapH);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a /Dest value (PdfArray, PdfString, or PdfName) to a 0-based page index.
|
|
/// Returns null if the destination cannot be resolved.
|
|
/// Note: PdfReference is internal to PdfSharpCore so we use reflection for ObjectNumber
|
|
/// and var-inferred types instead of the type name.
|
|
/// </summary>
|
|
private int? ResolveDest(PdfItem? destItem)
|
|
{
|
|
if (destItem is null || _doc is null) return null;
|
|
|
|
// Dereference indirect object if needed (PdfReference is internal, use duck-typing).
|
|
destItem = DerefItem(destItem);
|
|
|
|
PdfArray? arr = null;
|
|
|
|
if (destItem is PdfArray a)
|
|
{
|
|
arr = a;
|
|
}
|
|
else if (destItem is PdfString || destItem is PdfName)
|
|
{
|
|
// Named destination - look up in the document catalog
|
|
arr = ResolveNamedDest(destItem);
|
|
}
|
|
|
|
if (arr is null || arr.Elements.Count == 0) return null;
|
|
|
|
// First element of the destination array is an indirect page reference.
|
|
// PdfReference.ObjectNumber is public but its type is internal; use reflection.
|
|
var pageRefItem = arr.Elements[0];
|
|
int elemObjNum = PdfScrub.GetObjectNumber(pageRefItem);
|
|
if (elemObjNum > 0)
|
|
{
|
|
for (int i = 0; i < _doc.PageCount; i++)
|
|
{
|
|
// PdfPage.Reference (public) gives us access to ObjectNumber
|
|
var pgRef = _doc.Pages[i].Reference;
|
|
if (pgRef != null && pgRef.ObjectNumber == elemObjNum)
|
|
return i;
|
|
}
|
|
}
|
|
else if (pageRefItem is PdfInteger pageInt)
|
|
{
|
|
int pn = pageInt.Value;
|
|
if (pn >= 0 && pn < _doc.PageCount) return pn;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a named destination (string or name) to a destination array using the
|
|
/// catalog's /Dests dictionary or /Names /Dests name tree.
|
|
/// </summary>
|
|
private PdfArray? ResolveNamedDest(PdfItem nameItem)
|
|
{
|
|
if (_doc is null) return null;
|
|
string name = nameItem switch
|
|
{
|
|
PdfString s => s.Value,
|
|
PdfName n => n.Value.TrimStart('/'),
|
|
_ => ""
|
|
};
|
|
if (string.IsNullOrEmpty(name)) return null;
|
|
|
|
var catalog = _doc.Internals.Catalog;
|
|
|
|
// Legacy /Dests dictionary (direct mapping)
|
|
var dests = catalog.Elements.GetDictionary("/Dests");
|
|
if (dests != null)
|
|
{
|
|
PdfItem? val = DerefItem(dests.Elements[name] ?? dests.Elements["/" + name] ?? new PdfInteger(-1));
|
|
if (val is PdfArray da) return da;
|
|
if (val is PdfDictionary dd) return dd.Elements.GetArray("/D");
|
|
}
|
|
|
|
// Modern /Names /Dests name tree
|
|
var names = catalog.Elements.GetDictionary("/Names");
|
|
var destTree = names?.Elements.GetDictionary("/Dests");
|
|
if (destTree != null)
|
|
return ResolveNameTree(destTree, name);
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Walks a PDF name tree to find the destination array for the given name.
|
|
/// </summary>
|
|
private static PdfArray? ResolveNameTree(PdfDictionary node, string name)
|
|
{
|
|
// Leaf node: flat /Names array [key val key val ...]
|
|
var namesArr = node.Elements.GetArray("/Names");
|
|
if (namesArr != null)
|
|
{
|
|
for (int i = 0; i + 1 < namesArr.Elements.Count; i += 2)
|
|
{
|
|
var key = namesArr.Elements[i];
|
|
string keyStr = key is PdfString ks ? ks.Value : key?.ToString() ?? "";
|
|
if (keyStr == name)
|
|
{
|
|
PdfItem? val = Services.PdfScrub.DerefItemStatic(namesArr.Elements[i + 1]);
|
|
if (val is PdfArray va) return va;
|
|
if (val is PdfDictionary vd) return vd.Elements.GetArray("/D");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Intermediate node: recurse into /Kids
|
|
var kids = node.Elements.GetArray("/Kids");
|
|
if (kids != null)
|
|
{
|
|
for (int i = 0; i < kids.Elements.Count; i++)
|
|
{
|
|
PdfItem? kid = Services.PdfScrub.DerefItemStatic(kids.Elements[i]);
|
|
if (kid is PdfDictionary kd)
|
|
{
|
|
var result = ResolveNameTree(kd, name);
|
|
if (result != null) return result;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
}
|