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 [--log ] [--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; /// /// 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 . /// 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 [--log ] [--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 { "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; } /// /// Resaves a single PDF through the standard save pipeline. /// Returns "OK", "SKIP" (could not enter the plain Modify pipeline; /// reason in ), or "FAIL" (opened but the /// save itself failed - the case the harness exists to catch). /// 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("\"", "\"\"") + "\""; } } }