Files
mmd-pdf/Shell/ImportAndZip.cs
kua-agent 6610acfa44 feat: rebrand to MMD PDF, single corporate theme, and remove original text under an edit
- Services/PdfRedactText.cs: strip text whose origin falls inside a CoverAnnotation
  from the page content stream at save time, hooked into PdfBurn.DrawAnnotationsIntoDoc.
  Fixes edited values staying recoverable by text extraction.
- Themes/MMD.xaml replaces all thirteen themes; picker and accent strip removed; no dark mode.
- Rename KillerPDF -> MMD PDF across code, resources, packaging and locale strings; new icon.
- Remove the upstream author credit and the in-app install button.
2026-08-27 07:37:09 +02:00

352 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using Microsoft.Win32;
using PdfSharpCore.Drawing;
using PdfSharpCore.Pdf;
using PdfSharpCore.Pdf.IO;
using MmdPdf.Services;
namespace MmdPdf
{
// Import images as a PDF, and compress the current PDF to a .zip. Kept in its own partial-class
// file rather than the MainWindow monolith. User-facing strings go through Loc() (keys live in
// Strings/*.xaml) so the feature is fully localized.
public partial class MainWindow
{
// ----- Import images as a single PDF -------------------------------------------------
private void ImportImages_Click(object sender, RoutedEventArgs e)
{
var dlg = new Controls.FileDialog(Controls.FileDialogMode.Open)
{
Title = Loc("Str_Menu_Import"),
Filter = $"{Loc("Str_Filter_Images")}|*.jpg;*.jpeg;*.png;*.bmp;*.gif;*.tif;*.tiff|{Loc("Str_Filter_AllFiles")}|*.*",
Multiselect = true,
ShowImagePreview = true
};
if (dlg.ShowDialog(this) != true) return;
// Open in its own tab using the same flow as New, so the import is treated as UNSAVED work:
// dirty (orange Save icon), Save routes to Save As (no real file yet), and Close warns.
var target = BeginTabLoad(out var prev, out bool createdNew);
try
{
string tempPath = BuildPdfFromImages(dlg.FileNames);
_doc = PdfReader.Open(tempPath, PdfDocumentOpenMode.Modify);
FinishOpenFile("Imported.pdf", tempPath);
_originalFile = null; // no saved location yet -> Save becomes Save As
MarkDirty(true); // unsaved -> orange icon + close warns
SetStatus(string.Format(Loc("Str_Status_Imported"), dlg.FileNames.Length));
CaptureSessionState(_active!);
SetTool(_currentTool);
RebuildTabStrip();
}
catch (Exception ex)
{
AbortTabLoad(target, prev, createdNew);
KillerDialog.Show(this, Loc("Str_Err_ImportFailed") + "\n" + ex.Message,
"MmdPdf", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
// Builds a PDF where each page is exactly one source image. Multi-frame TIFF/GIF expand to one
// page per frame. Page size matches the image's physical size (pixels at its own DPI, 96 if the
// image declares none). Returns a temp PDF path; the caller opens it and the user can Save As.
private static string BuildPdfFromImages(string[] imagePaths)
{
using var pdf = new PdfDocument();
foreach (var path in imagePaths) PdfImport.AddImagePagesFromFile(pdf, path);
if (pdf.PageCount == 0)
throw new InvalidOperationException(
Application.Current.TryFindResource("Str_Err_NoImages") as string ?? "No images could be read.");
string outPath = Path.Combine(Path.GetTempPath(),
$"Imported-{DateTime.Now:yyyyMMdd-HHmmss-fff}.pdf");
pdf.Save(outPath);
return outPath;
}
// AddImagePagesFromFile and IsPdfPath live in Services/PdfImport.cs (MmdPdfUI refactor).
// ----- Drag/drop of folders, archives, and multiple files ----------------------------
private static readonly string[] DropImageExt = [".jpg", ".jpeg", ".png", ".bmp", ".gif", ".tif", ".tiff"];
private static bool IsImagePath(string p) => DropImageExt.Any(e => p.EndsWith(e, StringComparison.OrdinalIgnoreCase));
private static bool IsOpenablePath(string p) => PdfImport.IsPdfPath(p) || IsImagePath(p);
// Entry point for any file/folder/archive drop. Expands dropped folders (recursively) and .zip
// archives, then opens the collected PDFs/images - asking merge-vs-separate when there's >1.
private void OnPathsDropped(string[] paths)
{
if (paths == null || paths.Length == 0) return;
var found = new List<string>();
var tempDirs = new List<string>(); // extracted-zip temp dirs we may need to clean up
bool expanded = false; // a folder or archive was opened
try
{
foreach (var p in paths)
{
if (Directory.Exists(p)) { expanded = true; CollectOpenable(p, found); }
else if (p.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
var dir = ExtractZipToTemp(p);
if (dir != null) { expanded = true; tempDirs.Add(dir); CollectOpenable(dir, found); }
}
else if (IsOpenablePath(p)) found.Add(p);
}
}
catch (Exception ex)
{
CleanupDirs(tempDirs);
KillerDialog.Show(this, Loc("Str_Err_ImportFailed") + "\n" + ex.Message,
"MmdPdf", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
if (found.Count == 0)
{
CleanupDirs(tempDirs);
SetStatus(Loc("Str_Drop_NothingOpenable"));
return;
}
// Guard against a folder/archive holding a huge number of files: opening or merging them all
// would exhaust memory. Cap to a sane maximum, opening only the first N (name-sorted) after asking.
const int MaxDropFiles = 50;
if (found.Count > MaxDropFiles)
{
var proceed = KillerDialog.Show(this,
string.Format(Loc("Str_Drop_TooMany"), found.Count, MaxDropFiles),
"MmdPdf", MessageBoxButton.OKCancel);
if (proceed != MessageBoxResult.OK) { CleanupDirs(tempDirs); return; }
found = found.GetRange(0, MaxDropFiles);
}
// A single dropped file (no folder/zip): open it directly, as before. Temp from a single-file
// zip is kept for the session since the opened doc references it.
if (!expanded && found.Count == 1) { OpenDropped(found[0]); return; }
int choice = KillerDialog.ShowChoices(this,
string.Format(Loc("Str_Drop_Prompt"), found.Count),
[Loc("Str_Drop_Merge"), Loc("Str_Drop_Separate"), Loc("Str_Stamp_Cancel")],
accentIndex: 0);
if (choice == 0)
{
OpenMerged(found, tempDirs); // async: builds on a background thread, then owns temp cleanup
}
else if (choice == 1)
{
OpenSeparately(found); // opened docs may reference extracted files - keep temp for the session
}
else
{
CleanupDirs(tempDirs); // canceled
}
}
private void OpenDropped(string path)
{
if (PdfImport.IsPdfPath(path)) OpenInNewTab(path);
else OpenImagesAsImportedTab([path], Path.GetFileName(path));
}
private void OpenSeparately(List<string> found)
{
if (found.Count > 30)
{
var ok = KillerDialog.Show(this, string.Format(Loc("Str_Drop_ManyTabs"), found.Count),
"MmdPdf", MessageBoxButton.OKCancel);
if (ok != MessageBoxResult.OK) return;
}
foreach (var f in found)
{
if (PdfImport.IsPdfPath(f)) OpenInNewTab(f);
else OpenImagesAsImportedTab([f], Path.GetFileName(f));
}
}
private async void OpenMerged(List<string> found, List<string> tempDirs)
{
var target = BeginTabLoad(out var prev, out bool createdNew);
var busy = ShowBusyOverlay(Loc("Str_Drop_Merging"));
var ct = BeginCancellableOp(Loc("Str_Op_Merge")); // Esc cancels; the busy overlay keeps the window draggable
try
{
// Build off the UI thread so the window stays responsive (and movable) while it works.
string? tempPath = await Task.Run(() => BuildCombinedPdf(found, ct));
if (ct.IsCancellationRequested || tempPath == null)
{
HideBusyOverlay(busy);
AbortTabLoad(target, prev, createdNew);
SetStatus(Loc("Str_Drop_MergeCanceled"));
return;
}
_doc = PdfReader.Open(tempPath, PdfDocumentOpenMode.Modify);
FinishOpenFile("Combined.pdf", tempPath);
_originalFile = null; // unsaved -> Save routes to Save As
MarkDirty(true);
SetStatus(string.Format(Loc("Str_Status_Merged"), found.Count));
CaptureSessionState(_active!);
SetTool(_currentTool);
RebuildTabStrip();
HideBusyOverlay(busy);
}
catch (Exception ex)
{
HideBusyOverlay(busy);
AbortTabLoad(target, prev, createdNew);
KillerDialog.Show(this, Loc("Str_Err_ImportFailed") + "\n" + ex.Message,
"MmdPdf", MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
EndCancellableOp();
CleanupDirs(tempDirs);
}
}
// Opens the given image(s) as a single unsaved imported-PDF tab (same flow as Import Images).
private void OpenImagesAsImportedTab(string[] images, string displayName)
{
var target = BeginTabLoad(out var prev, out bool createdNew);
try
{
string tempPath = BuildPdfFromImages(images);
_doc = PdfReader.Open(tempPath, PdfDocumentOpenMode.Modify);
FinishOpenFile(displayName, tempPath);
_originalFile = null;
MarkDirty(true);
CaptureSessionState(_active!);
SetTool(_currentTool);
RebuildTabStrip();
}
catch (Exception ex)
{
AbortTabLoad(target, prev, createdNew);
KillerDialog.Show(this, Loc("Str_Err_ImportFailed") + "\n" + ex.Message,
"MmdPdf", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
// Builds one PDF from a mix of PDFs (pages imported in order) and images (one page each).
// Unreadable / encrypted entries are skipped rather than aborting the whole merge.
private static string? BuildCombinedPdf(List<string> files, CancellationToken ct)
{
using var outPdf = new PdfDocument();
foreach (var f in files)
{
if (ct.IsCancellationRequested) return null;
if (PdfImport.IsPdfPath(f))
{
try
{
using var src = PdfReader.Open(f, PdfDocumentOpenMode.Import);
for (int i = 0; i < src.PageCount; i++) outPdf.AddPage(src.Pages[i]);
}
catch { /* skip an unreadable/encrypted PDF */ }
}
else
{
try { PdfImport.AddImagePagesFromFile(outPdf, f); } catch { /* skip an unreadable image */ }
}
}
if (ct.IsCancellationRequested) return null;
if (outPdf.PageCount == 0)
throw new InvalidOperationException(
Application.Current.TryFindResource("Str_Err_NoImages") as string ?? "Nothing could be read.");
string outPath = Path.Combine(Path.GetTempPath(),
$"Combined-{DateTime.Now:yyyyMMdd-HHmmss-fff}.pdf");
outPdf.Save(outPath);
return outPath;
}
// Recursively gathers the PDFs and images under a folder, in a stable name order.
private static void CollectOpenable(string dir, List<string> found)
{
IEnumerable<string> files;
try { files = Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories); }
catch { return; }
foreach (var f in files.Where(IsOpenablePath).OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
found.Add(f);
}
private static string? ExtractZipToTemp(string zipPath)
{
string dir = Path.Combine(Path.GetTempPath(), "MmdPdf-zip-" + Guid.NewGuid().ToString("N")[..8]);
try { Directory.CreateDirectory(dir); ZipFile.ExtractToDirectory(zipPath, dir); return dir; }
catch { try { Directory.Delete(dir, true); } catch { } return null; }
}
private static void CleanupDirs(List<string> dirs)
{
foreach (var d in dirs) try { Directory.Delete(d, true); } catch { }
}
// ----- Compress the current PDF to a .zip --------------------------------------------
private void CompressToZip_Click(object sender, RoutedEventArgs e)
{
if (_doc is null) { KillerDialog.Show(this, Loc("Str_Msg_OpenFirst")); return; }
// The zip wraps the PDF on disk, so make sure what's on screen is saved first.
if (_isDirty || string.IsNullOrEmpty(_originalFile) || !File.Exists(_originalFile))
{
var ask = KillerDialog.Show(this, Loc("Str_Dlg_SaveBeforeZip"),
"MmdPdf", MessageBoxButton.OKCancel);
if (ask != MessageBoxResult.OK) return;
SaveInPlace();
if (_isDirty || string.IsNullOrEmpty(_originalFile) || !File.Exists(_originalFile))
return; // save was canceled or failed
}
string sourcePdf = _originalFile!;
var dlg = new Controls.FileDialog(Controls.FileDialogMode.Save)
{
Filter = $"{Loc("Str_Filter_Zip")}|*.zip",
Title = Loc("Str_Menu_CompressZip"),
FileName = Path.GetFileNameWithoutExtension(sourcePdf) + ".zip"
};
var srcDir = Path.GetDirectoryName(sourcePdf);
if (!string.IsNullOrEmpty(srcDir) && Directory.Exists(srcDir)) dlg.InitialDirectory = srcDir;
if (dlg.ShowDialog(this) != true) return;
try
{
if (File.Exists(dlg.FileName)) File.Delete(dlg.FileName);
using (var zip = ZipFile.Open(dlg.FileName, ZipArchiveMode.Create))
zip.CreateEntryFromFile(sourcePdf, Path.GetFileName(sourcePdf), CompressionLevel.Optimal);
long before = new FileInfo(sourcePdf).Length;
long after = new FileInfo(dlg.FileName).Length;
SetStatus(string.Format(Loc("Str_Status_Zipped"),
Path.GetFileName(dlg.FileName), FormatSize(after), FormatSize(before)));
}
catch (Exception ex)
{
KillerDialog.Show(this, Loc("Str_Err_ZipFailed") + "\n" + ex.Message,
"MmdPdf", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private static string FormatSize(long bytes)
{
if (bytes >= 1024 * 1024) return $"{bytes / 1024.0 / 1024.0:F1} MB";
if (bytes >= 1024) return $"{bytes / 1024.0:F0} KB";
return $"{bytes} B";
}
}
}