1490 lines
80 KiB
C#
1490 lines
80 KiB
C#
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text.Json;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Input;
|
|
using System.Windows.Interop;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Animation;
|
|
using System.Windows.Media.Imaging;
|
|
using System.Windows.Shapes;
|
|
using Docnet.Core;
|
|
using Docnet.Core.Models;
|
|
using Microsoft.Win32;
|
|
using PdfSharpCore.Drawing;
|
|
using PdfSharpCore.Pdf;
|
|
using PdfSharpCore.Pdf.IO;
|
|
using KillerPDF.Services;
|
|
// CliParsePageRange moved out with the CLI runner but is used here too (Export Images'
|
|
// range box); its encoder sibling is now Services/BitmapHelpers.EncodeJpeg.
|
|
using static KillerPDF.Features.CliRunner;
|
|
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
|
|
|
namespace KillerPDF
|
|
{
|
|
public partial class MainWindow
|
|
{
|
|
// True while an open is finishing on a background thread (encryption strip / repair). The
|
|
// synchronous open callers check this so they don't treat the not-yet-loaded _doc as a failure;
|
|
// the background path finalizes the tab itself via FinalizeAsyncOpen.
|
|
private bool _asyncOpenPending { get => ActiveViewer.AsyncOpenPendingRef; set => ActiveViewer.AsyncOpenPendingRef = value; }
|
|
|
|
// True when the open document came from a password/encryption-protected source file (#149).
|
|
// Drives the Save menu's Remove Password entry and the saved-without-protection status note.
|
|
// Cleared once a save writes the unprotected file; carried per tab via DocumentSession.
|
|
private bool _openedFromProtected { get => ActiveViewer.OpenedFromProtectedRef; set => ActiveViewer.OpenedFromProtectedRef = value; }
|
|
|
|
private void OpenFile(string path)
|
|
{
|
|
// Record real user files in the recent list (skips blank/new docs, which don't open a path).
|
|
if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path)) App.AddRecentFile(path);
|
|
|
|
// Files on UNC / network shares - notably the WSL \\wsl$ 9P filesystem - can hand
|
|
// back partial reads, making the PDF parser see a truncated file ("Unexpected EOF").
|
|
// Copy such files to a local temp via File.ReadAllBytes (which reads to EOF) and open
|
|
// from there. `path` stays the user's real path for display and Save.
|
|
string srcPath = path;
|
|
if (PdfImport.IsNetworkPath(path))
|
|
{
|
|
try
|
|
{
|
|
var localCopy = App.MakeTempFile("netopen");
|
|
File.WriteAllBytes(localCopy, File.ReadAllBytes(path));
|
|
srcPath = localCopy;
|
|
}
|
|
catch { srcPath = path; }
|
|
}
|
|
|
|
try
|
|
{
|
|
if (_doc is not null) { _doc.Close(); _doc = null; }
|
|
_doc = PdfReader.Open(srcPath, PdfDocumentOpenMode.Modify);
|
|
// PdfSharp cannot save modified encrypted PDFs - it copies unmodified encrypted
|
|
// stream bytes verbatim but fails when it has to re-serialize a dirty object.
|
|
// Strip encryption silently at open time via Import so all edits work correctly.
|
|
if (PdfImport.PdfFileHasEncryption(srcPath))
|
|
{
|
|
// PdfSharp can read encrypted PDFs but cannot re-save them once modified, so the
|
|
// encryption is stripped (PDFium, lossless; Import fallback). That strip is CPU-heavy,
|
|
// so it runs off-thread behind the busy overlay instead of freezing the window. The
|
|
// background path finalizes the tab itself, so the flag tells the synchronous caller
|
|
// not to treat the not-yet-set _doc as a failed open.
|
|
_asyncOpenPending = true;
|
|
StripEncryptionAndOpen(srcPath, path, busyMessage: Loc("Str_Busy_OpeningProtected"));
|
|
return;
|
|
}
|
|
_currentFile = srcPath;
|
|
FinishOpenFile(path, srcPath);
|
|
}
|
|
catch (Exception ex) when (PdfImport.IsOwnerPasswordException(ex))
|
|
{
|
|
// An empty user password can open the file while its owner password still forbids
|
|
// modification. Retrying through PdfSharp's ReadOnly parser is not safe for malformed
|
|
// linearized files such as the Fritzbox manual: it reaches a broken hint table and throws
|
|
// an array-index error. PDFium already has a tolerant, lossless rewrite path that removes
|
|
// the restriction and repairs those tables, so use it just as we do for other encrypted PDFs.
|
|
_asyncOpenPending = true;
|
|
StripEncryptionAndOpen(srcPath, path, busyMessage: Loc("Str_Busy_OpeningProtected"));
|
|
}
|
|
catch (Exception ex) when (PdfImport.IsPasswordException(ex))
|
|
{
|
|
string? pw = PromptForPassword(path);
|
|
if (pw is null) return;
|
|
try
|
|
{
|
|
if (_doc is not null) { _doc.Close(); _doc = null; }
|
|
_doc = PdfReader.Open(srcPath, pw, PdfDocumentOpenMode.Modify);
|
|
// Save a decrypted temp copy so Docnet can render without needing the password
|
|
var tempDec = App.MakeTempFile("dec");
|
|
_doc.Save(tempDec);
|
|
_doc.Close();
|
|
_doc = PdfReader.Open(tempDec, PdfDocumentOpenMode.Modify);
|
|
_currentFile = tempDec;
|
|
FinishOpenFile(path, tempDec);
|
|
_openedFromProtected = true; // #149: unlocked with the user's password
|
|
}
|
|
catch (Exception ex2)
|
|
{
|
|
KillerDialog.Show(this, string.Format(Loc("Str_Dlg_FailedOpen"), ex2.Message), Loc("Str_Dlg_AppTitle"), MessageBoxButton.OK, MessageBoxImage.Error);
|
|
}
|
|
}
|
|
catch (Exception ex) when (PdfImport.IsXRefException(ex))
|
|
{
|
|
// Some PDFs have malformed or non-standard XRef tables that PdfSharp can't
|
|
// open in Modify mode. Fall back to ReadOnly; if that also fails, offer repair.
|
|
try
|
|
{
|
|
if (_doc is not null) { _doc.Close(); _doc = null; }
|
|
_doc = PdfReader.Open(srcPath, PdfDocumentOpenMode.ReadOnly);
|
|
_currentFile = srcPath;
|
|
FinishOpenFile(path, srcPath);
|
|
SetStatus(string.Format(Loc("Str_OpenedReadOnlyXRef"), System.IO.Path.GetFileName(path), _doc.PageCount));
|
|
KillerDialog.Show(this,
|
|
$"\"{System.IO.Path.GetFileName(path)}\" has a non-standard structure and was opened read-only.\n\nEditing, saving, and some other features may not work correctly.",
|
|
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.Warning);
|
|
}
|
|
catch
|
|
{
|
|
// ReadOnly also failed - offer to repair.
|
|
var result = KillerDialog.Show(this,
|
|
$"This PDF has a damaged structure and couldn't be opened.\n\nWould you like KillerPDF to attempt a repair? A repaired copy will be created - the original file will not be changed.\n\nNote: repaired files may be missing bookmarks, forms, and other interactive features.",
|
|
"KillerPDF", MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
|
if (result == MessageBoxResult.Yes)
|
|
TryRepairAndOpen(srcPath);
|
|
}
|
|
}
|
|
catch (Exception ex) when (PdfImport.IsEofParseException(ex))
|
|
{
|
|
// PdfSharpCore rejects some structurally-valid PDFs with "Unexpected EOF" even though
|
|
// PDFium (and every common viewer) reads them fine. Re-save losslessly through PDFium on
|
|
// a background thread (so the window doesn't freeze). The recovered copy is content-
|
|
// equivalent, so it opens clean without nagging to save (markDirty: false).
|
|
_asyncOpenPending = true;
|
|
StripEncryptionAndOpen(srcPath, path, markDirty: false);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Any other open failure (truncated file, malformed objects, an out-of-range parse, etc.):
|
|
// we can't classify the damage, but the PDFium-based repair often recovers it anyway, so
|
|
// offer the repair rather than just failing outright.
|
|
var result = KillerDialog.Show(this,
|
|
"This PDF couldn't be opened - its structure may be damaged.\n\nWould you like KillerPDF to attempt a repair? A repaired copy will be created - the original file will not be changed.\n\nNote: repaired files may be missing bookmarks, forms, and other interactive features.",
|
|
"KillerPDF", MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
|
if (result == MessageBoxResult.Yes)
|
|
TryRepairAndOpen(srcPath); // sets _asyncOpenPending and finalizes the tab itself
|
|
}
|
|
}
|
|
|
|
// The open-failure classifiers (IsEofParseException, IsXRefException, IsNetworkPath,
|
|
// IsOwnerPasswordException, IsPasswordException) live in Services/PdfImport.cs.
|
|
|
|
private void FinishOpenFile(string displayPath, string workingPath)
|
|
{
|
|
_currentFile = workingPath;
|
|
_originalFile = displayPath;
|
|
FileNameLabel.Text = System.IO.Path.GetFileName(displayPath);
|
|
_annotations.Clear();
|
|
_continuousLinks.Clear(); // drop the previous document's cached link rects
|
|
CloseLinkPdfiumDoc(); // and release the cached PDFium link handle for the old file
|
|
_undoStack.Clear();
|
|
_redoStack.Clear();
|
|
// The map belongs to the previous document. Native rotations in a newly opened PDF
|
|
// remain on each page until SaveTempAndReload moves them into this map.
|
|
_pageRotations.Clear();
|
|
_navBack.Clear();
|
|
_navForward.Clear();
|
|
_renderDims.Clear();
|
|
_formTextValues.Clear();
|
|
_formCheckValues.Clear();
|
|
_formFontSizes.Clear();
|
|
_formRadioValues.Clear();
|
|
Search.ClearPageResults();
|
|
_gridScrollToPage = -1;
|
|
MarkDirty(false);
|
|
_openedFromProtected = false; // #149: set true by the two protected-open paths after this returns
|
|
// Restore this file's last view/page if we've seen it before. A user-selected Fit Width or
|
|
// Fit Page preference is global and wins over an older per-document fit choice, so small-screen
|
|
// users do not have to pick Fit Width again for every new manual they open.
|
|
FitMode? preferredFit = Enum.TryParse<FitMode>(App.GetSetting("DefaultFitMode"), out var savedFit)
|
|
&& savedFit != FitMode.None ? savedFit : null;
|
|
if (TryGetDocState(displayPath, out var sfit, out var szoom, out var sview, out var spage))
|
|
{
|
|
_viewMode = sview;
|
|
_fitMode = preferredFit ?? sfit;
|
|
_zoomLevel = szoom;
|
|
int pg = Math.Max(0, Math.Min(spage, _doc!.PageCount - 1));
|
|
BootstrapDocumentView(pg, autoFit: false, restoreFitMode: true);
|
|
}
|
|
else
|
|
{
|
|
if (preferredFit.HasValue)
|
|
{
|
|
_fitMode = preferredFit.Value;
|
|
BootstrapDocumentView(0, autoFit: false, restoreFitMode: true);
|
|
}
|
|
else BootstrapDocumentView(0, autoFit: true);
|
|
}
|
|
SetStatus(string.Format(Loc("Str_Opened"), System.IO.Path.GetFileName(displayPath), _doc!.PageCount));
|
|
SyncSidebarToDocState(hasDoc: true, startup: false); // a document is up: open the rail, show page controls
|
|
}
|
|
|
|
// Themed "Password Required" prompt (KillerDialog): family dialog chrome + themed PasswordBox.
|
|
private string? PromptForPassword(string filename) => KillerDialog.PromptPassword(this, filename);
|
|
|
|
// ALL direct PDFium P/Invoke lives in Services/PdfiumInterop.cs (KillerUI refactor) -
|
|
// one class, one lock (Docnet's), so the thread-safety discipline stays auditable.
|
|
// PdfFileHasEncryption and TryImportRepairToPath live in Services/PdfImport.cs.
|
|
|
|
private async void TryRepairAndOpen(string path)
|
|
{
|
|
// Repair is CPU/IO heavy, so it runs on a background thread behind a spinner overlay -
|
|
// otherwise the window froze (hourglass, no feedback) for the whole repair. Only the
|
|
// file production runs off-thread; opening/rendering the result stays on the UI thread.
|
|
_asyncOpenPending = true; // the synchronous open caller defers tab finalization to here
|
|
var ct = BeginCancellableOp(Loc("Str_Op_Repair"));
|
|
var busy = ShowBusyOverlay(Loc("Str_Busy_Repairing"));
|
|
try
|
|
{
|
|
// Release any open document before the worker reads the source file.
|
|
if (_doc is not null) { _doc.Close(); _doc = null; }
|
|
|
|
string? repairedPath = null;
|
|
bool raster = false;
|
|
|
|
// Strategy 0 (#103): lossless PDFium re-save. PDFium's tolerant parser recovers
|
|
// broken xref tables (including the dangling /Outlines entry older KillerPDF
|
|
// builds wrote) and rewrites a clean file preserving EVERYTHING - forms,
|
|
// bookmarks, text. The import-copy below drops the document-level AcroForm,
|
|
// which is what used to turn a repaired fillable form into a flat one.
|
|
repairedPath = await System.Threading.Tasks.Task.Run(() =>
|
|
{
|
|
var p = App.MakeTempFile("repaired");
|
|
return PdfiumInterop.TryPdfiumStripEncryption(path, p) ? p : null;
|
|
});
|
|
if (ct.IsCancellationRequested) { HideBusyOverlay(busy); _asyncOpenPending = false; SetStatus(Loc("Str_St_RepairCanceled")); return; } // canceled during strategy 0
|
|
|
|
// Strategy 1: PdfSharpCore Import mode - page-copy, more lenient than Modify/ReadOnly.
|
|
// Works when the XRef is partially corrupt but the object data is intact. (Returns
|
|
// null on failure rather than throwing.)
|
|
repairedPath ??= await System.Threading.Tasks.Task.Run(() => PdfImport.RepairViaImportToFile(path));
|
|
if (ct.IsCancellationRequested) { HideBusyOverlay(busy); _asyncOpenPending = false; SetStatus(Loc("Str_St_RepairCanceled")); return; } // canceled during strategy 1
|
|
|
|
// Strategy 2: PDFium rasterize. PDFium's internal XRef recovery handles damage
|
|
// PdfSharpCore cannot; each page is rendered to a bitmap and rebuilt into a clean PDF.
|
|
// Text won't be selectable in the result, but the file will open and print.
|
|
if (repairedPath is null)
|
|
{
|
|
repairedPath = await System.Threading.Tasks.Task.Run(() => PdfImport.RepairViaDocnetRasterizeToFile(path));
|
|
raster = repairedPath is not null;
|
|
}
|
|
if (ct.IsCancellationRequested) { HideBusyOverlay(busy); _asyncOpenPending = false; SetStatus(Loc("Str_St_RepairCanceled")); return; } // canceled during strategy 2
|
|
|
|
if (repairedPath is null)
|
|
{
|
|
HideBusyOverlay(busy);
|
|
_asyncOpenPending = false;
|
|
KillerDialog.Show(this,
|
|
"Repair failed - the file is too severely damaged to recover.\n\nTry opening the original in a different application (Adobe Acrobat, browsers) which may have additional recovery options.",
|
|
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
|
|
return;
|
|
}
|
|
|
|
// Open and render the repaired copy on the UI thread.
|
|
_doc = PdfReader.Open(repairedPath, PdfDocumentOpenMode.Modify);
|
|
_currentFile = repairedPath;
|
|
FinishOpenFile(path, repairedPath);
|
|
MarkDirty(true); // repaired copy lives in temp - user must Save As
|
|
SetStatus(string.Format(Loc(raster ? "Str_OpenedRasterRepair" : "Str_OpenedRepaired"),
|
|
System.IO.Path.GetFileName(path), _doc.PageCount));
|
|
HideBusyOverlay(busy);
|
|
FinalizeAsyncOpen();
|
|
KillerDialog.Show(this,
|
|
raster
|
|
? string.Format(Loc("Str_Dlg_RepairedRaster"), System.IO.Path.GetFileName(path))
|
|
: string.Format(Loc("Str_Dlg_Repaired"), System.IO.Path.GetFileName(path)),
|
|
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.None);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
HideBusyOverlay(busy);
|
|
_asyncOpenPending = false;
|
|
KillerDialog.Show(this, Loc("Str_Err_RepairFailed") + "\n" + ex.Message, "KillerPDF",
|
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
|
}
|
|
finally
|
|
{
|
|
EndCancellableOp();
|
|
}
|
|
}
|
|
|
|
// Strips a PDF's encryption on a background thread (so the window doesn't freeze), then opens the
|
|
// clean copy. Mirrors TryRepairAndOpen; finalizes the tab via FinalizeAsyncOpen.
|
|
private async void StripEncryptionAndOpen(string srcPath, string displayPath, bool markDirty = true, string? busyMessage = null)
|
|
{
|
|
_asyncOpenPending = true;
|
|
var ct = BeginCancellableOp(Loc("Str_Op_Operation"));
|
|
var busy = ShowBusyOverlay(busyMessage ?? Loc("Str_Busy_Opening"));
|
|
try
|
|
{
|
|
if (_doc is not null) { _doc.Close(); _doc = null; }
|
|
var repairedPath = App.MakeTempFile("repaired");
|
|
bool ok = await System.Threading.Tasks.Task.Run(() =>
|
|
PdfiumInterop.TryPdfiumStripEncryption(srcPath, repairedPath) || PdfImport.TryImportRepairToPath(srcPath, repairedPath));
|
|
if (ct.IsCancellationRequested) { HideBusyOverlay(busy); _asyncOpenPending = false; SetStatus(Loc("Str_St_Canceled")); EndCancellableOp(); return; }
|
|
if (!ok)
|
|
{
|
|
HideBusyOverlay(busy);
|
|
TryRepairAndOpen(srcPath); // re-registers the cancellable op; repair finalizes the tab
|
|
return;
|
|
}
|
|
_doc = PdfReader.Open(repairedPath, PdfDocumentOpenMode.Modify);
|
|
_currentFile = repairedPath;
|
|
FinishOpenFile(displayPath, repairedPath);
|
|
_openedFromProtected = true; // #149: source carried encryption, silently stripped above
|
|
if (markDirty) MarkDirty(true); // stripped copy lives in temp - user must Save As to keep it
|
|
HideBusyOverlay(busy);
|
|
FinalizeAsyncOpen();
|
|
EndCancellableOp();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
HideBusyOverlay(busy);
|
|
_asyncOpenPending = false;
|
|
KillerDialog.Show(this, Loc("Str_Err_OpenProtected") + "\n" + ex.Message,
|
|
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
|
|
EndCancellableOp();
|
|
}
|
|
}
|
|
|
|
// Finalizes a background open (encryption strip / repair) once the document is loaded on the UI
|
|
// thread: stores it into the active tab session and refreshes the tool + tab strip. Mirrors the
|
|
// tail of OpenInNewTab, which is skipped while _asyncOpenPending is set.
|
|
private void FinalizeAsyncOpen()
|
|
{
|
|
_asyncOpenPending = false;
|
|
if (_active != null) CaptureSessionState(_active);
|
|
SetTool(_currentTool);
|
|
RebuildTabStrip();
|
|
}
|
|
|
|
// The background-safe repair strategy workers (RepairViaImportToFile,
|
|
// RepairViaDocnetRasterizeToFile) live in Services/PdfImport.cs.
|
|
|
|
// ============================================================
|
|
// Close file (Ctrl+W) - returns to drop-zone state
|
|
// ============================================================
|
|
|
|
private void CloseFile()
|
|
{
|
|
if (_doc is null) return;
|
|
if (_isDirty)
|
|
{
|
|
var res = KillerDialog.Show(this,
|
|
Loc("Str_Dlg_UnsavedClose"),
|
|
"KillerPDF", MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
|
if (res != MessageBoxResult.Yes) return;
|
|
}
|
|
_doc.Close();
|
|
_doc = null;
|
|
_currentFile = null;
|
|
CloseLinkPdfiumDoc(); // release the cached PDFium link handle for the closed file
|
|
App.RemoveSetting("LastFile"); // don't reopen a manually-closed file on next launch (Issue #75)
|
|
_activeTextBox = null; // cancel any in-progress typewriter edit before canvas clear
|
|
RemoveTextEditHandles();
|
|
_annotations.Clear();
|
|
_undoStack.Clear();
|
|
_redoStack.Clear();
|
|
_navBack.Clear();
|
|
_navForward.Clear();
|
|
_renderDims.Clear();
|
|
_formTextValues.Clear();
|
|
_formCheckValues.Clear();
|
|
_formFontSizes.Clear();
|
|
_formRadioValues.Clear();
|
|
Search.ClearPageResults();
|
|
_thumbCts?.Cancel();
|
|
PageList.ItemsSource = null;
|
|
PageImage.Source = null;
|
|
_annotationCanvas.Children.Clear();
|
|
MarqueeLayer.Children.Clear(); // the layer is window-level; drop any orphaned marquee boxes (#121)
|
|
FileNameLabel.Text = "";
|
|
DropZone.Visibility = Visibility.Visible;
|
|
PopulateRecentFilesList(); // refresh the empty-state recent list
|
|
PagePreviewPanel.Visibility = Visibility.Collapsed;
|
|
CloseSearchBar();
|
|
HideDrawSettings();
|
|
HideTextSettings();
|
|
HideSignaturePopup();
|
|
SetTool(EditTool.Select);
|
|
if (_closeFileBtnRef != null) _closeFileBtnRef.IsEnabled = false;
|
|
_pageJumpBox.IsEnabled = false;
|
|
_continuousRenderCts?.Cancel();
|
|
_continuousPanel.Children.Clear();
|
|
_continuousTops.Clear();
|
|
_pageJumpBox.Text = "";
|
|
_pageTotalLabel.Text = "/ -";
|
|
OutlineTree.Items.Clear();
|
|
SidebarOutlinesTab.IsEnabled = false;
|
|
if (_sidebarShowingOutlines) SwitchSidebarToPagesTab();
|
|
MarkDirty(false);
|
|
SetStatus(Loc("Str_Ready"));
|
|
}
|
|
|
|
private void CloseFile_Click(object sender, RoutedEventArgs e) => CloseTab(_active);
|
|
|
|
// ============================================================
|
|
// File toolbar handlers
|
|
// ============================================================
|
|
|
|
private void New_Click(object sender, RoutedEventArgs e) => NewDocument();
|
|
|
|
private void NewDocument()
|
|
{
|
|
// A new blank document opens in its own tab; other open tabs keep their state, so
|
|
// there's no need to prompt about unsaved changes here.
|
|
var target = BeginTabLoad(out var prev, out bool createdNew);
|
|
try
|
|
{
|
|
var newDoc = new PdfDocument();
|
|
newDoc.AddPage(); // one blank A4 page
|
|
|
|
var tempPath = App.MakeTempFile("new");
|
|
newDoc.Save(tempPath);
|
|
newDoc.Close();
|
|
|
|
_doc = PdfReader.Open(tempPath, PdfDocumentOpenMode.Modify);
|
|
FinishOpenFile("Untitled.pdf", tempPath);
|
|
SetStatus(Loc("Str_KS_NewBlank"));
|
|
CaptureSessionState(_active!);
|
|
SetTool(_currentTool); // sync the tool UI to this (new) tab's tool
|
|
RebuildTabStrip();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AbortTabLoad(target, prev, createdNew);
|
|
KillerDialog.Show(this, Loc("Str_Err_NewDocFailed") + "\n" + ex.Message,
|
|
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
|
|
}
|
|
}
|
|
|
|
private void Open_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
var dlg = new Controls.FileDialog(Controls.FileDialogMode.Open)
|
|
{ Filter = Loc("Str_Filter_Pdf") + "|*.pdf", Title = Loc("Str_Dlg_OpenPdf") };
|
|
if (dlg.ShowDialog(this) == true) OpenInNewTab(dlg.FileName);
|
|
}
|
|
|
|
// Dropdown next to the Open button: the recent-files list.
|
|
private void OpenRecent_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
e.Handled = true; // also fired by right-click on the Open button; don't let it bubble
|
|
var menu = MakeThemedMenu();
|
|
|
|
menu.Items.Add(MakeMenuItem(Loc("Str_Menu_Import") + "...", (s2, e2) => ImportImages_Click(s2, e2), null, ""));
|
|
menu.Items.Add(new Separator());
|
|
|
|
var recents = App.GetRecentFiles();
|
|
if (recents.Count == 0)
|
|
{
|
|
menu.Items.Add(new MenuItem { Header = Loc("Str_Menu_RecentNone"), IsEnabled = false });
|
|
}
|
|
else
|
|
{
|
|
foreach (var p in recents)
|
|
{
|
|
string path = p; // capture
|
|
var item = MakeMenuItem(System.IO.Path.GetFileName(path), (_, _) =>
|
|
{
|
|
if (System.IO.File.Exists(path)) OpenInNewTab(path);
|
|
else KillerDialog.Show(this, Loc("Str_Err_FileNotFound") + "\n" + path, "KillerPDF",
|
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
|
});
|
|
item.ToolTip = path;
|
|
|
|
// Header = filename then a small X right after it (kept tight - no right whitespace).
|
|
var rmBtn = new Button
|
|
{
|
|
Content = "",
|
|
FontFamily = UiKit.IconFont,
|
|
FontSize = 11,
|
|
Width = 18, Height = 18,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
Margin = new Thickness(0),
|
|
// No local Foreground - it would override the DangerCloseButton hover trigger.
|
|
Background = Brushes.Transparent,
|
|
BorderThickness = new Thickness(0),
|
|
Cursor = Cursors.Hand,
|
|
Padding = new Thickness(0),
|
|
Style = (Style)FindResource("DangerCloseButton"),
|
|
ToolTip = Loc("Str_Menu_RemoveFromRecents")
|
|
};
|
|
rmBtn.Click += (_, ev) =>
|
|
{
|
|
ev.Handled = true;
|
|
App.RemoveRecentFile(path);
|
|
menu.Items.Remove(item); // drop just this row in place - no rebuild, no blink
|
|
if (!menu.Items.OfType<MenuItem>().Any(mi => mi.Header is Grid))
|
|
menu.IsOpen = false; // nothing left to show
|
|
};
|
|
// Filename (fills) + X right-aligned. Trim the MenuItem's default 40px right padding
|
|
// so the X sits near the edge instead of floating in whitespace.
|
|
// Negative right margin overlaps the template's empty InputGestureText column
|
|
// (it reserves ~24px), so the X lands near the real right edge instead of floating.
|
|
// Real file-type icon (left), filename (fills), X (right).
|
|
var fileIcon = new Image
|
|
{
|
|
Source = ShellIcons.GetShellIcon(path),
|
|
Width = 18,
|
|
Height = 18,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
Margin = new Thickness(0, 0, 8, 0),
|
|
Stretch = Stretch.Uniform,
|
|
SnapsToDevicePixels = true
|
|
};
|
|
RenderOptions.SetBitmapScalingMode(fileIcon, BitmapScalingMode.HighQuality);
|
|
fileIcon.Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = System.Windows.Media.Colors.Black, BlurRadius = 4, ShadowDepth = 2, Direction = 270, Opacity = TryFindResource("IconShadowOpacity") is double so2 ? so2 : 0.5 };
|
|
var hdr = new Grid { Width = 348, Margin = new Thickness(0, 0, 0, 0) }; // no negative right margin - it pushed the remove X past the menu's right edge, clipping it out of frame
|
|
hdr.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // icon
|
|
hdr.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // name
|
|
hdr.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // remove X
|
|
var nameText = new TextBlock { Text = System.IO.Path.GetFileName(path), TextTrimming = TextTrimming.CharacterEllipsis, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 12, 0) };
|
|
Grid.SetColumn(fileIcon, 0);
|
|
Grid.SetColumn(nameText, 1);
|
|
Grid.SetColumn(rmBtn, 2);
|
|
hdr.Children.Add(fileIcon);
|
|
hdr.Children.Add(nameText);
|
|
hdr.Children.Add(rmBtn);
|
|
item.Header = hdr;
|
|
item.Padding = new Thickness(20, 6, 8, 6);
|
|
|
|
menu.Items.Add(item);
|
|
}
|
|
menu.Items.Add(new Separator());
|
|
menu.Items.Add(MakeMenuItem(Loc("Str_Menu_ClearList"),
|
|
(_, _) => { App.ClearRecentFiles(); PopulateRecentFilesList(); }, glyph: "")); // keep the start screen in sync (#146)
|
|
}
|
|
|
|
menu.PlacementTarget = (UIElement)sender;
|
|
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
|
menu.IsOpen = true;
|
|
}
|
|
|
|
// Start-screen "Clear list" link (#146): one click, then the box hides itself (empty list).
|
|
// Handled = true, or the click bubbles into the surrounding DropZone and opens the file dialog.
|
|
// internal: PdfViewer's XAML binds this and forwards to it.
|
|
internal void RecentClearAll_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
|
{
|
|
e.Handled = true;
|
|
App.ClearRecentFiles();
|
|
PopulateRecentFilesList();
|
|
}
|
|
|
|
// The file-type icon lookup (per-extension cache + SHGetFileInfo) lives in
|
|
// Services/ShellIcons.cs - the same shape the KillerUI file picker uses.
|
|
|
|
// Fills the empty-state "Recent" list with clickable filenames (hidden when there are none).
|
|
// The start screen belongs to a PANE, so a pane filling its own list has to name itself: the
|
|
// bare RecentFilesBox / RecentFilesList members resolve through ActiveViewer, which meant an
|
|
// unfocused pane's empty state stayed blank while the focused pane's list was rebuilt.
|
|
private void PopulateRecentFilesList(Controls.PdfViewer? pane = null)
|
|
{
|
|
if (pane == null)
|
|
{
|
|
// No pane named means a window-level change to the recents themselves, which both
|
|
// start screens show - refresh both, or the unfocused pane keeps a stale list.
|
|
PopulateRecentFilesList(Viewer);
|
|
PopulateRecentFilesList(ViewerB);
|
|
return;
|
|
}
|
|
var box = pane.RecentBox;
|
|
var list = pane.RecentList;
|
|
if (list is null || box is null) return;
|
|
list.Items.Clear();
|
|
var recents = App.GetRecentFiles();
|
|
if (recents.Count == 0) { box.Visibility = Visibility.Collapsed; return; }
|
|
// Visibility and width belong to SyncRecentBoxWidth, called once the rows are in - it
|
|
// also has to drop the panel on a narrow pane, which this has no way of knowing.
|
|
var fam = UiKit.UiFont;
|
|
foreach (var p in recents)
|
|
{
|
|
string path = p; // capture
|
|
bool exists = System.IO.File.Exists(path);
|
|
string dir = System.IO.Path.GetDirectoryName(path) ?? "";
|
|
string dateStr = exists
|
|
? $"{System.IO.File.GetLastWriteTime(path):MMM d, yyyy}"
|
|
: Loc("Str_RecentMissing");
|
|
|
|
var name = new TextBlock
|
|
{
|
|
Text = System.IO.Path.GetFileName(path),
|
|
FontFamily = fam,
|
|
FontSize = 13,
|
|
TextTrimming = TextTrimming.CharacterEllipsis
|
|
};
|
|
// DynamicResource so the color tracks theme switches (FindResource would freeze
|
|
// whatever theme was active when the list was built).
|
|
name.SetResourceReference(TextBlock.ForegroundProperty, exists ? "TextBrush" : "DimTextBrush");
|
|
|
|
// File path line (slightly brighter) sits above the date line (slightly dimmer).
|
|
var pathTb = new TextBlock
|
|
{
|
|
Text = dir,
|
|
FontFamily = fam,
|
|
FontSize = 11,
|
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
|
Margin = new Thickness(0, 2, 0, 0)
|
|
};
|
|
pathTb.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
|
|
|
|
var dateTb = new TextBlock
|
|
{
|
|
Text = dateStr,
|
|
FontFamily = fam,
|
|
FontSize = 11,
|
|
Opacity = 0.6,
|
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
|
Margin = new Thickness(0, 1, 0, 0)
|
|
};
|
|
dateTb.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
|
|
|
|
var stack = new StackPanel();
|
|
stack.Children.Add(name);
|
|
stack.Children.Add(pathTb);
|
|
stack.Children.Add(dateTb);
|
|
|
|
// Per-row remove button: a small X that fades in on hover and drops just this
|
|
// entry from the recents list (it does not touch the file on disk).
|
|
var delIcon = new TextBlock
|
|
{
|
|
Text = "", // close (X) glyph below set via code
|
|
FontFamily = UiKit.IconFont,
|
|
FontSize = 11,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
HorizontalAlignment = HorizontalAlignment.Center
|
|
};
|
|
delIcon.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
|
|
delIcon.Text = ""; // Segoe MDL2 ChromeClose (X)
|
|
var del = new Border
|
|
{
|
|
Width = 22,
|
|
Height = 22,
|
|
Background = System.Windows.Media.Brushes.Transparent,
|
|
CornerRadius = new CornerRadius(4),
|
|
Cursor = Cursors.Hand,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
Opacity = 0, // hidden until the row is hovered
|
|
Child = delIcon,
|
|
ToolTip = Loc("Str_Menu_RemoveFromRecents")
|
|
};
|
|
del.MouseEnter += (_, _) => { delIcon.SetResourceReference(TextBlock.ForegroundProperty, "DangerRed"); delIcon.Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = System.Windows.Media.Colors.Black, BlurRadius = 4, ShadowDepth = 1, Direction = 270, Opacity = 0.5 }; };
|
|
del.MouseLeave += (_, _) => { delIcon.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush"); delIcon.Effect = null; };
|
|
del.MouseLeftButtonDown += (_, ev) =>
|
|
{
|
|
ev.Handled = true; // don't open the file
|
|
App.RemoveRecentFile(path);
|
|
PopulateRecentFilesList();
|
|
};
|
|
|
|
// Real Windows file-type icon for this extension (left of the text).
|
|
var icon = new Image
|
|
{
|
|
Source = ShellIcons.GetShellIcon(path),
|
|
Width = 32,
|
|
Height = 32,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
Margin = new Thickness(0, 0, 10, 0),
|
|
Stretch = Stretch.Uniform,
|
|
Opacity = exists ? 1.0 : 0.45, // dim missing files' icons, matching the text
|
|
SnapsToDevicePixels = true
|
|
};
|
|
RenderOptions.SetBitmapScalingMode(icon, BitmapScalingMode.HighQuality);
|
|
icon.Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = System.Windows.Media.Colors.Black, BlurRadius = 4, ShadowDepth = 2, Direction = 270, Opacity = TryFindResource("IconShadowOpacity") is double so ? so : 0.5 };
|
|
stack.VerticalAlignment = VerticalAlignment.Center;
|
|
|
|
var rowGrid = new Grid();
|
|
rowGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // icon
|
|
rowGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // text
|
|
rowGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // remove X
|
|
Grid.SetColumn(icon, 0);
|
|
Grid.SetColumn(stack, 1);
|
|
Grid.SetColumn(del, 2);
|
|
rowGrid.Children.Add(icon);
|
|
rowGrid.Children.Add(stack);
|
|
rowGrid.Children.Add(del);
|
|
|
|
var row = new Border
|
|
{
|
|
Background = System.Windows.Media.Brushes.Transparent,
|
|
CornerRadius = new CornerRadius(4),
|
|
Padding = new Thickness(8, 6, 8, 6),
|
|
Margin = new Thickness(0, 1, 0, 1),
|
|
Cursor = Cursors.Hand,
|
|
Child = rowGrid,
|
|
ToolTip = path
|
|
};
|
|
row.MouseEnter += (_, _) => { row.Background = (SolidColorBrush)FindResource("RowHoverBrush"); del.Opacity = 1; };
|
|
row.MouseLeave += (_, _) => { row.Background = System.Windows.Media.Brushes.Transparent; del.Opacity = 0; };
|
|
row.MouseLeftButtonDown += (_, ev) =>
|
|
{
|
|
ev.Handled = true; // don't bubble to the DropZone "click to browse" handler
|
|
// Name the pane rather than trusting focus to have followed the click: these
|
|
// rows belong to a specific pane's start screen, and OpenInNewTab routes
|
|
// through ActiveViewer. Clicking pane B's recents opened the file in pane A.
|
|
FocusPane(pane);
|
|
if (System.IO.File.Exists(path)) OpenInNewTab(path);
|
|
else KillerDialog.Show(this, Loc("Str_Err_FileNotFound") + "\n" + path, "KillerPDF",
|
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
|
};
|
|
list.Items.Add(row);
|
|
}
|
|
pane.SyncRecentBoxWidth();
|
|
}
|
|
|
|
// Dropdown next to the Save button: explicit Save / Save As.
|
|
private void SaveMenu_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
e.Handled = true; // also fired by right-click on the Save button; don't let it bubble
|
|
var menu = MakeThemedMenu();
|
|
|
|
if (_doc is null)
|
|
{
|
|
menu.Items.Add(new MenuItem { Header = Loc("Str_Menu_SaveNothing"), IsEnabled = false });
|
|
}
|
|
else
|
|
{
|
|
menu.Items.Add(MakeMenuItem(Loc("Str_Menu_Save"), (_, _) => SaveInPlace(), "Ctrl+S", ""));
|
|
menu.Items.Add(MakeMenuItem(Loc("Str_Menu_SaveAs"), (s2, e2) => SaveAs_Click(s2, e2), "Ctrl+Shift+S", ""));
|
|
menu.Items.Add(MakeMenuItem(Loc("Str_Menu_CompressZip"), (s2, e2) => CompressToZip_Click(s2, e2), null, ""));
|
|
menu.Items.Add(MakeMenuItem(Loc("Str_Menu_ExportImages"), (s2, e2) => ExportImages_Click(s2, e2), null, "")); // #132
|
|
menu.Items.Add(new Separator());
|
|
menu.Items.Add(MakeMenuItem(Loc("Str_Lbl_DigitalSig"), (_, _) => OpenSignDialog(), null, ""));
|
|
// #149: visible always (discoverability, the PDF Viewer Plus way), enabled only when the
|
|
// open file actually had a password/encryption. Saving in place IS the removal - the
|
|
// working doc is already decrypted - and the SaveInPlace tail reports it and drops the flag.
|
|
var removePw = MakeMenuItem(Loc("Str_Menu_RemovePassword"), (_, _) => SaveInPlace(), null, "");
|
|
removePw.IsEnabled = _openedFromProtected;
|
|
menu.Items.Add(removePw);
|
|
}
|
|
|
|
menu.PlacementTarget = (UIElement)sender;
|
|
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
|
menu.IsOpen = true;
|
|
}
|
|
|
|
private void DocInfo_Click(object sender, RoutedEventArgs e) => OpenDocumentInfo();
|
|
|
|
// Opens the Document Info dialog; edits are applied to the live doc and persist on the next save.
|
|
private void OpenDocumentInfo()
|
|
{
|
|
if (_doc is null) { KillerDialog.Show(this, Loc("Str_Msg_OpenFirst")); return; }
|
|
CommitActiveTextBox();
|
|
var dlg = new DocumentInfoDialog(this, _doc, _originalFile ?? _currentFile);
|
|
dlg.ShowDialog(); // fade-close dialogs don't reliably return true; rely on the Saved flag
|
|
if (dlg.Saved)
|
|
{
|
|
MarkDirty();
|
|
SetStatus(Loc("Str_St_DocInfoUpdated"));
|
|
}
|
|
}
|
|
|
|
private void Merge_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_doc is null) { KillerDialog.Show(this, Loc("Str_Msg_OpenFirst")); return; }
|
|
var doc = _doc;
|
|
var dlg = new Controls.FileDialog(Controls.FileDialogMode.Open)
|
|
{ Filter = Loc("Str_Filter_Pdf") + "|*.pdf", Title = Loc("Str_Dlg_SelectMerge"), Multiselect = true };
|
|
if (dlg.ShowDialog(this) != true) return;
|
|
try
|
|
{
|
|
foreach (var file in dlg.FileNames)
|
|
{
|
|
int pageOffset = doc.PageCount;
|
|
|
|
// Open twice: Import mode for AddPage, ReadOnly for catalog access.
|
|
using var srcRead = PdfReader.Open(file, PdfDocumentOpenMode.ReadOnly);
|
|
var namedDestMap = PdfImport.BuildNamedDestMap(srcRead);
|
|
|
|
using var src = PdfReader.Open(file, PdfDocumentOpenMode.Import);
|
|
for (int i = 0; i < src.PageCount; i++)
|
|
doc.AddPage(src.Pages[i]);
|
|
|
|
// Rewrite named-destination links in the newly added pages so they
|
|
// resolve correctly after the catalog is not imported.
|
|
if (namedDestMap.Count > 0)
|
|
PdfImport.RewriteNamedDestLinks(doc, pageOffset, namedDestMap);
|
|
}
|
|
SaveTempAndReload();
|
|
SetStatus(string.Format(Loc("Str_St_Merged"), dlg.FileNames.Length, _doc?.PageCount));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
KillerDialog.Show(this, Loc("Str_Err_MergeFailed") + "\n" + ex.Message, "KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
|
|
}
|
|
}
|
|
|
|
// The named-destination helpers (BuildNamedDestMap, RewriteNamedDestLinks and their
|
|
// private walkers) live in Services/PdfImport.cs (KillerUI refactor).
|
|
|
|
// DerefItemStatic, RectNum and the pre-save scrubs live in Services/PdfScrub.cs
|
|
// (KillerUI refactor) - pure functions shared by the GUI saves, TempReload and the CLI.
|
|
|
|
// ============================================================
|
|
// Adobe page-size guard
|
|
// ============================================================
|
|
|
|
// Adobe Reader only displays pages whose sides are 3-14400 points; anything outside
|
|
// that range shows "The dimensions of this page are out-of-range" and renders blank.
|
|
// Such pages usually come from images with broken DPI metadata turned into a PDF (by
|
|
// KillerPDF 1.6.1 and earlier, or by other tools). PDFium renders any size, so the
|
|
// file looks normal in KillerPDF and only fails in Adobe.
|
|
// Min/MaxAdobePageDim live in Services/PdfImport.cs (shared with the image importer).
|
|
|
|
private static bool PageOutOfAdobeRange(PdfPage p)
|
|
{
|
|
double w = p.Width.Point, h = p.Height.Point;
|
|
return w < PdfImport.MinAdobePageDim || w > PdfImport.MaxAdobePageDim || h < PdfImport.MinAdobePageDim || h > PdfImport.MaxAdobePageDim;
|
|
}
|
|
|
|
// Called at the top of every user-facing save. If any page is outside Adobe's supported
|
|
// range, offers a proportional rescale (content, page boxes, and annotations all scale
|
|
// by the same factor, so pages look identical at their new size). Declining saves as-is.
|
|
private void OfferRescaleOutOfRangePages()
|
|
{
|
|
if (_doc is null) return;
|
|
int bad = 0;
|
|
for (int i = 0; i < _doc.PageCount; i++)
|
|
if (PageOutOfAdobeRange(_doc.Pages[i])) bad++;
|
|
if (bad == 0) return;
|
|
var res = KillerDialog.Show(this, string.Format(Loc("Str_Dlg_PageOutOfRange"), bad),
|
|
"KillerPDF", MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
|
if (res != MessageBoxResult.Yes) return;
|
|
for (int i = 0; i < _doc.PageCount; i++)
|
|
if (PageOutOfAdobeRange(_doc.Pages[i]))
|
|
RescalePageToAdobeRange(_doc.Pages[i]);
|
|
}
|
|
|
|
// Rescales one page into Adobe's supported range: wraps the existing content in a
|
|
// "q <s> 0 0 <s> 0 0 cm ... Q" transform and scales the page boxes and annotation
|
|
// rectangles by the same factor.
|
|
private static void RescalePageToAdobeRange(PdfPage page)
|
|
{
|
|
double w = page.Width.Point, h = page.Height.Point;
|
|
double s = 1.0;
|
|
if (w > PdfImport.MaxAdobePageDim || h > PdfImport.MaxAdobePageDim)
|
|
s = Math.Min(PdfImport.MaxAdobePageDim / w, PdfImport.MaxAdobePageDim / h);
|
|
else if (w < PdfImport.MinAdobePageDim || h < PdfImport.MinAdobePageDim)
|
|
s = Math.Max(PdfImport.MinAdobePageDim / w, PdfImport.MinAdobePageDim / h);
|
|
if (s == 1.0) return;
|
|
|
|
string inv = s.ToString("0.########", System.Globalization.CultureInfo.InvariantCulture);
|
|
page.Contents.PrependContent().CreateStream(
|
|
System.Text.Encoding.ASCII.GetBytes($"q {inv} 0 0 {inv} 0 0 cm\n"));
|
|
page.Contents.AppendContent().CreateStream(
|
|
System.Text.Encoding.ASCII.GetBytes("\nQ\n"));
|
|
|
|
ScaleRectValue(page.Elements, "/MediaBox", s);
|
|
ScaleRectValue(page.Elements, "/CropBox", s);
|
|
ScaleRectValue(page.Elements, "/BleedBox", s);
|
|
ScaleRectValue(page.Elements, "/TrimBox", s);
|
|
ScaleRectValue(page.Elements, "/ArtBox", s);
|
|
|
|
// Annotation rectangles (and link quad points) must follow so they stay on target.
|
|
var annotsItem = page.Elements["/Annots"];
|
|
if (annotsItem != null && PdfScrub.DerefItemStatic(annotsItem) is PdfArray annots)
|
|
{
|
|
foreach (var item in annots.Elements)
|
|
{
|
|
if (PdfScrub.DerefItemStatic(item) is not PdfDictionary annot) continue;
|
|
ScaleRectValue(annot.Elements, "/Rect", s);
|
|
if (annot.Elements["/QuadPoints"] is PdfArray quads)
|
|
for (int i = 0; i < quads.Elements.Count; i++)
|
|
quads.Elements[i] = new PdfReal(PdfScrub.RectNum(quads.Elements[i]) * s);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Multiplies a rectangle-valued dictionary entry by s in place; no-op when absent.
|
|
// PdfSharpCore holds these as PdfRectangle (parsed) or PdfArray, so handle both.
|
|
private static void ScaleRectValue(PdfDictionary.DictionaryElements elements, string key, double s)
|
|
{
|
|
var item = elements[key];
|
|
if (item == null) return;
|
|
item = PdfScrub.DerefItemStatic(item);
|
|
if (item is PdfRectangle rect)
|
|
elements.SetRectangle(key, new PdfRectangle(
|
|
new XPoint(rect.X1 * s, rect.Y1 * s),
|
|
new XPoint(rect.X2 * s, rect.Y2 * s)));
|
|
else if (item is PdfArray arr && arr.Elements.Count == 4)
|
|
for (int i = 0; i < 4; i++)
|
|
arr.Elements[i] = new PdfReal(PdfScrub.RectNum(arr.Elements[i]) * s);
|
|
}
|
|
|
|
private void SaveInPlace()
|
|
{
|
|
if (_doc is null) { KillerDialog.Show(this, Loc("Str_Msg_OpenFirst")); return; }
|
|
// Save back to the user's real file. After a page edit (crop/rotate) _currentFile is a
|
|
// temp working copy, so the real path is kept in _originalFile. If there is no real path
|
|
// (e.g. a repaired temp-backed open), fall back to Save As.
|
|
if (string.IsNullOrEmpty(_originalFile)) { SaveAs_Click(this, new RoutedEventArgs()); return; }
|
|
CommitActiveTextBox();
|
|
OfferRescaleOutOfRangePages(); // Adobe page-size guard
|
|
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)
|
|
string saveTarget = _originalFile!;
|
|
// #129: the cached PDFium link handle (EnsureLinkPdfiumDoc) can hold _currentFile open,
|
|
// and on a plain open _currentFile IS the user's real file - PdfSharp then can't overwrite
|
|
// it (sharing violation, "being used by another process"). Release it before saving; it
|
|
// reopens lazily on the next render sweep, re-parsing the freshly saved file.
|
|
CloseLinkPdfiumDoc();
|
|
try
|
|
{
|
|
bool hasAnnotations = _annotations.Values.Any(list => list.Count > 0);
|
|
WriteFormValuesToDocument();
|
|
// Always strip link annotation borders regardless of user annotation count
|
|
// so mailto/URI links don't appear as strikethrough lines in other viewers.
|
|
PdfScrub.StripLinkAnnotationBorders(_doc);
|
|
|
|
if (hasAnnotations || HasActiveStamps) // #147: stamps alone must still burn
|
|
{
|
|
// Save a clean copy of the doc (without burned annotations), burn
|
|
// annotations into the real file, then restore the in-memory doc
|
|
// from the clean copy so future saves don't double-burn.
|
|
var tempClean = App.MakeTempFile("clean");
|
|
_doc.Save(tempClean);
|
|
DrawStampsOnDocument();
|
|
DrawAnnotationsOnDocument();
|
|
_doc.Save(saveTarget);
|
|
_doc.Close();
|
|
try
|
|
{
|
|
_doc = PdfReader.Open(tempClean, PdfDocumentOpenMode.Modify);
|
|
}
|
|
catch (Exception saveOpenEx) when (PdfImport.IsXRefException(saveOpenEx))
|
|
{
|
|
var fixedPath = App.MakeTempFile("savefixed");
|
|
if (!PdfImport.TryImportRepairToPath(tempClean, fixedPath)
|
|
&& !PdfiumInterop.TryPdfiumSaveWithZeroRotations(tempClean, fixedPath))
|
|
throw;
|
|
tempClean = fixedPath;
|
|
_doc = PdfReader.Open(tempClean, PdfDocumentOpenMode.Modify);
|
|
}
|
|
_currentFile = tempClean;
|
|
}
|
|
else
|
|
{
|
|
_doc.Save(saveTarget);
|
|
}
|
|
|
|
MarkDirty(false);
|
|
if (_openedFromProtected)
|
|
{
|
|
// #149: the file on disk no longer carries its password - say so instead of the
|
|
// plain saved message, and drop the flag (the source is unprotected from here on).
|
|
_openedFromProtected = false;
|
|
SetStatus(string.Format(Loc("Str_St_SavedNoPassword"), System.IO.Path.GetFileName(saveTarget)));
|
|
}
|
|
else
|
|
SetStatus(string.Format(Loc("Str_St_Saved"), System.IO.Path.GetFileName(saveTarget)));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
KillerDialog.Show(this, Loc("Str_Err_SaveFailed") + "\n" + ex.Message, "KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
|
|
}
|
|
}
|
|
|
|
private void Save_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_doc is null) { KillerDialog.Show(this, Loc("Str_Msg_OpenFirst")); return; }
|
|
// No real path yet (repaired temp-backed open) -> go straight to Save As.
|
|
if (string.IsNullOrEmpty(_originalFile)) { SaveAs_Click(sender, e); return; }
|
|
var name = System.IO.Path.GetFileName(_originalFile);
|
|
// The same file can be open in BOTH panes as two independent copies (2026-08-01),
|
|
// each with its own annotations and undo stack. Overwriting from one pane
|
|
// therefore discards whatever the other pane has done to it, with nothing on screen to
|
|
// suggest that - so say so before it happens rather than after.
|
|
if (OtherPaneHasDirtyCopyOf(_originalFile))
|
|
{
|
|
var warn = KillerDialog.Show(this,
|
|
string.Format(Loc("Str_Dlg_SaveOtherPaneDirty"), name),
|
|
Loc("Str_Dlg_AppTitle"), MessageBoxButton.OKCancel, MessageBoxImage.Warning,
|
|
defaultResult: MessageBoxResult.Cancel);
|
|
if (warn != MessageBoxResult.OK) return;
|
|
}
|
|
var choice = KillerDialog.Show(this, string.Format(Loc("Str_Dlg_OverwriteMsg"), name), Loc("Str_Lbl_Save"),
|
|
MessageBoxButton.YesNoCancel, MessageBoxImage.Question);
|
|
if (choice == MessageBoxResult.Yes) SaveInPlace();
|
|
else if (choice == MessageBoxResult.No) SaveAs_Click(sender, e);
|
|
// Cancel or closed: do nothing.
|
|
}
|
|
|
|
private void SaveAs_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_doc is null || _currentFile is null) { KillerDialog.Show(this, Loc("Str_Msg_OpenFirst")); return; }
|
|
CommitActiveTextBox();
|
|
var dlg = new Controls.FileDialog(Controls.FileDialogMode.Save)
|
|
{ Filter = Loc("Str_Filter_Pdf") + "|*.pdf", Title = Loc("Str_Dlg_SavePdfAs"),
|
|
CheckFileExists = false, CheckPathExists = true };
|
|
// Seed the dialog from the last real save location. Guard every path call: on .NET Framework
|
|
// Path.GetDirectoryName("") throws ArgumentException ("path is not of a legal form"), so a merged
|
|
// or imported doc (where _originalFile is null) would crash Save before the dialog opened (#112).
|
|
string? seed = _originalFile ?? _currentFile;
|
|
try
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(seed))
|
|
dlg.FileName = System.IO.Path.GetFileName(seed);
|
|
if (!string.IsNullOrWhiteSpace(_originalFile))
|
|
{
|
|
var seedDir = System.IO.Path.GetDirectoryName(_originalFile);
|
|
if (!string.IsNullOrEmpty(seedDir) && System.IO.Directory.Exists(seedDir))
|
|
dlg.InitialDirectory = seedDir;
|
|
}
|
|
}
|
|
catch { /* malformed seed path - just open the dialog with its defaults */ }
|
|
if (dlg.ShowDialog(this) != true) return;
|
|
CloseLinkPdfiumDoc(); // #129: the target may be the open file itself - release the cached PDFium handle
|
|
OfferRescaleOutOfRangePages(); // Adobe page-size guard
|
|
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)
|
|
try
|
|
{
|
|
bool hasAnnotations = _annotations.Values.Any(list => list.Count > 0);
|
|
WriteFormValuesToDocument();
|
|
// Always strip link annotation borders regardless of user annotation count.
|
|
PdfScrub.StripLinkAnnotationBorders(_doc);
|
|
|
|
if (hasAnnotations || HasActiveStamps) // #147: stamps alone must still burn
|
|
{
|
|
var tempClean = App.MakeTempFile("clean");
|
|
_doc.Save(tempClean);
|
|
DrawStampsOnDocument();
|
|
DrawAnnotationsOnDocument();
|
|
_doc.Save(dlg.FileName);
|
|
_doc.Close();
|
|
try
|
|
{
|
|
_doc = PdfReader.Open(tempClean, PdfDocumentOpenMode.Modify);
|
|
}
|
|
catch (Exception saveOpenEx) when (PdfImport.IsXRefException(saveOpenEx))
|
|
{
|
|
var fixedPath = App.MakeTempFile("savefixed");
|
|
if (!PdfImport.TryImportRepairToPath(tempClean, fixedPath)
|
|
&& !PdfiumInterop.TryPdfiumSaveWithZeroRotations(tempClean, fixedPath))
|
|
throw;
|
|
tempClean = fixedPath;
|
|
_doc = PdfReader.Open(tempClean, PdfDocumentOpenMode.Modify);
|
|
}
|
|
_currentFile = tempClean;
|
|
_originalFile = dlg.FileName;
|
|
FileNameLabel.Text = System.IO.Path.GetFileName(dlg.FileName);
|
|
MarkDirty(false);
|
|
if (_openedFromProtected)
|
|
{
|
|
// #149: the saved copy is now this tab's file, and it has no password.
|
|
_openedFromProtected = false;
|
|
SetStatus(string.Format(Loc("Str_St_SavedNoPassword"), System.IO.Path.GetFileName(dlg.FileName)));
|
|
}
|
|
else
|
|
SetStatus(string.Format(Loc("Str_St_SavedWithAnnots"), System.IO.Path.GetFileName(dlg.FileName)));
|
|
}
|
|
else
|
|
{
|
|
_doc.Save(dlg.FileName);
|
|
_originalFile = dlg.FileName;
|
|
FileNameLabel.Text = System.IO.Path.GetFileName(dlg.FileName);
|
|
MarkDirty(false);
|
|
if (_openedFromProtected)
|
|
{
|
|
// #149: the saved copy is now this tab's file, and it has no password.
|
|
_openedFromProtected = false;
|
|
SetStatus(string.Format(Loc("Str_St_SavedNoPassword"), System.IO.Path.GetFileName(dlg.FileName)));
|
|
}
|
|
else
|
|
SetStatus(string.Format(Loc("Str_St_SavedTo"), System.IO.Path.GetFileName(dlg.FileName)));
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
KillerDialog.Show(this, Loc("Str_Err_SaveFailed") + "\n" + ex.Message, "KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
|
|
}
|
|
}
|
|
|
|
private async void SaveFlattened_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_doc is null || _currentFile is null) { KillerDialog.Show(this, Loc("Str_Msg_OpenFirst")); return; }
|
|
CommitActiveTextBox();
|
|
var dlg = new Controls.FileDialog(Controls.FileDialogMode.Save)
|
|
{ Filter = Loc("Str_Filter_Pdf") + "|*.pdf", Title = Loc("Str_Dlg_SaveFlattened"),
|
|
CheckFileExists = false, CheckPathExists = true };
|
|
if (dlg.ShowDialog(this) != true) return;
|
|
CloseLinkPdfiumDoc(); // #129: the target may be the open file itself - release the cached PDFium handle
|
|
OfferRescaleOutOfRangePages(); // Adobe page-size guard (pageDims below must be in range)
|
|
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)
|
|
|
|
// Burn any pending annotations into a temp source for rasterization
|
|
// (must happen on UI thread before we go async)
|
|
string sourcePath;
|
|
bool hasAnnotations = _annotations.Values.Any(list => list.Count > 0);
|
|
if (hasAnnotations || HasActiveStamps) // #147: stamps alone must still burn
|
|
{
|
|
var tempClean = App.MakeTempFile("clean");
|
|
var tempBurned = App.MakeTempFile("burned");
|
|
_doc.Save(tempClean);
|
|
DrawStampsOnDocument();
|
|
DrawAnnotationsOnDocument();
|
|
_doc.Save(tempBurned);
|
|
_doc.Close();
|
|
try
|
|
{
|
|
_doc = PdfReader.Open(tempClean, PdfDocumentOpenMode.Modify);
|
|
}
|
|
catch (Exception saveOpenEx) when (PdfImport.IsXRefException(saveOpenEx))
|
|
{
|
|
var fixedPath = App.MakeTempFile("savefixed");
|
|
if (!PdfImport.TryImportRepairToPath(tempClean, fixedPath)
|
|
&& !PdfiumInterop.TryPdfiumSaveWithZeroRotations(tempClean, fixedPath))
|
|
throw;
|
|
tempClean = fixedPath;
|
|
_doc = PdfReader.Open(tempClean, PdfDocumentOpenMode.Modify);
|
|
}
|
|
_currentFile = tempClean;
|
|
sourcePath = tempBurned;
|
|
}
|
|
else
|
|
{
|
|
var temp = App.MakeTempFile("src");
|
|
_doc.Save(temp);
|
|
sourcePath = temp;
|
|
}
|
|
|
|
int pageCount = _doc.PageCount;
|
|
|
|
// Snapshot per-page dimensions (CropBox-aware) before going off-thread
|
|
var pageDims = new (double widthPt, double heightPt)[pageCount];
|
|
for (int i = 0; i < pageCount; i++)
|
|
{
|
|
var p = _doc.Pages[i];
|
|
pageDims[i] = (p.Width.Point, p.Height.Point);
|
|
}
|
|
|
|
// Show a progress overlay so the user knows we're working
|
|
var overlay = ShowFlattenProgress(pageCount, Loc("Str_Busy_FlattenPage"));
|
|
string outputPath = dlg.FileName;
|
|
|
|
try
|
|
{
|
|
var ct = BeginCancellableOp(Loc("Str_Op_Flatten"));
|
|
// Rasterize on a background thread - keeps the UI responsive. The core lives in
|
|
// Services/PdfRasterize.cs; progress marshals back to the overlay here.
|
|
await Task.Run(() => PdfRasterize.FlattenToPdf(sourcePath, pageCount, pageDims, outputPath,
|
|
(n, total) => Dispatcher.BeginInvoke(new Action(() => UpdateFlattenProgress(overlay, n, total))),
|
|
ct));
|
|
|
|
if (ct.IsCancellationRequested) { SetStatus(Loc("Str_St_FlattenCanceled")); return; }
|
|
MarkDirty(false);
|
|
SetStatus(string.Format(Loc("Str_St_FlattenedSaved"), System.IO.Path.GetFileName(outputPath)));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
try { KillerDialog.Show(this, Loc("Str_Err_FlattenFailed") + "\n" + ex.GetType().Name + ": " + ex.Message, "KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error); }
|
|
catch { /* dialog failed; overlay still removed in finally */ }
|
|
}
|
|
finally
|
|
{
|
|
try { HideFlattenProgress(overlay); } catch { /* ensure overlay never leaks */ }
|
|
EndCancellableOp();
|
|
}
|
|
}
|
|
|
|
// ---- Export pages as images (#132) ----
|
|
// The CLI --to-image pipeline behind a GUI entry (Save dropdown). Burns pending
|
|
// annotations + stamps into a temp render source first (same clean-copy dance as Save
|
|
// Flattened, so future saves don't double-burn), then renders each selected page at the
|
|
// chosen DPI and writes <base>-page-NNN.<ext> beside the base name the user picked.
|
|
private async void ExportImages_Click(object sender, RoutedEventArgs e)
|
|
=> await ExportImagesFlow("");
|
|
|
|
/// <summary>The image-export flow; presetRange scopes the dialog to specific pages
|
|
/// (the Pages panel's "Export page as image", #207) while staying fully editable.</summary>
|
|
private async Task ExportImagesFlow(string presetRange)
|
|
{
|
|
if (_doc is null || _currentFile is null) { KillerDialog.Show(this, Loc("Str_Msg_OpenFirst")); return; }
|
|
CommitActiveTextBox();
|
|
|
|
var opts = new ExportImagesDialog(this, presetRange);
|
|
opts.ShowDialog(); // fade-close dialogs don't reliably return true; rely on Confirmed
|
|
if (!opts.Confirmed) return;
|
|
|
|
List<int>? selected = null;
|
|
if (opts.Range.Length > 0)
|
|
{
|
|
selected = CliParsePageRange(opts.Range, _doc.PageCount, out string rangeErr);
|
|
if (selected is null)
|
|
{
|
|
KillerDialog.Show(this, rangeErr.Length > 0 ? rangeErr : Loc("Str_InvalidRange"),
|
|
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.Warning);
|
|
return;
|
|
}
|
|
}
|
|
selected ??= [.. Enumerable.Range(0, _doc.PageCount)];
|
|
|
|
string ext = opts.Jpeg ? "jpg" : "png";
|
|
var dlg = new Controls.FileDialog(Controls.FileDialogMode.Save)
|
|
{
|
|
Filter = opts.Jpeg ? "JPEG image|*.jpg" : "PNG image|*.png",
|
|
Title = Loc("Str_ExportImg_Suffix"),
|
|
CheckFileExists = false, CheckPathExists = true,
|
|
FileName = System.IO.Path.GetFileNameWithoutExtension(_originalFile ?? _currentFile) + "." + ext,
|
|
};
|
|
if (dlg.ShowDialog(this) != true) return;
|
|
string outDir = System.IO.Path.GetDirectoryName(dlg.FileName) is { Length: > 0 } d2 ? d2 : ".";
|
|
string baseName = System.IO.Path.GetFileNameWithoutExtension(dlg.FileName);
|
|
|
|
// Burn pending annotations + stamps into a temp render source (UI thread).
|
|
string sourcePath;
|
|
bool hasAnnotations = _annotations.Values.Any(list => list.Count > 0);
|
|
if (hasAnnotations || HasActiveStamps)
|
|
{
|
|
var tempClean = App.MakeTempFile("clean");
|
|
var tempBurned = App.MakeTempFile("burned");
|
|
_doc.Save(tempClean);
|
|
DrawStampsOnDocument();
|
|
DrawAnnotationsOnDocument();
|
|
_doc.Save(tempBurned);
|
|
_doc.Close();
|
|
try
|
|
{
|
|
_doc = PdfReader.Open(tempClean, PdfDocumentOpenMode.Modify);
|
|
}
|
|
catch (Exception saveOpenEx) when (PdfImport.IsXRefException(saveOpenEx))
|
|
{
|
|
var fixedPath = App.MakeTempFile("savefixed");
|
|
if (!PdfImport.TryImportRepairToPath(tempClean, fixedPath)
|
|
&& !PdfiumInterop.TryPdfiumSaveWithZeroRotations(tempClean, fixedPath))
|
|
throw;
|
|
tempClean = fixedPath;
|
|
_doc = PdfReader.Open(tempClean, PdfDocumentOpenMode.Modify);
|
|
}
|
|
_currentFile = tempClean;
|
|
sourcePath = tempBurned;
|
|
}
|
|
else
|
|
{
|
|
var temp = App.MakeTempFile("src");
|
|
_doc.Save(temp);
|
|
sourcePath = temp;
|
|
}
|
|
|
|
// In-app rotations live outside the file (the working copy has /Rotate stripped -
|
|
// BitmapHelpers), so snapshot them and rotate the pixels like the render path does.
|
|
var rotSnapshot = new int[_doc.PageCount];
|
|
for (int i = 0; i < rotSnapshot.Length; i++)
|
|
if (_pageRotations.TryGetValue(i, out int r)) rotSnapshot[i] = r;
|
|
|
|
var overlay = ShowFlattenProgress(selected.Count, Loc("Str_Busy_ExportPage"));
|
|
int digits = Math.Max(3, _doc.PageCount.ToString().Length);
|
|
bool jpeg = opts.Jpeg;
|
|
double dpi = opts.Dpi;
|
|
var pages = selected;
|
|
try
|
|
{
|
|
var ct = BeginCancellableOp(Loc("Str_Op_Export"));
|
|
// The per-page render/encode/write core lives in Services/PdfRasterize.cs;
|
|
// progress marshals back to the overlay here.
|
|
int written = await Task.Run(() => PdfRasterize.ExportPageImages(sourcePath, pages,
|
|
rotSnapshot, dpi, jpeg, outDir, baseName, digits,
|
|
(n, total) => Dispatcher.BeginInvoke(new Action(() => UpdateFlattenProgress(overlay, n, total))),
|
|
ct));
|
|
SetStatus(string.Format(Loc("Str_ExportImg_Done"), written, outDir));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
KillerDialog.Show(this, Loc("Str_Err_ExportFailed") + "\n" + ex.Message, "KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
|
|
}
|
|
finally
|
|
{
|
|
try { HideFlattenProgress(overlay); } catch { /* ensure overlay never leaks */ }
|
|
EndCancellableOp();
|
|
}
|
|
}
|
|
|
|
// ---- flatten progress overlay helpers ----
|
|
|
|
private Border ShowFlattenProgress(int pageCount, string progressFormat)
|
|
{
|
|
var progressText = new TextBlock
|
|
{
|
|
Text = string.Format(progressFormat, 0, pageCount),
|
|
Foreground = Brushes.White,
|
|
FontSize = 14,
|
|
Tag = progressFormat
|
|
};
|
|
var panel = new StackPanel
|
|
{
|
|
Orientation = Orientation.Vertical,
|
|
HorizontalAlignment = HorizontalAlignment.Center,
|
|
VerticalAlignment = VerticalAlignment.Center
|
|
};
|
|
panel.Children.Add(progressText);
|
|
|
|
var overlay = new Border
|
|
{
|
|
Background = new SolidColorBrush(Color.FromArgb(200, 0x1a, 0x1a, 0x1a)),
|
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
|
VerticalAlignment = VerticalAlignment.Stretch,
|
|
Child = panel,
|
|
Tag = "FlattenOverlay"
|
|
};
|
|
Panel.SetZIndex(overlay, 999);
|
|
|
|
// Attach to the root grid
|
|
if (Content is Grid rootGrid)
|
|
rootGrid.Children.Add(overlay);
|
|
|
|
return overlay;
|
|
}
|
|
|
|
private static void UpdateFlattenProgress(Border overlay, int current, int total)
|
|
{
|
|
if (overlay.Child is StackPanel panel)
|
|
foreach (var child in panel.Children)
|
|
if (child is TextBlock tb && tb.Tag is string progressFormat)
|
|
tb.Text = string.Format(progressFormat, current, total);
|
|
}
|
|
|
|
private void HideFlattenProgress(Border overlay)
|
|
{
|
|
if (Content is Grid rootGrid)
|
|
rootGrid.Children.Remove(overlay);
|
|
}
|
|
|
|
private void Print_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_doc is null || _currentFile is null) { KillerDialog.Show(this, Loc("Str_Msg_OpenFirst")); return; }
|
|
CommitActiveTextBox();
|
|
|
|
// The print prep (annotation burn + doc reopen) runs synchronously on the UI thread and
|
|
// freezes it for a moment; yield one render cycle first to keep the click responsive.
|
|
// (Deeper fix - backgrounding the burn - is tracked separately.)
|
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Background, new Action(RunPrintFlow));
|
|
}
|
|
|
|
private async void RunPrintFlow()
|
|
{
|
|
if (_doc is null || _currentFile is null) return;
|
|
string srcFile = _currentFile;
|
|
|
|
bool hasAnnotations = _annotations.Values.Any(list => list.Count > 0);
|
|
string printPath;
|
|
string? tempFlattened = null;
|
|
if (hasAnnotations || _docStampSpec is not null)
|
|
{
|
|
var tempClean = App.MakeTempFile("clean");
|
|
_doc.Save(tempClean); // UI-thread snapshot of the current doc (just serialization)
|
|
// Snapshot the data the burn needs so the background thread reads no live UI state.
|
|
var annotsSnap = _annotations.ToDictionary(kv => kv.Key, kv => new List<PageAnnotation>(kv.Value));
|
|
var dimsSnap = new Dictionary<int, (int w, int h)>(_renderDims);
|
|
var stampSnap = _docStampSpec?.Clone();
|
|
var rotSnap = new Dictionary<int, int>(_pageRotations); // #169: the burn needs the visual frame
|
|
var burnPath = App.MakeTempFile("print");
|
|
|
|
// Flatten the annotations onto a throwaway COPY on a background thread. The live _doc is never
|
|
// touched (no close/reopen), so the UI stays responsive and the editing session keeps its
|
|
// overlay annotations. DrawAnnotationsIntoDoc is static, so it can't reach UI state.
|
|
bool burned = await Task.Run(() =>
|
|
{
|
|
try
|
|
{
|
|
PdfDocument burnDoc;
|
|
try { burnDoc = PdfReader.Open(tempClean, PdfDocumentOpenMode.Modify); }
|
|
catch (Exception ex) when (PdfImport.IsXRefException(ex))
|
|
{
|
|
// PdfSharpCore can write a snapshot its own reader then chokes on; repair via
|
|
// Import then PDFium, same as the save/undo paths.
|
|
var fixedPath = App.MakeTempFile("printfixed");
|
|
if (!PdfImport.TryImportRepairToPath(tempClean, fixedPath) && !PdfiumInterop.TryPdfiumSaveWithZeroRotations(tempClean, fixedPath))
|
|
return false;
|
|
burnDoc = PdfReader.Open(fixedPath, PdfDocumentOpenMode.Modify);
|
|
}
|
|
using (burnDoc)
|
|
{
|
|
PdfBurn.DrawStampsIntoDoc(burnDoc, stampSnap, null, rotSnap); // stamps sit beneath annotations
|
|
PdfBurn.DrawAnnotationsIntoDoc(burnDoc, annotsSnap, dimsSnap, null, rotSnap);
|
|
burnDoc.Save(burnPath);
|
|
}
|
|
return true;
|
|
}
|
|
catch { return false; }
|
|
});
|
|
|
|
if (!burned) SetStatus(Loc("Str_St_FlattenPrintFailed"));
|
|
printPath = burned ? burnPath : srcFile;
|
|
tempFlattened = burned ? burnPath : null;
|
|
}
|
|
else
|
|
{
|
|
printPath = srcFile;
|
|
}
|
|
|
|
if (_doc is null) return; // re-check after the await (the doc was untouched, this satisfies flow analysis)
|
|
int pageCount = _doc.PageCount;
|
|
|
|
// Each page's true physical size in DIPs (96/inch) so the dialog can offer an exact
|
|
// "actual size" / custom scale. Computed on the UI thread (PdfSharp isn't thread-safe).
|
|
var pageDipW = new double[pageCount];
|
|
var pageDipH = new double[pageCount];
|
|
for (int i = 0; i < pageCount; i++)
|
|
{
|
|
double pw = _doc.Pages[i].Width.Point;
|
|
double ph = _doc.Pages[i].Height.Point;
|
|
if (_pageRotations.TryGetValue(i, out int rot) && (rot == 90 || rot == 270))
|
|
(pw, ph) = (ph, pw);
|
|
pageDipW[i] = pw * 96.0 / 72.0;
|
|
pageDipH[i] = ph * 96.0 / 72.0;
|
|
}
|
|
|
|
// Open the preview window immediately. Pages rasterize on a background thread and
|
|
// stream in via SetRenderedPage, so the window appears at once and the app stays
|
|
// responsive on large files. WPF's OS PrintDialog can't show a preview, so KillerPDF
|
|
// renders it and drives printing itself.
|
|
string renderPath = printPath;
|
|
string? cleanup = tempFlattened;
|
|
// Preview rasters are display-only (shown fit-to-pane in a Viewbox), so render them at a
|
|
// modest budget that scales DOWN as the document grows - this keeps the preview's resident
|
|
// bitmaps from ballooning on large files. The Print button re-renders the chosen pages at a
|
|
// true 300 DPI on demand (PrintPreviewWindow.DoPrint), so output stays crisp (issue #83).
|
|
int previewBox = pageCount <= 80 ? 1536 : pageCount <= 250 ? 1100 : 800;
|
|
var preview = new PrintPreviewWindow(this, pageCount, pageDipW, pageDipH, renderPath, cleanup);
|
|
|
|
_ = Task.Run(() =>
|
|
{
|
|
try
|
|
{
|
|
using var docReader = DocLib.Instance.GetDocReader(renderPath, new PageDimensions(previewBox, previewBox));
|
|
for (int i = 0; i < pageCount; i++)
|
|
{
|
|
if (preview.Canceled) return;
|
|
using var pr = docReader.GetPageReader(i);
|
|
int w = pr.GetPageWidth();
|
|
int h = pr.GetPageHeight();
|
|
byte[] raw = PdfiumInterop.RenderPageWithAnnotations(renderPath, i, w, h)
|
|
?? pr.GetImage();
|
|
byte[] png = BitmapHelpers.RenderToPng(raw, w, h); // #141
|
|
BitmapSource src;
|
|
using (var ms = new MemoryStream(png))
|
|
src = BitmapFrame.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
|
|
src.Freeze(); // frozen so it can cross back to the UI thread
|
|
int ci = i;
|
|
try { preview.Dispatcher.Invoke(() => preview.SetRenderedPage(ci, src, w, h)); }
|
|
catch { return; } // window closed mid-render
|
|
}
|
|
if (!preview.Canceled)
|
|
try { preview.Dispatcher.Invoke(preview.FinishLoading); } catch { }
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
try { preview.Dispatcher.Invoke(() => preview.LoadFailed(ex.Message)); } catch { }
|
|
}
|
|
// The flattened temp (cleanup) is NOT deleted here anymore: the Print button re-reads
|
|
// renderPath to rasterize at 300 DPI, so the window owns the temp and deletes it on close.
|
|
});
|
|
|
|
try
|
|
{
|
|
if (preview.ShowDialog() == true)
|
|
SetStatus(string.Format(Loc("Str_Printed"), preview.PrintedPageCount));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
try { KillerDialog.Show(this, Loc("Str_Err_PrintFailed") + "\n" + ex.GetType().Name + ": " + ex.Message, "KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error); }
|
|
catch { }
|
|
}
|
|
}
|
|
}
|
|
}
|