[--password ]",
" remove encryption (lossless when possible)",
" --to-image [--dpi ] [--format png|jpg] [--pages ] [--transparent]",
" render pages to images (default 150 dpi, png;",
" background composites to white unless --transparent, png only)",
" --flatten [--dpi ] rasterize into an uneditable PDF (default 150 dpi)",
" --print [--printer ] [--pages ] [--copies ]",
" print silently (default printer if none named)",
" --ocr [--lang ] add an invisible searchable text layer (default eng;",
" other languages download on first use)",
" --batch-resave [--log ] [--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.",
]);
///
/// Splits args into positionals (everything after the command flag that
/// is not an option) and an option dictionary (case-insensitive keys).
///
private static (List Positionals, Dictionary Options)
ParseCliArgs(string[] args, string command)
{
var positionals = new List();
var options = new Dictionary(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);
}
///
/// 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.
///
// 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? CliParsePageRange(string spec, int pageCount, out string error)
{
error = string.Empty;
var pages = new SortedSet();
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 ...
// ============================================================
// 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 pos, TextWriter con)
{
if (pos.Count < 3)
{
con.WriteLine("Usage: KillerPDF.exe --merge ...");
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 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
// ============================================================
// 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 pos, TextWriter con)
{
if (pos.Count != 3)
{
con.WriteLine("Usage: KillerPDF.exe --extract-pages (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
// ============================================================
private static int CliSplit(List pos, TextWriter con)
{
if (pos.Count != 2)
{
con.WriteLine("Usage: KillerPDF.exe --split ");
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 [--password ]
// ============================================================
// 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 pos, Dictionary options, TextWriter con)
{
if (pos.Count != 2)
{
con.WriteLine("Usage: KillerPDF.exe --decrypt [--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 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 [--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 pos, Dictionary options, TextWriter con)
{
if (pos.Count != 2)
{
con.WriteLine("Usage: KillerPDF.exe --to-image [--dpi ] [--format png|jpg] [--pages ] [--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 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 [--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 pos, Dictionary options, TextWriter con)
{
if (pos.Count != 2)
{
con.WriteLine("Usage: KillerPDF.exe --flatten [--dpi ]");
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 [--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 pos, Dictionary options, TextWriter con)
{
if (pos.Count != 1)
{
con.WriteLine("Usage: KillerPDF.exe --print [--printer ] [--pages ] [--copies ]");
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 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 [--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 pos, Dictionary options, TextWriter con)
{
if (pos.Count != 2)
{
con.WriteLine("Usage: KillerPDF.exe --ocr [--lang ] (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;
}
///
/// 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.
///
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;
}
}
}
}