vendor: import KillerPDF 1.7.5 source (GPL-3.0) as the base for MMD PDF
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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("\"", "\"\"") + "\"";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user