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
+328
View File
@@ -0,0 +1,328 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Windows;
namespace KillerPDF.Features
{
/// <summary>
/// Everything the About card does that is not drawing: reading the signature and release date,
/// hashing the exe, asking GitHub whether there is a newer release, and performing the
/// one-click self-update.
///
/// Holds no controls. Talks to the window only through <see cref="IAboutHost"/>, so the whole
/// of this file is testable against a stub host.
/// </summary>
internal sealed class AboutController
{
// The certificate subject is the legal name ("Open Source Developer Stephen Riley"), so the
// About card ties it back to the name people know. Gated on the subject actually being
// Steve's: a fork signed by somebody else must not claim the alias, and an unsigned build
// has no subject at all. Family standard, see code/CLAUDE.md.
private const string SignerName = "Stephen Riley";
private const string AkaName = "Steve the Killer";
private const string Repo = "https://github.com/SteveTheKiller/KillerPDF";
private readonly IAboutHost _host;
/// <summary>"vX.Y.Z" of the available update, set by the update check. Null until one is found.</summary>
private string? _updateTag;
internal AboutController(IAboutHost host) => _host = host;
/// <summary>The running assembly's version, three parts.</summary>
internal static string Version =>
System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "?";
/// <summary>Release date baked in from the csproj's ReleaseDate property, so a user can see
/// how old their build is. A file timestamp would not survive being copied and the PE linker
/// stamp is a build date, not a release date. Empty when the attribute is missing (an older
/// build), in which case the version line shows the version alone.</summary>
internal static string ReleaseDate
{
get
{
foreach (var a in System.Reflection.CustomAttributeExtensions.GetCustomAttributes
<System.Reflection.AssemblyMetadataAttribute>(
System.Reflection.Assembly.GetExecutingAssembly()))
if (a.Key == "ReleaseDate") return a.Value ?? string.Empty;
return string.Empty;
}
}
/// <summary>Populates the card and shows it. The SHA-256 is slow, so it lands later.</summary>
internal void Show()
{
var (sigValid, sigSubject, sigThumbprint) = App.GetExeSignerInfo();
_host.Publisher = sigValid ? sigSubject : "(not signed or chain failed)";
_host.Thumbprint = string.IsNullOrEmpty(sigThumbprint) ? "(none)" : sigThumbprint;
_host.Sha256 = _host.Loc("Str_About_Computing");
_host.ReleaseDate = ReleaseDate;
_host.SetVersion(Version);
// Signed, verified, AND signed by Steve - all three, not merely "is signed".
bool signedByMe = sigValid
&& sigSubject.IndexOf(SignerName, StringComparison.OrdinalIgnoreCase) >= 0;
// 0x201C / 0x201D are the curly quotes, built from codepoints so this file stays ASCII
// on disk - the same encoding trap that made release.ps1 PS7-only.
_host.SetAlias(signedByMe ? (char)0x201C + AkaName + (char)0x201D : null);
_host.UpdateVisible = false;
_host.ShowCard();
CheckForUpdateAsync(System.Reflection.Assembly.GetExecutingAssembly().GetName().Version);
ComputeSha256Async();
}
/// <summary>Opens the GitHub release for the running version.</summary>
internal void OpenReleaseNotes() => OpenUrl($"{Repo}/releases/tag/v{Version}");
internal static void OpenUrl(string url)
{
try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); }
catch { /* no browser, or the shell refused - nothing useful to say */ }
}
// ---- SHA-256 -------------------------------------------------------------------------
private async void ComputeSha256Async()
{
var sha256 = await System.Threading.Tasks.Task.Run(App.GetExeSha256).ConfigureAwait(true);
_host.Sha256 = sha256;
}
// ---- Update check --------------------------------------------------------------------
/// <summary>
/// Quietly checks GitHub for a newer release when the About card opens. Runs only on demand
/// (no background service), times out fast, and silently does nothing if there is no
/// internet or the request fails. Shows the update button only if a newer tag exists.
/// </summary>
private async void CheckForUpdateAsync(System.Version? current)
{
if (current is null) return;
try
{
System.Net.ServicePointManager.SecurityProtocol |= System.Net.SecurityProtocolType.Tls12;
using var http = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(4) };
http.DefaultRequestHeaders.UserAgent.ParseAdd("KillerPDF-UpdateCheck");
var json = await http.GetStringAsync($"{Repo.Replace("github.com", "api.github.com/repos")}/releases/latest")
.ConfigureAwait(true);
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("tag_name", out var tagEl)) return;
var tag = tagEl.GetString();
if (string.IsNullOrWhiteSpace(tag)) return;
// System.Version spelled out: this class has a string property called Version, which
// shadows the type in expression position, so a bare "Version.TryParse" binds to
// string.TryParse and does not compile.
if (!System.Version.TryParse(tag!.TrimStart('v', 'V').Trim(), out var latest)) return;
var cur = new System.Version(current.Major, current.Minor, current.Build < 0 ? 0 : current.Build);
var lat = new System.Version(latest.Major, latest.Minor, latest.Build < 0 ? 0 : latest.Build);
if (lat <= cur) return;
_updateTag = $"v{lat.ToString(3)}";
_host.UpdateText = string.Format(_host.Loc("Str_UpdateAvailable"), _updateTag);
_host.UpdateVisible = true;
}
catch { /* offline, timeout, or API error - quietly do nothing */ }
}
// ---- Self-update ---------------------------------------------------------------------
/// <summary>
/// One-click self-update: downloads and verifies the public portable/installer. Installed
/// copies hand it the same payload-based install command used by a manual upgrade; portable
/// copies replace their original launcher after both launcher and inner app have exited.
/// </summary>
internal async void Update()
{
var tag = _updateTag;
if (string.IsNullOrEmpty(tag)) return;
if (_host.IsDirty)
{
KillerDialog.Show(_host.Window, _host.Loc("Str_Dlg_SaveBeforeUpdate"),
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var confirm = KillerDialog.Show(_host.Window,
string.Format(_host.Loc("Str_UpdatePrompt"), tag),
"KillerPDF", MessageBoxButton.OKCancel, MessageBoxImage.Question);
if (confirm != MessageBoxResult.OK) return;
_host.UpdateEnabled = false;
_host.UpdateText = _host.Loc("Str_UpdateDownloading");
string? newExe = await DownloadVerifiedAsync(tag!).ConfigureAwait(true);
if (newExe is null)
{
// Offline, timed out, or verification failed: restore the button and open the
// releases page so the user can update manually.
_host.UpdateEnabled = true;
_host.UpdateText = string.Format(_host.Loc("Str_UpdateAvailable"), tag);
OpenUrl($"{Repo}/releases/latest");
return;
}
if (!LaunchSwapAndExit(newExe))
{
try { if (File.Exists(newExe)) File.Delete(newExe); } catch { }
_host.UpdateEnabled = true;
_host.UpdateText = string.Format(_host.Loc("Str_UpdateAvailable"), tag);
}
}
/// <summary>Downloads the release exe and checks it against the published checksum.
/// Returns the temp path, or null if anything at all went wrong.</summary>
private static async System.Threading.Tasks.Task<string?> DownloadVerifiedAsync(string tag)
{
try
{
System.Net.ServicePointManager.SecurityProtocol |= System.Net.SecurityProtocolType.Tls12;
using var http = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(90) };
http.DefaultRequestHeaders.UserAgent.ParseAdd("KillerPDF-UpdateCheck");
var exeUrl = $"{Repo}/releases/download/{tag}/KillerPDF.exe";
// Read the checksums from the release ASSET next to the exe, not from
// raw.githubusercontent at the tag. Both files are uploaded to the release
// together, so the hash can never drift from the exe the way a repo-committed
// file does when the tag/commit order gets muddled.
var sumsUrl = $"{Repo}/releases/download/{tag}/SHA256SUMS.txt";
var exeBytes = await http.GetByteArrayAsync(exeUrl).ConfigureAwait(false);
var sumsTxt = await http.GetStringAsync(sumsUrl).ConfigureAwait(false);
string? expected = null;
foreach (var line in sumsTxt.Replace("\r", "").Split('\n'))
{
if (line.TrimStart().StartsWith("KillerPDF.exe", StringComparison.OrdinalIgnoreCase))
{
var parts = line.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2) expected = parts[^1];
break;
}
}
if (string.IsNullOrEmpty(expected)) return null;
string actual;
using (var sha = System.Security.Cryptography.SHA256.Create())
actual = BitConverter.ToString(sha.ComputeHash(exeBytes)).Replace("-", "");
if (!actual.Equals(expected, StringComparison.OrdinalIgnoreCase)) return null;
var path = Path.Combine(Path.GetTempPath(), $"KillerPDF_update_{Guid.NewGuid():N}.exe");
File.WriteAllBytes(path, exeBytes);
return path;
}
catch { return null; }
}
/// <summary>Writes the swap batch, starts it, and shuts the app down. Returns false if the
/// helper could not be started, in which case nothing has been changed.</summary>
private bool LaunchSwapAndExit(string newExe)
{
try
{
var curExe = Process.GetCurrentProcess().MainModule!.FileName;
var reopen = _host.FileToReopen;
var pid = Process.GetCurrentProcess().Id;
var relArg = string.IsNullOrEmpty(reopen) ? "" : $" \"{reopen}\"";
var bat = Path.Combine(Path.GetTempPath(), $"killerpdf_update_{Guid.NewGuid():N}.bat");
bool portable = App.IsPortable();
string? portableLauncher = Environment.GetEnvironmentVariable("KILLERPDF_LAUNCHER_PATH");
bool packagedPortable = portable && !string.IsNullOrWhiteSpace(portableLauncher) && File.Exists(portableLauncher);
if (!App.VerifyAuthenticode(newExe).Valid)
throw new InvalidDataException("The downloaded update is not signed by a trusted publisher.");
// A machine-wide install (Program Files, from winget, choco or an RMM) is not
// writable by a normal user, so the swap has to run elevated. This previously ran
// the batch unelevated and sent the copy to >nul with no errorlevel check, so on
// those installs it silently failed and then relaunched the OLD exe - the app
// appeared to "update" to the same version, with no error.
string updateTarget = packagedPortable ? portableLauncher! : curExe;
bool needsElevation = !CanWriteTo(Path.GetDirectoryName(updateTarget)!);
// When elevated, relaunch through explorer.exe so the app comes back at the user's
// normal integrity level rather than inheriting the elevated token. explorer.exe
// cannot forward arguments, so the currently-open file is not reopened on that
// path - a one-off convenience loss, preferred over leaving KillerPDF running as
// administrator for the rest of the session.
var script = new StringBuilder()
.AppendLine("@echo off")
.AppendLine(":waitapp")
.AppendLine($"tasklist /fi \"PID eq {pid}\" 2>nul | find \"{pid}\" >nul")
.AppendLine("if not errorlevel 1 ( ping -n 2 127.0.0.1 >nul & goto waitapp )");
if (packagedPortable)
{
if (int.TryParse(Environment.GetEnvironmentVariable("KILLERPDF_LAUNCHER_PID"), out int launcherPid))
{
script.AppendLine(":waitlauncher")
.AppendLine($"tasklist /fi \"PID eq {launcherPid}\" 2>nul | find \"{launcherPid}\" >nul")
.AppendLine("if not errorlevel 1 ( ping -n 2 127.0.0.1 >nul & goto waitlauncher )");
}
script.AppendLine($"attrib -r \"{portableLauncher}\" >nul 2>&1")
.AppendLine($"copy /y \"{newExe}\" \"{portableLauncher}\" >nul 2>&1")
.AppendLine("if errorlevel 1 goto failed")
.AppendLine(needsElevation
? $"start \"\" explorer.exe \"{portableLauncher}\""
: $"start \"\" \"{portableLauncher}\"{relArg}");
}
else
{
bool machineInstall = !CanWriteTo(Path.GetDirectoryName(curExe)!);
string installArg = machineInstall ? "/silent" : "/install-user";
script.AppendLine($"start /wait \"\" \"{newExe}\" {installArg}")
.AppendLine("if errorlevel 1 goto failed")
.AppendLine(needsElevation
? $"start \"\" explorer.exe \"{curExe}\""
: $"start \"\" \"{curExe}\"{relArg}");
}
script.AppendLine("goto cleanup")
.AppendLine(":failed")
.AppendLine($"start \"\" \"{Repo}/releases/latest\"")
.AppendLine(":cleanup")
.AppendLine($"del \"{newExe}\" >nul 2>&1")
.AppendLine("del \"%~f0\" >nul 2>&1");
File.WriteAllText(bat, script.ToString());
var psi = new ProcessStartInfo("cmd.exe", $"/c \"{bat}\"")
{
WindowStyle = ProcessWindowStyle.Hidden,
UseShellExecute = true
};
if (needsElevation) psi.Verb = "runas"; // triggers the UAC prompt
// Declining UAC throws Win32Exception 1223, so only shut down once the helper is
// actually running - otherwise the app would close without updating.
Process.Start(psi);
Application.Current.Shutdown();
return true;
}
catch { return false; }
}
/// <summary>True if this process can create a file in <paramref name="dir"/>. Used to decide
/// whether the self-update swap needs elevating: Program Files installs are not writable by
/// a normal user, per-user installs under LOCALAPPDATA always are.</summary>
private static bool CanWriteTo(string dir)
{
try
{
var probe = Path.Combine(dir, $".kp_write_{Guid.NewGuid():N}.tmp");
using (new FileStream(probe, FileMode.CreateNew, FileAccess.Write, FileShare.None,
1, FileOptions.DeleteOnClose)) { }
return true;
}
catch { return false; }
}
}
}
+55
View File
@@ -0,0 +1,55 @@
namespace KillerPDF.Features
{
/// <summary>
/// What AboutController needs from the window hosting it, beyond the shared shell services.
///
/// Every member is a value or a plain string, never a control, so the controller holds no
/// reference to a TextBlock or a Button and can be driven by a stub in a test.
///
/// KillerPDF differs from Killendar's version in one way worth knowing: several of the About
/// card's lines are built as INLINES rather than set as text - the wordmark is two differently
/// styled runs, and the tagline, version and alias each carry a hyperlink. Constructing those
/// is UI work, so it stays in the shell and the controller hands over only the strings and the
/// one boolean the shell needs to decide what to build.
/// </summary>
internal interface IAboutHost : IShellServices
{
/// <summary>Code-signing subject, or the unsigned message.</summary>
string Publisher { set; }
/// <summary>Certificate thumbprint, or "(none)".</summary>
string Thumbprint { set; }
/// <summary>SHA-256 of the running exe. Set twice: the "computing" placeholder first,
/// then the real digest once the background hash finishes.</summary>
string Sha256 { set; }
/// <summary>Release date baked in from the csproj, shown muted opposite the version.
/// Empty on an older build that predates the attribute.</summary>
string ReleaseDate { set; }
/// <summary>Builds the version line as a hyperlink through to that release tag.</summary>
void SetVersion(string version);
/// <summary>The quoted alias line. Null hides it - which is the case unless the exe is
/// signed AND the signature verifies AND the subject is Steve's, because a fork signed by
/// somebody else must not claim the alias.</summary>
void SetAlias(string? alias);
/// <summary>Whether a newer release exists, and whether the button is live while a
/// download is running.</summary>
string UpdateText { set; }
bool UpdateVisible { set; }
bool UpdateEnabled { set; }
/// <summary>Blocks a self-update while there are unsaved changes.</summary>
bool IsDirty { get; }
/// <summary>The document to reopen after the update relaunches, if any.</summary>
string? FileToReopen { get; }
/// <summary>Dismisses any other full-window overlay, then fades the About card in.
/// The overlays are mutually exclusive rather than stacking.</summary>
void ShowCard();
}
}
+258
View File
@@ -0,0 +1,258 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using PdfSharpCore.Pdf;
using PdfSharpCore.Pdf.IO;
using KillerPDF.Services;
namespace KillerPDF.Features
{
// ============================================================
// Headless CLI batch mode
// ============================================================
//
// KillerPDF.exe --batch-resave <input.pdf|inputDir> <output.pdf|outputDir> [--log <file.csv>] [--quiet]
//
// Resaves one PDF (or every *.pdf under a folder tree, mirroring the
// relative structure into the output folder) through the same pipeline a
// GUI save uses: PdfReader.Open(Modify), PdfScrub.ScrubEmptyOutlines,
// PdfScrub.ScrubDegenerateCropBoxes, PdfScrub.StripLinkAnnotationBorders, Save. No window,
// no dialogs, no repair fallbacks, no encryption stripping - files that
// cannot go through the plain Modify pipeline are reported as SKIP with a
// reason instead of silently faking a result.
//
// Built for the veraPDF validation harness (validation/): baseline the
// corpus, --batch-resave it, validate the output tree, then diff with
// validation/Compare-VeraPDF.ps1. The claim being tested is "a KillerPDF
// save does not degrade standards conformance".
//
// Exit codes: 0 = every file OK or SKIP, 1 = at least one FAIL
// (file opened but the save failed), 2 = bad usage or bad paths.
//
// Invoked from App.OnStartup BEFORE the single-instance mutex, so a batch
// run works even while a GUI instance is open and never forwards to it.
//
// KillerPDF builds as a GUI-subsystem exe, so it has no console of its
// own; AttachConsole(-1) latches onto the parent terminal when launched
// from one. Output interleaves with the prompt (standard GUI-app quirk) -
// the authoritative record is the --log CSV and the exit code.
//
// All static, never touches the window - extracted from MainWindow 2026-07-31.
internal static class BatchRunner
{
[DllImport("kernel32.dll", EntryPoint = "AttachConsole", SetLastError = true)]
private static extern bool BatchAttachConsole(int dwProcessId);
private const int BatchAttachParentProcess = -1;
/// <summary>
/// Entry point for CLI batch mode. Returns false when args do not
/// request it (normal GUI launch); otherwise runs the whole batch and
/// returns true with the process exit code in <paramref name="exitCode"/>.
/// </summary>
internal static bool TryRunBatch(string[] args, out int exitCode)
{
exitCode = 0;
int flagIdx = Array.FindIndex(args,
a => string.Equals(a, "--batch-resave", StringComparison.OrdinalIgnoreCase));
if (flagIdx < 0) return false;
var con = OpenBatchConsole();
// Positional args after the flag: input, output. Options anywhere after the flag.
string? input = null, output = null, logPath = null;
bool quiet = false, badUsage = false;
for (int i = flagIdx + 1; i < args.Length; i++)
{
var a = args[i];
if (string.Equals(a, "--log", StringComparison.OrdinalIgnoreCase))
{
if (i + 1 < args.Length) logPath = args[++i];
else badUsage = true;
}
else if (string.Equals(a, "--quiet", StringComparison.OrdinalIgnoreCase))
{
quiet = true;
}
else if (input is null) input = a;
else if (output is null) output = a;
else badUsage = true; // extra positional arg
}
if (badUsage || string.IsNullOrWhiteSpace(input) || string.IsNullOrWhiteSpace(output))
{
con.WriteLine("Usage: KillerPDF.exe --batch-resave <input.pdf|inputDir> <output.pdf|outputDir> [--log <file.csv>] [--quiet]");
exitCode = 2;
return true;
}
try
{
exitCode = RunBatchResave(input!, output!, logPath, quiet, con);
}
catch (Exception ex)
{
con.WriteLine("Batch mode failed: " + FlattenBatchDetail(ex.Message));
exitCode = 2;
}
return true;
}
private static int RunBatchResave(string input, string output, string? logPath, bool quiet, TextWriter con)
{
// Build the work list: (relative path, source, destination).
var work = new List<(string Rel, string Src, string Dst)>();
if (File.Exists(input))
{
string dst = Directory.Exists(output)
? Path.Combine(output, Path.GetFileName(input))
: output;
work.Add((Path.GetFileName(input), Path.GetFullPath(input), Path.GetFullPath(dst)));
}
else if (Directory.Exists(input))
{
string inRoot = Path.GetFullPath(input).TrimEnd('\\', '/');
string outRoot = Path.GetFullPath(output).TrimEnd('\\', '/');
// Snapshot before any output is written, so an output folder nested
// under the input tree cannot feed the enumeration.
foreach (var f in Directory.GetFiles(inRoot, "*.pdf", SearchOption.AllDirectories))
{
string rel = f.Substring(inRoot.Length).TrimStart('\\', '/');
work.Add((rel, f, Path.Combine(outRoot, rel)));
}
}
else
{
con.WriteLine($"Input not found: {input}");
return 2;
}
var log = new List<string> { "File,Status,Detail" };
int ok = 0, skip = 0, fail = 0;
foreach (var item in work)
{
string status, detail;
try
{
var dstDir = Path.GetDirectoryName(item.Dst);
if (!string.IsNullOrEmpty(dstDir)) Directory.CreateDirectory(dstDir);
status = BatchResaveOne(item.Src, item.Dst, out detail);
}
catch (Exception ex)
{
status = "FAIL";
detail = FlattenBatchDetail(ex.Message);
}
if (status == "OK") ok++;
else if (status == "SKIP") skip++;
else fail++;
if (!quiet)
con.WriteLine(detail.Length > 0 ? $"{status} {item.Rel} ({detail})" : $"{status} {item.Rel}");
log.Add($"{BatchCsvField(item.Rel)},{status},{BatchCsvField(detail)}");
}
con.WriteLine($"Done. {work.Count} files: {ok} OK, {skip} skipped, {fail} failed.");
if (!string.IsNullOrWhiteSpace(logPath))
{
try
{
File.WriteAllLines(logPath, log, new UTF8Encoding(false));
con.WriteLine($"Log written to {logPath}");
}
catch (Exception ex)
{
con.WriteLine("Could not write log: " + FlattenBatchDetail(ex.Message));
}
}
return fail > 0 ? 1 : 0;
}
/// <summary>
/// Resaves a single PDF through the standard save pipeline.
/// Returns "OK", "SKIP" (could not enter the plain Modify pipeline;
/// reason in <paramref name="detail"/>), or "FAIL" (opened but the
/// save itself failed - the case the harness exists to catch).
/// </summary>
private static string BatchResaveOne(string src, string dst, out string detail)
{
detail = string.Empty;
// The GUI strips encryption at open time (PDFium round-trip) before editing.
// Batch mode deliberately does not: an encryption strip is not a plain resave,
// and reporting it as one would poison the conformance comparison.
try
{
if (PdfImport.PdfFileHasEncryption(src))
{
detail = "encrypted - batch mode does not strip encryption";
return "SKIP";
}
}
catch (Exception ex)
{
detail = "unreadable: " + FlattenBatchDetail(ex.Message);
return "SKIP";
}
PdfDocument doc;
try
{
doc = PdfReader.Open(src, PdfDocumentOpenMode.Modify);
}
catch (Exception ex)
{
detail = "open failed: " + FlattenBatchDetail(ex.Message);
return "SKIP";
}
try
{
// Same pre-save pipeline as SaveInPlace for a document with no user edits.
PdfScrub.ScrubEmptyOutlines(doc); // #103: never write a dangling /Outlines reference
PdfScrub.ScrubDegenerateCropBoxes(doc); // never write a zero-size /CropBox (Adobe out-of-range)
PdfScrub.ScrubDeadSignatures(doc); // a rewrite voids signatures; never ship a dead one (PDF/A 6.4.3)
PdfScrub.StripLinkAnnotationBorders(doc); // link borders are stripped on every GUI save
doc.Save(dst);
doc.Close();
return "OK";
}
catch (Exception ex)
{
try { doc.Close(); } catch { }
detail = "save failed: " + FlattenBatchDetail(ex.Message);
return "FAIL";
}
}
// Attaches to the parent terminal's console when launched from one.
// Returns TextWriter.Null when there is no parent console (e.g. double-click),
// so batch code can write unconditionally.
// internal: CliRunner shares the console attach and the CSV detail flattener.
internal static TextWriter OpenBatchConsole()
{
try
{
if (BatchAttachConsole(BatchAttachParentProcess))
return new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true };
}
catch { }
return TextWriter.Null;
}
internal static string FlattenBatchDetail(string? s) =>
(s ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim();
private static string BatchCsvField(string s)
{
if (s.IndexOfAny(new[] { ',', '"', '\r', '\n' }) < 0) return s;
return "\"" + s.Replace("\"", "\"\"") + "\"";
}
}
}
+821
View File
@@ -0,0 +1,821 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Printing;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Documents;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Docnet.Core;
using Docnet.Core.Models;
using PdfSharpCore.Drawing;
using PdfSharpCore.Pdf;
using PdfSharpCore.Pdf.IO;
using KillerPDF.Services;
// The scrubs, bitmap helpers, import helpers and PDFium interop all live in Services
// (PdfScrub.cs, BitmapHelpers.cs, PdfImport.cs, PdfiumInterop.cs; KillerUI refactor,
// 2026-07-31), called qualified below. No Features-to-Shell reaches remain in this file.
// OpenBatchConsole and FlattenBatchDetail are shared with the batch runner.
using static KillerPDF.Features.BatchRunner;
namespace KillerPDF.Features
{
// ============================================================
// Command-line interface
// ============================================================
//
// Dispatcher for every headless CLI command. Invoked from App.OnStartup
// BEFORE the single-instance mutex, so CLI runs work while a GUI instance
// is open, never forward to it, and never show a window. A launch with no
// recognized command flag falls through to the normal GUI (including the
// classic "KillerPDF.exe file.pdf" file-association open).
//
// Each command reuses the same pipeline its GUI equivalent runs - the
// merge named-destination rewrite, the pre-save scrubs, the PDFium
// decrypt, the rotation-safe rasterizer, the OCR text-layer builder - so
// CLI output is byte-for-byte the kind of file the GUI would produce.
//
// Exit codes: 0 = success, 1 = operation failed, 2 = bad usage.
//
// Console output rides on AttachConsole (GUI-subsystem exe, see
// BatchMode.cs); lines can interleave with the shell prompt. Exit codes
// are the scripting contract.
//
// Every member here is static and none of them touch the window, so this was never really a
// MainWindow partial - it just happened to be declared as one. Extracted to its own class
// 2026-07-31.
internal static class CliRunner
{
// Options that consume the next argument as their value.
private static readonly string[] CliValueOptions =
[
"--log", "--dpi", "--format", "--pages", "--printer", "--lang", "--password", "--copies",
];
/// <summary>
/// Entry point for all CLI commands. Returns false when args carry no
/// recognized command (normal GUI launch); otherwise runs the command
/// and returns true with the process exit code set.
/// </summary>
internal static bool TryRunCli(string[] args, out int exitCode)
{
exitCode = 0;
if (args is null || args.Length == 0) return false;
// The validation resave keeps its dedicated runner in BatchMode.cs.
if (args.Any(a => Eq(a, "--batch-resave")))
return BatchRunner.TryRunBatch(args, out exitCode);
string? command = args.FirstOrDefault(a =>
Eq(a, "--help") || Eq(a, "-h") || Eq(a, "/?") ||
Eq(a, "--version") || Eq(a, "-v") ||
Eq(a, "--merge") || Eq(a, "--extract-pages") || Eq(a, "--split") ||
Eq(a, "--decrypt") || Eq(a, "--to-image") || Eq(a, "--flatten") ||
Eq(a, "--print") || Eq(a, "--ocr"));
if (command is null) return false;
var con = OpenBatchConsole();
var (positionals, options) = ParseCliArgs(args, command);
try
{
switch (command.ToLowerInvariant())
{
case "--help":
case "-h":
case "/?":
con.WriteLine(CliHelpText());
break;
case "--version":
case "-v":
con.WriteLine(Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "unknown");
break;
case "--merge":
exitCode = CliMerge(positionals, con);
break;
case "--extract-pages":
exitCode = CliExtractPages(positionals, con);
break;
case "--split":
exitCode = CliSplit(positionals, con);
break;
case "--decrypt":
exitCode = CliDecrypt(positionals, options, con);
break;
case "--to-image":
exitCode = CliToImage(positionals, options, con);
break;
case "--flatten":
exitCode = CliFlatten(positionals, options, con);
break;
case "--print":
exitCode = CliPrint(positionals, options, con);
break;
case "--ocr":
exitCode = CliOcr(positionals, options, con);
break;
}
}
catch (Exception ex)
{
con.WriteLine("Error: " + FlattenBatchDetail(ex.Message));
exitCode = 1;
}
finally
{
App.CleanupSessionTemps(); // drop any decrypt/rotation temps the run created
}
return true;
}
private static bool Eq(string a, string b) =>
string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
private static string CliHelpText() => string.Join(Environment.NewLine,
[
"KillerPDF " + (Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "") + " - command line usage",
"",
" KillerPDF.exe <file.pdf> open in the app",
" KillerPDF.exe --version | -v print version",
" KillerPDF.exe --help | -h | /? this text",
"",
" --merge <out.pdf> <in1> <in2> ... merge PDFs (and images) into one PDF",
" --extract-pages <in.pdf> <pages> <out.pdf>",
" pull pages into a new PDF (pages like 1-3,5,9-12)",
" --split <in.pdf> <outDir> write one PDF per page",
" --decrypt <in.pdf> <out.pdf> [--password <p>]",
" remove encryption (lossless when possible)",
" --to-image <in.pdf> <outDir> [--dpi <n>] [--format png|jpg] [--pages <range>] [--transparent]",
" render pages to images (default 150 dpi, png;",
" background composites to white unless --transparent, png only)",
" --flatten <in.pdf> <out.pdf> [--dpi <n>] rasterize into an uneditable PDF (default 150 dpi)",
" --print <in.pdf> [--printer <name>] [--pages <range>] [--copies <n>]",
" print silently (default printer if none named)",
" --ocr <in.pdf> <out.pdf> [--lang <code>] add an invisible searchable text layer (default eng;",
" other languages download on first use)",
" --batch-resave <in> <out> [--log <f.csv>] [--quiet]",
" resave a file or tree through the standard",
" open/save pipeline (validation harness)",
"",
"Exit codes: 0 success, 1 operation failed, 2 bad usage.",
"Runs headless and works while the KillerPDF window is open.",
]);
/// <summary>
/// Splits args into positionals (everything after the command flag that
/// is not an option) and an option dictionary (case-insensitive keys).
/// </summary>
private static (List<string> Positionals, Dictionary<string, string> Options)
ParseCliArgs(string[] args, string command)
{
var positionals = new List<string>();
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
int start = Array.FindIndex(args, a => Eq(a, command)) + 1;
for (int i = start; i < args.Length; i++)
{
var a = args[i];
if (a.StartsWith("--", StringComparison.Ordinal))
{
if (CliValueOptions.Any(o => Eq(o, a)) && i + 1 < args.Length)
options[a] = args[++i];
else
options[a] = string.Empty;
}
else
{
positionals.Add(a);
}
}
return (positionals, options);
}
/// <summary>
/// Parses a 1-based page range spec like "1-3,5,9-12" into sorted,
/// distinct 0-based indices. Returns null with a message in error when
/// the spec is malformed or out of range.
/// </summary>
// internal, not private: FileOperations still calls these two. They are page-range parsing
// and JPEG encoding, neither of which is really CLI-specific - they want a home in
// Services/ eventually.
internal static List<int>? CliParsePageRange(string spec, int pageCount, out string error)
{
error = string.Empty;
var pages = new SortedSet<int>();
foreach (var rawPart in spec.Split(','))
{
var part = rawPart.Trim();
if (part.Length == 0) continue;
int a, b;
int dash = part.IndexOf('-');
if (dash > 0)
{
if (!int.TryParse(part[..dash].Trim(), out a) ||
!int.TryParse(part[(dash + 1)..].Trim(), out b))
{ error = $"Bad page range: \"{part}\""; return null; }
}
else
{
if (!int.TryParse(part, out a)) { error = $"Bad page number: \"{part}\""; return null; }
b = a;
}
if (a > b) (a, b) = (b, a);
if (a < 1 || b > pageCount)
{ error = $"Pages {part} out of range - the document has {pageCount} pages"; return null; }
for (int p = a; p <= b; p++) pages.Add(p - 1);
}
if (pages.Count == 0) { error = "Empty page range"; return null; }
return [.. pages];
}
// ============================================================
// --merge <out.pdf> <in1> <in2> ...
// ============================================================
// Mirrors the GUI merge (FileOperations.cs Merge_Click): per source PDF,
// harvest named destinations from a ReadOnly open, copy pages from an
// Import open, then rewrite named-destination links against the page
// offset. Image inputs go through the same importer the GUI drop
// pipeline uses (ImportAndZip.cs).
private static int CliMerge(List<string> pos, TextWriter con)
{
if (pos.Count < 3)
{
con.WriteLine("Usage: KillerPDF.exe --merge <out.pdf> <in1.pdf> <in2.pdf> ...");
return 2;
}
string outPath = Path.GetFullPath(pos[0]);
var inputs = pos.Skip(1).Select(Path.GetFullPath).ToList();
foreach (var f in inputs)
{
if (!File.Exists(f)) { con.WriteLine($"Input not found: {f}"); return 2; }
if (string.Equals(f, outPath, StringComparison.OrdinalIgnoreCase))
{ con.WriteLine("Output file cannot also be an input."); return 2; }
}
using var outPdf = new PdfDocument();
foreach (var f in inputs)
{
if (PdfImport.IsPdfPath(f))
{
int pageOffset = outPdf.PageCount;
Dictionary<string, int> namedDestMap;
using (var srcRead = PdfReader.Open(f, PdfDocumentOpenMode.ReadOnly))
namedDestMap = PdfImport.BuildNamedDestMap(srcRead);
using var src = PdfReader.Open(f, PdfDocumentOpenMode.Import);
for (int i = 0; i < src.PageCount; i++)
outPdf.AddPage(src.Pages[i]);
if (namedDestMap.Count > 0)
PdfImport.RewriteNamedDestLinks(outPdf, pageOffset, namedDestMap);
}
else
{
PdfImport.AddImagePagesFromFile(outPdf, f);
}
}
PdfScrub.ScrubEmptyOutlines(outPdf);
PdfScrub.ScrubDegenerateCropBoxes(outPdf);
CliEnsureParentDir(outPath);
outPdf.Save(outPath);
con.WriteLine($"Merged {inputs.Count} files ({outPdf.PageCount} pages) -> {outPath}");
return 0;
}
// ============================================================
// --extract-pages <in.pdf> <range> <out.pdf>
// ============================================================
// Same primitive as the GUI extract (PageOperations.cs Split_Click):
// Import-mode open, AddPage per selected index, save a fresh document.
private static int CliExtractPages(List<string> pos, TextWriter con)
{
if (pos.Count != 3)
{
con.WriteLine("Usage: KillerPDF.exe --extract-pages <in.pdf> <pages> <out.pdf> (pages like 1-3,5,9-12)");
return 2;
}
string inPath = Path.GetFullPath(pos[0]), spec = pos[1], outPath = Path.GetFullPath(pos[2]);
if (!File.Exists(inPath)) { con.WriteLine($"Input not found: {inPath}"); return 2; }
using var importDoc = PdfReader.Open(inPath, PdfDocumentOpenMode.Import);
var indices = CliParsePageRange(spec, importDoc.PageCount, out string err);
if (indices is null) { con.WriteLine(err); return 2; }
using var newDoc = new PdfDocument();
foreach (var idx in indices)
newDoc.AddPage(importDoc.Pages[idx]);
PdfScrub.ScrubEmptyOutlines(newDoc);
PdfScrub.ScrubDegenerateCropBoxes(newDoc);
CliEnsureParentDir(outPath);
newDoc.Save(outPath);
con.WriteLine($"Extracted {indices.Count} pages -> {outPath}");
return 0;
}
// ============================================================
// --split <in.pdf> <outDir>
// ============================================================
private static int CliSplit(List<string> pos, TextWriter con)
{
if (pos.Count != 2)
{
con.WriteLine("Usage: KillerPDF.exe --split <in.pdf> <outputFolder>");
return 2;
}
string inPath = Path.GetFullPath(pos[0]), outDir = Path.GetFullPath(pos[1]);
if (!File.Exists(inPath)) { con.WriteLine($"Input not found: {inPath}"); return 2; }
Directory.CreateDirectory(outDir);
using var importDoc = PdfReader.Open(inPath, PdfDocumentOpenMode.Import);
string baseName = Path.GetFileNameWithoutExtension(inPath);
int digits = Math.Max(3, importDoc.PageCount.ToString().Length);
for (int i = 0; i < importDoc.PageCount; i++)
{
using var single = new PdfDocument();
single.AddPage(importDoc.Pages[i]);
PdfScrub.ScrubEmptyOutlines(single);
PdfScrub.ScrubDegenerateCropBoxes(single);
single.Save(Path.Combine(outDir, $"{baseName}-page-{(i + 1).ToString().PadLeft(digits, '0')}.pdf"));
}
con.WriteLine($"Split {importDoc.PageCount} pages into {outDir}");
return 0;
}
// ============================================================
// --decrypt <in.pdf> <out.pdf> [--password <p>]
// ============================================================
// Without a password: the same lossless PDFium strip the GUI uses at
// open time (owner/permissions encryption), with the Import-rebuild
// fallback. With a password: PdfSharpCore opens with the password and
// saves a decrypted copy, the same sequence as the GUI password path.
private static int CliDecrypt(List<string> pos, Dictionary<string, string> options, TextWriter con)
{
if (pos.Count != 2)
{
con.WriteLine("Usage: KillerPDF.exe --decrypt <in.pdf> <out.pdf> [--password <password>]");
return 2;
}
string inPath = Path.GetFullPath(pos[0]), outPath = Path.GetFullPath(pos[1]);
if (!File.Exists(inPath)) { con.WriteLine($"Input not found: {inPath}"); return 2; }
CliEnsureParentDir(outPath);
options.TryGetValue("--password", out string? password);
if (!string.IsNullOrEmpty(password))
{
using var doc = PdfReader.Open(inPath, password!, PdfDocumentOpenMode.Modify);
PdfScrub.ScrubEmptyOutlines(doc);
PdfScrub.ScrubDegenerateCropBoxes(doc);
doc.Save(outPath);
con.WriteLine($"Decrypted -> {outPath}");
return 0;
}
if (PdfiumInterop.TryPdfiumStripEncryption(inPath, outPath))
{
con.WriteLine($"Decrypted (lossless) -> {outPath}");
return 0;
}
if (PdfImport.TryImportRepairToPath(inPath, outPath))
{
con.WriteLine($"Decrypted via page rebuild -> {outPath} (bookmarks/forms may be dropped)");
return 0;
}
con.WriteLine("Could not decrypt. If the file needs a password to open, pass --password.");
return 1;
}
// ============================================================
// Shared rasterization prep
// ============================================================
// PDFium sizes its bitmap from the un-rotated MediaBox, so pages with
// /Rotate 90/270 clip if rendered directly (same reason TempReload
// strips rotations app-wide). Prep: decrypt if needed, capture per-page
// /Rotate + point dims, strip rotations to a temp, and let callers
// rotate the pixel buffers afterward (BitmapHelpers.RotateBitmap).
// Falls back to rendering the file as-is when PdfSharpCore cannot open
// it (rare parser gaps PDFium tolerates); callers then derive
// dimensions from the rendered pixels.
private static (string RenderPath, int[]? Rotations, (double WPt, double HPt)[]? Dims)
CliPrepareRenderSource(string inPath, string? password, TextWriter con)
{
string workPath = inPath;
if (PdfImport.PdfFileHasEncryption(inPath))
{
var dec = App.MakeTempFile("clidec");
if (!string.IsNullOrEmpty(password))
{
using var pdoc = PdfReader.Open(inPath, password!, PdfDocumentOpenMode.Modify);
pdoc.Save(dec);
}
else if (!PdfiumInterop.TryPdfiumStripEncryption(inPath, dec) && !PdfImport.TryImportRepairToPath(inPath, dec))
{
throw new InvalidOperationException(
"File is encrypted and could not be unlocked - pass --password if it needs one.");
}
workPath = dec;
}
try
{
using var doc = PdfReader.Open(workPath, PdfDocumentOpenMode.Modify);
var rotations = new int[doc.PageCount];
var dims = new (double WPt, double HPt)[doc.PageCount];
bool anyRot = false;
for (int i = 0; i < doc.PageCount; i++)
{
var p = doc.Pages[i];
rotations[i] = ((p.Rotate % 360) + 360) % 360;
dims[i] = (p.Width.Point, p.Height.Point);
if (rotations[i] != 0) { anyRot = true; p.Rotate = 0; }
}
if (!anyRot) return (workPath, rotations, dims);
var renderTemp = App.MakeTempFile("clirender");
PdfScrub.ScrubEmptyOutlines(doc);
PdfScrub.ScrubDegenerateCropBoxes(doc);
doc.Save(renderTemp);
return (renderTemp, rotations, dims);
}
catch (Exception ex)
{
con.WriteLine("Note: structure parse failed (" + FlattenBatchDetail(ex.Message) +
") - rendering as-is; rotated pages may clip.");
return (workPath, null, null);
}
}
// Both encoders live in Services/BitmapHelpers.cs (RenderToPng, and EncodeJpeg - which
// was born here as CliEncodeJpeg when --to-image needed a JPEG encoder).
private static void CliEnsureParentDir(string path)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
}
private static double CliParseDpi(Dictionary<string, string> options, double fallback)
{
if (options.TryGetValue("--dpi", out var s) &&
double.TryParse(s, out double d) && d >= 24 && d <= 1200)
return d;
return fallback;
}
// ============================================================
// --to-image <in.pdf> <outDir> [--dpi n] [--format png|jpg] [--pages range] [--transparent]
// ============================================================
// PDFium leaves unpainted background pixels as BGRA 0,0,0,0. Encoders
// that drop alpha (JPEG) then show them BLACK, and PNG/flatten output
// carries a useless full-page alpha channel (issue #148, Ryokoxx).
// Default is now composite-over-white via Docnet's transparency
// remover; --transparent keeps the raw alpha for PNG output.
private static int CliToImage(List<string> pos, Dictionary<string, string> options, TextWriter con)
{
if (pos.Count != 2)
{
con.WriteLine("Usage: KillerPDF.exe --to-image <in.pdf> <outputFolder> [--dpi <n>] [--format png|jpg] [--pages <range>] [--transparent]");
return 2;
}
string inPath = Path.GetFullPath(pos[0]), outDir = Path.GetFullPath(pos[1]);
if (!File.Exists(inPath)) { con.WriteLine($"Input not found: {inPath}"); return 2; }
double dpi = CliParseDpi(options, 150);
options.TryGetValue("--format", out var fmtRaw);
string fmt = (fmtRaw ?? "png").ToLowerInvariant();
if (fmt == "jpeg") fmt = "jpg";
if (fmt != "png" && fmt != "jpg") { con.WriteLine("--format must be png or jpg"); return 2; }
// JPEG has no alpha channel, so --transparent only means anything for png.
bool transparent = fmt == "png" && options.ContainsKey("--transparent");
Directory.CreateDirectory(outDir);
options.TryGetValue("--password", out var password);
var (renderPath, rotations, _) = CliPrepareRenderSource(inPath, password, con);
using var dr = DocLib.Instance.GetDocReader(renderPath, new PageDimensions(dpi / 72.0));
int pageCount = dr.GetPageCount();
List<int> selected;
if (options.TryGetValue("--pages", out var rangeSpec))
{
var parsed = CliParsePageRange(rangeSpec, pageCount, out string err);
if (parsed is null) { con.WriteLine(err); return 2; }
selected = parsed;
}
else
{
selected = [.. Enumerable.Range(0, pageCount)];
}
string baseName = Path.GetFileNameWithoutExtension(inPath);
int digits = Math.Max(3, pageCount.ToString().Length);
foreach (var idx in selected)
{
byte[] raw; int w, h;
using (var pr = dr.GetPageReader(idx))
{
w = pr.GetPageWidth();
h = pr.GetPageHeight();
raw = KillerPDF.Services.PdfiumInterop.RenderPageWithAnnotations(
renderPath, idx, w, h, transparent)
?? (transparent
? pr.GetImage()
: pr.GetImage(new Docnet.Core.Converters.NaiveTransparencyRemover()));
}
int rot = rotations != null && idx < rotations.Length ? rotations[idx] : 0;
if (rot != 0) (raw, w, h) = BitmapHelpers.RotateBitmap(raw, w, h, rot);
var bytes = fmt == "png" ? BitmapHelpers.RenderToPng(raw, w, h, dpi) : BitmapHelpers.EncodeJpeg(raw, w, h, dpi);
var name = $"{baseName}-page-{(idx + 1).ToString().PadLeft(digits, '0')}.{fmt}";
File.WriteAllBytes(Path.Combine(outDir, name), bytes);
}
con.WriteLine($"Rendered {selected.Count} pages at {dpi:0} dpi ({fmt}) into {outDir}");
return 0;
}
// ============================================================
// --flatten <in.pdf> <out.pdf> [--dpi n]
// ============================================================
// Same rasterize-and-rebuild the GUI's Save Flattened runs (150 dpi
// default, PNG-embedded pages sized in points), plus the rotation
// handling the GUI gets for free from its normalized working copy.
private static int CliFlatten(List<string> pos, Dictionary<string, string> options, TextWriter con)
{
if (pos.Count != 2)
{
con.WriteLine("Usage: KillerPDF.exe --flatten <in.pdf> <out.pdf> [--dpi <n>]");
return 2;
}
string inPath = Path.GetFullPath(pos[0]), outPath = Path.GetFullPath(pos[1]);
if (!File.Exists(inPath)) { con.WriteLine($"Input not found: {inPath}"); return 2; }
double dpi = CliParseDpi(options, 150);
options.TryGetValue("--password", out var password);
var (renderPath, rotations, dims) = CliPrepareRenderSource(inPath, password, con);
using var dr = DocLib.Instance.GetDocReader(renderPath, new PageDimensions(dpi / 72.0));
int pageCount = dr.GetPageCount();
using var outDoc = new PdfDocument();
for (int i = 0; i < pageCount; i++)
{
byte[] raw; int w, h;
using (var pr = dr.GetPageReader(i))
{
// Composite over white (#148): keeps the /SMask alpha channel out
// of the rebuilt page images entirely.
// #141: WithAnnotations, or the rebuild drops the file's own markup.
w = pr.GetPageWidth();
h = pr.GetPageHeight();
raw = KillerPDF.Services.PdfiumInterop.RenderPageWithAnnotations(renderPath, i, w, h)
?? pr.GetImage(new Docnet.Core.Converters.NaiveTransparencyRemover());
}
int rot = rotations != null && i < rotations.Length ? rotations[i] : 0;
if (rot != 0) (raw, w, h) = BitmapHelpers.RotateBitmap(raw, w, h, rot);
var png = BitmapHelpers.RenderToPng(raw, w, h);
double wPt, hPt;
if (dims != null && i < dims.Length)
{
// Page keeps its point size; swap for the viewed orientation.
bool swap = rot == 90 || rot == 270;
wPt = swap ? dims[i].HPt : dims[i].WPt;
hPt = swap ? dims[i].WPt : dims[i].HPt;
}
else
{
wPt = w * 72.0 / dpi;
hPt = h * 72.0 / dpi;
}
var newPage = outDoc.AddPage();
newPage.Width = XUnit.FromPoint(wPt);
newPage.Height = XUnit.FromPoint(hPt);
using var xi = XImage.FromStream(() => new MemoryStream(png));
using var gfx = XGraphics.FromPdfPage(newPage);
gfx.DrawImage(xi, 0, 0, newPage.Width.Point, newPage.Height.Point);
}
CliEnsureParentDir(outPath);
outDoc.Save(outPath);
con.WriteLine($"Flattened {pageCount} pages at {dpi:0} dpi -> {outPath}");
return 0;
}
// ============================================================
// --print <in.pdf> [--printer name] [--pages range] [--copies n]
// ============================================================
// Slimmed headless version of the GUI print spool: rasterize at 300
// dpi (the GUI's print resolution), fit-scale each page centered on
// the printable area, build a FixedDocument, and write it to the
// queue via XPS. Copies replicate the page sequence (ticket CopyCount
// is unreliable across drivers - same reason the GUI does this, #83).
private static int CliPrint(List<string> pos, Dictionary<string, string> options, TextWriter con)
{
if (pos.Count != 1)
{
con.WriteLine("Usage: KillerPDF.exe --print <in.pdf> [--printer <name>] [--pages <range>] [--copies <n>]");
return 2;
}
string inPath = Path.GetFullPath(pos[0]);
if (!File.Exists(inPath)) { con.WriteLine($"Input not found: {inPath}"); return 2; }
int copies = 1;
if (options.TryGetValue("--copies", out var copiesRaw) &&
(!int.TryParse(copiesRaw, out copies) || copies < 1 || copies > 99))
{ con.WriteLine("--copies must be 1-99"); return 2; }
options.TryGetValue("--password", out var password);
var (renderPath, rotations, _) = CliPrepareRenderSource(inPath, password, con);
// Resolve the print queue. Match --printer against FullName,
// exact first then substring, both case-insensitive.
using var server = new LocalPrintServer();
PrintQueue? queue = null;
if (options.TryGetValue("--printer", out var printerName) && !string.IsNullOrWhiteSpace(printerName))
{
var queues = server.GetPrintQueues(
[EnumeratedPrintQueueTypes.Local, EnumeratedPrintQueueTypes.Connections]).ToList();
queue = queues.FirstOrDefault(q =>
string.Equals(q.FullName, printerName, StringComparison.OrdinalIgnoreCase))
?? queues.FirstOrDefault(q =>
q.FullName.IndexOf(printerName, StringComparison.OrdinalIgnoreCase) >= 0);
if (queue is null)
{
con.WriteLine($"Printer not found: {printerName}. Available:");
foreach (var q in queues) con.WriteLine(" " + q.FullName);
return 2;
}
}
else
{
queue = LocalPrintServer.GetDefaultPrintQueue();
}
// Rasterize the selected pages at 300 dpi, rotation-corrected.
var bitmaps = new List<(BitmapSource Bs, int W, int H)>();
List<int> selected;
using (var dr = DocLib.Instance.GetDocReader(renderPath, new PageDimensions(300.0 / 72.0)))
{
int pageCount = dr.GetPageCount();
if (options.TryGetValue("--pages", out var rangeSpec))
{
var parsed = CliParsePageRange(rangeSpec, pageCount, out string err);
if (parsed is null) { con.WriteLine(err); return 2; }
selected = parsed;
}
else
{
selected = [.. Enumerable.Range(0, pageCount)];
}
foreach (var idx in selected)
{
byte[] raw; int w, h;
using (var pr = dr.GetPageReader(idx))
{
w = pr.GetPageWidth();
h = pr.GetPageHeight();
raw = KillerPDF.Services.PdfiumInterop.RenderPageWithAnnotations(renderPath, idx, w, h)
?? pr.GetImage(); // #141
}
int rot = rotations != null && idx < rotations.Length ? rotations[idx] : 0;
if (rot != 0) (raw, w, h) = BitmapHelpers.RotateBitmap(raw, w, h, rot);
var bs = BitmapSource.Create(w, h, 96, 96, PixelFormats.Bgra32, null, raw, w * 4);
bs.Freeze();
bitmaps.Add((bs, w, h));
}
}
// Orient the sheet to the majority of the selected pages.
bool landscape = bitmaps.Count(b => b.W > b.H) * 2 > bitmaps.Count;
var pd = new System.Windows.Controls.PrintDialog { PrintQueue = queue };
var ticket = pd.PrintTicket;
ticket.CopyCount = 1;
ticket.PageOrientation = landscape ? PageOrientation.Landscape : PageOrientation.Portrait;
pd.PrintTicket = ticket;
double aw = pd.PrintableAreaWidth, ah = pd.PrintableAreaHeight;
if (landscape && ah > aw) (aw, ah) = (ah, aw);
var fixedDoc = new FixedDocument();
for (int c = 0; c < copies; c++)
{
foreach (var (bs, w, h) in bitmaps)
{
double wDip = w * 96.0 / 300.0, hDip = h * 96.0 / 300.0;
double s = Math.Min(aw / wDip, ah / hDip);
double sw = wDip * s, sh = hDip * s;
var img = new System.Windows.Controls.Image { Source = bs, Width = sw, Height = sh };
var fp = new FixedPage { Width = aw, Height = ah };
FixedPage.SetLeft(img, (aw - sw) / 2);
FixedPage.SetTop(img, (ah - sh) / 2);
fp.Children.Add(img);
fp.Measure(new Size(aw, ah));
fp.Arrange(new Rect(0, 0, aw, ah));
fp.UpdateLayout();
var pc = new PageContent();
((IAddChild)pc).AddChild(fp);
fixedDoc.Pages.Add(pc);
}
}
// Write the FixedDocument (not its paginator) - see PrintPreviewWindow
// DoPrint for why. Synchronous Write is fine headless.
var writer = PrintQueue.CreateXpsDocumentWriter(queue);
writer.Write(fixedDoc, ticket);
con.WriteLine($"Sent {selected.Count} pages x{copies} to \"{queue.FullName}\".");
return 0;
}
// ============================================================
// --ocr <in.pdf> <out.pdf> [--lang code]
// ============================================================
// Reuses the GUI's searchable-PDF core (OcrController.BuildSearchablePdf):
// Docnet render, Tesseract per page, invisible text drawn over each
// word. The GUI's model-download gate is dialog-driven, so the CLI
// has its own silent equivalent honoring the OcrHighQuality setting.
private static int CliOcr(List<string> pos, Dictionary<string, string> options, TextWriter con)
{
if (pos.Count != 2)
{
con.WriteLine("Usage: KillerPDF.exe --ocr <in.pdf> <out.pdf> [--lang <code>] (default eng)");
return 2;
}
string inPath = Path.GetFullPath(pos[0]), outPath = Path.GetFullPath(pos[1]);
if (!File.Exists(inPath)) { con.WriteLine($"Input not found: {inPath}"); return 2; }
options.TryGetValue("--lang", out var langRaw);
string lang = string.IsNullOrWhiteSpace(langRaw) ? "eng" : langRaw!.Trim().ToLowerInvariant();
if (!CliEnsureOcrLanguage(lang, con)) return 1;
options.TryGetValue("--password", out var password);
var (srcForOcr, rotations, _) = CliPrepareRenderSource(inPath, password, con);
CliEnsureParentDir(outPath);
var (pages, words) = OcrController.BuildSearchablePdf(srcForOcr, outPath,
(i, n) => { if (i == 1 || i == n || i % 10 == 0) con.WriteLine($"OCR page {i}/{n}"); },
CancellationToken.None, lang);
// The render source had /Rotate stripped; put the angles back on the
// output so rotated pages still display rotated. Content and text
// layer share page space, so they stay aligned.
if (rotations != null && rotations.Any(r => r != 0))
{
using var outDoc = PdfReader.Open(outPath, PdfDocumentOpenMode.Modify);
for (int i = 0; i < outDoc.PageCount && i < rotations.Length; i++)
if (rotations[i] != 0) outDoc.Pages[i].Rotate = rotations[i];
PdfScrub.ScrubEmptyOutlines(outDoc);
PdfScrub.ScrubDegenerateCropBoxes(outDoc);
outDoc.Save(outPath);
}
con.WriteLine($"OCR complete: {pages} pages, {words} words -> {outPath}");
return 0;
}
/// <summary>
/// Silent equivalent of the GUI's model-download gate: nothing is
/// bundled - every language model streams from the tessdata repos on
/// first use, honoring the app's High Quality setting, with the same
/// .part-then-move atomicity. Runs the download on the thread pool -
/// OnStartup's dispatcher is not pumping, so awaiting here directly
/// would deadlock on the captured WPF context.
/// </summary>
private static bool CliEnsureOcrLanguage(string lang, TextWriter con)
{
OcrNativeBootstrap.EnsureLanguageData();
var dest = Path.Combine(OcrNativeBootstrap.TessDataDir, lang + ".traineddata");
if (File.Exists(dest)) return true;
bool hq = App.GetSetting("OcrHighQuality") == "1";
string url = (hq
? "https://raw.githubusercontent.com/tesseract-ocr/tessdata_best/main/"
: "https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/main/") + lang + ".traineddata";
con.WriteLine($"Downloading OCR language '{lang}' ({(hq ? "high quality" : "standard")})...");
try
{
Task.Run(async () =>
{
using var http = OcrLanguages.MakeDownloadClient();
using var resp = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
var part = dest + ".part";
using (var s = await resp.Content.ReadAsStreamAsync().ConfigureAwait(false))
using (var f = File.Create(part))
await s.CopyToAsync(f).ConfigureAwait(false);
if (File.Exists(dest)) File.Delete(dest);
File.Move(part, dest);
}).GetAwaiter().GetResult();
return true;
}
catch (Exception ex)
{
con.WriteLine($"Could not download language '{lang}': " + FlattenBatchDetail(ex.Message));
con.WriteLine("Check the language code (e.g. eng, spa, fra, deu, jpn, tur, ben, chi_sim, chi_tra) and your connection.");
return false;
}
}
}
}
+23
View File
@@ -0,0 +1,23 @@
using System.Windows;
namespace KillerPDF.Features
{
/// <summary>
/// The three things every feature needs from the window: an owner for modal dialogs, string
/// lookup, and the status line. Each feature's own interface extends this, so the shell
/// implements these once rather than once per feature.
///
/// Ported from Killendar, which is the reference implementation for this pattern.
/// </summary>
internal interface IShellServices
{
/// <summary>Owner for modal dialogs.</summary>
Window Window { get; }
/// <summary>Localized string for a Str_ key.</summary>
string Loc(string key);
/// <summary>Writes the status line.</summary>
void SetStatus(string text);
}
}
+65
View File
@@ -0,0 +1,65 @@
using System.Threading;
using System.Threading.Tasks;
namespace KillerPDF.Features
{
/// <summary>
/// What OcrController needs from the window hosting it, beyond the shared shell services.
///
/// Every member is a value, a plain string, or an intent ("put up the busy overlay"), never a
/// control, so the controller holds no reference to a Border or a Dictionary of window state
/// and can be driven by a stub in a test.
/// </summary>
internal interface IOcrHost : IShellServices
{
/// <summary>True while a document is open (both the live doc and its backing file).</summary>
bool HasDocument { get; }
/// <summary>Page count of the open document, 0 when none.</summary>
int PageCount { get; }
/// <summary>Path of the working (temp) file the renderer reads. Null when no document.</summary>
string? CurrentFile { get; }
/// <summary>Path of the file the user actually opened, for suggesting output names.
/// Null for a document that never had an original (e.g. built from images).</summary>
string? OriginalFile { get; }
/// <summary>The page's in-memory rotation in degrees, 0 when untouched. The working file
/// has /Rotate stripped, so renders must rotate the pixel buffer by this.</summary>
int RotationFor(int pageIdx);
/// <summary>Render-dim space of the page overlay (the (w,h) its canvas coordinates live
/// in), for mapping a canvas rect onto the OCR bitmap. False when the page has not
/// rendered yet.</summary>
bool TryGetRenderDims(int pageIdx, out int w, out int h);
/// <summary>The '+'-joined Tesseract language string, e.g. "eng" or "eng+spa".</summary>
string OcrLanguageString { get; }
/// <summary>Makes sure the selected language models are on disk, downloading behind a
/// heads-up dialog if not. False when the user declined or the download failed.</summary>
Task<bool> EnsureOcrModelsReadyAsync();
/// <summary>Commits any annotation text box still being edited, so the saved snapshot
/// matches what is on screen.</summary>
void CommitActiveTextBox();
/// <summary>Saves the live document to <paramref name="path"/>. Throws on failure.</summary>
void SaveDocumentTo(string path);
/// <summary>Registers the cancellable operation (Esc offers to cancel it) and puts up the
/// busy overlay. Returns the token to thread through the work.</summary>
CancellationToken BeginOp(string label, string busyMessage);
/// <summary>Updates the busy overlay's message line (per-page progress). UI thread only.</summary>
void SetBusyMessage(string message);
/// <summary>Takes the busy overlay down - before any completion dialog, so the overlay is
/// gone when the dialog appears. Safe to call when it is already down.</summary>
void HideBusy();
/// <summary>Disposes the cancellable-operation registration. Always called from finally.</summary>
void EndOp();
}
}
+397
View File
@@ -0,0 +1,397 @@
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using Docnet.Core;
using Docnet.Core.Models;
using Microsoft.Win32;
using PdfSharpCore.Drawing;
using PdfSharpCore.Pdf;
using PdfSharpCore.Pdf.IO;
using KillerPDF.Services;
namespace KillerPDF.Features
{
/// <summary>
/// The four OCR operations that work on the open document: page to clipboard, region to
/// clipboard, searchable PDF, and extract-all-text. Moved out of Ocr.cs (MainWindow) in the
/// KillerUI refactor.
///
/// Holds no controls. Talks to the window only through <see cref="IOcrHost"/>, so the whole
/// of this file is testable against a stub host. The pure language/download helpers live in
/// Services/OcrLanguages.cs; the OCR menu, region arming, and model downloads stay in the
/// shell half (Shell/Ocr.cs).
/// </summary>
internal sealed class OcrController
{
// Longest-side pixel budget for the OCR render. ~300 DPI on a Letter page, which is the sweet
// spot for Tesseract: high enough for small body text, not so high it wastes time/memory.
private const int OcrRenderMax = 2600;
private readonly IOcrHost _host;
internal OcrController(IOcrHost host) => _host = host;
// Right-click "OCR Page" action: rasterize the page, recognize text off the UI thread, and drop
// the result on the clipboard. Render + OCR are both slow, so they run inside Task.Run behind the
// busy overlay; everything touching the clipboard/UI happens back on the UI thread.
internal async void OcrPageToClipboard(int pageIdx)
{
if (!_host.HasDocument) { KillerDialog.Show(_host.Window, _host.Loc("Str_Msg_OpenFirst")); return; }
if (pageIdx < 0 || pageIdx >= _host.PageCount) return;
if (!await _host.EnsureOcrModelsReadyAsync()) return;
// Capture everything off the live UI state before going async.
string file = _host.CurrentFile!;
int rot = _host.RotationFor(pageIdx);
string lang = _host.OcrLanguageString;
var ct = _host.BeginOp(_host.Loc("Str_Op_Ocr"), _host.Loc("Str_Busy_Ocr"));
try
{
OcrResult result = await Task.Run(() =>
{
using var docReader = DocLib.Instance.GetDocReader(file, new PageDimensions(OcrRenderMax, OcrRenderMax));
using var pageReader = docReader.GetPageReader(pageIdx);
int w = pageReader.GetPageWidth();
int h = pageReader.GetPageHeight();
byte[] bgra = pageReader.GetImage();
// Temp file has /Rotate stripped, so rotate the pixel buffer to the page's visual orientation.
if (rot != 0) (bgra, w, h) = BitmapHelpers.RotateBitmap(bgra, w, h, rot);
using var ocr = new OcrService(language: lang); // engine is not thread-safe: one per operation
return ocr.RecognizeBgra(bgra, w, h);
});
_host.HideBusy();
// Cooperative cancel: a single page can't be interrupted mid-recognition, so we just discard
// the result if the user canceled. No exceptions are thrown for cancellation anywhere.
if (ct.IsCancellationRequested) { _host.SetStatus(_host.Loc("Str_St_OcrCanceled")); return; }
string text = result.Text.Trim();
if (text.Length == 0)
{
_host.SetStatus(string.Format(_host.Loc("Str_St_OcrNoTextPage"), pageIdx + 1));
return;
}
Clipboard.SetText(text);
_host.SetStatus(string.Format(_host.Loc("Str_St_OcrCopiedPage"), text.Length, pageIdx + 1, result.MeanConfidence.ToString("P0")));
}
catch (Exception ex)
{
_host.HideBusy();
KillerDialog.Show(_host.Window, _host.Loc("Str_Err_OcrFailed") + "\n" + ex.Message, "KillerPDF",
MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
_host.EndOp();
}
}
// OCR Region: armed by the shell's menu item (Select-tool box-drag); Annotations' drag handler
// lands here with the page index and the canvas-space rect. Works on scans with no text layer.
internal async void OcrRegion(int pageIdx, Rect canvasBounds)
{
if (!_host.HasDocument) return;
if (pageIdx < 0 || pageIdx >= _host.PageCount) return;
if (!_host.TryGetRenderDims(pageIdx, out int renderW, out int renderH) || renderW <= 0 || renderH <= 0) return;
if (canvasBounds.Width < 4 || canvasBounds.Height < 4) { _host.SetStatus(_host.Loc("Str_St_OcrRegionTooSmall")); return; }
if (!await _host.EnsureOcrModelsReadyAsync()) return;
string file = _host.CurrentFile!;
int rot = _host.RotationFor(pageIdx);
string lang = _host.OcrLanguageString;
Rect cb = canvasBounds;
var ct = _host.BeginOp(_host.Loc("Str_Op_OcrRegion"), _host.Loc("Str_Busy_Region"));
try
{
OcrResult result = await Task.Run(() =>
{
using var docReader = DocLib.Instance.GetDocReader(file, new PageDimensions(OcrRenderMax, OcrRenderMax));
using var pageReader = docReader.GetPageReader(pageIdx);
int w = pageReader.GetPageWidth();
int h = pageReader.GetPageHeight();
byte[] bgra = pageReader.GetImage();
if (rot != 0) (bgra, w, h) = BitmapHelpers.RotateBitmap(bgra, w, h, rot);
double sx = (double)w / renderW, sy = (double)h / renderH;
byte[] crop = CropBgra(bgra, w, h,
(int)Math.Round(cb.Left * sx), (int)Math.Round(cb.Top * sy),
(int)Math.Round(cb.Width * sx), (int)Math.Round(cb.Height * sy),
out int cw, out int chh);
using var ocr = new OcrService(language: lang);
return ocr.RecognizeBgra(crop, cw, chh);
});
_host.HideBusy();
if (ct.IsCancellationRequested) { _host.SetStatus(_host.Loc("Str_St_OcrCanceled")); return; }
string text = result.Text.Trim();
if (text.Length == 0) { _host.SetStatus(_host.Loc("Str_St_OcrNoText")); return; }
Clipboard.SetText(text);
_host.SetStatus(string.Format(_host.Loc("Str_St_OcrCopiedRegion"), text.Length, result.MeanConfidence.ToString("P0")));
}
catch (Exception ex)
{
_host.HideBusy();
KillerDialog.Show(_host.Window, _host.Loc("Str_Err_OcrFailed") + "\n" + ex.Message, "KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
_host.EndOp();
}
}
private static byte[] CropBgra(byte[] src, int srcW, int srcH, int x, int y, int cw, int ch, out int outW, out int outH)
{
x = Math.Max(0, Math.Min(x, srcW - 1));
y = Math.Max(0, Math.Min(y, srcH - 1));
outW = Math.Max(1, Math.Min(cw, srcW - x));
outH = Math.Max(1, Math.Min(ch, srcH - y));
var dst = new byte[outW * outH * 4];
for (int row = 0; row < outH; row++)
Array.Copy(src, ((y + row) * srcW + x) * 4, dst, row * outW * 4, outW * 4);
return dst;
}
// ============================================================
// Make Searchable PDF - OCR every page and write an invisible text
// layer aligned to the image, so the existing PdfPig search and text
// selection start working on scans.
// ============================================================
internal async void MakeSearchablePdf()
{
if (!_host.HasDocument) { KillerDialog.Show(_host.Window, _host.Loc("Str_Ocr_NoDoc")); return; }
if (!await _host.EnsureOcrModelsReadyAsync()) return;
_host.CommitActiveTextBox();
var dlg = new KillerPDF.Controls.FileDialog(KillerPDF.Controls.FileDialogMode.Save)
{
Filter = _host.Loc("Str_Filter_Pdf") + "|*.pdf",
Title = _host.Loc("Str_Ocr_SaveSearchable"),
FileName = SuggestSearchableName(),
CheckFileExists = false,
CheckPathExists = true
};
if (dlg.ShowDialog(_host.Window) != true) return;
string outPath = dlg.FileName;
// Snapshot the current document to a temp; we render and re-open from this so the live doc
// is never touched. (Unburned overlay annotations are not included in v1.)
string src = App.MakeTempFile("ocrsrc");
try { _host.SaveDocumentTo(src); }
catch (Exception ex)
{
KillerDialog.Show(_host.Window, _host.Loc("Str_Err_PrepareDoc") + "\n" + ex.Message, "KillerPDF",
MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
var ct = _host.BeginOp(_host.Loc("Str_Op_Ocr"), _host.Loc("Str_Busy_Searchable"));
void report(int i, int n) => _host.Window.Dispatcher.Invoke(() =>
_host.SetBusyMessage(string.Format(_host.Loc("Str_Busy_SearchablePage"), i + 1, n)));
string lang = _host.OcrLanguageString;
try
{
var (pages, words) = await Task.Run(() => BuildSearchablePdf(src, outPath, report, ct, lang));
_host.HideBusy();
if (ct.IsCancellationRequested) { _host.SetStatus(_host.Loc("Str_St_SearchablePdfCanceled")); return; }
_host.SetStatus(string.Format(_host.Loc("Str_St_SearchableSaved"), pages, words));
KillerDialog.Show(_host.Window,
string.Format(_host.Loc("Str_Dlg_SearchableSaved"), outPath, pages, words),
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
_host.HideBusy();
KillerDialog.Show(_host.Window, _host.Loc("Str_Err_SearchableFailed") + "\n" + ex.Message, "KillerPDF",
MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
_host.EndOp();
}
}
// Suggest "<original name>-searchable.pdf" for the save dialog.
private string SuggestSearchableName()
{
string baseName = Path.GetFileNameWithoutExtension(_host.OriginalFile ?? _host.CurrentFile ?? "document");
return baseName + "-searchable.pdf";
}
// Renders each page, OCRs it, and appends an invisible (alpha 0) text layer positioned over the
// recognized words. The text is real content-stream text, so PdfPig extracts it for search/select;
// alpha 0 keeps it from showing or printing. Runs entirely off the UI thread. Also the core of the
// CLI's --ocr command (CliRunner).
internal static (int pages, int words) BuildSearchablePdf(string src, string outPath, Action<int, int> report, CancellationToken ct, string language)
{
// Cache one XFont per integer point size so a page of words doesn't allocate thousands of fonts.
var fontCache = new Dictionary<int, XFont>();
XFont FontFor(double heightPt)
{
int key = Math.Max(4, (int)Math.Round(heightPt));
if (!fontCache.TryGetValue(key, out var f))
{
try { f = new XFont("Arial", key, XFontStyle.Regular); }
catch { f = new XFont("Segoe UI", key, XFontStyle.Regular); }
fontCache[key] = f;
}
return f;
}
int totalWords = 0;
var invisible = new XSolidBrush(XColor.FromArgb(0, 0, 0, 0));
using var docReader = DocLib.Instance.GetDocReader(src, new PageDimensions(OcrRenderMax, OcrRenderMax));
using var ocr = new OcrService(language: language); // one engine reused across the whole document (single-threaded here)
var outDoc = PdfReader.Open(src, PdfDocumentOpenMode.Modify);
int pages = outDoc.PageCount;
for (int i = 0; i < pages; i++)
{
// Cooperative cancel: bail before the next page; the caller sees the canceled token and the
// file is never saved (outDoc.Save is past the loop), so no partial output is written.
if (ct.IsCancellationRequested) return (i, totalWords);
report(i, pages);
using var pr = docReader.GetPageReader(i);
int w = pr.GetPageWidth();
int h = pr.GetPageHeight();
byte[] bgra = pr.GetImage();
if (bgra is null || bgra.Length == 0 || w <= 0 || h <= 0) continue;
OcrResult result = ocr.RecognizeBgra(bgra, w, h);
if (result.Words.Count == 0) continue;
var page = outDoc.Pages[i];
using var gfx = XGraphics.FromPdfPage(page, XGraphicsPdfPageOptions.Append);
// OCR boxes are top-left pixel space; XGraphics is top-left point space. Same convention,
// so mapping is a straight scale (mirrors DrawAnnotationsOnDocument).
double sx = page.Width.Point / w;
double sy = page.Height.Point / h;
foreach (var word in result.Words)
{
double bx = word.Left * sx;
double by = word.Top * sy;
double bh = Math.Max(1, (word.Bottom - word.Top) * sy);
try
{
// (bx, by) is the top-left of the text by default (Near/Near alignment).
gfx.DrawString(word.Text, FontFor(bh), invisible, bx, by);
totalWords++;
}
catch { /* a single word that won't lay out should not abort the page */ }
}
}
outDoc.Save(outPath);
outDoc.Close();
return (pages, totalWords);
}
// ============================================================
// Extract All Text - OCR every page and save the plain text to a .txt or .md file.
// ============================================================
internal async void ExtractAllText()
{
if (!_host.HasDocument) { KillerDialog.Show(_host.Window, _host.Loc("Str_Ocr_NoDoc")); return; }
if (!await _host.EnsureOcrModelsReadyAsync()) return;
_host.CommitActiveTextBox();
var dlg = new KillerPDF.Controls.FileDialog(KillerPDF.Controls.FileDialogMode.Save)
{
Filter = _host.Loc("Str_Filter_Text") + "|*.txt|Markdown|*.md",
Title = _host.Loc("Str_Ocr_ExtractAllText"),
FileName = Path.GetFileNameWithoutExtension(_host.OriginalFile ?? _host.CurrentFile ?? "document") + ".txt",
CheckFileExists = false,
CheckPathExists = true
};
if (dlg.ShowDialog(_host.Window) != true) return;
string outPath = dlg.FileName;
bool markdown = Path.GetExtension(outPath).Equals(".md", StringComparison.OrdinalIgnoreCase);
string src = App.MakeTempFile("ocrtxt");
int pageCount;
try { _host.SaveDocumentTo(src); pageCount = _host.PageCount; }
catch (Exception ex)
{
KillerDialog.Show(_host.Window, _host.Loc("Str_Err_PrepareDoc") + "\n" + ex.Message, "KillerPDF",
MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
var ct = _host.BeginOp(_host.Loc("Str_Op_Ocr"), _host.Loc("Str_Busy_Extracting"));
void report(int i, int n) => _host.Window.Dispatcher.Invoke(() =>
_host.SetBusyMessage(string.Format(_host.Loc("Str_Busy_ExtractingPage"), i + 1, n)));
string lang = _host.OcrLanguageString;
try
{
int pages = await Task.Run(() => ExtractText(src, pageCount, outPath, markdown, report, ct, lang));
_host.HideBusy();
if (ct.IsCancellationRequested) { _host.SetStatus(_host.Loc("Str_St_TextExtractCanceled")); return; }
_host.SetStatus(string.Format(_host.Loc("Str_St_TextExtracted"), pages, Path.GetFileName(outPath)));
}
catch (Exception ex)
{
_host.HideBusy();
KillerDialog.Show(_host.Window, _host.Loc("Str_Err_ExtractFailed") + "\n" + ex.Message, "KillerPDF",
MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
_host.EndOp();
}
}
// OCR each page and concatenate the text into one file. Markdown gets a "## Page N" heading per
// page; plain text uses a simple divider. Cancellable - nothing is written if canceled.
private static int ExtractText(string src, int pageCount, string outPath, bool markdown,
Action<int, int> report, CancellationToken ct, string language)
{
string nl = Environment.NewLine;
var sb = new StringBuilder();
using var docReader = DocLib.Instance.GetDocReader(src, new PageDimensions(OcrRenderMax, OcrRenderMax));
using var ocr = new OcrService(language: language);
for (int i = 0; i < pageCount; i++)
{
// Cooperative cancel: stop and write nothing if the user canceled (caller checks the token).
if (ct.IsCancellationRequested) return 0;
report(i, pageCount);
using var pr = docReader.GetPageReader(i);
int w = pr.GetPageWidth();
int h = pr.GetPageHeight();
byte[] bgra = pr.GetImage();
string text = (bgra is null || bgra.Length == 0 || w <= 0 || h <= 0)
? string.Empty
: ocr.RecognizeBgra(bgra, w, h).Text.TrimEnd();
// Normalize Tesseract's LF line breaks to the platform's so .txt opens cleanly everywhere.
text = text.Replace("\r\n", "\n").Replace("\n", nl);
if (markdown)
sb.Append("## Page ").Append(i + 1).Append(nl).Append(nl).Append(text).Append(nl).Append(nl);
else
sb.Append("----- Page ").Append(i + 1).Append(" -----").Append(nl).Append(text).Append(nl).Append(nl);
}
if (ct.IsCancellationRequested) return 0;
File.WriteAllText(outPath, sb.ToString());
return pageCount;
}
}
}
+33
View File
@@ -0,0 +1,33 @@
namespace KillerPDF.Features
{
/// <summary>
/// What SearchController needs from the window hosting it, beyond the shared shell services.
/// Values and intents only, never a control - the highlight painting itself stays in the
/// shell (Shell/Search.cs), because it draws onto the page overlay canvases.
/// </summary>
internal interface ISearchHost : IShellServices
{
/// <summary>Path of the working (temp) file the search reads. Null when no document.</summary>
string? CurrentFile { get; }
/// <summary>The page currently selected in the sidebar/page list.</summary>
int CurrentPageIndex { get; }
/// <summary>Navigates to the page (the match being stepped to lives there).</summary>
void GoToPage(int pageIdx);
/// <summary>Writes the result counter's text only ("", "No matches", "Search error"),
/// leaving the tooltip alone - mirrors what the old code did on those paths.</summary>
void SetResultText(string text);
/// <summary>Writes the "12 / 73" counter and its page-breakdown tooltip.</summary>
void SetResultCount(string text, string? tooltip);
/// <summary>Removes every highlight rectangle from the page overlays.</summary>
void ClearHighlights();
/// <summary>Repaints highlights on every page on screen right now, with the current
/// match emphasized.</summary>
void RepaintHighlights();
}
}
+155
View File
@@ -0,0 +1,155 @@
using KillerPDF.Services;
namespace KillerPDF.Features
{
/// <summary>
/// The document-search state machine: runs SearchService over the working file, keeps the
/// per-page rect map and the flat reading-ordered match list, and steps the match cursor.
/// Moved out of Search.cs (MainWindow) in the KillerUI refactor.
///
/// Holds no controls. Talks to the window only through <see cref="ISearchHost"/>; the search
/// bar UI, debounce, and the highlight painting stay in the shell half (Shell/Search.cs).
/// </summary>
internal sealed class SearchController
{
private readonly ISearchHost _host;
private readonly SearchService _searchService = new();
internal SearchController(ISearchHost host) => _host = host;
// Whole-document search results (PDF-space rects per page). Settable references on
// purpose: Tabs.cs parks these per tab and swaps them back on a tab switch, exactly as
// it did when they were MainWindow fields.
internal Dictionary<int, List<(double left, double bottom, double right, double top)>> AllSearchRects { get; set; } = [];
internal List<int> ResultPages { get; set; } = [];
internal int PageCursor { get; set; } = -1;
// Flat, reading-ordered list of every match (page + rect) so Enter steps word-by-word rather
// than page-by-page; _matchCursor indexes it and that match is drawn with extra emphasis.
private readonly List<(int page, double left, double bottom, double right, double top)> _matches = [];
private int _matchCursor = -1;
private int _totalHits;
/// <summary>True while any page has result rects - the F3 and repaint-on-page-change gate.</summary>
internal bool HasResults => AllSearchRects.Count > 0;
/// <summary>The emphasized match, or null when the cursor is not on one.</summary>
internal (int page, double left, double bottom, double right, double top)? CurrentMatch =>
_matchCursor >= 0 && _matchCursor < _matches.Count ? _matches[_matchCursor] : null;
internal bool TryGetPageRects(int page,
out List<(double left, double bottom, double right, double top)> rects) =>
AllSearchRects.TryGetValue(page, out rects!);
internal IEnumerable<int> PagesWithResults => AllSearchRects.Keys;
/// <summary>The query-too-short reset (search box text dropped under 2 chars).</summary>
internal void ClearMatches()
{
AllSearchRects.Clear();
ResultPages.Clear();
_matches.Clear();
_matchCursor = -1;
PageCursor = -1;
}
/// <summary>The new/closed-document reset - exactly the three things FileOperations
/// cleared when these were fields (the match list is rebuilt by the next Run).</summary>
internal void ClearPageResults()
{
AllSearchRects.Clear();
ResultPages.Clear();
PageCursor = -1;
}
internal void Run(string query)
{
_host.ClearHighlights();
AllSearchRects.Clear();
ResultPages.Clear();
_matches.Clear();
_matchCursor = -1;
PageCursor = -1;
if (string.IsNullOrWhiteSpace(query) || _host.CurrentFile is null)
{
_host.SetResultText("");
return;
}
try
{
var sr = _searchService.Search(_host.CurrentFile, query);
foreach (var kvp in sr.PageRects)
AllSearchRects[kvp.Key] = kvp.Value;
ResultPages.AddRange(sr.ResultPages);
// Flatten every match into one reading-ordered list (page asc, then top-to-bottom,
// then left-to-right) so navigation steps word-by-word across the whole document.
foreach (var page in ResultPages)
foreach (var (left, bottom, right, top) in AllSearchRects[page].OrderByDescending(r => r.top).ThenBy(r => r.left))
_matches.Add((page, left, bottom, right, top));
if (_matches.Count == 0)
{
_host.SetResultText(_host.Loc("Str_Search_NoMatches"));
return;
}
_totalHits = sr.TotalHits;
// Start at the first match on or after the current page.
int startPage = _host.CurrentPageIndex;
_matchCursor = _matches.FindIndex(m => m.page >= startPage);
if (_matchCursor < 0) _matchCursor = 0;
GoToCurrentMatch();
}
catch
{
_host.SetResultText(_host.Loc("Str_Search_Error"));
}
}
internal void Next()
{
if (_matches.Count == 0) return;
_matchCursor = (_matchCursor + 1) % _matches.Count;
GoToCurrentMatch();
}
internal void Prev()
{
if (_matches.Count == 0) return;
_matchCursor = (_matchCursor - 1 + _matches.Count) % _matches.Count;
GoToCurrentMatch();
}
// Navigates to the current match's page (if needed), updates the counter, and repaints
// highlights with the current match emphasized. Shared by Run and Next/Prev.
private void GoToCurrentMatch()
{
if (_matchCursor < 0 || _matchCursor >= _matches.Count) return;
int targetPage = _matches[_matchCursor].page;
PageCursor = ResultPages.IndexOf(targetPage); // keep the persisted page-cursor sane
UpdateStatus();
if (_host.CurrentPageIndex != targetPage)
_host.GoToPage(targetPage);
_host.RepaintHighlights();
}
// Compact count ("12 / 73" = current match / total matches); page breakdown in the tooltip.
private void UpdateStatus()
{
if (_matches.Count == 0)
{
_host.SetResultCount(_host.Loc("Str_Search_NoMatches"), null);
return;
}
int pages = ResultPages.Count;
_host.SetResultCount($"{_matchCursor + 1} / {_matches.Count}",
string.Format(_host.Loc("Str_Search_Summary"), _matches.Count, pages));
}
}
}
+166
View File
@@ -0,0 +1,166 @@
using System.Windows;
using System.Windows.Input;
using System.Windows.Controls;
using KillerPDF.Controls;
namespace KillerPDF.Features
{
/// <summary>
/// What a document viewer needs from the window around it.
///
/// EXTENDS IShellServices, per the family rule in that file - the shell implements Window /
/// Loc / SetStatus once, not once per feature. Those three cover the viewer's two heaviest
/// call groups on their own (Loc 70 uses, SetStatus 68) plus the modal-dialog owner that
/// TextEditing and Links need for KillerDialog (3 uses).
///
/// DERIVED FROM MEASUREMENT, not guessed: every member here came from grepping what the nine
/// files bound for the viewer control (Viewport, Zoom, Annotations, Selection, TextEditing,
/// Crop, Links, Forms, PageSelection) actually reach for on MainWindow today. Use counts are
/// noted per member so the cost of each is visible.
///
/// That audit split the coupling into three groups, and only the first belongs here:
///
/// A. HOST SERVICES - chrome and app-level services the viewer asks for. This interface.
///
/// B. PER-DOCUMENT STATE - _doc (120 uses), _currentFile (33), _annotations (56),
/// _renderDims (34), _pageRotations (11), _undoStack (4). These are NOT host services:
/// they already ride in DocumentSession, which tab switching swaps by reference. The
/// viewer will hold its own active session and read them from it. Routing them through
/// the host would be a mistake that undoes the session design.
///
/// C. PageList - 84 uses, the single biggest coupling, and a DESIGN DECISION rather than a
/// mechanical one. The sidebar's page-thumbnail list is window chrome, but the viewer
/// drives it constantly (selection sync, scroll-to-page). With two panes there is still
/// ONE sidebar, so it has to follow the FOCUSED pane. The viewer therefore must not touch
/// PageList directly; it raises the notifications below and the window decides whether
/// this viewer is the focused one before acting. Getting this wrong is how the two panes
/// would end up fighting over the sidebar.
/// </summary>
internal interface IViewerHost : IShellServices
{
// ── Chrome the viewer updates (group A) ─────────────────────────────────────────────
/// <summary>Mark the active document dirty (unsaved changes). 32 uses. Stays a host
/// service even though dirtiness is per-document, because it also drives window chrome -
/// the tab's dirty dot and the title bar.</summary>
void MarkDirty(bool dirty = true);
// PushUndo is deliberately NOT here, despite 9 uses. Undo is per-document state (group B):
// _undoStack rides in DocumentSession, so the viewer pushes onto the session it is showing
// rather than asking the window. It was in this interface briefly and the compiler caught
// it - UndoEntry is a private nested record struct on MainWindow, and widening it plus its
// UndoKind enum just to satisfy the signature would have been the wrong fix for a member
// that should not have been here. (2026-08-01.)
/// <summary>Switch tools - Crop uses this to drop back to Select when it finishes. 2 uses.</summary>
void SetTool(EditTool tool);
bool SidebarShowingOutlines { get; }
void PopulateRecentFilesList(PdfViewer viewer);
void SwitchSidebarToPagesTab();
void SyncSidebarToDocState(bool hasDoc, bool startup);
void OpenFile(string path);
void UpdateFooterFade();
void UpdateTabStripFade();
Border? SearchBar { get; }
SearchController Search { get; }
TextBlock FileNameLabel { get; }
TreeView OutlineTree { get; }
Button SidebarOutlinesTab { get; }
TextBlock StatusText { get; }
FrameworkElement ShortcutOverlay { get; }
CheckBox LinkConfirmCheck { get; }
ContextMenu MakeThemedMenu();
void CloseSearchBar();
void HideSignaturePopup();
void SaveTempAndReload(bool keepAnnotations, bool preserveZoom);
void RecordNavJump();
PageAnnotation? PairPartner(PageAnnotation annotation);
void RenderStamps(int page);
void OpenStampTool();
bool StampHitTest(int page, Point position);
void ApplySearchHighlights(int page, Canvas canvas);
void HighlightSearchResultsOnCurrentPage();
void ShowTextSettings();
void HideTextSettings();
void StyleEditBox(TextBox textBox);
void ApplyTextStyleToSelection();
void ShowDrawSettings(EditTool tool);
void HideDrawSettings();
Border MakeBarGrip(int dotCount);
FrameworkElement BuildBarHost(FrameworkElement content);
void PlaceAnnotationBar(Border bar, Border grip, bool fadeIn);
void PlaceImageFromDialog(Point position, int pageIndex);
void PlaceSignature(Point position, int pageIndex);
void ShowSignaturePopup();
void FillSignField(bool initials, int objectNumber, int pageIndex,
double x, double y, double width, double height);
void ShapeToolMouseDown(int pageIndex, Point position, MouseButtonEventArgs e);
void CommitShapeDrag(int pageIndex);
void UpdateShapePolyRubber(MouseEventArgs e);
void OcrRegion(int pageIndex, Rect canvasBounds);
void ShowShortcutsOverlayExclusive();
System.Windows.Media.SolidColorBrush SwatchDimBorder { get; }
PageAnnotation? CloneAnnotation(PageAnnotation annotation);
System.Windows.TextDecorationCollection? BuildDecorations(bool underline, bool strike);
System.Windows.Media.Effects.DropShadowEffect AnnotBarShadow();
void FadeOverlayOut(UIElement element);
void FadeOutAndRemoveBar(Border? bar);
PdfSharpCore.Pdf.PdfItem DerefItem(PdfSharpCore.Pdf.PdfItem item);
string WordsToText(System.Collections.Generic.IEnumerable<UglyToad.PdfPig.Content.Word> words);
MenuItem MakeMenuItem(string header, RoutedEventHandler click, string? gesture, string? glyph);
bool FullScreen { get; }
bool VerticalScrollVisible { get; set; }
bool SpaceHeld { get; }
void RepositionAnnotationBars();
void PopulateContextMenu(PdfViewer viewer, Point point, int pageIndex);
void RefreshPageList(PdfViewer viewer);
void LoadOutlines(PdfViewer viewer);
Cursor CursorForTool(EditTool tool);
// ── Notifications, so the window can update chrome for the FOCUSED viewer only ───────
// These replace the viewer poking at PageList / ZoomBox / PageLabel / StatusText itself.
/// <summary>This viewer scrolled or paged to a different page.</summary>
void ViewerPageChanged(PdfViewer viewer, int pageIndex);
void EnsureSidebarPageVisible(PdfViewer viewer, int pageIndex);
void ScrollSidebar(PdfViewer viewer, double delta);
void ClearSidebarPages(PdfViewer viewer);
string PageJumpText { get; set; }
bool PageJumpEnabled { set; }
bool CloseFileEnabled { set; }
string PageTotalText { set; }
void SelectAllPageJumpText();
void SyncZoomDisplay(string? fitTag, string displayText);
string? SelectedZoomTag { get; }
void CollapseZoomTextSelection();
/// <summary>This viewer's zoom or fit mode changed (updates the zoom box). 16 uses of
/// ZoomBox today.</summary>
void ViewerZoomChanged(double zoomLevel);
/// <summary>This viewer took focus - the window repoints the sidebar, page list and
/// status line at it, and moves the accent halo.</summary>
void ViewerFocused();
// Window-owned chrome and start-screen actions raised by one viewer instance.
void ViewerSizeChanged(PdfViewer viewer, object sender, SizeChangedEventArgs e);
void ViewerDrop(PdfViewer viewer, object sender, DragEventArgs e);
void ViewerDragOver(object sender, DragEventArgs e);
void ViewerDropZoneClick(object sender, MouseButtonEventArgs e);
void ClearRecentFiles(object sender, MouseButtonEventArgs e);
void ViewerBackgroundRightClick(object sender, MouseButtonEventArgs e);
void ViewerTabStripMouseDown(object sender, MouseButtonEventArgs e);
bool IsViewerFocused(PdfViewer viewer);
bool IsSplitView { get; }
void FocusViewer(PdfViewer viewer);
bool OtherViewerHasFile(PdfViewer viewer, string? originalFile);
PdfViewer? TabDropTarget(PdfViewer source, MouseEventArgs e);
void UpdateTabDragFeedback(PdfViewer source, PdfViewer.DocumentSession session,
MouseEventArgs e, PdfViewer? target);
void HideTabDragFeedback();
void MoveTabToPane(PdfViewer source, PdfViewer target,
PdfViewer.DocumentSession session, MouseEventArgs e);
void RunWithViewerContext(PdfViewer viewer, System.Action work);
}
}
+97
View File
@@ -0,0 +1,97 @@
using System.Collections.Generic;
namespace KillerPDF
{
/// <summary>
/// The tab and session members under the names the window already calls them by, routed to the
/// focused pane. Keeping the old names resolvable leaves the call sites in FileOperations,
/// KeyboardShortcuts, ImportAndZip, TempReload, WindowChrome and SettingsPanel unchanged while
/// making them act on whichever pane has focus.
///
/// Ctrl+W, Ctrl+Tab, Ctrl+Q and CloseFile_Click are keyboard- or XAML-bound, so these
/// declarations are load-bearing rather than convenience.
/// </summary>
public partial class MainWindow
{
private void OpenInNewTab(string path) => ActiveViewer.OpenInNewTabExt(path);
private void CloseTab(Controls.PdfViewer.DocumentSession? s) => ActiveViewer.CloseTabExt(s);
private void CloseAllTabs() => ActiveViewer.CloseAllTabsExt();
private void CloseOtherTabs(Controls.PdfViewer.DocumentSession? s) => ActiveViewer.CloseOtherTabsExt(s);
private void CycleTab(int dir) => ActiveViewer.CycleTabExt(dir);
private void EnsureInitialSession() => ActiveViewer.EnsureInitialSessionExt();
private void MaterializeDeferred(Controls.PdfViewer.DocumentSession target)
=> ActiveViewer.MaterializeDeferredExt(target);
private Controls.PdfViewer.DocumentSession BeginTabLoad(
out Controls.PdfViewer.DocumentSession? prev, out bool createdNew)
=> ActiveViewer.BeginTabLoadExt(out prev, out createdNew);
private void AbortTabLoad(Controls.PdfViewer.DocumentSession target,
Controls.PdfViewer.DocumentSession? prev, bool createdNew)
=> ActiveViewer.AbortTabLoadExt(target, prev, createdNew);
private void CaptureSessionState(Controls.PdfViewer.DocumentSession s)
=> ActiveViewer.CaptureSessionStateExt(s);
private void ApplySessionState(Controls.PdfViewer.DocumentSession s)
=> ActiveViewer.ApplySessionStateExt(s);
private void SaveDocState(string? path, FitMode fit, double zoom, ViewMode view, int page)
=> ActiveViewer.SaveDocStateExt(path, fit, zoom, view, page);
private bool TryGetDocState(string? path, out FitMode fit, out double zoom,
out ViewMode view, out int page)
=> ActiveViewer.TryGetDocStateExt(path, out fit, out zoom, out view, out page);
private void RebuildTabStrip() => ActiveViewer.RebuildTabStripExt();
private void ScheduleTabReflow() => ActiveViewer.ScheduleTabReflowExt();
private void RenderActiveSession() => ActiveViewer.RenderActiveSessionExt();
private void ShowEmptyState() => ActiveViewer.ShowEmptyStateExt();
/// <summary>Night mode changed: flush both panes. The invert state is baked into cached
/// pixels, so pane B's cache is as stale as pane A's.</summary>
private void FlushAllRenderCaches()
{
Viewer.FlushAllRenderCachesExt();
ViewerB.FlushAllRenderCachesExt();
}
/// <summary>Every open document across both panes. The quit prompt and the settings writer
/// need all of them, or closing the window silently drops pane B's unsaved work.</summary>
private IEnumerable<Controls.PdfViewer.DocumentSession> AllSessions()
{
foreach (var s in Viewer.SessionsRef) yield return s;
foreach (var s in ViewerB.SessionsRef) yield return s;
}
/// <summary>The focused pane's open documents. Callers that mean "this pane" - the tab
/// context menu's Close Others, the reorder resync - want this rather than AllSessions.</summary>
private System.Collections.ObjectModel.ObservableCollection<Controls.PdfViewer.DocumentSession> _sessions
=> ActiveViewer.SessionsRef;
/// <summary>The focused pane's tab strip, for the chrome that positions or hides it.
/// AppScale and FullScreen act on both panes at their own call sites; SidebarLayout's fade
/// mask only describes the pane it is measuring, so it takes the active one.</summary>
private System.Windows.Controls.Border TabStripBorder => ActiveViewer.TabStripBorderCtl;
private System.Windows.Controls.Border TabStripFade => ActiveViewer.TabStripFadeCtl;
/// <summary>True when the other pane holds an unsaved copy of the same file.
///
/// The split opens a file as two independent copies, not two views of one document: each
/// pane has its own annotations, undo stack and dirty flag, so whichever saves last wins.
/// This is the guard on that.
///
/// Compares OriginalFile, not CurrentFile: crop and rotate swap the working file out to a
/// temp path, so CurrentFile can differ between two panes showing the same document.</summary>
private bool OtherPaneHasDirtyCopyOf(string? originalFile)
{
if (string.IsNullOrEmpty(originalFile)) return false;
var other = ReferenceEquals(ActiveViewer, Viewer) ? ViewerB : Viewer;
other.CaptureActiveIfAny(); // its live dirty flag may not be folded into its session yet
return other.SessionsRef.Any(s => s.IsDirty
&& string.Equals(s.OriginalFile, originalFile, System.StringComparison.OrdinalIgnoreCase));
}
/// <summary>Place the restored session list into pane A. The startup restore builds the list
/// itself rather than going through EnsureInitialSession, so it hands the result over.
/// Per-pane restore is not implemented; everything reopens in pane A.</summary>
private void SetRestoredSessions(IEnumerable<Controls.PdfViewer.DocumentSession> sessions,
Controls.PdfViewer.DocumentSession? active)
=> Viewer.SetSessionsExt(sessions, active);
}
}
+111
View File
@@ -0,0 +1,111 @@
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
using PdfSharpCore.Pdf;
namespace KillerPDF
{
/// <summary>
/// The window's half of the viewer bridge. Read this alongside
/// Controls/PdfViewer.Bridge.cs, which is the other end of every line here.
///
/// TWO DIRECTIONS, and they are kept apart on purpose:
///
/// INWARD - accessors the viewer reads. MainWindow's own members are private and a control
/// in another namespace cannot see them. Rather than widen ~40 fields in place and
/// scatter `internal` through fifteen files, each is exposed once, here, under a
/// name that says it is a bridge rather than an ordinary member. The private
/// fields stay private, so nothing else in the app gains reach by accident.
///
/// OUTWARD - focused-pane routing for shared toolbar and keyboard commands. Per-viewer
/// editing and document state stays on PdfViewer; only window-owned commands
/// cross this boundary.
///
/// XAML handlers remain on MainWindow because WPF resolves them against the XAML root.
/// </summary>
public partial class MainWindow
{
// ══ INWARD: chrome the viewer reads ═════════════════════════════════════════════════
// PageList is not here - x:Name fields are generated internal, so the control already
// sees it. These four are hand-declared private fields assigned from FindName, so they
// are not.
/// <summary>The window's ONE PageList selection delegate. Handed out rather than rebuilt
/// because SyncCurrentPageTo detaches and reattaches it - a method group would make a new
/// delegate per call and the -= would quietly remove nothing.</summary>
internal SelectionChangedEventHandler PageListSelectionHandler
=> _pageListSelectionHandler ??= PageList_SelectionChanged;
private SelectionChangedEventHandler? _pageListSelectionHandler;
// ══ INWARD: per-document state (group B - goes when the viewer owns its session) ═════
// Settable: the xref-repair path in Annotations.cs, which lives in the viewer, reopens the
// document and re-points the temp file.
// Reads OUT of the focused pane rather than exposing a window field: the session list
// belongs to the viewer. Callers still spell it `_active` - see the alias below.
internal Controls.PdfViewer.DocumentSession? ActiveSession => ActiveViewer.ActiveSessionRef;
private Controls.PdfViewer.DocumentSession? _active => ActiveViewer.ActiveSessionRef;
// Reads OUT of the control rather than exposing a window field: the link-rect map belongs
// to the viewer, alongside Links.cs. ContextMenu.cs and FileOperations.cs still call it by
// this name.
private Dictionary<int, List<LinkInfo>> _continuousLinks => ActiveViewer.ContinuousLinks;
// Live gesture state, shared with the annotation and crop tools that have not moved yet.
// ══ OUTWARD: the viewer's members, under the names the window already calls ══════════
// Signatures mirror the originals exactly, defaults included, so no call site changed.
private System.Threading.Tasks.Task RenderContinuousPages(int centerPage) => ActiveViewer.RenderContinuousPages(centerPage);
private void BootstrapDocumentView(int initialPage, bool autoFit, bool restoreFitMode = false)
=> ActiveViewer.BootstrapDocumentView(initialPage, autoFit, restoreFitMode);
private void RefreshPageView(int pageIndex) => ActiveViewer.RefreshPageView(pageIndex);
private void ScrollContinuousToPage(int pageIndex) => ActiveViewer.ScrollContinuousToPage(pageIndex);
private void StartRerenderTimer() => ActiveViewer.StartRerenderTimer();
private void SetZoom(double level) => ActiveViewer.SetZoom(level);
private void SetTrueZoom(double trueZoom) => ActiveViewer.SetTrueZoom(trueZoom);
private void GridZoomStep(bool zoomOut) => ActiveViewer.GridZoomStep(zoomOut);
private double GridZoomForN(int n) => ActiveViewer.GridZoomForN(n);
private double DisplayZoomPct() => ActiveViewer.DisplayZoomPct();
private void SyncZoomBox() => ActiveViewer.SyncZoomBox();
private void FitToWidth(bool lite = false) => ActiveViewer.FitToWidth(lite);
private void FitToPage(bool lite = false) => ActiveViewer.FitToPage(lite);
private void SetViewMode(ViewMode mode) => ActiveViewer.SetViewMode(mode);
private void SelectViewMode(ViewMode mode) => ActiveViewer.SelectViewMode(mode);
private void ApplyViewMode(ViewMode mode) => ActiveViewer.ApplyViewMode(mode);
private ViewMode? _pendingViewMode { get => ActiveViewer.PendingViewMode; set => ActiveViewer.PendingViewMode = value; }
private bool NavigatePageStep(int direction) => ActiveViewer.NavigatePageStep(direction);
private void NavigatePageByWheel(int delta) => ActiveViewer.NavigatePageByWheel(delta);
private int _gridColumns { get => ActiveViewer.GridColumns; set => ActiveViewer.GridColumns = value; }
private void BuildPrimaryTile() => ActiveViewer.BuildPrimaryTile();
private void PagePreviewPanel_SizeChanged(object sender, SizeChangedEventArgs e)
=> ActiveViewer.PagePreviewPanel_SizeChanged(sender, e);
// Bound from MainWindow.xaml (the zoom toolbar stays on the window) and from
// ContextMenu.cs, so these three cannot simply live on the control.
private void ZoomIn_Click(object sender, RoutedEventArgs e) => ActiveViewer.ZoomIn_Click(sender, e);
private void ZoomOut_Click(object sender, RoutedEventArgs e) => ActiveViewer.ZoomOut_Click(sender, e);
// ACTIVEVIEWER, like every other stub in this file - this one said `Viewer` (pane A,
// hardcoded) and was the split's cross-zoom bug: FocusPane(B) -> SyncZoomBox writes the
// shared box -> SelectionChanged -> this stub ran PANE A's handler, which FitToWidth'd
// pane A against pane B's document (proven by zoomtrace, 2026-08-01).
// NULL-CONDITIONAL, and it must stay that way. ZoomBox declares
// <ComboBoxItem Tag="1.0" IsSelected="True"> (MainWindow.xaml), so SelectionChanged fires
// while InitializeComponent is still walking the tree - ActiveViewer is not assigned until
// InitSplitPanes runs after it, so the `?.` no-ops the mid-parse fire exactly like the old
// `_zoomBox?.SelectedItem` guard did. Click handlers do not need this - a click cannot
// happen mid-parse.
private void ZoomBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
=> ActiveViewer?.ZoomBox_SelectionChanged(sender, e);
// Wheel over the zoom dropdown nudges the zoom like Ctrl+scroll. Null-conditional for the
// same mid-parse reason as SelectionChanged above.
private void ZoomBox_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
=> ActiveViewer?.ZoomBoxWheel(e);
}
}
@@ -0,0 +1,37 @@
using System.Windows.Controls;
using KillerPDF.Controls;
namespace KillerPDF
{
// The element names MainWindow.xaml used to generate, now that the document pane is a control
// (Controls/PdfViewer.xaml).
//
// Same trick as the ViewerState forwarding: the names stay, the storage moves. Every
// existing call site - DocPaneBorder.CornerRadius in Tabs.cs, DocPaneShadow.Visibility in
// FullScreen.cs, PagePreviewPanel and MarqueeLayer across the render and annotation code -
// compiles untouched.
//
// Get-only is correct here: callers mutate the ELEMENT (its margin, radius, visibility), never
// rebind the reference.
//
// NOTE the one thing that could NOT be forwarded: the card's MARGIN. It lives on the control
// now, not on PaneBorder, because the control is what the layout positions. ApplySidebarSide
// and ApplyFullScreen set ActiveViewer.Margin directly.
public partial class MainWindow
{
private Border DocPaneShadow => ActiveViewer.PaneShadowBorder;
private Border DocPaneBorder => ActiveViewer.PaneCardBorder;
private Grid DocPaneContent => ActiveViewer.ContentHost;
// 7 bare uses in Tabs.cs and Viewport.cs. Note this is a SEPARATE member from the
// underscore-prefixed _pageContentGrid, which forwards to ViewerState - the code uses both
// spellings, so both have to resolve.
private Grid PageContentGrid => ActiveViewer.PageGrid;
private ScrollViewer PagePreviewPanel => ActiveViewer.PreviewScroller;
private Border DropZone => ActiveViewer.DropSurface;
private Border RecentFilesBox => ActiveViewer.RecentBox;
private ItemsControl RecentFilesList => ActiveViewer.RecentList;
private Canvas MarqueeLayer => ActiveViewer.Marquee;
private Border DocSurfacePad => ActiveViewer.SurfacePad;
private System.Windows.Media.ImageBrush GrainBrush => ActiveViewer.Grain;
}
}
+241
View File
@@ -0,0 +1,241 @@
using KillerPDF.Features;
using KillerPDF.Controls;
using System.Windows;
using System.Windows.Input;
using System;
using System.Linq;
namespace KillerPDF
{
// MainWindow's half of IViewerHost.
//
// Explicit implementation throughout, matching Shell/About.cs: MainWindow's own members are
// private, and a private member cannot implement an interface. Forwarding explicitly satisfies
// the contract without widening anything to public - a change nobody asked for.
//
// IShellServices (Window / Loc / SetStatus) is already implemented in Shell/About.cs and is
// inherited through IViewerHost, so it is deliberately NOT repeated here.
public partial class MainWindow : IViewerHost
{
void IViewerHost.MarkDirty(bool dirty) => MarkDirty(dirty);
void IViewerHost.SetTool(EditTool tool) => SetTool(tool);
bool IViewerHost.SidebarShowingOutlines => _sidebarShowingOutlines;
void IViewerHost.PopulateRecentFilesList(PdfViewer viewer) => PopulateRecentFilesList(viewer);
void IViewerHost.SwitchSidebarToPagesTab() => SwitchSidebarToPagesTab();
void IViewerHost.SyncSidebarToDocState(bool hasDoc, bool startup)
=> SyncSidebarToDocState(hasDoc, startup);
void IViewerHost.OpenFile(string path) => OpenFile(path);
void IViewerHost.UpdateFooterFade() => UpdateFooterFade();
void IViewerHost.UpdateTabStripFade() => UpdateTabStripFade();
System.Windows.Controls.Border? IViewerHost.SearchBar => _searchBar;
Features.SearchController IViewerHost.Search => Search;
System.Windows.Controls.TextBlock IViewerHost.FileNameLabel => FileNameLabel;
System.Windows.Controls.TreeView IViewerHost.OutlineTree => OutlineTree;
System.Windows.Controls.Button IViewerHost.SidebarOutlinesTab => SidebarOutlinesTab;
System.Windows.Controls.TextBlock IViewerHost.StatusText => StatusText;
FrameworkElement IViewerHost.ShortcutOverlay => ShortcutOverlay;
System.Windows.Controls.CheckBox IViewerHost.LinkConfirmCheck => LinkConfirmCheck;
System.Windows.Controls.ContextMenu IViewerHost.MakeThemedMenu() => MakeThemedMenu();
void IViewerHost.CloseSearchBar() => CloseSearchBar();
void IViewerHost.HideSignaturePopup() => HideSignaturePopup();
void IViewerHost.SaveTempAndReload(bool keepAnnotations, bool preserveZoom)
=> SaveTempAndReload(keepAnnotations, preserveZoom);
void IViewerHost.RecordNavJump() => RecordNavJump();
PageAnnotation? IViewerHost.PairPartner(PageAnnotation annotation) => PairPartner(annotation);
void IViewerHost.RenderStamps(int page) => RenderStamps(page);
void IViewerHost.OpenStampTool() => OpenStampTool();
bool IViewerHost.StampHitTest(int page, Point position) => StampHitTest(page, position);
void IViewerHost.ApplySearchHighlights(int page, System.Windows.Controls.Canvas canvas)
=> ApplySearchHighlights(page, canvas);
void IViewerHost.HighlightSearchResultsOnCurrentPage() => HighlightSearchResultsOnCurrentPage();
void IViewerHost.ShowTextSettings() => ShowTextSettings();
void IViewerHost.HideTextSettings() => HideTextSettings();
void IViewerHost.StyleEditBox(System.Windows.Controls.TextBox textBox) => StyleEditBox(textBox);
void IViewerHost.ApplyTextStyleToSelection() => ApplyTextStyleToSelection();
void IViewerHost.ShowDrawSettings(EditTool tool) => ShowDrawSettings(tool);
void IViewerHost.HideDrawSettings() => HideDrawSettings();
System.Windows.Controls.Border IViewerHost.MakeBarGrip(int dotCount) => MakeBarGrip(dotCount);
FrameworkElement IViewerHost.BuildBarHost(FrameworkElement content) => BuildBarHost(content);
void IViewerHost.PlaceAnnotationBar(System.Windows.Controls.Border bar,
System.Windows.Controls.Border grip, bool fadeIn) => PlaceAnnotationBar(bar, grip, fadeIn);
void IViewerHost.PlaceImageFromDialog(Point position, int pageIndex)
=> PlaceImageFromDialog(position, pageIndex);
void IViewerHost.PlaceSignature(Point position, int pageIndex) => PlaceSignature(position, pageIndex);
void IViewerHost.ShowSignaturePopup() => ShowSignaturePopup();
void IViewerHost.FillSignField(bool initials, int objectNumber, int pageIndex,
double x, double y, double width, double height)
=> FillSignField(initials, objectNumber, pageIndex, x, y, width, height);
void IViewerHost.ShapeToolMouseDown(int pageIndex, Point position, MouseButtonEventArgs e)
=> ShapeToolMouseDown(pageIndex, position, e);
void IViewerHost.CommitShapeDrag(int pageIndex) => CommitShapeDrag(pageIndex);
void IViewerHost.UpdateShapePolyRubber(MouseEventArgs e) => UpdateShapePolyRubber(e);
void IViewerHost.OcrRegion(int pageIndex, Rect canvasBounds) => OcrRegion(pageIndex, canvasBounds);
void IViewerHost.ShowShortcutsOverlayExclusive() => ShowShortcutsOverlayExclusive();
System.Windows.Media.SolidColorBrush IViewerHost.SwatchDimBorder => _swatchDimBorder;
PageAnnotation? IViewerHost.CloneAnnotation(PageAnnotation annotation) => CloneAnnotation(annotation);
System.Windows.TextDecorationCollection? IViewerHost.BuildDecorations(bool underline, bool strike)
=> BuildDecorations(underline, strike);
System.Windows.Media.Effects.DropShadowEffect IViewerHost.AnnotBarShadow() => AnnotBarShadow();
void IViewerHost.FadeOverlayOut(UIElement element) => FadeOverlayOut(element);
void IViewerHost.FadeOutAndRemoveBar(System.Windows.Controls.Border? bar) => FadeOutAndRemoveBar(bar);
PdfSharpCore.Pdf.PdfItem IViewerHost.DerefItem(PdfSharpCore.Pdf.PdfItem item) => DerefItem(item);
string IViewerHost.WordsToText(System.Collections.Generic.IEnumerable<UglyToad.PdfPig.Content.Word> words)
=> WordsToText(words);
System.Windows.Controls.MenuItem IViewerHost.MakeMenuItem(string header, RoutedEventHandler click,
string? gesture, string? glyph) => MakeMenuItem(header, click, gesture, glyph);
bool IViewerHost.FullScreen => _fullScreen;
bool IViewerHost.VerticalScrollVisible
{
get => _vScrollVisible;
set => _vScrollVisible = value;
}
bool IViewerHost.SpaceHeld => _spaceHeld;
void IViewerHost.RepositionAnnotationBars() => RepositionAnnotationBars();
void IViewerHost.PopulateContextMenu(PdfViewer viewer, Point point, int pageIndex)
=> ((IViewerHost)this).RunWithViewerContext(viewer, () => PopulateContextMenu(point, pageIndex));
void IViewerHost.RefreshPageList(PdfViewer viewer)
=> ((IViewerHost)this).RunWithViewerContext(viewer, RefreshPageList);
void IViewerHost.LoadOutlines(PdfViewer viewer)
=> ((IViewerHost)this).RunWithViewerContext(viewer, LoadOutlines);
Cursor IViewerHost.CursorForTool(EditTool tool) => CursorForTool(tool);
// ---- Focused-viewer notifications ----------------------------------------------------
// Single pane today, so these just drive the existing chrome directly. When there are two,
// each body gains a "is the caller the focused viewer?" guard - the sidebar, page list and
// status line follow focus rather than whichever pane happened to update last. Keeping the
// calls routed through here means that guard lands in three known places instead of being
// hunted through 84 PageList call sites. (BACKLOG.md, group C.)
void IViewerHost.ViewerPageChanged(PdfViewer viewer, int pageIndex)
{
if (!ReferenceEquals(ActiveViewer, viewer)) return;
// Direct assignment, because that is what the 84 existing call sites do - there is no
// SyncPageListSelection helper today. The guard avoids re-entering the selection
// handler when the list already agrees.
if (pageIndex < 0 || PageList is null) return;
if (PageList.SelectedIndex != pageIndex) PageList.SelectedIndex = pageIndex;
}
void IViewerHost.EnsureSidebarPageVisible(PdfViewer viewer, int pageIndex)
{
if (!ReferenceEquals(ActiveViewer, viewer) || pageIndex < 0 || pageIndex >= PageList.Items.Count) return;
PageList.ScrollIntoView(PageList.Items[pageIndex]);
}
void IViewerHost.ScrollSidebar(PdfViewer viewer, double delta)
{
if (!ReferenceEquals(ActiveViewer, viewer)) return;
var scroller = FindSidebarDescendant<System.Windows.Controls.ScrollViewer>(PageList);
scroller?.ScrollToVerticalOffset(scroller.VerticalOffset + delta);
}
void IViewerHost.ClearSidebarPages(PdfViewer viewer)
{
if (ReferenceEquals(ActiveViewer, viewer)) PageList.ItemsSource = null;
}
string IViewerHost.PageJumpText { get => _pageJumpBox.Text; set => _pageJumpBox.Text = value; }
bool IViewerHost.PageJumpEnabled { set => _pageJumpBox.IsEnabled = value; }
bool IViewerHost.CloseFileEnabled { set => _closeFileBtnRef.IsEnabled = value; }
string IViewerHost.PageTotalText { set => _pageTotalLabel.Text = value; }
void IViewerHost.SelectAllPageJumpText() => _pageJumpBox.SelectAll();
void IViewerHost.SyncZoomDisplay(string? fitTag, string displayText)
{
if (fitTag != null)
foreach (System.Windows.Controls.ComboBoxItem item in _zoomBox.Items)
if (item.Tag?.ToString() == fitTag) { _zoomBox.SelectedItem = item; return; }
foreach (System.Windows.Controls.ComboBoxItem item in _zoomBox.Items)
if (item.Content?.ToString() == displayText) { _zoomBox.SelectedItem = item; return; }
_zoomBox.SelectedItem = null;
_zoomBox.Text = displayText;
}
string? IViewerHost.SelectedZoomTag
=> (_zoomBox.SelectedItem as System.Windows.Controls.ComboBoxItem)?.Tag?.ToString();
void IViewerHost.CollapseZoomTextSelection()
{
if (_zoomBox.Template?.FindName("PART_EditableTextBox", _zoomBox) is System.Windows.Controls.TextBox box)
box.Select(box.Text.Length, 0);
}
// SyncZoomBox reads the current zoom itself rather than taking one, so the parameter is
// unused today. It stays in the signature because with two panes the window has to know
// WHICH viewer's zoom changed before deciding whether the toolbar box should follow.
void IViewerHost.ViewerZoomChanged(double zoomLevel) => SyncZoomBox();
void IViewerHost.ViewerFocused()
{
// Nothing to do while there is one viewer. With two panes this moves the accent halo
// here and repoints the sidebar at the caller.
}
void IViewerHost.ViewerSizeChanged(PdfViewer viewer, object sender, SizeChangedEventArgs e)
=> DocPane_SizeChanged(sender, e);
void IViewerHost.ViewerDrop(PdfViewer viewer, object sender, DragEventArgs e)
{
FocusPane(viewer);
DropZone_Drop(sender, e);
}
void IViewerHost.ViewerDragOver(object sender, DragEventArgs e)
=> DropZone_DragOver(sender, e);
void IViewerHost.ViewerDropZoneClick(object sender, MouseButtonEventArgs e)
=> DropZone_Click(sender, e);
void IViewerHost.ClearRecentFiles(object sender, MouseButtonEventArgs e)
=> RecentClearAll_Click(sender, e);
void IViewerHost.ViewerBackgroundRightClick(object sender, MouseButtonEventArgs e)
=> DocPaneBackground_RightClick(sender, e);
void IViewerHost.ViewerTabStripMouseDown(object sender, MouseButtonEventArgs e)
=> TitleBar_MouseLeftButtonDown(sender, e);
bool IViewerHost.IsViewerFocused(PdfViewer viewer)
=> ReferenceEquals(ActiveViewer, viewer);
bool IViewerHost.IsSplitView => _isSplit;
void IViewerHost.FocusViewer(PdfViewer viewer) => FocusPane(viewer);
bool IViewerHost.OtherViewerHasFile(PdfViewer viewer, string? originalFile)
{
if (string.IsNullOrEmpty(originalFile)) return false;
var other = ReferenceEquals(Viewer, viewer) ? ViewerB : Viewer;
return other.SessionsRef.Any(x => (x.Doc != null || x.DeferredPath != null)
&& string.Equals(x.OriginalFile, originalFile, StringComparison.OrdinalIgnoreCase));
}
PdfViewer? IViewerHost.TabDropTarget(PdfViewer source, MouseEventArgs e)
=> TabDropTargetPane(source, e);
void IViewerHost.UpdateTabDragFeedback(PdfViewer source,
PdfViewer.DocumentSession session, MouseEventArgs e, PdfViewer? target)
=> UpdateTabDragFeedback(source, session, e, target);
void IViewerHost.HideTabDragFeedback() => HideTabDragFeedback();
void IViewerHost.MoveTabToPane(PdfViewer source, PdfViewer target,
PdfViewer.DocumentSession session, MouseEventArgs e)
=> MoveTabToPane(source, target, session, e);
void IViewerHost.RunWithViewerContext(PdfViewer viewer, Action work)
{
var focused = ActiveViewer;
focused.CaptureActiveIfAny();
var previous = SwapActiveViewer(viewer);
try { work(); }
finally
{
SwapActiveViewer(previous);
focused.RestoreActiveFieldsOnly();
}
}
}
}
+122
View File
@@ -0,0 +1,122 @@
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using PdfSharpCore.Pdf;
namespace KillerPDF
{
/// <summary>
/// Outward stubs: the annotation, text, crop, form and link members under the names the rest of
/// the window already calls them by.
///
/// The point of this file is that ~170 call sites across ContextMenu.cs, TextSettingsBar.cs,
/// KeyboardShortcuts.cs, FileOperations.cs, Tabs.cs, Signing.cs, Shapes.cs, Search.cs,
/// SidebarOutline.cs, Stamps.cs, ToolSelection.cs, TempReload.cs, Rotate.cs, Ocr.cs and
/// DirtyTracking.cs need no changes at all.
///
/// THE XAML ONES ARE NOT OPTIONAL. WPF resolves Click="Undo_Click" against the code-behind of
/// the XAML ROOT - MainWindow - not against whichever class the method ended up in. Eleven
/// handlers in MainWindow.xaml point at members that now live in the viewer, and without these
/// declarations InitializeComponent throws XamlParseException before the window ever appears.
/// Verified against MainWindow.xaml rather than remembered: Undo_Click (2 bindings),
/// ClearAllAnnotations_Click (2), PageJumpBox_KeyDown, PageJumpBox_GotFocus,
/// PageList_SelectionChanged, ShortcutHelp_Click, ShortcutOverlay_MouseLeftButtonDown (2),
/// ShortcutOverlayCard_MouseLeftButtonDown (2), ShortcutOverlayClose_Click,
/// Hyperlink_RequestNavigate.
/// </summary>
public partial class MainWindow
{
// ── Annotations ──────────────────────────────────────────────────────────────────────
private void RenderAllAnnotations(int pageIndex) => ActiveViewer.RenderAllAnnotations(pageIndex);
private void ClearSelection() => ActiveViewer.ClearSelection();
private void ClearTextSelection() => ActiveViewer.ClearTextSelection();
private SolidColorBrush AccentBrush(byte alpha = 255) => ActiveViewer.AccentBrush(alpha);
private void AddAnnotation(PageAnnotation a) => ActiveViewer.AddAnnotationExt(a);
private Rect AnnotBounds(PageAnnotation a) => ActiveViewer.AnnotBoundsExt(a);
private static Point AnnotGetPos(PageAnnotation a) => Controls.PdfViewer.AnnotGetPosExt(a);
private static void AnnotSetPos(PageAnnotation a, Point pos) => Controls.PdfViewer.AnnotSetPosExt(a, pos);
private Point ClampAnnotPos(PageAnnotation a) => ActiveViewer.ClampAnnotPosExt(a);
private bool HitTestAnnotation(PageAnnotation a, Point pos, out Rect bounds)
=> ActiveViewer.HitTestAnnotationExt(a, pos, out bounds);
private static bool IsDraggable(PageAnnotation a) => Controls.PdfViewer.IsDraggableExt(a);
private void SelectAnnotation(PageAnnotation a, Rect bounds) => ActiveViewer.SelectAnnotationExt(a, bounds);
private void ToggleMultiSelect(PageAnnotation a, Rect bounds, Canvas canvas)
=> ActiveViewer.ToggleMultiSelectExt(a, bounds, canvas);
private void SelectGroup(PageAnnotation lead) => ActiveViewer.SelectGroupExt(lead);
private PageAnnotation? SelectedPaired() => ActiveViewer.SelectedPairedExt();
private int SelectionCount() => ActiveViewer.SelectionCountExt();
private void ReattachSelectionVisuals() => ActiveViewer.ReattachSelectionVisualsExt();
private void UnpairSelected() => ActiveViewer.UnpairSelectedExt();
private void GroupSelected() => ActiveViewer.GroupSelectedExt();
private void UngroupAnnotation(PageAnnotation a) => ActiveViewer.UngroupAnnotationExt(a);
private void RemoveFromGroup(PageAnnotation a) => ActiveViewer.RemoveFromGroupExt(a);
private void DeleteSelected() => ActiveViewer.DeleteSelectedExt();
private bool SelectAllAnnotations() => ActiveViewer.SelectAllAnnotationsExt();
private void HideBrushPreview() => ActiveViewer.HideBrushPreviewExt();
private void FinishStuckGesture() => ActiveViewer.FinishStuckGestureExt();
private void RefreshSelectionAccent() => ActiveViewer.RefreshSelectionAccentExt();
// ── Page canvases ────────────────────────────────────────────────────────────────────
private Canvas CanvasForPage(int page) => ActiveViewer.CanvasForPageExt(page);
private Canvas? VisibleCanvasForPage(int page) => ActiveViewer.VisibleCanvasForPageExt(page);
private IEnumerable<Canvas> AllPageCanvases() => ActiveViewer.AllPageCanvasesExt();
// ── Undo ─────────────────────────────────────────────────────────────────────────────
private void PushDocUndo() => ActiveViewer.PushDocUndoExt();
private void PushPageSnapshotUndo(int pageIdx) => ActiveViewer.PushPageSnapshotUndoExt(pageIdx);
// ── Text editing ─────────────────────────────────────────────────────────────────────
private void CommitActiveTextBox() => ActiveViewer.CommitActiveTextBoxExt();
private void RemoveTextEditHandles() => ActiveViewer.RemoveTextEditHandlesExt();
private void EditTextAtPosition(Point canvasPos, int pageIdx) => ActiveViewer.EditTextAtPositionExt(canvasPos, pageIdx);
private void PlaceTextBox(Point pos, int pageIdx) => ActiveViewer.PlaceTextBoxExt(pos, pageIdx);
private Brush TextEditBackground() => ActiveViewer.TextEditBackgroundExt();
private static ControlTemplate FlatTextBoxTemplate() => Controls.PdfViewer.FlatTextBoxTemplateExt();
// ── Text selection ───────────────────────────────────────────────────────────────────
private void CopySelectedText() => ActiveViewer.CopySelectedTextExt();
private void SelectAllText() => ActiveViewer.SelectAllTextExt();
// ── Crop ─────────────────────────────────────────────────────────────────────────────
private void ApplyCrop(int[] pageIndices) => ActiveViewer.ApplyCropExt(pageIndices);
private void HideCropConfirmBar() => ActiveViewer.HideCropConfirmBarExt();
private void ShowDefaultCropBox() => ActiveViewer.ShowDefaultCropBoxExt();
private void RebuildCropBarForLocale() => ActiveViewer.RebuildCropBarForLocaleExt();
// ── Links ────────────────────────────────────────────────────────────────────────────
private void CloseLinkPdfiumDoc() => ActiveViewer.CloseLinkPdfiumDocExt();
private void AddLinkMenuItems(ContextMenu menu, object target, int annotIndex, int pageIndex)
=> ActiveViewer.AddLinkMenuItemsExt(menu, target, annotIndex, pageIndex);
private int? ResolveDest(PdfItem? destItem) => ActiveViewer.ResolveDestExt(destItem);
private const double LinkHitPad = Controls.PdfViewer.LinkHitPadShared;
internal const string ConfirmLinksSetting = Controls.PdfViewer.ConfirmLinksSetting;
// ── Save paths ───────────────────────────────────────────────────────────────────────
private void DrawAnnotationsOnDocument(int? onlyPage = null) => ActiveViewer.DrawAnnotationsOnDocumentExt(onlyPage);
private void WriteFormValuesToDocument() => ActiveViewer.WriteFormValuesToDocumentExt();
// ── Bound from MainWindow.xaml - see the class comment, these are load-bearing ───────
private void Undo_Click(object sender, RoutedEventArgs e) => ActiveViewer.UndoClickExt(sender, e);
private void Redo_Click(object sender, RoutedEventArgs e) => ActiveViewer.RedoClickExt(sender, e);
private void ClearAnnotations_Click(object sender, RoutedEventArgs e) => ActiveViewer.ClearAnnotationsClickExt(sender, e);
private void ClearAllAnnotations_Click(object sender, RoutedEventArgs e) => ActiveViewer.ClearAllAnnotationsClickExt(sender, e);
private void PageJumpBox_KeyDown(object sender, KeyEventArgs e) => ActiveViewer.PageJumpBoxKeyDownExt(sender, e);
private void PageJumpBox_GotFocus(object sender, RoutedEventArgs e) => ActiveViewer.PageJumpBoxGotFocusExt(sender, e);
private void PageList_SelectionChanged(object sender, SelectionChangedEventArgs e)
=> ActiveViewer.PageListSelectionChangedExt(sender, e);
private void ShortcutHelp_Click(object sender, RoutedEventArgs e) => ActiveViewer.ShortcutHelpClickExt(sender, e);
private void ShortcutOverlay_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
=> FadeOverlayOut(ShortcutOverlay);
private void ShortcutOverlayCard_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
=> e.Handled = true;
private void ShortcutOverlayClose_Click(object sender, RoutedEventArgs e)
{
e.Handled = true;
FadeOverlayOut(ShortcutOverlay);
}
private void Hyperlink_RequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e)
=> ActiveViewer.HyperlinkRequestNavigateExt(sender, e);
}
}