vendor: import KillerPDF 1.7.5 source (GPL-3.0) as the base for MMD PDF

This commit is contained in:
2026-08-27 06:58:22 +02:00
commit 532485a830
577 changed files with 149058 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
using System.Windows;
namespace KillerPDF.Services
{
// #169: a plain page rotation used to reload with SaveTempAndReload's keepAnnotations
// default, which cleared every overlay annotation - committed, unsaved user work was
// destroyed the moment a page was rotated. Rotation now keeps the annotations and maps
// their canvas coordinates through the turn instead. Coordinates live in the page's
// render-dim space (the visual frame the user drew on); rotating the page turns that
// frame, so the old (w, h) render dims become (h, w) when the reload re-renders.
internal static class AnnotationRotate
{
/// <summary>Remaps one page's annotations for an in-app rotation by <paramref name="delta"/>
/// degrees (clockwise positive, matching the render path), where <paramref name="oldW"/> and
/// <paramref name="oldH"/> are the page's render dims BEFORE the turn. Region-shaped
/// annotations (highlights, covers, ink, shapes) turn with the content they mark; text boxes
/// and placed items (signatures, images) keep their own size and orientation and follow
/// their center to the same page spot - their content renders upright either way.</summary>
public static void Remap(IEnumerable<PageAnnotation> annots, int delta, double oldW, double oldH)
{
int d = ((delta % 360) + 360) % 360;
if (d == 0) return;
double newW = d == 90 || d == 270 ? oldH : oldW;
double newH = d == 90 || d == 270 ? oldW : oldH;
Point MapPoint(Point p) => d switch
{
// Forward quarter-turn of the visual frame, same convention as the render path's
// clockwise bitmap rotation (see PdfBurn's VisualToPageMatrix, which is its inverse).
90 => new Point(oldH - p.Y, p.X),
270 => new Point(p.Y, oldW - p.X),
_ => new Point(oldW - p.X, oldH - p.Y), // 180
};
Rect MapRect(Rect r)
{
var a = MapPoint(new Point(r.X, r.Y));
var b = MapPoint(new Point(r.Right, r.Bottom));
return new Rect(a, b); // Rect(Point, Point) normalizes the corners
}
Point MapAnchor(double x, double y, double w, double h)
{
var c = MapPoint(new Point(x + w / 2, y + h / 2));
// Text, images and signatures stay upright while their centre follows the sheet.
// A tall item near the old long edge can therefore need more room on the new axis
// than it did before the turn. Keep the complete item reachable rather than
// preserving an off-page coordinate the user cannot recover (#169 follow-up).
double px = c.X - w / 2;
double py = c.Y - h / 2;
return new Point(
Math.Max(0, Math.Min(px, Math.Max(0, newW - w))),
Math.Max(0, Math.Min(py, Math.Max(0, newH - h))));
}
foreach (var annot in annots)
{
switch (annot)
{
case HighlightAnnotation ha: // includes CoverAnnotation
ha.Bounds = MapRect(ha.Bounds);
if (ha.Erases != null)
foreach (var e in ha.Erases)
for (int i = 0; i < e.Points.Count; i++)
e.Points[i] = MapPoint(e.Points[i]);
break;
case InkAnnotation ia:
for (int i = 0; i < ia.Points.Count; i++)
ia.Points[i] = MapPoint(ia.Points[i]);
break;
case TextAnnotation ta:
ta.Position = MapAnchor(ta.Position.X, ta.Position.Y, ta.Width, ta.Height);
break;
case PlacedAnnotation pa: // signature / image
pa.Position = MapAnchor(pa.Position.X, pa.Position.Y,
pa.SourceWidth * pa.Scale, pa.SourceHeight * pa.Scale);
break;
}
}
}
}
}
+196
View File
@@ -0,0 +1,196 @@
using System.IO;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace KillerPDF.Services
{
// ============================================================
// Raw-bitmap helpers - pure functions over BGRA pixel buffers,
// no window state. Formerly a MainWindow partial (KillerUI
// refactor); shared by the render paths, thumbnails, page
// export, OCR and the CLI.
// ============================================================
internal static class BitmapHelpers
{
/// <summary>
/// Rotates a raw BGRA (4 bytes/pixel) bitmap clockwise by degrees.
/// Used because Docnet's FPDF_RenderPageBitmapWithMatrix uses a pure-scaling
/// matrix, so PDFium renders the page in its MediaBox orientation (no rotation).
/// We strip /Rotate from the temp file so content is never clipped, then rotate
/// the pixel buffer here to match the intended visual orientation.
/// </summary>
internal static (byte[] bytes, int w, int h) RotateBitmap(byte[] src, int w, int h, int degrees)
{
degrees = ((degrees % 360) + 360) % 360;
if (degrees == 0) return (src, w, h);
int newW = (degrees == 90 || degrees == 270) ? h : w;
int newH = (degrees == 90 || degrees == 270) ? w : h;
byte[] dst = new byte[newW * newH * 4];
for (int y = 0; y < h; y++)
{
for (int x = 0; x < w; x++)
{
int srcIdx = (y * w + x) * 4;
int dstX, dstY;
switch (degrees)
{
case 90: dstX = h - 1 - y; dstY = x; break; // CW
case 180: dstX = w - 1 - x; dstY = h - 1 - y; break;
default: dstX = y; dstY = w - 1 - x; break; // 270 CW
}
int dstIdx = (dstY * newW + dstX) * 4;
dst[dstIdx] = src[srcIdx];
dst[dstIdx + 1] = src[srcIdx + 1];
dst[dstIdx + 2] = src[srcIdx + 2];
dst[dstIdx + 3] = src[srcIdx + 3];
}
}
return (dst, newW, newH);
}
// ============================================================
// Document color inversion (#135, "dark mode")
// ============================================================
// Document dark-mode invert is PER PANE now (PdfViewer.DocInvert) so a split can read
// one document inverted beside a normal one - the global static that used to live here
// flipped both panes at once. DISPLAY ONLY either way: saves, prints, exports, OCR,
// thumbnails, and tool previews all keep the document's true colors.
// True = night mode inverts pictures along with everything else (the pre-carve-out
// behavior, now opt-in from the moon button's right-click menu; default off). Loaded
// from the "DocInvertImages" setting at startup.
internal static bool DocInvertImages;
/// <summary>In-place inversion for the display dark mode, applied at the Viewport render
/// sites BEFORE the pixel-buffer rotation (via InvertBgraInPlaceExcept, which carves the
/// image regions back out). PDF pages usually paint NO background - the "paper" is
/// transparent pixels compositing over the white page slot - so a plain RGB flip left
/// the page white and merely faded the ink. Composite over white and invert in one
/// step: out = a*(255-c)/255 with alpha forced opaque. White (or unpainted) paper
/// becomes black, dark ink becomes light, and opaque images get a true negative.</summary>
internal static void InvertBgraInPlace(byte[] bgra)
{
for (int i = 0; i + 3 < bgra.Length; i += 4)
{
int a = bgra[i + 3];
bgra[i] = (byte)(a * (255 - bgra[i]) / 255);
bgra[i + 1] = (byte)(a * (255 - bgra[i + 1]) / 255);
bgra[i + 2] = (byte)(a * (255 - bgra[i + 2]) / 255);
bgra[i + 3] = 255;
}
}
/// <summary>An image's bounding box as FRACTIONS of the unrotated page (top-left origin),
/// so one cached set serves every render resolution. Produced by PdfImages.GetFracRects.</summary>
internal readonly record struct FracRect(double L, double T, double R, double B);
/// <summary>
/// #135 follow-up: dark mode that does NOT invert pictures. Inverts the whole page with
/// the operator above, then applies the SAME operator once more over the image regions.
/// That second pass is exact, not approximate: for an already-inverted opaque pixel,
/// out = 255 - (a*(255-c)/255) = (a*c + (255-a)*255)/255 - the ORIGINAL pixel composited
/// over white, which is precisely what the image looked like on the normal white page.
/// Overlapping image boxes are merged per scanline so no pixel gets the operator twice.
/// </summary>
internal static void InvertBgraInPlaceExcept(byte[] bgra, int width, int height, FracRect[] keep)
{
InvertBgraInPlace(bgra);
if (keep is null || keep.Length == 0 || width <= 0 || height <= 0) return;
// Fractions -> pixel boxes, clamped. Floor/ceiling so a box never leaves a 1px
// inverted sliver of the image at its edge.
var px = new List<(int x0, int y0, int x1, int y1)>(keep.Length);
foreach (var r in keep)
{
int x0 = Math.Max(0, (int)Math.Floor(r.L * width));
int x1 = Math.Min(width, (int)Math.Ceiling(r.R * width));
int y0 = Math.Max(0, (int)Math.Floor(r.T * height));
int y1 = Math.Min(height, (int)Math.Ceiling(r.B * height));
if (x1 > x0 && y1 > y0) px.Add((x0, y0, x1, y1));
}
if (px.Count == 0) return;
var spans = new List<(int x0, int x1)>(px.Count);
for (int y = 0; y < height; y++)
{
spans.Clear();
foreach (var b in px)
if (y >= b.y0 && y < b.y1) spans.Add((b.x0, b.x1));
if (spans.Count == 0) continue;
spans.Sort((a, b) => a.x0.CompareTo(b.x0));
int row = y * width * 4;
int curStart = spans[0].x0, curEnd = spans[0].x1;
for (int s = 1; s <= spans.Count; s++)
{
if (s < spans.Count && spans[s].x0 <= curEnd)
{
if (spans[s].x1 > curEnd) curEnd = spans[s].x1;
continue;
}
for (int x = curStart; x < curEnd; x++)
{
int i = row + x * 4;
int a = bgra[i + 3];
bgra[i] = (byte)(a * (255 - bgra[i]) / 255);
bgra[i + 1] = (byte)(a * (255 - bgra[i + 1]) / 255);
bgra[i + 2] = (byte)(a * (255 - bgra[i + 2]) / 255);
bgra[i + 3] = 255;
}
if (s < spans.Count) { curStart = spans[s].x0; curEnd = spans[s].x1; }
}
}
}
/// <summary>
/// Encodes raw BGRA pixel data from pdfium to PNG without touching the UI thread.
/// GDI+ Format32bppArgb is BGRA in memory - matches pdfium output exactly.
/// </summary>
internal static byte[] RenderToPng(byte[] bgra, int width, int height, double dpi = 96)
{
var pin = GCHandle.Alloc(bgra, GCHandleType.Pinned);
try
{
using var bmp = new System.Drawing.Bitmap(
width, height, width * 4,
System.Drawing.Imaging.PixelFormat.Format32bppArgb,
pin.AddrOfPinnedObject());
// #188: bake the render DPI into the file's metadata; GDI+ defaults to 96.
bmp.SetResolution((float)dpi, (float)dpi);
using var ms = new MemoryStream();
bmp.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
return ms.ToArray();
}
finally { pin.Free(); }
}
// Builds a frozen bitmap sized so its baked DPI displays it at (dipW x dipH) DIPs. Shared by the
// tile and the render cache so a cached tile bitmap reuses the exact same geometry.
internal static BitmapSource BuildScaledBitmap(int w, int h, byte[] rawBytes, int dipW, int dipH)
{
var wb = new WriteableBitmap(w, h, 96.0 * w / Math.Max(1, dipW), 96.0 * h / Math.Max(1, dipH), PixelFormats.Bgra32, null);
wb.WritePixels(new Int32Rect(0, 0, w, h), rawBytes, w * 4, 0);
wb.Freeze();
return wb;
}
/// <summary>
/// Encodes raw BGRA pixel data to JPEG (quality 90) via WPF's encoder. Born as the CLI's
/// CliEncodeJpeg (no JPEG encoder existed before --to-image); homed here beside RenderToPng
/// in the KillerUI refactor, shared by the CLI and the GUI image export.
/// </summary>
internal static byte[] EncodeJpeg(byte[] bgra, int width, int height, double dpi = 96)
{
// #188: dpi lands in the JFIF density header; pixel dimensions are unaffected.
var bmp = BitmapSource.Create(width, height, dpi, dpi, PixelFormats.Bgra32, null, bgra, width * 4);
var encoder = new JpegBitmapEncoder { QualityLevel = 90 };
encoder.Frames.Add(BitmapFrame.Create(bmp));
using var ms = new MemoryStream();
encoder.Save(ms);
return ms.ToArray();
}
}
}
+201
View File
@@ -0,0 +1,201 @@
namespace KillerPDF.Services
{
// ============================================================
// Minimal 'cmap' reader: answers "does this face have a glyph for this
// codepoint" and nothing else (#168). Deliberately small - it does not
// decode glyph outlines or metrics, it only walks the character-to-glyph
// table so the save path can pick a font that will not emit boxes.
//
// Formats handled: 4 (BMP, the universal one) and 12 (full Unicode, needed
// for anything past U+FFFF - CJK Ext B, emoji). Format 6 is read too since
// some older CJK faces still ship it. Anything else yields no ranges, which
// reads as "cannot promise coverage" and simply moves the chain along.
// ============================================================
internal sealed class CmapCoverage
{
// Sorted, non-overlapping-enough ranges of covered codepoints. A list plus binary search
// beats a HashSet here: a CJK face covers tens of thousands of codepoints, and the ranges
// collapse that to a few hundred entries.
private readonly List<(int lo, int hi)> _ranges;
private CmapCoverage(List<(int lo, int hi)> ranges) => _ranges = ranges;
internal bool Covers(int codePoint)
{
int lo = 0, hi = _ranges.Count - 1;
while (lo <= hi)
{
int mid = (lo + hi) / 2;
var r = _ranges[mid];
if (codePoint < r.lo) hi = mid - 1;
else if (codePoint > r.hi) lo = mid + 1;
else return true;
}
return false;
}
// ── Parsing ───────────────────────────────────────────────────────────────────────────
private static ushort U16(byte[] b, int p) => (ushort)((b[p] << 8) | b[p + 1]);
private static uint U32(byte[] b, int p) => (uint)((b[p] << 24) | (b[p + 1] << 16) | (b[p + 2] << 8) | b[p + 3]);
/// <summary>Reads the best available cmap subtable. Returns null when the font has none
/// this understands, which callers treat as "unknown coverage".</summary>
internal static CmapCoverage? Parse(byte[] font)
{
try
{
if (font.Length < 12) return null;
int numTables = U16(font, 4);
int cmapOff = -1;
for (int i = 0; i < numTables; i++)
{
int e = 12 + i * 16;
if (e + 16 > font.Length) return null;
// 'cmap' = 0x636D6170
if (U32(font, e) == 0x636D6170) { cmapOff = (int)U32(font, e + 8); break; }
}
if (cmapOff < 0 || cmapOff + 4 > font.Length) return null;
int numSub = U16(font, cmapOff + 2);
int best = -1, bestScore = -1;
for (int i = 0; i < numSub; i++)
{
int rec = cmapOff + 4 + i * 8;
if (rec + 8 > font.Length) break;
int platform = U16(font, rec);
int encoding = U16(font, rec + 2);
int off = cmapOff + (int)U32(font, rec + 4);
if (off < 0 || off + 2 > font.Length) continue;
int format = U16(font, off);
// Prefer full-Unicode tables, then BMP ones. (3,10) and (3,1) are the Windows
// encodings every Windows font ships; platform 0 is Unicode-proper.
int score = (platform, encoding, format) switch
{
(3, 10, 12) => 100,
(0, _, 12) => 95,
(3, 1, 4) => 80,
(0, _, 4) => 75,
(_, _, 12) => 60,
(_, _, 4) => 50,
(_, _, 6) => 20,
_ => -1,
};
if (score > bestScore) { bestScore = score; best = off; }
}
if (best < 0) return null;
var ranges = U16(font, best) switch
{
4 => ParseFormat4(font, best),
6 => ParseFormat6(font, best),
12 => ParseFormat12(font, best),
_ => null,
};
if (ranges is null || ranges.Count == 0) return null;
ranges.Sort((a, b) => a.lo.CompareTo(b.lo));
// Merge touching/overlapping ranges so the binary search stays correct and small.
var merged = new List<(int lo, int hi)>(ranges.Count);
foreach (var r in ranges)
{
if (merged.Count > 0 && r.lo <= merged[^1].hi + 1)
{
if (r.hi > merged[^1].hi) merged[^1] = (merged[^1].lo, r.hi);
}
else merged.Add(r);
}
return new CmapCoverage(merged);
}
catch { return null; }
}
// Format 4: segmented mapping. Segments whose idRangeOffset is 0 map by delta (covered
// wholesale unless the delta lands on glyph 0); the rest index a glyph array, so each
// codepoint is checked individually.
private static List<(int lo, int hi)>? ParseFormat4(byte[] b, int off)
{
if (off + 14 > b.Length) return null;
int segX2 = U16(b, off + 6);
int seg = segX2 / 2;
if (seg <= 0) return null;
int endP = off + 14;
int startP = endP + segX2 + 2; // +2 skips reservedPad
int deltaP = startP + segX2;
int rangeP = deltaP + segX2;
if (rangeP + segX2 > b.Length) return null;
var list = new List<(int, int)>(seg);
for (int i = 0; i < seg; i++)
{
int end = U16(b, endP + i * 2);
int start = U16(b, startP + i * 2);
if (start > end) continue;
if (start == 0xFFFF) continue; // the mandatory terminator segment
int delta = (short)U16(b, deltaP + i * 2);
int rangeOff = U16(b, rangeP + i * 2);
if (rangeOff == 0)
{
// Glyph = (code + delta) mod 65536. Only a delta that maps something to 0
// needs the per-codepoint walk; otherwise the whole segment is covered.
if (((start + delta) & 0xFFFF) != 0 && ((end + delta) & 0xFFFF) != 0)
{
list.Add((start, Math.Min(end, 0xFFFE)));
continue;
}
for (int c = start; c <= end && c <= 0xFFFE; c++)
if (((c + delta) & 0xFFFF) != 0) list.Add((c, c));
continue;
}
int glyphBase = rangeP + i * 2 + rangeOff;
for (int c = start; c <= end && c <= 0xFFFE; c++)
{
int gp = glyphBase + (c - start) * 2;
if (gp + 2 > b.Length) break;
if (U16(b, gp) != 0) list.Add((c, c));
}
}
return list;
}
// Format 6: a single contiguous run of codes.
private static List<(int lo, int hi)>? ParseFormat6(byte[] b, int off)
{
if (off + 10 > b.Length) return null;
int first = U16(b, off + 6);
int count = U16(b, off + 8);
var list = new List<(int, int)>();
for (int i = 0; i < count; i++)
{
int gp = off + 10 + i * 2;
if (gp + 2 > b.Length) break;
if (U16(b, gp) != 0) list.Add((first + i, first + i));
}
return list;
}
// Format 12: groups of (startChar, endChar, startGlyph) covering all of Unicode.
private static List<(int lo, int hi)>? ParseFormat12(byte[] b, int off)
{
if (off + 16 > b.Length) return null;
uint nGroups = U32(b, off + 12);
if (nGroups > 200000) return null; // implausible: treat as corrupt rather than churn
var list = new List<(int, int)>((int)Math.Min(nGroups, 4096));
for (uint i = 0; i < nGroups; i++)
{
int g = off + 16 + (int)i * 12;
if (g + 12 > b.Length) break;
int start = (int)U32(b, g);
int end = (int)U32(b, g + 4);
int startGlyph = (int)U32(b, g + 8);
if (startGlyph == 0) continue; // maps onto .notdef: not coverage
if (start > end || start < 0 || end > 0x10FFFF) continue;
list.Add((start, end));
}
return list;
}
}
}
+125
View File
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text;
namespace KillerPDF
{
/// <summary>
/// Writes structured crash logs to %LOCALAPPDATA%\KillerPDF\Logs\ and maintains
/// a rolling buffer of recent status-bar messages for post-mortem context.
/// </summary>
internal static class CrashReporter
{
private const int StatusBufferSize = 50;
private const long LogDirCapBytes = 20L * 1024 * 1024; // 20 MB
private static readonly Queue<string> _statusRing = new();
// ── Public properties ────────────────────────────────────────────────
internal static string LogDir { get; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"KillerPDF", "Logs");
/// <summary>Path of the log file written by the most recent Capture() call.</summary>
internal static string? LastLogPath { get; private set; }
// ── Status ring buffer ───────────────────────────────────────────────
/// <summary>
/// Called by MainWindow.SetStatus so crash logs include the last N status messages.
/// Thread-safe.
/// </summary>
internal static void PushStatusMessage(string text)
{
lock (_statusRing)
{
_statusRing.Enqueue($"[{DateTime.Now:HH:mm:ss.fff}] {text}");
while (_statusRing.Count > StatusBufferSize)
_statusRing.Dequeue();
}
}
// ── Log capture ──────────────────────────────────────────────────────
/// <summary>
/// Writes a structured crash log to LogDir and returns the path.
/// Safe to call from any thread; best-effort on I/O failure.
/// </summary>
internal static string Capture(Exception ex, string context)
{
try { Directory.CreateDirectory(LogDir); } catch { /* best-effort */ }
var sb = new StringBuilder();
var ver = Assembly.GetExecutingAssembly().GetName().Version;
sb.AppendLine($"KillerPDF v{ver?.ToString(3)} crash report");
sb.AppendLine($"Time : {DateTime.Now:yyyy-MM-dd HH:mm:ss zzz}");
sb.AppendLine($"OS : {Environment.OSVersion}");
sb.AppendLine($"CLR : {Environment.Version}");
sb.AppendLine($"Context : {context}");
sb.AppendLine();
var inner = ex;
var depth = 0;
while (inner != null && depth < 5)
{
if (depth > 0) { sb.AppendLine(); sb.AppendLine("=== Inner Exception ==="); }
sb.AppendLine($"Type : {inner.GetType().FullName}");
sb.AppendLine($"Message : {inner.Message}");
sb.AppendLine("Stack trace:");
sb.AppendLine(inner.StackTrace ?? "(no stack trace)");
inner = inner.InnerException;
depth++;
}
sb.AppendLine();
sb.AppendLine("=== Last Status Messages ===");
lock (_statusRing)
{
if (_statusRing.Count == 0)
sb.AppendLine("(none)");
else
foreach (var msg in _statusRing)
sb.AppendLine(msg);
}
string logPath = Path.Combine(LogDir,
$"crash_{DateTime.Now:yyyyMMdd_HHmmss}_{ex.GetType().Name}.log");
try
{
File.WriteAllText(logPath, sb.ToString());
LastLogPath = logPath;
RotateLogs();
}
catch { /* best-effort */ }
return logPath;
}
// ── Log rotation ─────────────────────────────────────────────────────
private static void RotateLogs()
{
try
{
var files = new DirectoryInfo(LogDir).GetFiles("crash_*.log");
long total = 0;
foreach (var f in files) total += f.Length;
if (total <= LogDirCapBytes) return;
// Delete oldest first until we're at half-cap
Array.Sort(files, (a, b) => a.LastWriteTime.CompareTo(b.LastWriteTime));
foreach (var f in files)
{
if (total <= LogDirCapBytes / 2) break;
try { total -= f.Length; f.Delete(); } catch { }
}
}
catch { /* best-effort */ }
}
}
}
+160
View File
@@ -0,0 +1,160 @@
namespace KillerPDF.Services
{
// ============================================================
// Glyph coverage + the fallback chain (#168).
//
// The editor is WPF, which falls back per character across every installed
// font, so anything typed looks right on screen. PdfSharpCore resolves ONE
// face and emits .notdef (a box) for anything that face lacks. So before
// drawing, ask which family can actually carry this text.
//
// Coverage is read from the font's own 'cmap' table rather than from a
// helper library: the bytes are already in hand (KillerFontResolver hands
// back a standalone face, collections included), and parsing the table is
// deterministic across font-library versions.
//
// SCOPE: this picks the best single family for a whole run of text, which
// is what real documents need - a Japanese face covers Latin too, so a line
// mixing English and Japanese still lands on one font. Text no ONE can carry
// (say Japanese and Bengali in the same box) still falls back to the user's
// font for the uncovered part; that case is what the commit-time warning is
// for. True per-character run splitting would mean re-implementing
// XTextFormatter's line breaking, which is not worth it for that tail.
// ============================================================
internal static class FontCoverage
{
// Per-script preference, first match wins. Sans-first throughout, mirroring what Windows
// itself falls back to, so a saved file looks like the editor did. Every entry is a family
// that ships with Windows; missing ones are skipped harmlessly at lookup time.
private static readonly string[] ChainJapanese = ["Yu Gothic UI", "Yu Gothic", "Meiryo", "MS Gothic", "Yu Mincho"];
private static readonly string[] ChainSimplified = ["Microsoft YaHei", "DengXian", "SimSun"];
private static readonly string[] ChainTraditional = ["Microsoft JhengHei", "MingLiU", "PMingLiU"];
private static readonly string[] ChainKorean = ["Malgun Gothic", "Gulim"];
private static readonly string[] ChainIndic = ["Nirmala UI"];
private static readonly string[] ChainThai = ["Leelawadee UI", "Tahoma"];
private static readonly string[] ChainArabic = ["Segoe UI", "Tahoma", "Traditional Arabic"];
private static readonly string[] ChainDefault = ["Segoe UI", "Arial", "Tahoma"];
/// <summary>The family to draw <paramref name="text"/> with: the user's choice when it
/// covers everything, otherwise the first family in the script's chain that does. Falls
/// back to the user's choice when nothing covers it, so behavior never gets worse than
/// before - the caller warns in that case.</summary>
internal static string PickFamily(string preferred, string? text)
{
if (string.IsNullOrEmpty(text)) return preferred;
if (Covers(preferred, text!)) return preferred;
foreach (var family in ChainFor(text!))
{
if (string.Equals(family, preferred, StringComparison.OrdinalIgnoreCase)) continue;
if (Covers(family, text!)) return family;
}
return preferred;
}
/// <summary>True when no installed family in the text's chain can render all of it, so the
/// save will contain boxes however it is drawn. Drives the commit-time warning.</summary>
internal static bool WillLoseGlyphs(string preferred, string? text)
=> !string.IsNullOrEmpty(text) && !Covers(PickFamily(preferred, text), text!);
/// <summary>The characters that survive nothing - what the warning shows the user.</summary>
internal static string UncoveredChars(string family, string? text)
{
if (string.IsNullOrEmpty(text)) return "";
var cov = CoverageFor(family);
if (cov is null) return "";
var bad = new List<char>();
foreach (int cp in CodePoints(text!))
{
if (cov.Covers(cp) || IsIgnorable(cp)) continue;
string s = char.ConvertFromUtf32(cp);
foreach (char c in s) if (!bad.Contains(c)) bad.Add(c);
if (bad.Count >= 12) break; // a sample is enough; the box could be a whole page
}
return new string([.. bad]);
}
// ── Chain selection ───────────────────────────────────────────────────────────────────
// Picked from the first character that needs help, not the first character overall: a line
// starting "Re: " and continuing in Japanese is Japanese text, not Latin text.
private static string[] ChainFor(string text)
{
foreach (int cp in CodePoints(text))
{
if (cp < 0x0370) continue; // Latin / punctuation: no chain needed to decide
if (cp is >= 0x3040 and <= 0x30FF) return ChainJapanese; // kana - unambiguous
if (cp is >= 0xAC00 and <= 0xD7AF or >= 0x1100 and <= 0x11FF) return ChainKorean;
if (cp is >= 0x0E00 and <= 0x0E7F) return ChainThai;
if (cp is >= 0x0590 and <= 0x08FF) return ChainArabic; // Hebrew + Arabic
if (cp is >= 0x0900 and <= 0x0DFF) return ChainIndic; // Devanagari..Sinhala
if (cp is >= 0x3400 and <= 0x9FFF or >= 0xF900 and <= 0xFAFF)
{
// Han with no kana anywhere: Chinese. Traditional-only blocks are rare, so
// prefer Simplified and let the Traditional chain cover what it misses.
foreach (int c2 in CodePoints(text))
if (c2 is >= 0x3040 and <= 0x30FF) return ChainJapanese;
return HasTraditionalMarker(text) ? ChainTraditional : ChainSimplified;
}
}
return ChainDefault;
}
// Bopomofo is Traditional-only, so it settles the Simplified/Traditional question when a
// document carries it. Otherwise the Simplified chain leads and Traditional follows.
private static bool HasTraditionalMarker(string text)
{
foreach (int cp in CodePoints(text))
if (cp is >= 0x3100 and <= 0x312F) return true;
return false;
}
// ── Coverage ──────────────────────────────────────────────────────────────────────────
private static readonly Dictionary<string, CmapCoverage?> Cache = new(StringComparer.OrdinalIgnoreCase);
private static readonly object Gate = new();
private static bool Covers(string family, string text)
{
var cov = CoverageFor(family);
if (cov is null) return false; // not installed / unreadable: cannot promise anything
foreach (int cp in CodePoints(text))
if (!cov.Covers(cp) && !IsIgnorable(cp)) return false;
return true;
}
// Whitespace and control characters never render a box, so they must not veto a font.
private static bool IsIgnorable(int cp) =>
cp is 0x09 or 0x0A or 0x0D or 0x20 or 0xA0 or 0x200B or 0x200C or 0x200D or 0xFEFF;
private static CmapCoverage? CoverageFor(string family)
{
lock (Gate)
{
if (Cache.TryGetValue(family, out var hit)) return hit;
CmapCoverage? cov = null;
try
{
var bytes = KillerFontResolver.RegularFaceBytes(family);
if (bytes is not null) cov = CmapCoverage.Parse(bytes);
}
catch { cov = null; }
Cache[family] = cov;
return cov;
}
}
private static IEnumerable<int> CodePoints(string s)
{
for (int i = 0; i < s.Length; i++)
{
if (char.IsHighSurrogate(s[i]) && i + 1 < s.Length && char.IsLowSurrogate(s[i + 1]))
{
yield return char.ConvertToUtf32(s[i], s[i + 1]);
i++;
}
else yield return s[i];
}
}
}
}
+105
View File
@@ -0,0 +1,105 @@
using System.Runtime.InteropServices;
using System.Text;
using System.Windows.Input;
namespace KillerPDF.Services
{
// ============================================================
// Keyboard-layout aware shortcut matching (#153).
//
// WPF's Key enum is a VIRTUAL KEY code, which is positional: it says which
// key was pressed, not what character that key types. Every punctuation
// shortcut matched by VK is therefore a US-layout assumption. On a German
// keyboard "?" is Shift+ss and "=" is Shift+0, so Ctrl+? and Ctrl+= never
// matched - and the exact-equality modifier test failed a second time,
// because producing those characters holds Shift down as well.
//
// So punctuation is matched by the character the keystroke PRODUCES under
// the active layout, which works on German, AZERTY, Nordic and everything
// else at once instead of one layout at a time. Letters and F-keys keep
// using the VK path: they are positional by nature and much cheaper.
// ============================================================
internal static class KeyLayout
{
[DllImport("user32.dll")] private static extern IntPtr GetKeyboardLayout(uint idThread);
[DllImport("user32.dll")] private static extern uint MapVirtualKeyEx(uint uCode, uint uMapType, IntPtr dwhkl);
[DllImport("user32.dll")] private static extern short VkKeyScanEx(char ch, IntPtr dwhkl);
[DllImport("user32.dll")]
private static extern int ToUnicodeEx(uint wVirtKey, uint wScanCode, byte[] lpKeyState,
[Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pwszBuff, int cchBuff, uint wFlags, IntPtr dwhkl);
private const uint MAPVK_VK_TO_VSC = 0;
private const int VK_SHIFT = 0x10;
private const int VK_SPACE = 0x20;
/// <summary>The character <paramref name="key"/> types on the CURRENT layout, or '\0' when
/// it types nothing (F-keys, arrows, modifiers). Ctrl is deliberately NOT fed to the
/// translator - with Ctrl down Windows reports control codes rather than characters.</summary>
internal static char CharFor(Key key, bool shift)
{
try
{
uint vk = (uint)KeyInterop.VirtualKeyFromKey(key);
if (vk == 0) return '\0';
IntPtr hkl = GetKeyboardLayout(0);
uint sc = MapVirtualKeyEx(vk, MAPVK_VK_TO_VSC, hkl);
var state = new byte[256];
if (shift) state[VK_SHIFT] = 0x80;
var sb = new StringBuilder(8);
int rc = ToUnicodeEx(vk, sc, state, sb, sb.Capacity, 0, hkl);
// DEAD KEYS: a negative result means this key is a dead key (accents on many
// European layouts) and the translator has just swallowed it into its internal
// state, where it would silently combine with whatever the user types next.
// Pressing a harmless key through the same call clears it back out. Calling
// twice is the documented dance; without it, typing an accent right after a
// shortcut check produces the wrong letter.
if (rc < 0)
{
var flush = new StringBuilder(8);
ToUnicodeEx(VK_SPACE, MapVirtualKeyEx(VK_SPACE, MAPVK_VK_TO_VSC, hkl),
new byte[256], flush, flush.Capacity, 0, hkl);
return '\0';
}
return rc > 0 ? sb[sb.Length - 1] : '\0';
}
catch { return '\0'; } // never let a shortcut check throw
}
/// <summary>True when Ctrl (and not Alt) is held and the keystroke types one of
/// <paramref name="chars"/>. Shift is ignored on purpose: on most layouts the shifted
/// state is exactly how these characters are produced.</summary>
internal static bool IsCtrlChar(Key key, params char[] chars)
{
var mods = Keyboard.Modifiers;
if ((mods & ModifierKeys.Control) == 0) return false;
if ((mods & ModifierKeys.Alt) != 0) return false; // AltGr combinations are not ours
char c = CharFor(key, (mods & ModifierKeys.Shift) != 0);
if (c == '\0') return false;
foreach (char want in chars) if (c == want) return true;
return false;
}
/// <summary>Can this character be typed on the current layout WITHOUT Shift? Used to label
/// shortcuts honestly: on a layout where "=" needs Shift, advertising Ctrl+= is a lie.</summary>
internal static bool TypedUnshifted(char ch)
{
try
{
short r = VkKeyScanEx(ch, GetKeyboardLayout(0));
if (r == -1) return false; // not typeable at all here
return ((r >> 8) & 0xFF) == 0; // high byte 0 = no modifiers needed
}
catch { return false; }
}
/// <summary>The character to print for "zoom in" on this layout: "+" when it is a plain
/// keypress, otherwise "=". On US both are unshifted and "=" is the familiar spelling; on
/// German "+" is the unshifted one and "=" would need Shift.</summary>
internal static string ZoomInChar() => TypedUnshifted('=') ? "=" : "+";
internal static string ZoomOutChar() => "-";
}
}
+153
View File
@@ -0,0 +1,153 @@
using System;
using System.IO;
using System.Windows;
namespace KillerPDF.Services
{
internal enum Locale { EnUS, Es, ZhTW, ZhCN, Bn, TrTR, De, Fr, JaJP, CsCZ, PlPL, HuHU }
internal static class LocaleManager
{
private static Locale _current = Locale.EnUS;
public static Locale Current => _current;
// ── Translator test mode (#211, thanks bovirus) ─────────────────────────
// --lang-file <path> loads an external translation xaml as the language override, winning
// over the saved locale, and re-applies it on every save of the file - so a translation
// can be checked for string length and context in the live app before it is built in.
// Untranslated keys fall back to the en-US base like any partial locale.
/// <summary>Full path of the external translation file, or null. Set from the
/// --lang-file switch before <see cref="Initialize"/> runs.</summary>
internal static string? ExternalFile;
/// <summary>Raised on the UI thread after a successful live re-apply, so the window can
/// rebuild its code-built captions (toolbar, context menu) the way a language switch does.</summary>
internal static event Action? ExternalReloaded;
private static FileSystemWatcher? _watcher;
private static System.Windows.Threading.DispatcherTimer? _reloadDebounce;
private static bool _externalLoadedOnce;
/// <summary>
/// Call once at startup (after ThemeManager.Initialize) to restore the saved locale.
/// </summary>
public static void Initialize()
{
var saved = App.GetSetting("Locale");
_current = Enum.TryParse<Locale>(saved, out var l) ? l : Locale.EnUS;
ApplyInternal(_current);
}
/// <summary>
/// Switch locale, persist choice, and hot-swap the string ResourceDictionary.
/// </summary>
public static void Apply(Locale locale)
{
_current = locale;
App.SetSetting("Locale", locale.ToString());
ApplyInternal(locale);
}
// ── Internal ─────────────────────────────────────────────────────
private static void ApplyInternal(Locale locale)
{
var merged = Application.Current.Resources.MergedDictionaries;
// [0] theme. [1] en-US BASE - always present so any partial locale falls back to English for
// keys it doesn't translate. [2] the chosen locale's overrides (absent for English).
if (merged.Count > 1)
merged[1] = new ResourceDictionary { Source = new Uri("pack://application:,,,/Strings/en-US.xaml") };
// Translator test mode wins over the chosen locale for the override slot.
if (ExternalFile is not null)
{
if (TryApplyExternal()) { EnsureWatcher(); return; }
if (!_externalLoadedOnce)
MessageBox.Show($"Could not load the translation file:\n{ExternalFile}\n\nCheck the path and the file's XML, then start KillerPDF again.",
"KillerPDF --lang-file", MessageBoxButton.OK, MessageBoxImage.Warning);
// Fall through to the normal locale so the app still comes up usable.
}
Uri? overrideUri = locale switch
{
Locale.Es => new Uri("pack://application:,,,/Strings/es.xaml"),
Locale.Fr => new Uri("pack://application:,,,/Strings/fr-FR.xaml"),
Locale.ZhTW => new Uri("pack://application:,,,/Strings/zh-TW.xaml"),
Locale.ZhCN => new Uri("pack://application:,,,/Strings/zh-CN.xaml"),
Locale.Bn => new Uri("pack://application:,,,/Strings/bn.xaml"),
Locale.TrTR => new Uri("pack://application:,,,/Strings/tr-TR.xaml"),
Locale.De => new Uri("pack://application:,,,/Strings/de-DE.xaml"),
Locale.JaJP => new Uri("pack://application:,,,/Strings/ja-JP.xaml"),
Locale.CsCZ => new Uri("pack://application:,,,/Strings/cs-CZ.xaml"),
Locale.PlPL => new Uri("pack://application:,,,/Strings/pl-PL.xaml"),
Locale.HuHU => new Uri("pack://application:,,,/Strings/hu-HU.xaml"),
_ => null, // English: base only
};
if (overrideUri is not null)
{
var ov = new ResourceDictionary { Source = overrideUri };
if (merged.Count > 2) merged[2] = ov; else merged.Add(ov);
}
else if (merged.Count > 2)
{
merged.RemoveAt(2);
}
}
/// <summary>Loads <see cref="ExternalFile"/> into the override slot. False on any parse or
/// IO failure - during live reload the last good version simply stays applied, since a
/// text editor mid-save routinely produces momentarily unreadable or invalid XML.</summary>
private static bool TryApplyExternal()
{
try
{
using var fs = new FileStream(ExternalFile!, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
if (System.Windows.Markup.XamlReader.Load(fs) is not ResourceDictionary rd) return false;
var merged = Application.Current.Resources.MergedDictionaries;
if (merged.Count > 2) merged[2] = rd; else merged.Add(rd);
_externalLoadedOnce = true;
return true;
}
catch { return false; }
}
private static void EnsureWatcher()
{
if (_watcher is not null) return;
string dir = Path.GetDirectoryName(ExternalFile!) is { Length: > 0 } d ? d : ".";
string name = Path.GetFileName(ExternalFile!);
_watcher = new FileSystemWatcher(dir, name) { EnableRaisingEvents = true };
// Editors save as write, replace, or delete-and-rename depending on the editor, so
// watch every shape. Events arrive on a worker thread and usually several per save -
// marshal to the UI thread and debounce into one re-apply.
_watcher.Changed += (_, _) => QueueExternalReload();
_watcher.Created += (_, _) => QueueExternalReload();
_watcher.Renamed += (_, _) => QueueExternalReload();
}
private static void QueueExternalReload()
{
Application.Current?.Dispatcher.BeginInvoke((Action)(() =>
{
_reloadDebounce ??= NewDebounce();
_reloadDebounce.Stop();
_reloadDebounce.Start();
}));
}
private static System.Windows.Threading.DispatcherTimer NewDebounce()
{
var t = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
t.Tick += (_, _) =>
{
t.Stop();
if (TryApplyExternal()) ExternalReloaded?.Invoke();
};
return t;
}
}
}
+58
View File
@@ -0,0 +1,58 @@
namespace KillerPDF.Services
{
// ============================================================
// OCR catalog - pure data, no IO, no App, no bootstrap.
// ============================================================
//
// Split out of OcrLanguages.cs so the test project can link THIS file alone. KillerPDF.Tests
// compiles individual sources rather than referencing the app assembly, and OcrLanguages
// reaches App.GetSetting, OcrNativeBootstrap and HttpClient - none of which a data check needs.
//
// THE RULE THIS FILE ENCODES: OCR languages track interface languages. If KillerPDF ships a UI
// in a language, it ships an OCR model for that language. There is no interface language whose
// text the app cannot read.
//
// It drifted once already, because nothing checked: the UI shipped hu-HU while the catalog
// stayed at eleven entries, and killerpdf.net kept claiming OCR covered ten languages and that
// Polish and Hungarian did not need models. OcrCatalogTests now reads Strings\*.xaml and fails
// the build if these lists and that folder disagree, and release.ps1 runs the suite.
internal static class OcrCatalog
{
// Tesseract code -> display name. English is NOT bundled; every model downloads on demand
// into OcrNativeBootstrap.TessDataDir. Order mirrors the language picker (English first,
// then the LangGroup radios in MainWindow.xaml).
internal static readonly (string Code, string Name)[] Languages =
[
("eng", "English"),
("ben", "Bengali"),
("ces", "Czech"),
("deu", "German"),
("spa", "Spanish"),
("fra", "French"),
("hun", "Hungarian"),
("jpn", "Japanese"),
("pol", "Polish"),
("tur", "Turkish"),
("chi_sim", "Chinese (Simplified)"),
("chi_tra", "Chinese (Traditional)"),
];
// The Strings\*.xaml locale each model backs. Kept beside the catalog so the two are always
// edited together; the test asserts this covers exactly the locales that ship.
internal static readonly (string Locale, string Code)[] LocaleToCode =
[
("en-US", "eng"),
("bn", "ben"),
("cs-CZ", "ces"),
("de-DE", "deu"),
("es", "spa"),
("fr-FR", "fra"),
("hu-HU", "hun"),
("ja-JP", "jpn"),
("pl-PL", "pol"),
("tr-TR", "tur"),
("zh-CN", "chi_sim"),
("zh-TW", "chi_tra"),
];
}
}
+102
View File
@@ -0,0 +1,102 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace KillerPDF.Services
{
// ============================================================
// OCR languages - the catalog, install checks and traineddata
// downloads. Pure helpers over files, settings and HTTP; no
// window state. Split out of Ocr.cs (KillerUI refactor).
// ============================================================
internal static class OcrLanguages
{
// The catalog itself lives in OcrCatalog.cs - pure data with no App, bootstrap or HTTP
// dependency, so the test project can link that one file and check it against Strings\.
// These forwarders keep every existing call site (OcrLanguageCatalog) working unchanged.
internal static readonly (string Code, string Name)[] OcrLanguageCatalog = OcrCatalog.Languages;
internal static readonly (string Locale, string Code)[] LocaleToOcrCode = OcrCatalog.LocaleToCode;
// True if <code>.traineddata exists in the tessdata folder. Nothing is bundled now (not even English);
// models are downloaded on demand, so this is a pure file-presence check.
internal static bool IsLanguageInstalled(string code) =>
File.Exists(Path.Combine(OcrNativeBootstrap.TessDataDir, code + ".traineddata"));
// Download URL for a language's traineddata, honoring the caller's high-quality preference.
// Standard tier uses tessdata_fast: the same integer LSTM model as the full "tessdata" repo but without
// the unused legacy-engine data, so it is ~4MB instead of ~22MB with identical LSTM accuracy. HQ uses
// tessdata_best (float LSTM): larger (~14MB) but the most accurate.
internal static string LanguageDataUrl(string code, bool highQuality) => highQuality
? $"https://raw.githubusercontent.com/tesseract-ocr/tessdata_best/main/{code}.traineddata"
: $"https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/main/{code}.traineddata";
internal static string NameForCode(string code)
{
foreach (var (c, n) in OcrLanguageCatalog) if (c == code) return n;
return code;
}
// Tracks which installed languages currently hold the high-quality (best) model, so toggling HQ off
// then on again doesn't re-download ones that are already HQ.
internal static HashSet<string> GetHqLanguages()
{
var set = new HashSet<string>();
foreach (var c in (App.GetSetting("OcrHqLanguages") ?? "").Split(['+'], StringSplitOptions.RemoveEmptyEntries))
set.Add(c);
return set;
}
internal static void MarkLanguageHq(string code, bool isHq)
{
var set = GetHqLanguages();
if (isHq) set.Add(code); else set.Remove(code);
App.SetSetting("OcrHqLanguages", string.Join("+", set));
}
internal static System.Net.Http.HttpClient MakeDownloadClient()
{
// Timeout covers connect + headers; the body is bounded by the cancellation token instead.
System.Net.ServicePointManager.SecurityProtocol |= System.Net.SecurityProtocolType.Tls12;
var http = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(100) };
http.DefaultRequestHeaders.UserAgent.ParseAdd("KillerPDF-OCR");
return http;
}
// Streams one traineddata file to destFile, reporting MB progress through the callback and honoring
// the cancel token; writes via a .part file and atomically moves into place only on full success.
// Throws on cancel/error. The GUI points the callback at the busy overlay's message line.
internal static async Task DownloadTrainedDataAsync(System.Net.Http.HttpClient http, string url, string destFile,
string label, string cancelHint, Action<string> progress, CancellationToken ct)
{
string part = destFile + ".part";
using (var resp = await http.GetAsync(url, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, ct))
{
resp.EnsureSuccessStatusCode();
long? total = resp.Content.Headers.ContentLength;
// using-var (not a block): these dispose at the end of the resp block, before the File.Move below.
using var netStream = await resp.Content.ReadAsStreamAsync();
using var fileStream = new FileStream(part, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true);
var buffer = new byte[81920];
long read = 0;
int n;
while ((n = await netStream.ReadAsync(buffer, 0, buffer.Length, ct)) > 0)
{
await fileStream.WriteAsync(buffer, 0, n, ct);
read += n;
double mb = read / 1048576.0;
progress(total.HasValue
? $"{label} {mb:F1} / {total.Value / 1048576.0:F1} MB {cancelHint}"
: $"{label} {mb:F1} MB {cancelHint}");
}
}
if (File.Exists(destFile)) File.Delete(destFile);
File.Move(part, destFile);
}
internal static void TryDeleteFile(string path)
{
try { if (File.Exists(path)) File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
}
+143
View File
@@ -0,0 +1,143 @@
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
namespace KillerPDF.Services
{
/// <summary>
/// Keeps the single-exe build self-sufficient for OCR. The native Tesseract DLLs (x64) and the bundled
/// language data are embedded as resources and self-extracted on first use, the same pattern Costura
/// uses for the managed assemblies. Native libs go in a per-version cache (they must match the app);
/// language data goes in a STABLE folder so user-downloaded packs survive app updates. Thread-safe.
/// </summary>
internal static class OcrNativeBootstrap
{
private const string NativePrefix = "KillerPDF.OcrNative.";
private const string TessDataPrefix = "KillerPDF.OcrTessData.";
private static readonly object _gate = new();
private static bool _langReady;
private static bool _nativeReady;
[DllImport("kernel32", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool SetDllDirectory(string lpPathName);
[DllImport("kernel32", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern IntPtr LoadLibrary(string lpFileName);
/// <summary>
/// Version-independent tessdata folder. The bundled English is extracted here on first use, and
/// user-downloaded language packs are written here too, so they persist across app updates.
/// </summary>
public static string TessDataDir { get; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"KillerPDF", "tessdata");
/// <summary>
/// Ensures the bundled language data (English) is present in <see cref="TessDataDir"/> and returns
/// that folder. Light - does not touch the native libraries, so it is safe to call just to inspect
/// or list installed languages (e.g. when building the language menu).
/// </summary>
public static string EnsureLanguageData()
{
if (_langReady) return TessDataDir;
lock (_gate)
{
if (_langReady) return TessDataDir;
Directory.CreateDirectory(TessDataDir);
var asm = typeof(OcrNativeBootstrap).Assembly;
foreach (string res in asm.GetManifestResourceNames())
{
if (res.StartsWith(TessDataPrefix, StringComparison.Ordinal))
{
string file = res[TessDataPrefix.Length..];
ExtractResource(asm, res, Path.Combine(TessDataDir, file), onlyIfMissing: true);
}
}
_langReady = true;
return TessDataDir;
}
}
/// <summary>
/// Extracts the native libs to a per-version cache, ensures language data, configures Tesseract's
/// native loader, and returns the tessdata folder for OcrService. Call before constructing OcrService.
/// </summary>
public static string EnsureReady()
{
EnsureLanguageData();
if (_nativeReady) return TessDataDir;
lock (_gate)
{
if (_nativeReady) return TessDataDir;
var asm = typeof(OcrNativeBootstrap).Assembly;
string version = asm.GetName().Version?.ToString() ?? "0";
string baseDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"KillerPDF", "ocr", version);
string nativeDir = Path.Combine(baseDir, "x64");
Directory.CreateDirectory(nativeDir);
foreach (string res in asm.GetManifestResourceNames())
{
if (res.StartsWith(NativePrefix, StringComparison.Ordinal))
{
string file = res[NativePrefix.Length..];
// Tesseract's loader looks in the x64 subfolder; the flat copy covers any loader
// path that does not append the platform name.
ExtractResource(asm, res, Path.Combine(nativeDir, file), onlyIfMissing: false);
ExtractResource(asm, res, Path.Combine(baseDir, file), onlyIfMissing: false);
}
}
// Point Tesseract's native loader at the cache. Reflection avoids a compile-time bind in
// case the loader type's visibility differs across package versions; the preload below is
// the hard guarantee regardless.
try
{
var loaderType = Type.GetType("InteropDotNet.LibraryLoader, Tesseract");
object? instance = loaderType?
.GetProperty("Instance", BindingFlags.Public | BindingFlags.Static)?
.GetValue(null);
loaderType?.GetProperty("CustomSearchPath")?.SetValue(instance, baseDir);
}
catch { /* fall through to the preload */ }
// Belt and suspenders: add the native dir to the DLL search path and preload the libs.
// leptonica must load before tesseract50, which depends on it.
try
{
SetDllDirectory(nativeDir);
foreach (string dll in Directory.GetFiles(nativeDir, "leptonica*.dll")) LoadLibrary(dll);
foreach (string dll in Directory.GetFiles(nativeDir, "tesseract*.dll")) LoadLibrary(dll);
}
catch { /* loader search paths above still apply */ }
_nativeReady = true;
return TessDataDir;
}
}
private static void ExtractResource(Assembly asm, string resourceName, string targetPath, bool onlyIfMissing)
{
// Language data is extracted only-if-missing: a user-downloaded pack (e.g. a high-quality model,
// or an HQ English) must never be clobbered by the bundled copy on the next launch. Native libs
// keep the length check so a version change refreshes them.
if (onlyIfMissing && File.Exists(targetPath)) return;
using var src = asm.GetManifestResourceStream(resourceName);
if (src == null) return;
if (!onlyIfMissing && File.Exists(targetPath) && new FileInfo(targetPath).Length == src.Length) return;
string tmp = targetPath + ".tmp";
using (var dst = File.Create(tmp))
src.CopyTo(dst);
if (File.Exists(targetPath)) File.Delete(targetPath);
File.Move(tmp, targetPath);
}
}
}
+117
View File
@@ -0,0 +1,117 @@
using System.IO;
using System.Windows.Media.Imaging;
using Tesseract;
namespace KillerPDF.Services
{
/// <summary>A single recognized word with its confidence and pixel box (top-left origin, OCR image space).</summary>
internal sealed class OcrWord
{
public string Text { get; set; } = "";
public float Confidence { get; set; }
public int Left { get; set; }
public int Top { get; set; }
public int Right { get; set; }
public int Bottom { get; set; }
}
/// <summary>Result of recognizing one image/page: full text, mean confidence, and per-word boxes.</summary>
internal sealed class OcrResult
{
public string Text { get; set; } = "";
public float MeanConfidence { get; set; }
public List<OcrWord> Words { get; } = [];
}
/// <summary>
/// Local Tesseract OCR. The tessdata folder (with at least eng.traineddata) must sit next to the
/// exe; the native engine loads language data by path. A TesseractEngine is NOT thread-safe, so run
/// OCR off the UI thread and create a fresh OcrService per operation (or serialize calls). Dispose when done.
/// </summary>
internal sealed class OcrService : IDisposable
{
private readonly TesseractEngine _engine;
/// <param name="tessDataPath">Folder holding *.traineddata. Defaults to the self-extracted cache (OcrNativeBootstrap).</param>
/// <param name="language">Tesseract language code(s), e.g. "eng" or "eng+ben".</param>
public OcrService(string? tessDataPath = null, string language = "eng")
{
// EnsureReady() extracts the embedded natives + language data and configures the native
// loader, so it must run before the engine is constructed.
string dataPath = tessDataPath ?? OcrNativeBootstrap.EnsureReady();
_engine = new TesseractEngine(dataPath, language, EngineMode.Default);
}
/// <summary>OCR an image file on disk (PNG, TIFF, JPEG, BMP).</summary>
public OcrResult RecognizeImageFile(string imagePath)
{
using var pix = Pix.LoadFromFile(imagePath);
return Run(pix);
}
/// <summary>OCR an encoded image already in memory (e.g. PNG bytes).</summary>
public OcrResult RecognizeImageBytes(byte[] encodedImage)
{
using var pix = Pix.LoadFromMemory(encodedImage);
return Run(pix);
}
/// <summary>
/// OCR a rendered page straight from the render pipeline (raw BGRA, 4 bytes/pixel).
/// Encodes to PNG via WPF first so we avoid a System.Drawing dependency.
/// </summary>
public OcrResult RecognizeBgra(byte[] bgra, int width, int height)
=> RecognizeImageBytes(EncodePng(bgra, width, height));
private OcrResult Run(Pix pix)
{
using var page = _engine.Process(pix);
var res = new OcrResult
{
Text = page.GetText() ?? "",
MeanConfidence = page.GetMeanConfidence(),
};
using var iter = page.GetIterator();
iter.Begin();
do
{
if (iter.TryGetBoundingBox(PageIteratorLevel.Word, out var r))
{
string w = iter.GetText(PageIteratorLevel.Word) ?? "";
if (!string.IsNullOrWhiteSpace(w))
{
res.Words.Add(new OcrWord
{
Text = w,
Confidence = iter.GetConfidence(PageIteratorLevel.Word),
Left = r.X1,
Top = r.Y1,
Right = r.X2,
Bottom = r.Y2,
});
}
}
}
while (iter.Next(PageIteratorLevel.Word));
return res;
}
private static byte[] EncodePng(byte[] bgra, int width, int height)
{
var bmp = BitmapSource.Create(
width, height, 96, 96,
System.Windows.Media.PixelFormats.Bgra32, null,
bgra, width * 4);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(bmp));
using var ms = new MemoryStream();
encoder.Save(ms);
return ms.ToArray();
}
public void Dispose() => _engine.Dispose();
}
}
+511
View File
@@ -0,0 +1,511 @@
using System.IO;
using System.Windows;
using System.Windows.Media;
using PdfSharpCore.Drawing;
using PdfSharpCore.Pdf;
namespace KillerPDF.Services
{
// ============================================================
// Burn-to-document core - draws the overlay annotation layer
// and the stamp layer (page numbers / watermark) into a
// PdfDocument via XGraphics, in PDF-point space. Static and
// window-free by design (split out of Shell/Annotations.cs and
// Shell/Stamps.cs in the KillerUI refactor): the print flow
// runs these on a background thread against a throwaway copy
// of the document, and being static means the compiler
// guarantees no live UI state is touched.
// ============================================================
internal static class PdfBurn
{
// Builds the geometry for a carved highlight: the painted rectangle MINUS the union of the eraser
// strokes (each widened to its brush radius with round caps) - one smooth, anti-aliased shape. Used
// for both on-screen rendering and PDF export. Null when the highlight hasn't been carved.
internal static Geometry? HighlightEraseGeometry(HighlightAnnotation h)
{
if (h.Erases is not { Count: > 0 } erases) return null;
var holes = new GeometryGroup { FillRule = FillRule.Nonzero };
foreach (var e in erases)
{
if (e.Points.Count == 0) continue;
if (e.Points.Count == 1)
{
holes.Children.Add(new EllipseGeometry(e.Points[0], e.Radius, e.Radius));
continue;
}
var fig = new PathFigure { StartPoint = e.Points[0], IsClosed = false, IsFilled = false };
for (int i = 1; i < e.Points.Count; i++) fig.Segments.Add(new LineSegment(e.Points[i], true));
var pg = new PathGeometry();
pg.Figures.Add(fig);
var pen = new Pen(Brushes.Black, Math.Max(0.5, e.Radius * 2))
{ StartLineCap = PenLineCap.Round, EndLineCap = PenLineCap.Round, LineJoin = PenLineJoin.Round };
holes.Children.Add(pg.GetWidenedPathGeometry(pen));
}
if (holes.Children.Count == 0) return null;
return new CombinedGeometry(GeometryCombineMode.Exclude, new RectangleGeometry(h.DrawRect()), holes);
}
// ── The rotated-page frame (#169, thanks terada-d) ────────────────────────────────────
// A page's rotation lives OUTSIDE the working document: TempReload strips /Rotate to 0 and
// keeps the angle in the shell's _pageRotations, and the render path rotates the bitmap
// instead. So the canvas - and everything measured against it - is in the VISUAL frame,
// while XGraphics draws in the page's own unrotated frame. Every path that writes canvas
// coordinates back into a page has to bridge the two; these two helpers are that bridge,
// shared by the annotation burn and the stamp burn.
//
// Both frames are top-left origin with y down (XGraphics' default page direction and the
// bitmap convention), and the render path rotates CLOCKWISE by the angle, so the mapping
// is a plain quarter-turn about the page box.
/// <summary>The page's size as the user sees it: the point dimensions swap on a quarter turn.</summary>
private static (double w, double h) VisualPageSize(double pw, double ph, int rot)
=> rot == 90 || rot == 270 ? (ph, pw) : (pw, ph);
/// <summary>Maps VISUAL-frame points onto the unrotated page frame XGraphics draws in.
/// Null for an unrotated page (nothing to apply). Prepend it to the graphics transform and
/// every subsequent draw call can keep passing visual coordinates unchanged.</summary>
private static XMatrix? VisualToPageMatrix(int rot, double pw, double ph) => rot switch
{
// Derived from the render path's clockwise bitmap rotation, then inverted:
// 90: x_page = y_vis, y_page = ph - x_vis
// 180: x_page = pw - x_vis, y_page = ph - y_vis
// 270: x_page = pw - y_vis, y_page = x_vis
// XMatrix is (m11, m12, m21, m22, dx, dy) with x' = x*m11 + y*m21 + dx.
90 => new XMatrix(0, -1, 1, 0, 0, ph),
180 => new XMatrix(-1, 0, 0, -1, pw, ph),
270 => new XMatrix(0, 1, -1, 0, pw, 0),
_ => null,
};
private static int NormalizeRot(IReadOnlyDictionary<int, int>? rotations, int pageIdx, PdfPage page)
{
// KillerPDF-managed rotations live in the map after SaveTempAndReload strips them
// from the working file. A newly opened PDF still carries its native /Rotate on the
// page, so use that value whenever the map has no entry for this page.
int r = rotations is not null && rotations.TryGetValue(pageIdx, out int stored)
? stored
: page.Rotate;
return ((r % 360) + 360) % 360;
}
// Burns annotations into the given document using only the supplied annotation + render-dim data and
// nothing from the live UI state (static, so the compiler guarantees it). This makes it safe to run on
// a background thread against a throwaway copy of the document - the print flow uses that to keep the
// UI responsive while annotated pages are flattened.
//
// rotations: the out-of-document page angles (the shell's _pageRotations). Omitting them was
// #169 - annotations were burned in the unrotated frame, so on a rotated page they landed
// turned 90 degrees from where they were placed, offset, and scaled on swapped axes.
internal static void DrawAnnotationsIntoDoc(
PdfDocument? doc,
IReadOnlyDictionary<int, List<PageAnnotation>> annotations,
IReadOnlyDictionary<int, (int w, int h)> renderDims,
int? onlyPage = null,
IReadOnlyDictionary<int, int>? rotations = null)
{
if (doc is null) return;
// Strip link annotation borders so they don't render as colored rectangles
// (e.g. strikethrough-like lines) in other PDF viewers.
PdfScrub.StripLinkAnnotationBorders(doc);
foreach (var kvp in annotations)
{
int pageIdx = kvp.Key;
if (onlyPage.HasValue && pageIdx != onlyPage.Value) continue;
var annots = kvp.Value;
if (annots.Count == 0 || pageIdx >= doc.PageCount) continue;
if (!renderDims.ContainsKey(pageIdx)) continue;
var page = doc.Pages[pageIdx];
var (renderW, renderH) = renderDims[pageIdx];
// #169: the render dims are in the VISUAL frame (that is what the user drew on),
// so scale against the visual page size, not the raw page box - on a quarter-turned
// page those are on swapped axes.
int rot = NormalizeRot(rotations, pageIdx, page);
double pwPt = page.Width.Point, phPt = page.Height.Point;
var (visW, visH) = VisualPageSize(pwPt, phPt, rot);
double sx = visW / renderW;
double sy = visH / renderH;
using var gfx = XGraphics.FromPdfPage(page, XGraphicsPdfPageOptions.Append);
// ...then turn the whole drawing back into the page's own frame, so every draw
// call below can keep working in visual coordinates exactly as it always has.
if (VisualToPageMatrix(rot, pwPt, phPt) is XMatrix m) gfx.MultiplyTransform(m);
foreach (var annot in annots)
{
switch (annot)
{
case TextAnnotation ta:
{
double tboxX = ta.Position.X * sx;
double tboxY = ta.Position.Y * sy;
double tboxW = ta.Width * sx;
double tboxH = ta.Height * sy;
// Background fill (whiteout) first, behind the text.
if (ta.HasFill)
{
var fc = ta.GetFill();
gfx.DrawRectangle(new XSolidBrush(XColor.FromArgb(fc.A, fc.R, fc.G, fc.B)),
tboxX, tboxY, Math.Max(1, tboxW), Math.Max(1, tboxH));
}
// Match the on-screen typeface + B/I/S. Strikeout is a font-style flag PDFsharp
// draws as a line. Fall back to Segoe UI if the font can't be resolved/embedded.
var xstyle = XFontStyle.Regular;
if (ta.Bold) xstyle |= XFontStyle.Bold;
if (ta.Italic) xstyle |= XFontStyle.Italic;
if (ta.Strike) xstyle |= XFontStyle.Strikeout;
if (ta.Underline) xstyle |= XFontStyle.Underline;
XFont font;
// #168: the editor is WPF and falls back per character, so anything
// typed looks right on screen; PdfSharpCore resolves one face and
// boxes whatever it lacks. Pick a family that actually covers this
// text - the user's own font whenever it can carry it.
string wantFamily = string.IsNullOrEmpty(ta.FontName) ? "Segoe UI" : ta.FontName;
string useFamily = FontCoverage.PickFamily(wantFamily, ta.Content);
try { font = new XFont(useFamily, ta.FontSize * sy, xstyle); }
catch { font = new XFont("Segoe UI", ta.FontSize * sy, xstyle); }
var taColor = ta.GetColor();
var taBrush = new XSolidBrush(XColor.FromArgb(taColor.A, taColor.R, taColor.G, taColor.B));
// Wrap inside the box, matching the on-screen TextWrapping=Wrap. The 2px editor
// padding is scaled into the layout rect so wrap points line up with the canvas.
double padX = 2 * sx, padY = 2 * sy;
var layoutRect = new XRect(tboxX + padX, tboxY + padY,
Math.Max(1, tboxW - 2 * padX), Math.Max(1, tboxH));
// #142 root cause (PR #144, thanks Ryokoxx): the short DrawString
// overload hardcodes Justify, whose draw path NREd on the null-Text
// LineBreak blocks a newline produces - any font, any machine. The
// formatter is fixed, and the alignment is now stated explicitly:
// the on-screen TextBlock sets no TextAlignment, so WPF renders it
// Left - burned output must match, not silently justify.
// The retry/skip below stays as the net for GENUINE font failures:
// DrawString resolves the typeface lazily, so a font that CONSTRUCTED
// fine can still throw at first draw on machines missing that face.
var taAlign = new PdfSharpCore.Drawing.Layout.TextFormatAlignment
{
Horizontal = PdfSharpCore.Drawing.Layout.XParagraphAlignment.Left
};
if (!string.IsNullOrEmpty(ta.Content))
{
try
{
var tf = new PdfSharpCore.Drawing.Layout.XTextFormatter(gfx);
tf.DrawString(ta.Content, font, taBrush, layoutRect, taAlign);
}
catch
{
try
{
var tf2 = new PdfSharpCore.Drawing.Layout.XTextFormatter(gfx);
// Retry with the same COVERING family (#168) - this catch is
// for the formatter's null-LineBreak crash (#142), not a font
// problem, so dropping to Segoe UI here would only add boxes.
tf2.DrawString(ta.Content, new XFont(useFamily, ta.FontSize * sy, xstyle), taBrush, layoutRect, taAlign);
}
catch { /* skip this annotation rather than fail the whole save (#142) */ }
}
}
break;
}
case HighlightAnnotation ha:
var hc = ha.GetColor();
var hBrush = new XSolidBrush(XColor.FromArgb(hc.A, hc.R, hc.G, hc.B));
// #200: fill highlights burn with the Multiply blend mode, so the color
// darkens the paper and the text stays crisp underneath instead of being
// washed out by an alpha rectangle - the way other PDF viewers highlight.
// Strikethrough/underline bands stay normal draws.
bool multiply = ha.Style == HighlightStyle.Fill;
XGraphicsState? hState = null;
if (multiply) { hState = gfx.Save(); gfx.SetPdfBlendMode("Multiply"); }
if (HighlightEraseGeometry(ha) is { } hgeo)
{
// Carved highlight: flatten the rect-minus-strokes geometry to polygons and
// draw as one filled path so the smooth hole survives into the saved PDF.
var flat = hgeo.GetFlattenedPathGeometry();
var hpath = new XGraphicsPath();
foreach (var fig in flat.Figures)
{
var poly = new System.Collections.Generic.List<XPoint> { new(fig.StartPoint.X * sx, fig.StartPoint.Y * sy) };
foreach (var seg in fig.Segments)
if (seg is PolyLineSegment pls) foreach (var p in pls.Points) poly.Add(new XPoint(p.X * sx, p.Y * sy));
else if (seg is LineSegment ls) poly.Add(new XPoint(ls.Point.X * sx, ls.Point.Y * sy));
if (poly.Count >= 3) hpath.AddPolygon([.. poly]);
}
hpath.FillMode = XFillMode.Winding;
gfx.DrawPath(hBrush, hpath);
}
else
{
var hdr = ha.DrawRect();
gfx.DrawRectangle(hBrush,
hdr.X * sx, hdr.Y * sy,
hdr.Width * sx, hdr.Height * sy);
}
if (hState != null) gfx.Restore(hState);
break;
case InkAnnotation ia:
if (ia.Points.Count < 2) break;
var ic = ia.GetColor();
if (ia.HasFill)
{
// Filled shape (#127 Phase 3): fill the enclosed region first, then stroke.
var fc = ia.GetFillColor();
var fillPts = ia.Points.Select(p => new XPoint(p.X * sx, p.Y * sy)).ToArray();
gfx.DrawPolygon(new XSolidBrush(XColor.FromArgb(fc.A, fc.R, fc.G, fc.B)),
fillPts, XFillMode.Alternate);
}
var pen = new XPen(XColor.FromArgb(ic.A, ic.R, ic.G, ic.B), ia.StrokeWidth * sx)
{
LineJoin = XLineJoin.Round,
LineCap = XLineCap.Round
};
for (int i = 0; i < ia.Points.Count - 1; i++)
{
gfx.DrawLine(pen,
ia.Points[i].X * sx, ia.Points[i].Y * sy,
ia.Points[i + 1].X * sx, ia.Points[i + 1].Y * sy);
}
break;
case SignatureAnnotation sa:
if (sa.ImageData is not null)
{
try
{
var imgBytes = Convert.FromBase64String(sa.ImageData);
var xImg = XImage.FromStream(() => new System.IO.MemoryStream(imgBytes));
double imgX = sa.Position.X * sx;
double imgY = sa.Position.Y * sy;
double imgW = sa.SourceWidth * sa.Scale * sx;
double imgH = sa.SourceHeight * sa.Scale * sy;
gfx.DrawImage(xImg, imgX, imgY, imgW, imgH);
}
catch { /* skip broken image */ }
}
else
{
var sigPen = new XPen(XColors.Black, sa.StrokeWidth * sa.Scale * sx)
{
LineJoin = XLineJoin.Round,
LineCap = XLineCap.Round
};
foreach (var stroke in sa.Strokes)
{
for (int i = 0; i < stroke.Count - 1; i++)
{
double x1 = (sa.Position.X + stroke[i].X * sa.Scale) * sx;
double y1 = (sa.Position.Y + stroke[i].Y * sa.Scale) * sy;
double x2 = (sa.Position.X + stroke[i + 1].X * sa.Scale) * sx;
double y2 = (sa.Position.Y + stroke[i + 1].Y * sa.Scale) * sy;
gfx.DrawLine(sigPen, x1, y1, x2, y2);
}
}
}
break;
case ImageAnnotation ia:
try
{
var iaBytes = Convert.FromBase64String(ia.ImageData);
var xia = XImage.FromStream(() => new System.IO.MemoryStream(iaBytes));
double iaX = ia.Position.X * sx;
double iaY = ia.Position.Y * sy;
double iaW = ia.SourceWidth * ia.Scale * sx;
double iaH = ia.SourceHeight * ia.Scale * sy;
gfx.DrawImage(xia, iaX, iaY, iaW, iaH);
}
catch { /* skip broken image */ }
break;
}
}
}
}
// ---- Stamp layer (page numbers / watermark) ------------------------------------------
// 0-based page indices for a 1-based "1-3,5" range string ("" = all pages). Shared with
// the shell's on-screen stamp renderer.
internal static IEnumerable<int> StampPageRange(string range, int pageCount)
{
var set = new SortedSet<int>();
if (string.IsNullOrWhiteSpace(range))
{
for (int i = 0; i < pageCount; i++) set.Add(i);
return set;
}
foreach (var part in range.Split(','))
{
var p = part.Trim();
if (p.Length == 0) continue;
int dash = p.IndexOf('-');
if (dash > 0)
{
if (int.TryParse(p[..dash].Trim(), out int a) && int.TryParse(p[(dash + 1)..].Trim(), out int b))
{
// Clamp the ends rather than testing each i: an unclamped "1-2147483647" wrapped
// i++ to int.MinValue at the top and never terminated. Same fix as ParseRange.
int lo = Math.Max(1, Math.Min(a, b)), hi = Math.Min(pageCount, Math.Max(a, b));
for (int i = lo; i <= hi; i++) set.Add(i - 1);
}
}
else if (int.TryParse(p, out int single) && single >= 1 && single <= pageCount) set.Add(single - 1);
}
return set;
}
// Draws the active stamps into the doc via XGraphics, in PDF-point space. Called BEFORE
// DrawAnnotationsIntoDoc at each save site so stamps sit beneath annotations. Static so the
// print flow can run it on a background thread against a throwaway document copy.
// rotations: same story as the annotation burn (#169) - stamp positions are corners of the
// page as the USER sees it, so on a rotated page they have to be laid out in the visual
// frame and mapped back. The stamp PREVIEW already swapped the dimensions, so preview and
// output disagreed until this landed.
internal static void DrawStampsIntoDoc(PdfDocument? doc, StampSpec? spec, int? onlyPage = null,
IReadOnlyDictionary<int, int>? rotations = null)
{
if (doc is null || spec is null || (!spec.NumbersEnabled && !spec.WmEnabled)) return;
int n = doc.PageCount;
HashSet<int> numPages = spec.NumbersEnabled ? [.. StampPageRange(spec.NumRange, n)] : [];
HashSet<int> wmPages = spec.WmEnabled ? [.. StampPageRange(spec.WmRange, n)] : [];
int firstNumPage = int.MaxValue;
foreach (int p in numPages) if (p < firstNumPage) firstNumPage = p;
if (firstNumPage == int.MaxValue) firstNumPage = 0;
// Pre-fade the watermark image once (reused for all pages).
XImage? wmImg = null;
if (spec.WmEnabled && spec.WmIsImage && !string.IsNullOrEmpty(spec.WmImagePath) && System.IO.File.Exists(spec.WmImagePath))
wmImg = LoadStampImage(spec.WmImagePath!, spec.WmOpacity);
for (int i = 0; i < n && i < doc.PageCount; i++)
{
if (onlyPage.HasValue && i != onlyPage.Value) continue;
bool doNum = numPages.Contains(i);
bool doWm = wmPages.Contains(i);
if (!doNum && !doWm) continue;
var page = doc.Pages[i];
int rot = NormalizeRot(rotations, i, page);
double pwPt = page.Width.Point, phPt = page.Height.Point;
var (pw, ph) = VisualPageSize(pwPt, phPt, rot); // #169: lay out on the visual page
double mx = pw * 0.05, my = ph * 0.04;
using var gfx = XGraphics.FromPdfPage(page, XGraphicsPdfPageOptions.Append);
if (VisualToPageMatrix(rot, pwPt, phPt) is XMatrix m) gfx.MultiplyTransform(m);
if (doWm) DrawWatermarkPdf(gfx, spec, pw, ph, mx, my, wmImg); // watermark first (underneath)
if (doNum) DrawNumberPdf(gfx, spec, i, firstNumPage, n, pw, ph, mx, my);
}
}
private static void DrawNumberPdf(XGraphics gfx, StampSpec spec, int pageIndex, int firstNumPage, int total, double pw, double ph, double mx, double my)
{
int number = spec.StartNumber + Math.Max(0, pageIndex - firstNumPage);
string text = (string.IsNullOrEmpty(spec.Format) ? "{n}" : spec.Format)
.Replace("{n}", number.ToString()).Replace("{N}", total.ToString());
if (text.Length == 0) return;
// #168: the format string is user text - a "Page {n}" written in Japanese or Bengali
// has to survive the save the same as an annotation does.
var font = new XFont(FontCoverage.PickFamily("Segoe UI", text), Math.Max(1, spec.NumFontPt), XFontStyle.Regular);
var c = spec.NumColor;
var brush = new XSolidBrush(XColor.FromArgb(255, c.R, c.G, c.B));
var size = gfx.MeasureString(text, font);
double w = size.Width, h = size.Height;
int posH = spec.NumPosH;
double x, y;
if (posH < 0) // custom
{
double cx = spec.NumCustomX;
if (spec.NumMirror && (pageIndex % 2 == 1)) cx = 1 - cx;
x = cx * pw - w / 2; y = spec.NumCustomY * ph - h / 2;
}
else
{
if (spec.NumMirror && posH != 1 && (pageIndex % 2 == 1)) posH = 2 - posH;
x = posH == 0 ? mx : posH == 2 ? pw - w - mx : (pw - w) / 2;
y = spec.NumPosV == 0 ? my : spec.NumPosV == 1 ? (ph - h) / 2 : ph - h - my;
}
gfx.DrawString(text, font, brush, new XRect(x, y, w, h), XStringFormats.TopLeft);
}
private static void DrawWatermarkPdf(XGraphics gfx, StampSpec spec, double pw, double ph, double mx, double my, XImage? img)
{
double w, h;
XFont? font = null;
if (spec.WmIsImage)
{
if (img is null) return;
w = pw * 0.5 * spec.WmScale;
h = w * img.PixelHeight / Math.Max(1, img.PixelWidth);
}
else
{
if (string.IsNullOrEmpty(spec.WmText)) return;
// #168: same as the page numbers - a watermark is user text in any script.
string wmWant = string.IsNullOrWhiteSpace(spec.WmFont) ? "Segoe UI" : spec.WmFont;
try { font = new XFont(FontCoverage.PickFamily(wmWant, spec.WmText), Math.Max(1, spec.WmFontPt), XFontStyle.Bold); }
catch { font = new XFont("Segoe UI", Math.Max(1, spec.WmFontPt), XFontStyle.Bold); }
var size = gfx.MeasureString(spec.WmText, font);
w = size.Width; h = size.Height;
}
double cx, cy;
if (spec.WmPosH < 0) { cx = spec.WmCustomX * pw; cy = spec.WmCustomY * ph; }
else
{
cx = spec.WmPosH == 0 ? mx + w / 2 : spec.WmPosH == 2 ? pw - mx - w / 2 : pw / 2;
cy = spec.WmPosV == 0 ? my + h / 2 : spec.WmPosV == 1 ? ph / 2 : ph - my - h / 2;
}
var state = gfx.Save();
gfx.TranslateTransform(cx, cy);
gfx.RotateTransform(-spec.WmAngle);
if (spec.WmIsImage)
{
gfx.DrawImage(img, -w / 2, -h / 2, w, h);
}
else
{
byte a = (byte)Math.Max(0, Math.Min(255, spec.WmOpacity * 255));
var c = spec.WmColor;
gfx.DrawString(spec.WmText, font, new XSolidBrush(XColor.FromArgb(a, c.R, c.G, c.B)), new XRect(-w / 2, -h / 2, w, h), XStringFormats.Center);
}
gfx.Restore(state);
}
// Loads a watermark image as an XImage, pre-faded to the requested opacity (PdfSharpCore has no
// per-draw image opacity, so we bake it into the pixels).
private static XImage? LoadStampImage(string path, double opacity)
{
try
{
byte[] bytes;
if (opacity >= 0.999)
{
bytes = System.IO.File.ReadAllBytes(path);
}
else
{
using var src = System.Drawing.Image.FromFile(path);
using var bmp = new System.Drawing.Bitmap(src.Width, src.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
using (var g = System.Drawing.Graphics.FromImage(bmp))
{
var cm = new System.Drawing.Imaging.ColorMatrix { Matrix33 = (float)Math.Max(0, Math.Min(1, opacity)) };
using var ia = new System.Drawing.Imaging.ImageAttributes();
ia.SetColorMatrix(cm);
g.DrawImage(src, new System.Drawing.Rectangle(0, 0, src.Width, src.Height), 0, 0, src.Width, src.Height, System.Drawing.GraphicsUnit.Pixel, ia);
}
using var ms = new System.IO.MemoryStream();
bmp.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
bytes = ms.ToArray();
}
return XImage.FromStream(() => new System.IO.MemoryStream(bytes));
}
catch { return null; }
}
}
}
+85
View File
@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace KillerPDF.Services
{
internal readonly record struct DetectedPdfFontStyle(string Family, bool Bold, bool Italic);
internal static class PdfFontStyle
{
// #187: PDF font resources carry POSTSCRIPT names, which are not Windows family names.
// "ArialMT", "TimesNewRomanPSMT" and "Helvetica" resolve to no installed family, so WPF
// silently fell back and the save path landed on the default font - the detected face was
// right but the family never applied, which read as "all formatting lost". Keyed on the
// name with separators removed, lowercase.
private static readonly Dictionary<string, string> PsNameMap = new(StringComparer.Ordinal)
{
["helvetica"] = "Arial",
["helveticaneue"] = "Arial",
["arial"] = "Arial",
["arialmt"] = "Arial",
["arialnarrow"] = "Arial Narrow",
["times"] = "Times New Roman",
["timesnewroman"] = "Times New Roman",
["timesnewromanps"] = "Times New Roman",
["timesnewromanpsmt"] = "Times New Roman",
["courier"] = "Courier New",
["couriernew"] = "Courier New",
["couriernewps"] = "Courier New",
["couriernewpsmt"] = "Courier New",
["symbol"] = "Symbol",
["zapfdingbats"] = "Wingdings",
["segoeui"] = "Segoe UI",
};
// PDF font resources commonly carry face styling in their PostScript names rather than
// separate metadata. Keep that styling when a source line is lifted into the text editor.
internal static DetectedPdfFontStyle FromPdfName(string rawName)
{
string name = rawName?.Trim() ?? string.Empty;
int subset = name.IndexOf('+');
if (subset >= 0 && subset + 1 < name.Length) name = name[(subset + 1)..];
bool bold = Regex.IsMatch(name, @"(?i)(bold|semibold|demibold|black|heavy|[-_,]bd(?:mt)?$)");
bool italic = Regex.IsMatch(name, @"(?i)(italic|oblique|[-_,](?:it|obl)(?:mt)?$)");
// Remove only trailing face tokens. A style word that is genuinely part of a family
// name elsewhere in the string is left alone.
string family = Regex.Replace(name,
@"(?i)(?:[-_, ]?(?:bolditalic|boldoblique|semibolditalic|demibolditalic|bold|semibold|demibold|black|heavy|italic|oblique|regular|roman|bd|it|obl)(?:mt)?)$",
string.Empty).Trim(' ', '-', '_', ',');
family = NormalizePsFamily(family);
if (string.IsNullOrWhiteSpace(family)) family = "Segoe UI";
return new DetectedPdfFontStyle(family, bold, italic);
}
// Maps a face-stripped PostScript family to the Windows family it means (#187). Unknown
// names get their trailing PS/MT foundry tags dropped and CamelCase split into words
// ("BookAntiqua" -> "Book Antiqua"), which is how PostScript names encode the family.
private static string NormalizePsFamily(string family)
{
if (string.IsNullOrWhiteSpace(family)) return family;
string key = Regex.Replace(family, @"[-_, ]", "").ToLowerInvariant();
if (PsNameMap.TryGetValue(key, out var mapped)) return mapped;
// Trailing foundry tags: TimesNewRomanPSMT-style names that are not in the map.
string trimmed = Regex.Replace(family, @"(?:PSMT|PS|MT)$", string.Empty);
if (trimmed.Length > 0 && trimmed != family)
{
key = Regex.Replace(trimmed, @"[-_, ]", "").ToLowerInvariant();
if (PsNameMap.TryGetValue(key, out mapped)) return mapped;
family = trimmed;
}
// CamelCase -> spaced words, only when the name has no separators already.
if (!family.Contains(' ') && !family.Contains('-') && !family.Contains('_'))
family = Regex.Replace(family, @"(?<=[a-z])(?=[A-Z])|(?<=[A-Za-z])(?=\d)", " ");
return family;
}
}
}
+295
View File
@@ -0,0 +1,295 @@
using System.IO;
using PdfSharpCore.Drawing;
using PdfSharpCore.Fonts;
using SixLabors.Fonts;
namespace KillerPDF.Services
{
// ============================================================
// Font resolution for the SAVE path (#168).
//
// The editor is a WPF TextBox, which falls back per character across the
// whole system font set, so anything typed LOOKS right. The save path is
// PdfSharpCore, which resolves exactly one face and emits .notdef (a box)
// for every codepoint that face lacks - so CJK, Indic and other non-Latin
// text was displayed correctly and then saved as boxes.
//
// The stock resolver enumerates "*.ttf" ONLY, and on Windows nearly every
// CJK family ships as a TrueType Collection (.ttc): Yu Gothic, MS Gothic,
// Meiryo, BIZ UD, Microsoft YaHei, JhengHei, SimSun, MingLiU. They appear
// in our font picker (that is populated from WPF, which reads .ttc fine),
// so a user could pick one, see it render, and still get boxes on save.
//
// Enumerating .ttc is necessary but NOT sufficient: PdfSharpCore's parser
// rejects collections outright -
// OpenTypeFontface.Read(): if (startTag == TTCF) throw ...
// "TrueType collection fonts are not yet supported"
// - so the bytes handed back must already be a single standalone face.
// ExtractTtcFace below rebuilds one, which is why nothing in third_party/
// needed patching: the engine never sees a 'ttcf' tag.
//
// NOTE ON FILE SIZE: embedded fonts are SUBSET (PdfTrueTypeFont /PdfCIDFont
// call CreateFontSubSet), so a few Japanese characters cost tens of KB in
// the output, not megabytes. The exception is fonts with no 'loca' table -
// i.e. CFF/.otf - which PdfCIDFont embeds WHOLE. That is why .otf is
// enumerated last and only used when nothing else covers the text.
// ============================================================
internal sealed class KillerFontResolver : IFontResolver
{
public string DefaultFontName => "Arial";
// faceKey -> the physical face. faceKey is what we hand PdfSharpCore in
// FontResolverInfo and get back in GetFont, so it just has to be unique.
private static readonly Dictionary<string, FaceFile> Faces = new(StringComparer.OrdinalIgnoreCase);
// family (lower) -> style -> faceKey
private static readonly Dictionary<string, Dictionary<XFontStyle, string>> Families = new(StringComparer.OrdinalIgnoreCase);
private static readonly object Gate = new();
private static bool _indexed;
private readonly record struct FaceFile(string Path, int FaceIndex);
/// <summary>Installs this resolver process-wide. Call once at startup, BEFORE any XFont is
/// created - PdfSharpCore caches the resolver on first use and warns on a later swap.</summary>
internal static void Install()
{
try { GlobalFontSettings.FontResolver = new KillerFontResolver(); }
catch { /* a resolver is already in use; the stock one still works for Latin */ }
}
// ── Index ─────────────────────────────────────────────────────────────────────────────
private static void EnsureIndexed()
{
lock (Gate)
{
if (_indexed) return;
_indexed = true; // set first: a failed scan must not retry on every glyph
foreach (var dir in FontDirectories())
{
// .ttf first, then .ttc, then .otf - AddFace keeps the first face registered
// for a (family, style), so this order is the preference order. .otf is last
// because CFF faces embed unsubsetted (see the note above).
foreach (var pattern in new[] { "*.ttf", "*.ttc", "*.otf" })
{
string[] files;
try { files = Directory.GetFiles(dir, pattern, SearchOption.AllDirectories); }
catch { continue; }
foreach (var file in files) IndexFile(file);
}
}
}
}
private static IEnumerable<string> FontDirectories()
{
var dirs = new List<string>();
void Add(string p) { try { if (Directory.Exists(p)) dirs.Add(p); } catch { } }
Add(Environment.ExpandEnvironmentVariables(@"%SystemRoot%\Fonts"));
// Per-user installs (fonts installed without admin rights) live here.
Add(Environment.ExpandEnvironmentVariables(@"%LOCALAPPDATA%\Microsoft\Windows\Fonts"));
return dirs;
}
private static void IndexFile(string path)
{
try
{
bool isCollection = path.EndsWith(".ttc", StringComparison.OrdinalIgnoreCase);
if (isCollection)
{
// One description per face inside the collection; the array index IS the face
// index, which is what ExtractTtcFace needs.
var descs = FontDescription.LoadFontCollectionDescriptions(path);
for (int i = 0; i < descs.Length; i++) AddFace(descs[i], path, i);
}
else
{
AddFace(FontDescription.LoadDescription(path), path, 0);
}
}
catch { /* unreadable or exotic font file - skip it, never fail the scan */ }
}
private static void AddFace(FontDescription desc, string path, int faceIndex)
{
string family = desc.FontFamilyInvariantCulture;
if (string.IsNullOrWhiteSpace(family)) return;
var style = desc.Style switch
{
SixLabors.Fonts.FontStyle.Bold => XFontStyle.Bold,
SixLabors.Fonts.FontStyle.Italic => XFontStyle.Italic,
SixLabors.Fonts.FontStyle.BoldItalic => XFontStyle.BoldItalic,
_ => XFontStyle.Regular,
};
string faceKey = family + "#" + style + "#" + faceIndex + "#" + Path.GetFileName(path);
if (!Faces.ContainsKey(faceKey)) Faces[faceKey] = new FaceFile(path, faceIndex);
if (!Families.TryGetValue(family, out var byStyle))
Families[family] = byStyle = new Dictionary<XFontStyle, string>();
if (!byStyle.ContainsKey(style)) byStyle[style] = faceKey; // first wins = pattern order
}
// ── IFontResolver ─────────────────────────────────────────────────────────────────────
// The interface declares these non-nullable but documents null as "cannot satisfy" (and the
// stock resolver returns null the same way), so the null-forgiving returns below match the
// contract as written rather than as annotated.
public FontResolverInfo ResolveTypeface(string familyName, bool isBold, bool isItalic)
{
EnsureIndexed();
if (string.IsNullOrWhiteSpace(familyName)) return null!;
if (!Families.TryGetValue(familyName, out var byStyle))
{
// WPF's picker can hand back a localized family name on a non-English Windows while
// this index is keyed on the invariant one. Fall back to a loose match so a font the
// user can see in the list still resolves.
var hit = Families.FirstOrDefault(kv =>
kv.Key.Replace(" ", "").Equals(familyName.Replace(" ", ""), StringComparison.OrdinalIgnoreCase));
if (hit.Value is null) return null!;
byStyle = hit.Value;
}
var want = (isBold, isItalic) switch
{
(true, true) => XFontStyle.BoldItalic,
(true, false) => XFontStyle.Bold,
(false, true) => XFontStyle.Italic,
_ => XFontStyle.Regular,
};
// Exact style, else regular, else whatever this family has. PdfSharpCore can simulate
// the missing emphasis, which is better than failing to resolve the family at all.
if (byStyle.TryGetValue(want, out var key))
return new FontResolverInfo(key);
if (byStyle.TryGetValue(XFontStyle.Regular, out var regular))
return new FontResolverInfo(regular, isBold, isItalic);
var any = byStyle.Values.FirstOrDefault();
return any is null ? null! : new FontResolverInfo(any, isBold, isItalic);
}
public byte[] GetFont(string faceName)
{
EnsureIndexed();
if (!Faces.TryGetValue(faceName, out var face)) return null!;
try
{
byte[] bytes = File.ReadAllBytes(face.Path);
// A collection must be split before PdfSharpCore sees it (it throws on 'ttcf').
return (IsCollection(bytes) ? ExtractTtcFace(bytes, face.FaceIndex) : bytes) ?? null!;
}
catch { return null!; }
}
/// <summary>The regular face of a family as standalone font bytes, or null when the family
/// is not installed. Used by FontCoverage to read the 'cmap' - the collection split has
/// already happened here, so callers never have to know a face came out of a .ttc.</summary>
internal static byte[]? RegularFaceBytes(string family)
{
EnsureIndexed();
if (string.IsNullOrWhiteSpace(family)) return null;
if (!Families.TryGetValue(family, out var byStyle)) return null;
if (!byStyle.TryGetValue(XFontStyle.Regular, out var key))
{
key = byStyle.Values.FirstOrDefault();
if (key is null) return null;
}
if (!Faces.TryGetValue(key, out var face)) return null;
try
{
byte[] bytes = File.ReadAllBytes(face.Path);
return IsCollection(bytes) ? ExtractTtcFace(bytes, face.FaceIndex) : bytes;
}
catch { return null; }
}
// ── TrueType Collection -> standalone face ────────────────────────────────────────────
// A .ttc is one file holding several faces that SHARE table data: a 'ttcf' header, then one
// offset table per face, whose directory entries point at tables anywhere in the file. So a
// face is extracted by copying its tables out into a fresh sfnt with rewritten offsets - no
// glyph data is touched or re-encoded.
private static bool IsCollection(byte[] b) =>
b.Length >= 4 && b[0] == 0x74 && b[1] == 0x74 && b[2] == 0x63 && b[3] == 0x66; // 'ttcf'
private static uint ReadU32(byte[] b, int p) =>
(uint)((b[p] << 24) | (b[p + 1] << 16) | (b[p + 2] << 8) | b[p + 3]);
private static ushort ReadU16(byte[] b, int p) => (ushort)((b[p] << 8) | b[p + 1]);
private static void WriteU32(byte[] b, int p, uint v)
{
b[p] = (byte)(v >> 24); b[p + 1] = (byte)(v >> 16); b[p + 2] = (byte)(v >> 8); b[p + 3] = (byte)v;
}
private static void WriteU16(byte[] b, int p, ushort v) { b[p] = (byte)(v >> 8); b[p + 1] = (byte)v; }
/// <summary>Rebuilds face <paramref name="faceIndex"/> of a TrueType Collection as a
/// standalone font file. Returns null if the collection is malformed or the index is out
/// of range, which sends the caller back to its own fallback.</summary>
private static byte[]? ExtractTtcFace(byte[] ttc, int faceIndex)
{
try
{
// ttcf header: tag(4) version(4) numFonts(4) then numFonts offsets(4 each)
if (ttc.Length < 12) return null;
uint numFonts = ReadU32(ttc, 8);
if (faceIndex < 0 || faceIndex >= numFonts) return null;
int offsetPos = 12 + faceIndex * 4;
if (offsetPos + 4 > ttc.Length) return null;
int tableDir = (int)ReadU32(ttc, offsetPos);
if (tableDir < 0 || tableDir + 12 > ttc.Length) return null;
uint sfntVersion = ReadU32(ttc, tableDir);
int numTables = ReadU16(ttc, tableDir + 4);
if (numTables <= 0 || numTables > 512) return null;
int entries = tableDir + 12;
if (entries + numTables * 16 > ttc.Length) return null;
// Lay the new file out: 12-byte header, the directory, then each table padded to a
// 4-byte boundary (required by the sfnt spec and assumed by table checksums).
int headerSize = 12 + numTables * 16;
int total = headerSize;
var tabs = new (uint tag, uint checksum, int srcOff, int len)[numTables];
for (int i = 0; i < numTables; i++)
{
int e = entries + i * 16;
uint tag = ReadU32(ttc, e);
uint sum = ReadU32(ttc, e + 4);
int off = (int)ReadU32(ttc, e + 8);
int len = (int)ReadU32(ttc, e + 12);
if (off < 0 || len < 0 || off + len > ttc.Length) return null;
tabs[i] = (tag, sum, off, len);
total += (len + 3) & ~3;
}
var outBytes = new byte[total];
WriteU32(outBytes, 0, sfntVersion);
WriteU16(outBytes, 4, (ushort)numTables);
// searchRange / entrySelector / rangeShift: derived, and some parsers do read them.
int pow2 = 1, sel = 0;
while (pow2 * 2 <= numTables) { pow2 *= 2; sel++; }
WriteU16(outBytes, 6, (ushort)(pow2 * 16));
WriteU16(outBytes, 8, (ushort)sel);
WriteU16(outBytes, 10, (ushort)(numTables * 16 - pow2 * 16));
int write = headerSize;
for (int i = 0; i < numTables; i++)
{
var t = tabs[i];
int e = 12 + i * 16;
WriteU32(outBytes, e, t.tag);
WriteU32(outBytes, e + 4, t.checksum); // table data is copied verbatim, so it stands
WriteU32(outBytes, e + 8, (uint)write);
WriteU32(outBytes, e + 12, (uint)t.len);
Buffer.BlockCopy(ttc, t.srcOff, outBytes, write, t.len);
write += (t.len + 3) & ~3; // the pad bytes stay zero
}
return outBytes;
}
catch { return null; }
}
}
}
+45
View File
@@ -0,0 +1,45 @@
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
namespace KillerPDF.Services
{
// ============================================================
// Image placement extraction for the display dark mode (#135
// follow-up: pictures keep their real colors while the page
// inverts). Pure functions over an OPEN PdfPig document - the
// caller owns the open/dispose, because a held handle on the
// temp file would block the save-time file swap.
// ============================================================
internal static class PdfImages
{
/// <summary>
/// The page's image bounding boxes as fractions of the unrotated page, top-left origin
/// (PdfPig reports PDF points, bottom-left origin - the same y-flip the annotation
/// pipeline uses). Fractional so one cached set serves every render resolution, and
/// computed against the UNROTATED page because the render sites apply the display
/// inversion before the pixel-buffer rotation. pageIndex is 0-based (PdfPig is 1-based).
/// </summary>
internal static BitmapHelpers.FracRect[] GetFracRects(PdfPigDoc doc, int pageIndex)
{
var page = doc.GetPage(pageIndex + 1);
double pw = page.Width, ph = page.Height;
if (pw <= 0 || ph <= 0) return [];
var list = new List<BitmapHelpers.FracRect>();
foreach (var img in page.GetImages())
{
var b = img.BoundingBox; // Bounds is obsolete in current PdfPig
double l = b.Left / pw, r = b.Right / pw;
double t = (ph - b.Top) / ph, bo = (ph - b.Bottom) / ph;
if (r < l) { var tmp = l; l = r; r = tmp; }
if (bo < t) { var tmp = t; t = bo; bo = tmp; }
l = Clamp01(l); r = Clamp01(r);
t = Clamp01(t); bo = Clamp01(bo);
if (r - l <= 0 || bo - t <= 0) continue; // degenerate or fully off-page
list.Add(new BitmapHelpers.FracRect(l, t, r, bo));
}
return list.ToArray();
}
private static double Clamp01(double v) => v < 0 ? 0 : (v > 1 ? 1 : v);
}
}
+453
View File
@@ -0,0 +1,453 @@
using System.IO;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Docnet.Core;
using Docnet.Core.Models;
using PdfSharpCore.Drawing;
using PdfSharpCore.Pdf;
using PdfSharpCore.Pdf.IO;
namespace KillerPDF.Services
{
// ============================================================
// File import/repair helpers - pure functions over paths and
// PdfDocuments, no window state. Split out of FileOperations.cs
// and ImportAndZip.cs (KillerUI refactor); shared by the GUI
// open/merge/repair paths, TempReload, and the CLI. The one
// import helper NOT here is TryPdfiumStripEncryption - it rides
// the shared PDFium interop block (and its lock), which stays
// on MainWindow until the interop gets its own deliberate home.
// ============================================================
internal static class PdfImport
{
// Adobe Reader only displays pages whose sides are within this range (points); outside it
// shows "The dimensions of this page are out-of-range". Shared by the image importer here
// and FileOperations' Adobe page-size guard.
internal const double MinAdobePageDim = 3.0;
internal const double MaxAdobePageDim = 14400.0;
internal static bool IsPdfPath(string p) => p.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Returns true if the PDF file has an /Encrypt entry in its trailer.
/// Scans the last 2 KB so it's fast; works regardless of how PdfSharp
/// reports security state after authenticating with an empty password.
/// </summary>
internal static bool PdfFileHasEncryption(string path)
{
try
{
using var fs = File.OpenRead(path);
long scan = Math.Min(2048, fs.Length);
fs.Seek(-scan, SeekOrigin.End);
var buf = new byte[scan];
_ = fs.Read(buf, 0, buf.Length);
// Look for /Encrypt in the raw bytes (Latin-1 safe)
var text = System.Text.Encoding.GetEncoding(1252).GetString(buf);
return text.Contains("/Encrypt");
}
catch { return false; }
}
/// <param name="stripRotations">
/// Pass true when called from SaveTempAndReload (rotations already stripped in source).
/// Pass false for open-time repair so original page rotations are preserved.
/// </param>
internal static bool TryImportRepairToPath(string sourcePath, string destPath, bool stripRotations = false)
{
try
{
using var importDoc = PdfReader.Open(sourcePath, PdfDocumentOpenMode.Import);
var cleanDoc = new PdfDocument();
for (int i = 0; i < importDoc.PageCount; i++)
cleanDoc.Pages.Add(importDoc.Pages[i]);
if (stripRotations)
for (int i = 0; i < cleanDoc.PageCount; i++)
cleanDoc.Pages[i].Rotate = 0;
cleanDoc.Save(destPath);
cleanDoc.Close();
return true;
}
catch { return false; }
}
// Appends one page per image frame (multi-frame TIFF/GIF expand to one page per frame). Page
// size matches the image's physical size at its own DPI (96 if it declares none).
internal static void AddImagePagesFromFile(PdfDocument pdf, string path)
{
using var img = System.Drawing.Image.FromFile(path);
var dim = new System.Drawing.Imaging.FrameDimension(img.FrameDimensionsList[0]);
int frameCount = Math.Max(1, img.GetFrameCount(dim));
for (int f = 0; f < frameCount; f++)
{
img.SelectActiveFrame(dim, f);
int wpx = img.Width, hpx = img.Height;
// Broken resolution metadata is common (WhatsApp and some scanners tag ~1 DPI,
// screenshots 0); trusting it makes pages Adobe Reader refuses to display
// ("dimensions out-of-range", limit 3-14400 pt per side). PDFium renders any
// size, so the file looks fine here and only fails in other viewers. Outside a
// plausible DPI range, fall back to 96.
double dpiX = img.HorizontalResolution;
double dpiY = img.VerticalResolution;
if (!(dpiX >= 24 && dpiX <= 4800)) dpiX = 96.0;
if (!(dpiY >= 24 && dpiY <= 4800)) dpiY = 96.0;
double wPt = wpx * 72.0 / dpiX;
double hPt = hpx * 72.0 / dpiY;
// Even with a sane DPI, clamp into Adobe's supported range, preserving aspect.
double shrink = Math.Min(1.0, MaxAdobePageDim / Math.Max(wPt, hPt));
wPt *= shrink; hPt *= shrink;
double grow = Math.Max(1.0, MinAdobePageDim / Math.Min(wPt, hPt));
wPt *= grow; hPt *= grow;
// Copy the active frame to a fresh 32bpp bitmap, then encode PNG (XImage reads that).
byte[] png;
using (var frame = new System.Drawing.Bitmap(wpx, hpx,
System.Drawing.Imaging.PixelFormat.Format32bppArgb))
{
using (var g = System.Drawing.Graphics.FromImage(frame))
{
g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
g.DrawImage(img, 0, 0, wpx, hpx);
}
using var ms = new MemoryStream();
frame.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
png = ms.ToArray();
}
var page = pdf.AddPage();
page.Width = wPt; // XUnit implicitly treats a double as points
page.Height = hPt;
using var gfx = XGraphics.FromPdfPage(page);
using var xImg = XImage.FromStream(() => new MemoryStream(png));
gfx.DrawImage(xImg, 0, 0, wPt, hPt);
}
}
/// <summary>
/// Builds a map of named destination string -> 0-based page index from a source document's
/// /Dests dictionary and /Names /Dests name tree.
/// </summary>
internal static Dictionary<string, int> BuildNamedDestMap(PdfDocument src)
{
var map = new Dictionary<string, int>(StringComparer.Ordinal);
try
{
var catalog = src.Internals.Catalog;
// Legacy flat /Dests dictionary
var destsDict = catalog.Elements.GetDictionary("/Dests");
if (destsDict != null)
{
foreach (var key in destsDict.Elements.Keys)
{
PdfItem? val = PdfScrub.DerefItemStatic(destsDict.Elements[key] ?? new PdfInteger(-1));
int? idx = ResolveDestPageIndexInDoc(src, val);
if (idx.HasValue) map[key.TrimStart('/')] = idx.Value;
}
}
// Modern /Names /Dests name tree
var namesDict = catalog.Elements.GetDictionary("/Names");
var destTree = namesDict?.Elements.GetDictionary("/Dests");
if (destTree != null)
WalkNameTree(src, destTree, map);
}
catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"BuildNamedDestMap: {ex}"); }
return map;
}
private static void WalkNameTree(PdfDocument src, PdfDictionary node, Dictionary<string, int> map)
{
var namesArr = node.Elements.GetArray("/Names");
if (namesArr != null)
{
for (int i = 0; i + 1 < namesArr.Elements.Count; i += 2)
{
var keyItem = namesArr.Elements[i];
string key = keyItem is PdfString ks ? ks.Value : keyItem?.ToString()?.TrimStart('/') ?? "";
if (string.IsNullOrEmpty(key)) continue;
PdfItem? val = PdfScrub.DerefItemStatic(namesArr.Elements[i + 1]);
int? idx = ResolveDestPageIndexInDoc(src, val);
if (idx.HasValue) map[key] = idx.Value;
}
}
var kids = node.Elements.GetArray("/Kids");
if (kids != null)
{
for (int i = 0; i < kids.Elements.Count; i++)
{
if (PdfScrub.DerefItemStatic(kids.Elements[i]) is PdfDictionary kid)
WalkNameTree(src, kid, map);
}
}
}
/// <summary>
/// Resolves a destination value (PdfArray or PdfDictionary with /D) to a page index
/// within the given source document by matching the page object number.
/// </summary>
private static int? ResolveDestPageIndexInDoc(PdfDocument src, PdfItem? val)
{
PdfArray? arr = val as PdfArray;
if (arr is null && val is PdfDictionary vd)
arr = vd.Elements.GetArray("/D");
if (arr is null || arr.Elements.Count == 0) return null;
var first = arr.Elements[0];
int objNum = PdfScrub.GetObjectNumber(first);
if (objNum > 0)
{
for (int i = 0; i < src.PageCount; i++)
{
var pgRef = src.Pages[i].Reference;
if (pgRef != null && pgRef.ObjectNumber == objNum) return i;
}
}
else if (first is PdfInteger pi && pi.Value >= 0 && pi.Value < src.PageCount)
{
return pi.Value;
}
return null;
}
/// <summary>
/// Walks all link annotations in pages [pageOffset, doc.PageCount) and rewrites any
/// named-destination /D values to explicit [pageRef /Fit] arrays using the merged
/// document's page references. This is needed because PdfSharpCore's import does not
/// copy the source document's /Names /Dests catalog entries.
/// </summary>
internal static void RewriteNamedDestLinks(PdfDocument doc, int pageOffset,
Dictionary<string, int> namedDestMap)
{
for (int pi = pageOffset; pi < doc.PageCount; pi++)
{
try
{
var page = doc.Pages[pi];
var annotsArr = page.Elements.GetArray("/Annots");
if (annotsArr is null) continue;
for (int ai = 0; ai < annotsArr.Elements.Count; ai++)
{
PdfItem? elem = annotsArr.Elements[ai];
PdfDictionary? ann = elem as PdfDictionary
?? (PdfScrub.DerefItemStatic(elem) as PdfDictionary);
if (ann is null) continue;
var subtype = ann.Elements["/Subtype"]?.ToString() ?? "";
if (!subtype.Contains("Link")) continue;
// Check /A /D (GoTo action)
var actionDict = ann.Elements.GetDictionary("/A");
if (actionDict != null)
{
var s = actionDict.Elements["/S"]?.ToString() ?? "";
if (s.Contains("GoTo"))
{
var destItem = actionDict.Elements["/D"];
string? name = ExtractDestName(destItem);
if (name != null && namedDestMap.TryGetValue(name, out int srcIdx))
{
int targetIdx = pageOffset + srcIdx;
if (targetIdx < doc.PageCount)
actionDict.Elements["/D"] = MakeExplicitDest(doc, targetIdx);
}
}
}
else
{
// Bare /Dest on annotation
var destItem = ann.Elements["/Dest"];
string? name = ExtractDestName(destItem);
if (name != null && namedDestMap.TryGetValue(name, out int srcIdx))
{
int targetIdx = pageOffset + srcIdx;
if (targetIdx < doc.PageCount)
ann.Elements["/Dest"] = MakeExplicitDest(doc, targetIdx);
}
}
}
}
catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"RewriteNamedDestLinks p{pi}: {ex}"); }
}
}
private static string? ExtractDestName(PdfItem? item)
{
if (item is null) return null;
if (item is PdfString ps) return ps.Value;
if (item is PdfName pn) return pn.Value.TrimStart('/');
return null;
}
private static PdfArray MakeExplicitDest(PdfDocument doc, int pageIndex)
{
var arr = new PdfArray(doc);
arr.Elements.Add(doc.Pages[pageIndex].Reference);
arr.Elements.Add(new PdfName("/Fit"));
return arr;
}
// ---- Open-failure classifiers --------------------------------------------------------
// Pattern-match PdfSharpCore's exception messages to pick the right repair strategy.
// PdfSharpCore throws on some structurally-valid PDFs that PDFium opens fine - most
// often "Unexpected EOF" from SharpZipLib's Flate inflater while reading a FlateDecode
// cross-reference stream (multi-revision PDFs with incremental updates / dangling xref
// entries that tolerant parsers ignore). Match by message AND exception type across the
// whole inner-exception chain so a wrapped SharpZipBaseException is still recovered.
internal static bool IsEofParseException(Exception ex)
{
for (Exception? e = ex; e != null; e = e.InnerException)
{
string msg = e.Message ?? string.Empty;
string type = e.GetType().FullName ?? string.Empty;
if (msg.IndexOf("EOF", StringComparison.OrdinalIgnoreCase) >= 0
|| msg.IndexOf("end of file", StringComparison.OrdinalIgnoreCase) >= 0
|| msg.IndexOf("Inflater", StringComparison.OrdinalIgnoreCase) >= 0
|| msg.IndexOf("FlateDecode", StringComparison.OrdinalIgnoreCase) >= 0
|| type.IndexOf("SharpZip", StringComparison.OrdinalIgnoreCase) >= 0)
return true;
}
return false;
}
// True for recoverable PdfSharpCore read/parse failures that our repair path
// (import-rebuild / PDFium round-trip) can usually fix. Named for the original xref case,
// but now also covers other parser-level errors surfaced when reopening a saved temp.
internal static bool IsXRefException(Exception ex) =>
ex.Message.IndexOf("XRef", StringComparison.OrdinalIgnoreCase) >= 0 ||
ex.Message.IndexOf("cross-reference", StringComparison.OrdinalIgnoreCase) >= 0 ||
ex.Message.IndexOf("trailer", StringComparison.OrdinalIgnoreCase) >= 0 ||
ex.Message.IndexOf("Invalid PDF file", StringComparison.OrdinalIgnoreCase) >= 0 ||
ex.Message.IndexOf("startxref", StringComparison.OrdinalIgnoreCase) >= 0 ||
ex.Message.IndexOf("Unexpected token", StringComparison.OrdinalIgnoreCase) >= 0 ||
// #106: "Cannot retrieve stream length." - a stream whose /Length is indirect or broken.
ex.Message.IndexOf("stream length", StringComparison.OrdinalIgnoreCase) >= 0 ||
ex.Message.IndexOf("File streams are not yet implemented", StringComparison.OrdinalIgnoreCase) >= 0;
// True for UNC paths (\\server\share, \\wsl$\..., \\wsl.localhost\...) and mapped
// network drives. Such files are copied locally before opening to avoid 9P short reads.
internal static bool IsNetworkPath(string path)
{
if (string.IsNullOrEmpty(path)) return false;
if (path.StartsWith(@"\\", StringComparison.Ordinal)) return true;
try
{
var root = System.IO.Path.GetPathRoot(path);
if (!string.IsNullOrEmpty(root) && root!.Length >= 2 && root[1] == ':')
return new DriveInfo(root).DriveType == DriveType.Network;
}
catch { }
return false;
}
internal static bool IsOwnerPasswordException(Exception ex) =>
ex.Message.IndexOf("owner", StringComparison.OrdinalIgnoreCase) >= 0 &&
ex.Message.IndexOf("password", StringComparison.OrdinalIgnoreCase) >= 0;
internal static bool IsPasswordException(Exception ex) =>
ex.Message.IndexOf("password", StringComparison.OrdinalIgnoreCase) >= 0 ||
ex.Message.IndexOf("protected", StringComparison.OrdinalIgnoreCase) >= 0 ||
ex.Message.IndexOf("encrypted", StringComparison.OrdinalIgnoreCase) >= 0;
// ---- Background-safe repair strategies -----------------------------------------------
/// <summary>
/// Strategy 1 worker (background-safe, no UI/_doc access): page-copies the source through
/// PdfSharpCore Import mode into a clean temp PDF and returns its path.
/// </summary>
internal static string? RepairViaImportToFile(string path)
{
// Returns null (never throws) so a failed strategy falls through cleanly to the next one
// and doesn't surface as a debugger "user-unhandled" break during the awaited Task.
try
{
PdfDocument repairedDoc;
using (var importDoc = PdfReader.Open(path, PdfDocumentOpenMode.Import))
{
repairedDoc = new PdfDocument();
for (int i = 0; i < importDoc.PageCount; i++)
repairedDoc.Pages.Add(importDoc.Pages[i]);
}
var repairedPath = App.MakeTempFile("repaired");
repairedDoc.Save(repairedPath);
repairedDoc.Close();
return repairedPath;
}
catch { return null; }
}
/// <summary>
/// Strategy 2 worker (background-safe, no UI/_doc access): uses PDFium (Docnet) to render
/// each page to a bitmap, rebuilds a clean PdfSharpCore document from those bitmaps, and
/// returns its temp path. Mirrors the flatten path, which also encodes off the UI thread.
/// </summary>
internal static string? RepairViaDocnetRasterizeToFile(string path)
{
// Returns null (never throws) so the caller can show a clean "repair failed" message
// without a debugger break on the awaited Task.
try
{
const int RenderPx = 2048;
using var docReader = DocLib.Instance.GetDocReader(path, new PageDimensions(RenderPx, RenderPx));
int pageCount = docReader.GetPageCount();
if (pageCount <= 0) return null;
var newDoc = new PdfDocument();
for (int i = 0; i < pageCount; i++)
{
using var pr = docReader.GetPageReader(i);
int bw = pr.GetPageWidth();
int bh = pr.GetPageHeight();
if (bw <= 0 || bh <= 0) continue;
var raw = PdfiumInterop.RenderPageWithAnnotations(path, i, bw, bh)
?? pr.GetImage(); // #141
if (raw is null || raw.Length == 0) continue;
var wb = new WriteableBitmap(bw, bh, 96, 96, PixelFormats.Bgra32, null);
wb.WritePixels(new Int32Rect(0, 0, bw, bh), raw, bw * 4, 0);
wb.Freeze();
byte[] pngBytes;
using (var ms = new System.IO.MemoryStream())
{
var enc = new PngBitmapEncoder();
enc.Frames.Add(BitmapFrame.Create(wb));
enc.Save(ms);
pngBytes = ms.ToArray();
}
// Build the page at correct aspect ratio scaled to A4-ish width.
double pageW = 595.28;
double pageH = pageW * bh / bw;
var page = newDoc.AddPage();
page.Width = XUnit.FromPoint(pageW);
page.Height = XUnit.FromPoint(pageH);
using var gfx = XGraphics.FromPdfPage(page);
var xImg = XImage.FromStream(() => new System.IO.MemoryStream(pngBytes));
gfx.DrawImage(xImg, 0, 0, pageW, pageH);
}
if (newDoc.PageCount == 0) return null;
var repairedPath = App.MakeTempFile("repaired");
newDoc.Save(repairedPath);
newDoc.Close();
return repairedPath;
}
catch { return null; }
}
}
}
+92
View File
@@ -0,0 +1,92 @@
using PdfSharpCore.Pdf;
namespace KillerPDF.Services
{
// ============================================================
// Outline/bookmark document helpers - pure functions over the
// PdfSharpCore outline tree, no window state. Split out of
// SidebarOutline.cs (KillerUI refactor); the TreeView panel and
// bookmark editing UI stay in the shell.
// ============================================================
internal static class PdfOutlines
{
/// <summary>
/// #133: PdfSharpCore's lexer decodes UTF-16 bookmark titles by their BOM, but strings it
/// decrypts AFTER parsing (owner-password protected files) never get that BOM re-check, so
/// the title arrives as raw bytes widened to chars: a U+00FE U+00FF prefix (the BOM bytes)
/// followed by one char per byte (mojibake).
/// Detect the widened BOM, re-pack the chars into bytes, and decode as UTF-16. Titles that
/// parsed correctly don't start with those two chars and pass through untouched.
/// </summary>
internal static string FixRawUnicodeTitle(string s)
{
if (s.Length < 2) return s;
bool be = s[0] == '\u00FE' && s[1] == '\u00FF'; // UTF-16BE BOM as raw chars
bool le = s[0] == '\u00FF' && s[1] == '\u00FE'; // UTF-16LE (Adobe tolerance)
if (!be && !le) return s;
foreach (char c in s)
if (c > '\u00FF') return s; // not byte-widened data - a real (odd) title, leave it
var sb = new System.Text.StringBuilder((s.Length - 2) / 2);
for (int i = 2; i + 1 < s.Length; i += 2) // a trailing odd byte is dropped rather than corrupting the pairs
sb.Append(be ? (char)((s[i] << 8) | s[i + 1])
: (char)((s[i + 1] << 8) | s[i]));
return sb.ToString();
}
internal static int CountOutlines(PdfSharpCore.Pdf.PdfOutlineCollection col)
{
int n = 0;
foreach (PdfSharpCore.Pdf.PdfOutline o in col) n += 1 + CountOutlines(o.Outlines);
return n;
}
// Bottom-up: Collection.Remove() drops the removed object from the document's reference
// table, so deleting the whole branch leaf-first leaves no orphaned outline objects (with
// dangling /Parent refs) behind in the saved file.
internal static void RemoveOutlineRecursive(PdfSharpCore.Pdf.PdfOutlineCollection parent,
PdfSharpCore.Pdf.PdfOutline outline)
{
while (outline.Outlines.Count > 0)
RemoveOutlineRecursive(outline.Outlines, outline.Outlines[outline.Outlines.Count - 1]);
parent.Remove(outline);
}
// PdfSharpCore's PrepareForSave rewrites outline linkage keys (/First /Last /Next /Prev
// /Parent /Count) from the in-memory collections but never REMOVES entries that no longer
// apply: an item that became last keeps its old /Next, a parent whose children were all
// deleted keeps /First /Last, and an emptied root would dangle (ScrubEmptyOutlines only
// drops the catalog entry when /First is gone). After any bookmark edit, strip the linkage
// keys everywhere - the writer rebuilds all of them from the collections on save.
internal static void ScrubStaleOutlineLinkKeys(PdfDocument? doc)
{
if (doc is null) return;
try
{
var item = doc.Internals.Catalog.Elements["/Outlines"];
if (item is null) return;
if (PdfScrub.DerefItemStatic(item) is PdfDictionary root)
{
root.Elements.Remove("/First");
root.Elements.Remove("/Last");
root.Elements.Remove("/Count");
}
ScrubOutlineLinkKeys(doc.Outlines);
}
catch { /* malformed outline tree - the save-time scrubs are the backstop */ }
}
private static void ScrubOutlineLinkKeys(PdfSharpCore.Pdf.PdfOutlineCollection col)
{
foreach (PdfSharpCore.Pdf.PdfOutline o in col)
{
o.Elements.Remove("/First");
o.Elements.Remove("/Last");
o.Elements.Remove("/Next");
o.Elements.Remove("/Prev");
o.Elements.Remove("/Parent");
o.Elements.Remove("/Count");
ScrubOutlineLinkKeys(o.Outlines);
}
}
}
}
+131
View File
@@ -0,0 +1,131 @@
using System.IO;
using System.Threading;
using Docnet.Core;
using Docnet.Core.Models;
using PdfSharpCore.Drawing;
using PdfSharpCore.Pdf;
namespace KillerPDF.Services
{
// ============================================================
// Document rasterization cores - pure functions over a rendered
// source file, no window state (KillerUI refactor; split out of
// FileOperations.cs' Save Flattened and Export Images flows).
// The shell keeps the dialogs, the annotation burn, and the
// progress overlay; progress arrives via callback, cancel via
// token, exactly the BuildSearchablePdf pattern.
// ============================================================
internal static class PdfRasterize
{
/// <summary>
/// Rasterizes every page of <paramref name="sourcePath"/> at 150 DPI and assembles them
/// into a new PDF at <paramref name="outputPath"/>, each page at its original point size.
/// Cancellable - nothing is saved if canceled. Runs entirely off the UI thread.
/// </summary>
internal static void FlattenToPdf(string sourcePath, int pageCount,
(double widthPt, double heightPt)[] pageDims, string outputPath,
Action<int, int> progress, CancellationToken ct)
{
// Rasterize pages across CPU cores. Docnet/PDFium is not thread-safe, so the
// pdfium render is serialized behind a lock; the PNG encode (GDI+) runs in
// parallel. Pages are assembled into the PDF afterwards, in order.
//
// The source document is opened ONCE here. The old code re-opened it inside
// the per-page loop, re-parsing the whole file on every page (O(pages) full
// document parses) - the dominant cost on large files. A single scaling
// factor renders each page at its own size at 150 DPI (150/72), so the doc
// no longer needs reopening to apply per-page pixel dimensions.
var pngPages = new byte[pageCount][];
var docGate = new object();
int done = 0;
var po = new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount) };
using var flattenReader = DocLib.Instance.GetDocReader(sourcePath, new PageDimensions(150.0 / 72.0));
Parallel.For(0, pageCount, po, i =>
{
if (ct.IsCancellationRequested) return; // cooperative: skip remaining pages' work
byte[] bgra; int rw, rh;
lock (docGate)
{
using var pr = flattenReader.GetPageReader(i);
// Composite over white (#148, Ryokoxx): PDFium leaves unpainted
// background as BGRA 0,0,0,0, which used to embed a full-page
// /SMask alpha channel in the flattened output.
// #141: WithAnnotations, or flattening an annotated PDF silently dropped the
// markup the file carried - this path builds a NEW document from the pixels.
rw = pr.GetPageWidth();
rh = pr.GetPageHeight();
bgra = PdfiumInterop.RenderPageWithAnnotations(sourcePath, i, rw, rh)
?? pr.GetImage(new Docnet.Core.Converters.NaiveTransparencyRemover());
}
// Encode BGRA to PNG (GDI+) outside the lock so it parallelizes.
pngPages[i] = BitmapHelpers.RenderToPng(bgra, rw, rh);
int n = System.Threading.Interlocked.Increment(ref done);
progress(n, pageCount);
});
if (ct.IsCancellationRequested) return; // canceled during render: assemble/save nothing
// Assemble the output PDF in page order (PdfSharp is single-threaded).
var outDoc = new PdfDocument();
try
{
for (int i = 0; i < pageCount; i++)
{
var newPage = outDoc.AddPage();
newPage.Width = XUnit.FromPoint(pageDims[i].widthPt);
newPage.Height = XUnit.FromPoint(pageDims[i].heightPt);
using var xi = XImage.FromStream(() => new MemoryStream(pngPages[i]));
using var gfx = XGraphics.FromPdfPage(newPage);
gfx.DrawImage(xi, 0, 0, newPage.Width.Point, newPage.Height.Point);
}
outDoc.Save(outputPath);
}
finally
{
outDoc.Dispose();
}
}
/// <summary>
/// Renders each selected page of <paramref name="sourcePath"/> at <paramref name="dpi"/>
/// and writes base-page-NNN.png/.jpg files into <paramref name="outDir"/>. In-app
/// rotations arrive as a snapshot array (the working file has /Rotate stripped). Returns
/// how many files were written; cancellation stops after the current page.
/// </summary>
internal static int ExportPageImages(string sourcePath, IReadOnlyList<int> pages,
int[] rotSnapshot, double dpi, bool jpeg, string outDir, string baseName, int digits,
Action<int, int> progress, CancellationToken ct)
{
int written = 0;
using var dr = DocLib.Instance.GetDocReader(sourcePath, new PageDimensions(dpi / 72.0));
int done = 0;
foreach (var idx in pages)
{
if (ct.IsCancellationRequested) return written;
byte[] raw; int w, h;
using (var pr = dr.GetPageReader(idx))
{
// Composite over white (#148, Ryokoxx): bare GetImage leaves the
// unpainted background at BGRA 0,0,0,0 - JPEG export dropped the
// alpha and produced black pages, PNG came out transparent.
// #141: WithAnnotations - an exported image should show the markup the file
// carries, the same as the page does on screen.
w = pr.GetPageWidth();
h = pr.GetPageHeight();
raw = PdfiumInterop.RenderPageWithAnnotations(sourcePath, idx, w, h)
?? pr.GetImage(new Docnet.Core.Converters.NaiveTransparencyRemover());
}
int rot = idx < rotSnapshot.Length ? rotSnapshot[idx] : 0;
if (rot != 0) (raw, w, h) = BitmapHelpers.RotateBitmap(raw, w, h, rot);
var bytes = jpeg ? BitmapHelpers.EncodeJpeg(raw, w, h, dpi) : BitmapHelpers.RenderToPng(raw, w, h, dpi);
var name = $"{baseName}-page-{(idx + 1).ToString().PadLeft(digits, '0')}.{(jpeg ? "jpg" : "png")}";
File.WriteAllBytes(Path.Combine(outDir, name), bytes);
written++;
int n = ++done;
progress(n, pages.Count);
}
return written;
}
}
}
+233
View File
@@ -0,0 +1,233 @@
using PdfSharpCore.Pdf;
namespace KillerPDF.Services
{
// ============================================================
// Pre-save document scrubs - pure functions over a PdfDocument,
// no window state. Split out of FileOperations.cs and Links.cs
// (KillerUI refactor); shared by the GUI save paths, TempReload,
// the CLI runner and the batch runner.
// ============================================================
internal static class PdfScrub
{
/// <summary>
/// Dereferences a PdfItem if it is an indirect reference (PdfReference is internal to
/// PdfSharpCore; we detect it by looking for a public "Value" property returning
/// PdfObject). Null-tolerant: absent dictionary keys arrive here as null and mean
/// "not there".
/// </summary>
internal static PdfItem? DerefItemStatic(PdfItem? item)
{
// Absent dictionary keys arrive here as null (Elements["/X"] on a fresh document is
// null for /AcroForm, /Kids, ...). The scrubs' pattern matches treat null as "not
// there", which is correct - dereferencing it here just tripped an NRE first.
if (item is null) return null;
var valueProp = item.GetType().GetProperty("Value",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
if (valueProp?.GetValue(item) is PdfObject resolved) return resolved;
return item;
}
internal static double RectNum(PdfItem item) =>
item is PdfReal r ? r.Value : item is PdfInteger n ? n.Value : 0;
/// <summary>
/// Returns the PDF object number of a PdfItem that is an indirect reference, or -1.
/// Handles the internal PdfReference type via reflection, like DerefItemStatic above.
/// </summary>
internal static int GetObjectNumber(PdfItem? item)
{
if (item is null) return -1;
var prop = item.GetType().GetProperty("ObjectNumber",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
return prop?.GetValue(item) is int n2 ? n2 : -1;
}
// #103: PdfSharpCore's writer can emit the catalog's /Outlines reference without ever
// writing the (empty, lazily created) outlines object itself - a dangling xref entry
// that strict parsers, including PdfSharpCore on reopen, refuse. An outlines dictionary
// with no /First contains no bookmarks, so dropping the entry is a semantic no-op that
// keeps the file consistent. Real bookmark trees (/First present) are left untouched.
// Called before every save of the working document.
internal static void ScrubEmptyOutlines(PdfDocument doc)
{
try
{
var cat = doc.Internals.Catalog;
var item = cat.Elements["/Outlines"];
if (item == null) return;
var resolved = DerefItemStatic(item);
if (resolved is not PdfDictionary o || o.Elements["/First"] == null)
cat.Elements.Remove("/Outlines");
}
catch { /* malformed catalog - leave the save as-is */ }
}
// PdfSharpCore's PdfPage.MediaBox/CropBox property GETTERS have create-on-read semantics:
// touching page.CropBox on a page that has none plants an empty /CropBox [0 0 0 0] into
// the page dictionary (the same lazy-getter trap as the phantom /Outlines above). A
// zero-size page box saves to disk and Adobe then rejects the page as "dimensions
// out-of-range" even though the MediaBox is fine (Chrome falls back to the MediaBox,
// which is why such files still open there). Dropping a degenerate CropBox is a semantic
// no-op - the page falls back to its MediaBox - and it also HEALS files written by
// affected versions (1.6.x up to 1.6.2) when they are re-saved. Real crops are untouched.
// Called before every save of the working document.
internal static void ScrubDegenerateCropBoxes(PdfDocument doc)
{
try
{
for (int i = 0; i < doc.PageCount; i++)
{
var elements = doc.Pages[i].Elements;
var item = elements["/CropBox"];
if (item is null) continue;
var resolved = DerefItemStatic(item);
// The box can be a parsed PdfArray (loaded from disk) or a PdfRectangle
// (planted in memory by the lazy getter) - handle both, like ScaleRectValue.
bool cropReadable = TryReadPageBox(resolved, out double cx1, out double cy1,
out double cx2, out double cy2);
double w = cropReadable ? cx2 - cx1 : -1;
double h = cropReadable ? cy2 - cy1 : -1;
// Remove only when we could read the box AND it is degenerate; anything we
// cannot interpret is left alone rather than destroyed.
if (w >= 0 && (w < 1 || h < 1))
{
elements.Remove("/CropBox");
continue;
}
// PDF requires CropBox to stay inside MediaBox. A rotated page could previously
// be saved with portrait MediaBox dimensions and a landscape CropBox, producing
// a malformed page that strict validators reject. Removing that invalid crop is
// lossless: the page falls back to its complete MediaBox instead of clipping data.
if (cropReadable && TryReadInheritedPageBox(doc.Pages[i], "/MediaBox",
out double mx1, out double my1, out double mx2, out double my2) &&
(cx1 < mx1 - 0.01 || cy1 < my1 - 0.01 ||
cx2 > mx2 + 0.01 || cy2 > my2 + 0.01))
elements.Remove("/CropBox");
}
}
catch { /* malformed page tree - leave the save as-is */ }
}
private static bool TryReadInheritedPageBox(PdfPage page, string key,
out double x1, out double y1, out double x2, out double y2)
{
PdfDictionary? node = page;
for (int depth = 0; node is not null && depth < 32; depth++)
{
var item = node.Elements[key];
if (item is not null && TryReadPageBox(DerefItemStatic(item), out x1, out y1, out x2, out y2))
return true;
node = DerefItemStatic(node.Elements["/Parent"]) as PdfDictionary;
}
x1 = y1 = x2 = y2 = 0;
return false;
}
private static bool TryReadPageBox(PdfItem? item,
out double x1, out double y1, out double x2, out double y2)
{
if (item is PdfRectangle rect)
{
x1 = Math.Min(rect.X1, rect.X2);
y1 = Math.Min(rect.Y1, rect.Y2);
x2 = Math.Max(rect.X1, rect.X2);
y2 = Math.Max(rect.Y1, rect.Y2);
return true;
}
if (item is PdfArray arr && arr.Elements.Count == 4 &&
arr.Elements[0] is PdfReal or PdfInteger && arr.Elements[1] is PdfReal or PdfInteger &&
arr.Elements[2] is PdfReal or PdfInteger && arr.Elements[3] is PdfReal or PdfInteger)
{
double ax = RectNum(arr.Elements[0]), ay = RectNum(arr.Elements[1]);
double bx = RectNum(arr.Elements[2]), by = RectNum(arr.Elements[3]);
x1 = Math.Min(ax, bx);
y1 = Math.Min(ay, by);
x2 = Math.Max(ax, bx);
y2 = Math.Max(ay, by);
return true;
}
x1 = y1 = x2 = y2 = 0;
return false;
}
// A KillerPDF save fully REWRITES the file, which mathematically invalidates any existing
// digital signature: its /ByteRange and digest describe the old bytes (ISO 19005-2, 6.4.3
// requires the digest to cover the entire file). Carrying the dead signature forward
// misleads viewers and fails PDF/A validation, so strip signature VALUES (/V) from
// signature fields and the catalog's /Perms certification (DocMDP / usage rights) that
// references them. The empty fields stay and can be re-signed via Sign Document.
// Called before every save of the working document.
internal static void ScrubDeadSignatures(PdfDocument doc)
{
try
{
var cat = doc.Internals.Catalog;
cat.Elements.Remove("/Perms");
if (DerefItemStatic(cat.Elements["/AcroForm"]) is not PdfDictionary acro) return;
if (DerefItemStatic(acro.Elements["/Fields"]) is PdfArray fields)
ScrubSigFieldValues(fields, 0);
}
catch { /* malformed catalog - leave the save as-is */ }
}
private static void ScrubSigFieldValues(PdfArray fields, int depth)
{
if (depth > 8) return; // defensive: malformed circular /Kids
foreach (var item in fields.Elements)
{
if (DerefItemStatic(item) is not PdfDictionary field) continue;
if (field.Elements.GetName("/FT") == "/Sig" && field.Elements["/V"] != null)
field.Elements.Remove("/V");
if (DerefItemStatic(field.Elements["/Kids"]) is PdfArray kids)
ScrubSigFieldValues(kids, depth + 1);
}
}
/// <summary>
/// Strips visual styling (border, color, appearance stream) from all Link annotations
/// in the document so they render as invisible clickable areas rather than colored
/// rectangles that can look like strikethroughs in other PDF viewers.
/// </summary>
internal static void StripLinkAnnotationBorders(PdfDocument doc)
{
foreach (var pdfPage in doc.Pages)
{
var annotsArr = pdfPage.Elements.GetArray("/Annots");
if (annotsArr is null) continue;
for (int i = 0; i < annotsArr.Elements.Count; i++)
{
PdfItem? elem = annotsArr.Elements[i];
PdfDictionary? ann = elem as PdfDictionary ?? DerefItemStatic(elem) as PdfDictionary;
if (ann is null) continue;
// Dereference subtype in case it is an indirect name.
var subtypeItem = ann.Elements["/Subtype"];
var subtype = (subtypeItem as PdfDictionary ?? DerefItemStatic(subtypeItem) as PdfDictionary) is null
? subtypeItem?.ToString() ?? ""
: "";
if (!subtype.Contains("Link")) continue;
// Remove appearance stream and color.
ann.Elements.Remove("/AP");
ann.Elements.Remove("/C");
// /BS (border style dict) takes precedence over /Border in PDF spec;
// set W=0 explicitly. Also set /Border [0 0 0] for older viewers.
var bs = new PdfDictionary();
bs.Elements["/W"] = new PdfInteger(0);
ann.Elements["/BS"] = bs;
var borderArr = new PdfArray();
borderArr.Elements.Add(new PdfInteger(0));
borderArr.Elements.Add(new PdfInteger(0));
borderArr.Elements.Add(new PdfInteger(0));
ann.Elements["/Border"] = borderArr;
}
}
}
}
}
+474
View File
@@ -0,0 +1,474 @@
using System.IO;
using System.Runtime.InteropServices;
using Docnet.Core;
namespace KillerPDF.Services
{
// ============================================================
// Direct PDFium P/Invoke - the ONE home for every direct
// pdfium.dll call in the app (KillerUI refactor; formerly split
// across FileOperations.cs and Links.cs).
//
// THREADING: PDFium is single-threaded. Docnet serializes every
// native call it makes on an internal static lock
// (Docnet.Core.DocLib.Lock). Every DIRECT pdfium.dll call in
// this app must hold that SAME lock, or a background Docnet
// render and a direct call (link extraction, encryption strip)
// can be inside PDFium at the same time - native heap
// corruption, exit code 0xc0000374. Confirmed from a 1.6.3
// crash dump (2026-07-17): two threads with concurrent PDFium
// frames. The raw externs are suffixed Raw; only the
// lock-holding wrappers may be called. Keeping every extern in
// this one class is what keeps the lock discipline auditable.
// ============================================================
internal static class PdfiumInterop
{
internal static readonly object PdfiumLock =
typeof(DocLib).GetField("Lock",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)
?.GetValue(null) ?? new object();
// ---- Document / page lifecycle -------------------------------------------------------
[DllImport("pdfium.dll", EntryPoint = "FPDF_LoadDocument", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr FPDF_LoadDocumentRaw(
[MarshalAs(UnmanagedType.LPStr)] string filePath,
[MarshalAs(UnmanagedType.LPStr)] string? password);
internal static IntPtr FPDF_LoadDocument(string filePath, string? password)
{ lock (PdfiumLock) return FPDF_LoadDocumentRaw(filePath, password); }
[DllImport("pdfium.dll", EntryPoint = "FPDF_CloseDocument", CallingConvention = CallingConvention.Cdecl)]
private static extern void FPDF_CloseDocumentRaw(IntPtr document);
internal static void FPDF_CloseDocument(IntPtr document)
{ lock (PdfiumLock) FPDF_CloseDocumentRaw(document); }
[DllImport("pdfium.dll", EntryPoint = "FPDF_GetPageCount", CallingConvention = CallingConvention.Cdecl)]
private static extern int FPDF_GetPageCountRaw(IntPtr document);
private static int FPDF_GetPageCount(IntPtr document)
{ lock (PdfiumLock) return FPDF_GetPageCountRaw(document); }
[DllImport("pdfium.dll", EntryPoint = "FPDF_LoadPage", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr FPDF_LoadPageRaw(IntPtr document, int page_index);
internal static IntPtr FPDF_LoadPage(IntPtr document, int page_index)
{ lock (PdfiumLock) return FPDF_LoadPageRaw(document, page_index); }
[DllImport("pdfium.dll", EntryPoint = "FPDF_ClosePage", CallingConvention = CallingConvention.Cdecl)]
private static extern void FPDF_ClosePageRaw(IntPtr page);
internal static void FPDF_ClosePage(IntPtr page)
{ lock (PdfiumLock) FPDF_ClosePageRaw(page); }
[DllImport("pdfium.dll", EntryPoint = "FPDFPage_SetRotation", CallingConvention = CallingConvention.Cdecl)]
private static extern void FPDFPage_SetRotationRaw(IntPtr page, int rotation);
private static void FPDFPage_SetRotation(IntPtr page, int rotation)
{ lock (PdfiumLock) FPDFPage_SetRotationRaw(page, rotation); }
[DllImport("pdfium.dll", EntryPoint = "FPDFPage_GenerateContent", CallingConvention = CallingConvention.Cdecl)]
private static extern bool FPDFPage_GenerateContentRaw(IntPtr page);
private static bool FPDFPage_GenerateContent(IntPtr page)
{ lock (PdfiumLock) return FPDFPage_GenerateContentRaw(page); }
// ---- Page rendering ----------------------------------------------------------------
[DllImport("pdfium.dll", EntryPoint = "FPDFBitmap_CreateEx", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr FPDFBitmap_CreateExRaw(
int width, int height, int format, IntPtr firstScan, int stride);
[DllImport("pdfium.dll", EntryPoint = "FPDFBitmap_Destroy", CallingConvention = CallingConvention.Cdecl)]
private static extern void FPDFBitmap_DestroyRaw(IntPtr bitmap);
[DllImport("pdfium.dll", EntryPoint = "FPDFBitmap_FillRect", CallingConvention = CallingConvention.Cdecl)]
private static extern void FPDFBitmap_FillRectRaw(
IntPtr bitmap, int left, int top, int width, int height, uint color);
[DllImport("pdfium.dll", EntryPoint = "FPDF_RenderPageBitmap", CallingConvention = CallingConvention.Cdecl)]
private static extern void FPDF_RenderPageBitmapRaw(
IntPtr bitmap, IntPtr page, int startX, int startY, int sizeX, int sizeY,
int rotate, int flags);
[DllImport("pdfium.dll", EntryPoint = "FPDFDOC_InitFormFillEnvironment", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr FPDFDOC_InitFormFillEnvironmentRaw(
IntPtr document, IntPtr formInfo);
[DllImport("pdfium.dll", EntryPoint = "FPDFDOC_ExitFormFillEnvironment", CallingConvention = CallingConvention.Cdecl)]
private static extern void FPDFDOC_ExitFormFillEnvironmentRaw(IntPtr formHandle);
[DllImport("pdfium.dll", EntryPoint = "FPDF_FFLDraw", CallingConvention = CallingConvention.Cdecl)]
private static extern void FPDF_FFLDrawRaw(
IntPtr formHandle, IntPtr bitmap, IntPtr page, int startX, int startY,
int sizeX, int sizeY, int rotate, int flags);
[DllImport("pdfium.dll", EntryPoint = "FPDFPage_GetAnnotCount", CallingConvention = CallingConvention.Cdecl)]
private static extern int FPDFPage_GetAnnotCountRaw(IntPtr page);
[DllImport("pdfium.dll", EntryPoint = "FPDFPage_GetAnnot", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr FPDFPage_GetAnnotRaw(IntPtr page, int index);
[DllImport("pdfium.dll", EntryPoint = "FPDFPage_CloseAnnot", CallingConvention = CallingConvention.Cdecl)]
private static extern void FPDFPage_CloseAnnotRaw(IntPtr annot);
[DllImport("pdfium.dll", EntryPoint = "FPDFAnnot_GetSubtype", CallingConvention = CallingConvention.Cdecl)]
private static extern int FPDFAnnot_GetSubtypeRaw(IntPtr annot);
[DllImport("pdfium.dll", EntryPoint = "FPDFAnnot_GetFlags", CallingConvention = CallingConvention.Cdecl)]
private static extern int FPDFAnnot_GetFlagsRaw(IntPtr annot);
[DllImport("pdfium.dll", EntryPoint = "FPDFAnnot_SetFlags", CallingConvention = CallingConvention.Cdecl)]
private static extern int FPDFAnnot_SetFlagsRaw(IntPtr annot, int flags);
private const int FPDFBitmapBgra = 4;
private const int FpdfAnnot = 0x01;
private const int FpdfLcdText = 0x02;
private const int FpdfAnnotSubtypeWidget = 20; // fpdf_annot.h FPDF_ANNOT_WIDGET
private const int FpdfAnnotFlagHidden = 1 << 1; // fpdf_annot.h FPDF_ANNOT_FLAG_HIDDEN
// Marks every WIDGET annotation on the loaded page hidden so the FPDF_ANNOT render pass
// does not paint form-field appearances, and returns each widget's original flags by
// annotation index so RestoreWidgetAnnotationFlags can put them back before FFLDraw.
// In-memory only: this renderer's document is a one-shot load that is closed right after,
// never saved. EntryPointNotFound (an older bundled PDFium without the annot API) returns
// null and degrades to leaving the fields baked in.
private static System.Collections.Generic.Dictionary<int, int>? HideWidgetAnnotations(IntPtr page)
{
try
{
var saved = new System.Collections.Generic.Dictionary<int, int>();
int count = FPDFPage_GetAnnotCountRaw(page);
for (int i = 0; i < count; i++)
{
IntPtr annot = FPDFPage_GetAnnotRaw(page, i);
if (annot == IntPtr.Zero) continue;
try
{
if (FPDFAnnot_GetSubtypeRaw(annot) == FpdfAnnotSubtypeWidget)
{
int flags = FPDFAnnot_GetFlagsRaw(annot);
saved[i] = flags;
FPDFAnnot_SetFlagsRaw(annot, flags | FpdfAnnotFlagHidden);
}
}
finally { FPDFPage_CloseAnnotRaw(annot); }
}
return saved;
}
catch { return null; /* annot API unavailable: fields stay baked, no crash */ }
}
// Puts back the widget flags HideWidgetAnnotations saved, so FFLDraw sees the original
// visibility: a genuinely hidden field stays hidden, everything else draws once there.
private static void RestoreWidgetAnnotationFlags(
IntPtr page, System.Collections.Generic.Dictionary<int, int>? saved)
{
if (saved is null) return;
try
{
foreach (var kv in saved)
{
IntPtr annot = FPDFPage_GetAnnotRaw(page, kv.Key);
if (annot == IntPtr.Zero) continue;
try { FPDFAnnot_SetFlagsRaw(annot, kv.Value); }
finally { FPDFPage_CloseAnnotRaw(annot); }
}
}
catch { }
}
/// <summary>
/// Renders one page through PDFium with annotation appearance streams enabled, but without
/// Docnet's form-fill environment. This avoids the native teardown crash caused by Docnet's
/// annotation flag while still painting text notes, highlights, stamps, ink, and widget
/// appearances into viewer, print, flatten, and image-export pixels.
/// </summary>
/// <param name="includeFormFields">False for the on-screen viewer, whose live form
/// overlays already show the field values - baking them into the page bitmap as well
/// painted the same text twice, slightly offset (the "drop shadow" ghost, thanks Thomas).
/// True everywhere the pixels ARE the output: print, flatten, export, thumbnails.</param>
internal static byte[]? RenderPageWithAnnotations(
string sourcePath, int pageIndex, int width, int height,
bool transparentBackground = false, bool includeFormFields = true)
{
if (width <= 0 || height <= 0) return null;
try
{
try { _ = DocLib.Instance; } catch { }
int stride = checked(width * 4);
var bytes = new byte[checked(stride * height)];
var pinned = GCHandle.Alloc(bytes, GCHandleType.Pinned);
try
{
lock (PdfiumLock)
{
IntPtr doc = FPDF_LoadDocumentRaw(sourcePath, null);
if (doc == IntPtr.Zero) return null;
// PDFium retains this pointer until ExitFormFillEnvironment, so it must be
// stable unmanaged memory, not a temporary buffer produced by the P/Invoke
// marshaller. The bundled ABI is one 32-bit version plus 31 pointer slots.
int formInfoSize = IntPtr.Size == 8 ? 256 : 128;
IntPtr formInfo = Marshal.AllocHGlobal(formInfoSize);
for (int offset = 0; offset < formInfoSize; offset += 4)
Marshal.WriteInt32(formInfo, offset, 0);
IntPtr form = IntPtr.Zero;
for (int version = 1; version <= 2 && form == IntPtr.Zero; version++)
{
Marshal.WriteInt32(formInfo, 0, version);
form = FPDFDOC_InitFormFillEnvironmentRaw(doc, formInfo);
}
try
{
IntPtr page = FPDF_LoadPageRaw(doc, pageIndex);
if (page == IntPtr.Zero) return null;
try
{
// Widgets are hidden for the static FPDF_ANNOT pass in BOTH modes:
// the on-screen viewer replaces them with live overlays, and the
// output path paints them once via FFLDraw below - letting the
// static pass draw the /AP as well painted every field twice
// whenever the /AP layout and FFLDraw's (NeedAppearances) layout
// disagreed. If the output path has no form environment to draw
// with, the widgets stay visible so the static pass still shows them.
var savedWidgetFlags = includeFormFields && form == IntPtr.Zero
? null
: HideWidgetAnnotations(page);
IntPtr bitmap = FPDFBitmap_CreateExRaw(
width, height, FPDFBitmapBgra, pinned.AddrOfPinnedObject(), stride);
if (bitmap == IntPtr.Zero) return null;
try
{
FPDFBitmap_FillRectRaw(bitmap, 0, 0, width, height,
transparentBackground ? 0x00000000 : 0xFFFFFFFF);
FPDF_RenderPageBitmapRaw(bitmap, page, 0, 0, width, height, 0,
FpdfAnnot | FpdfLcdText);
if (includeFormFields && form != IntPtr.Zero)
{
RestoreWidgetAnnotationFlags(page, savedWidgetFlags);
FPDF_FFLDrawRaw(form, bitmap, page, 0, 0, width, height, 0,
FpdfAnnot | FpdfLcdText);
}
}
finally { FPDFBitmap_DestroyRaw(bitmap); }
}
finally
{
// This PDFium build expects the form environment to be released while
// its page is still alive. The one-shot renderer closes the page and
// document immediately afterwards, so no damaged native state is reused.
if (form != IntPtr.Zero)
{
FPDFDOC_ExitFormFillEnvironmentRaw(form);
form = IntPtr.Zero;
}
FPDF_ClosePageRaw(page);
}
}
finally
{
if (form != IntPtr.Zero) FPDFDOC_ExitFormFillEnvironmentRaw(form);
Marshal.FreeHGlobal(formInfo);
FPDF_CloseDocumentRaw(doc);
}
}
return bytes;
}
finally { pinned.Free(); }
}
catch { return null; }
}
// ---- Save ---------------------------------------------------------------------------
[DllImport("pdfium.dll", EntryPoint = "FPDF_SaveWithVersion", CallingConvention = CallingConvention.Cdecl)]
private static extern bool FPDF_SaveWithVersionRaw(
IntPtr document, ref FPDF_FILEWRITE fileWrite, uint flags, int fileVersion);
private static bool FPDF_SaveWithVersion(IntPtr document, ref FPDF_FILEWRITE fileWrite, uint flags, int fileVersion)
{ lock (PdfiumLock) return FPDF_SaveWithVersionRaw(document, ref fileWrite, flags, fileVersion); }
[StructLayout(LayoutKind.Sequential)]
private struct FPDF_FILEWRITE
{
public int version; // must be 1
public IntPtr WriteBlock; // cdecl: int WriteBlock(FPDF_FILEWRITE*, const void*, unsigned long)
}
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int PdfWriteBlockDelegate(IntPtr pThis, IntPtr pData, uint size);
private const uint FPDF_REMOVE_SECURITY = 3;
// ---- Link extraction entry points (fallback for object-stream PDFs) ------------------
// PdfSharpCore silently drops link annotations stored in object streams (linearized /
// PDF 1.5+); PDFium resolves them natively. Consumed by Links.cs' cached-handle pass.
[StructLayout(LayoutKind.Sequential)]
internal struct FS_RECTF { public float left, top, right, bottom; }
[DllImport("pdfium.dll", EntryPoint = "FPDF_GetPageWidth", CallingConvention = CallingConvention.Cdecl)]
private static extern double FPDF_GetPageWidthRaw(IntPtr page);
internal static double FPDF_GetPageWidth(IntPtr page)
{ lock (PdfiumLock) return FPDF_GetPageWidthRaw(page); }
[DllImport("pdfium.dll", EntryPoint = "FPDF_GetPageHeight", CallingConvention = CallingConvention.Cdecl)]
private static extern double FPDF_GetPageHeightRaw(IntPtr page);
internal static double FPDF_GetPageHeight(IntPtr page)
{ lock (PdfiumLock) return FPDF_GetPageHeightRaw(page); }
[DllImport("pdfium.dll", EntryPoint = "FPDFLink_Enumerate", CallingConvention = CallingConvention.Cdecl)]
private static extern bool FPDFLink_EnumerateRaw(IntPtr page, ref int startPos, out IntPtr linkAnnot);
internal static bool FPDFLink_Enumerate(IntPtr page, ref int startPos, out IntPtr linkAnnot)
{ lock (PdfiumLock) return FPDFLink_EnumerateRaw(page, ref startPos, out linkAnnot); }
[DllImport("pdfium.dll", EntryPoint = "FPDFLink_GetAnnotRect", CallingConvention = CallingConvention.Cdecl)]
private static extern bool FPDFLink_GetAnnotRectRaw(IntPtr linkAnnot, out FS_RECTF rect);
internal static bool FPDFLink_GetAnnotRect(IntPtr linkAnnot, out FS_RECTF rect)
{ lock (PdfiumLock) return FPDFLink_GetAnnotRectRaw(linkAnnot, out rect); }
[DllImport("pdfium.dll", EntryPoint = "FPDFLink_GetDest", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr FPDFLink_GetDestRaw(IntPtr document, IntPtr link);
internal static IntPtr FPDFLink_GetDest(IntPtr document, IntPtr link)
{ lock (PdfiumLock) return FPDFLink_GetDestRaw(document, link); }
[DllImport("pdfium.dll", EntryPoint = "FPDFLink_GetAction", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr FPDFLink_GetActionRaw(IntPtr link);
internal static IntPtr FPDFLink_GetAction(IntPtr link)
{ lock (PdfiumLock) return FPDFLink_GetActionRaw(link); }
[DllImport("pdfium.dll", EntryPoint = "FPDFAction_GetType", CallingConvention = CallingConvention.Cdecl)]
private static extern uint FPDFAction_GetTypeRaw(IntPtr action);
internal static uint FPDFAction_GetType(IntPtr action)
{ lock (PdfiumLock) return FPDFAction_GetTypeRaw(action); }
[DllImport("pdfium.dll", EntryPoint = "FPDFAction_GetDest", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr FPDFAction_GetDestRaw(IntPtr document, IntPtr action);
internal static IntPtr FPDFAction_GetDest(IntPtr document, IntPtr action)
{ lock (PdfiumLock) return FPDFAction_GetDestRaw(document, action); }
[DllImport("pdfium.dll", EntryPoint = "FPDFAction_GetURIPath", CallingConvention = CallingConvention.Cdecl)]
private static extern uint FPDFAction_GetURIPathRaw(IntPtr document, IntPtr action, byte[]? buffer, uint buflen);
internal static uint FPDFAction_GetURIPath(IntPtr document, IntPtr action, byte[]? buffer, uint buflen)
{ lock (PdfiumLock) return FPDFAction_GetURIPathRaw(document, action, buffer, buflen); }
[DllImport("pdfium.dll", EntryPoint = "FPDFDest_GetDestPageIndex", CallingConvention = CallingConvention.Cdecl)]
private static extern int FPDFDest_GetDestPageIndexRaw(IntPtr document, IntPtr dest);
internal static int FPDFDest_GetDestPageIndex(IntPtr document, IntPtr dest)
{ lock (PdfiumLock) return FPDFDest_GetDestPageIndexRaw(document, dest); }
// ---- The two direct-PDFium file operations -------------------------------------------
/// <summary>
/// Uses PDFium to save a copy of <paramref name="sourcePath"/> with all security/encryption
/// removed. Returns true on success. Falls back gracefully if PDFium is unavailable.
/// PDFium is already initialized by Docnet; no separate init call is needed.
/// </summary>
internal static bool TryPdfiumStripEncryption(string sourcePath, string destPath)
{
try
{
// Ensure PDFium is initialized - Docnet does this lazily on first use,
// so force it now before we call PDFium P/Invoke directly.
try { _ = DocLib.Instance; } catch { }
var doc = FPDF_LoadDocument(sourcePath, null);
if (doc == IntPtr.Zero) return false;
try
{
using var ms = new MemoryStream();
PdfWriteBlockDelegate cb = (_, pData, size) =>
{
var buf = new byte[size];
Marshal.Copy(pData, buf, 0, (int)size);
ms.Write(buf, 0, (int)size);
return 1;
};
var gch = GCHandle.Alloc(cb);
try
{
var fw = new FPDF_FILEWRITE
{
version = 1,
WriteBlock = Marshal.GetFunctionPointerForDelegate(cb)
};
if (!FPDF_SaveWithVersion(doc, ref fw, FPDF_REMOVE_SECURITY, 0))
return false;
}
finally { gch.Free(); }
File.WriteAllBytes(destPath, ms.ToArray());
return true;
}
finally { FPDF_CloseDocument(doc); }
}
catch { return false; }
}
/// <summary>
/// Uses PDFium to load <paramref name="sourcePath"/>, zero-out all page /Rotate values,
/// strip encryption, and save to <paramref name="destPath"/>. Returns true on success.
/// Called from SaveTempAndReload's xref-error fallback - PDFium is guaranteed to be
/// initialized by then because the page preview has already rendered via Docnet.
/// </summary>
internal static bool TryPdfiumSaveWithZeroRotations(string sourcePath, string destPath)
{
try
{
var doc = FPDF_LoadDocument(sourcePath, null);
if (doc == IntPtr.Zero)
{
try { File.AppendAllText(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "killerpdf_pdfium_debug.txt"), $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] FPDF_LoadDocument returned null for: {sourcePath}\n\n"); } catch { }
return false;
}
try
{
int pageCount = FPDF_GetPageCount(doc);
for (int i = 0; i < pageCount; i++)
{
var page = FPDF_LoadPage(doc, i);
if (page == IntPtr.Zero) continue;
try
{
FPDFPage_SetRotation(page, 0); // strip /Rotate so Docnet renders cleanly
FPDFPage_GenerateContent(page);
}
finally { FPDF_ClosePage(page); }
}
using var ms = new MemoryStream();
PdfWriteBlockDelegate cb = (_, pData, size) =>
{
var buf = new byte[size];
Marshal.Copy(pData, buf, 0, (int)size);
ms.Write(buf, 0, (int)size);
return 1;
};
var gch = GCHandle.Alloc(cb);
try
{
var fw = new FPDF_FILEWRITE
{
version = 1,
WriteBlock = Marshal.GetFunctionPointerForDelegate(cb)
};
if (!FPDF_SaveWithVersion(doc, ref fw, FPDF_REMOVE_SECURITY, 0))
return false;
}
finally { gch.Free(); }
File.WriteAllBytes(destPath, ms.ToArray());
return true;
}
finally { FPDF_CloseDocument(doc); }
}
catch (Exception ex)
{
try
{
File.AppendAllText(
System.IO.Path.Combine(System.IO.Path.GetTempPath(), "killerpdf_pdfium_debug.txt"),
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] TryPdfiumSaveWithZeroRotations failed\n" +
$" source: {sourcePath}\n" +
$" type: {ex.GetType().FullName}\n" +
$" msg: {ex.Message}\n" +
$" stack: {ex.StackTrace}\n\n");
}
catch { /* log failure is non-fatal */ }
return false;
}
}
}
}
+132
View File
@@ -0,0 +1,132 @@
using System;
using System.Linq;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace KillerPDF.Services
{
internal static class PerspectiveWarp
{
internal static bool IsIdentity(System.Collections.Generic.IReadOnlyList<Point> corners)
=> corners.Count == 4 &&
Near(corners[0], new Point(0, 0)) && Near(corners[1], new Point(1, 0)) &&
Near(corners[2], new Point(1, 1)) && Near(corners[3], new Point(0, 1));
private static bool Near(Point a, Point b)
=> Math.Abs(a.X - b.X) < 0.0001 && Math.Abs(a.Y - b.Y) < 0.0001;
internal static BitmapSource Apply(BitmapSource source, System.Collections.Generic.IReadOnlyList<Point> normalizedCorners)
{
if (normalizedCorners.Count != 4) throw new ArgumentException("Four corners are required.");
Point[] q = normalizedCorners.Select(p => new Point(
Math.Max(0, Math.Min(1, p.X)) * (source.PixelWidth - 1),
Math.Max(0, Math.Min(1, p.Y)) * (source.PixelHeight - 1))).ToArray();
double signedArea = 0;
for (int i = 0; i < 4; i++)
{
Point next = q[(i + 1) % 4];
signedArea += q[i].X * next.Y - next.X * q[i].Y;
}
if (Math.Abs(signedArea) < 1 || !IsConvex(q))
throw new InvalidOperationException("The selected corners must form a non-crossing four-sided page.");
double top = Distance(q[0], q[1]), bottom = Distance(q[3], q[2]);
double left = Distance(q[0], q[3]), right = Distance(q[1], q[2]);
int outW = Math.Max(2, (int)Math.Round((top + bottom) / 2));
int outH = Math.Max(2, (int)Math.Round((left + right) / 2));
var converted = new FormatConvertedBitmap(source, PixelFormats.Bgra32, null, 0);
int srcStride = converted.PixelWidth * 4;
byte[] src = new byte[srcStride * converted.PixelHeight];
converted.CopyPixels(src, srcStride, 0);
byte[] dst = new byte[outW * outH * 4];
SquareToQuad(q, out double a, out double b, out double c,
out double d, out double e, out double f,
out double g, out double h);
for (int y = 0; y < outH; y++)
{
double v = outH == 1 ? 0 : y / (double)(outH - 1);
for (int x = 0; x < outW; x++)
{
double u = outW == 1 ? 0 : x / (double)(outW - 1);
double den = g * u + h * v + 1;
double sx = (a * u + b * v + c) / den;
double sy = (d * u + e * v + f) / den;
SampleBilinear(src, converted.PixelWidth, converted.PixelHeight, srcStride,
sx, sy, dst, (y * outW + x) * 4);
}
}
var result = BitmapSource.Create(outW, outH, source.DpiX, source.DpiY,
PixelFormats.Bgra32, null, dst, outW * 4);
result.Freeze();
return result;
}
private static double Distance(Point a, Point b)
{
double dx = b.X - a.X, dy = b.Y - a.Y;
return Math.Sqrt(dx * dx + dy * dy);
}
private static bool IsConvex(Point[] p)
{
double sign = 0;
for (int i = 0; i < 4; i++)
{
Point a = p[i], b = p[(i + 1) % 4], c = p[(i + 2) % 4];
double cross = (b.X - a.X) * (c.Y - b.Y) - (b.Y - a.Y) * (c.X - b.X);
if (Math.Abs(cross) < 0.000001) return false;
if (sign == 0) sign = Math.Sign(cross);
else if (Math.Sign(cross) != sign) return false;
}
return true;
}
private static void SquareToQuad(Point[] p,
out double a, out double b, out double c,
out double d, out double e, out double f,
out double g, out double h)
{
double dx1 = p[1].X - p[2].X, dx2 = p[3].X - p[2].X;
double dy1 = p[1].Y - p[2].Y, dy2 = p[3].Y - p[2].Y;
double dx3 = p[0].X - p[1].X + p[2].X - p[3].X;
double dy3 = p[0].Y - p[1].Y + p[2].Y - p[3].Y;
double den = dx1 * dy2 - dx2 * dy1;
if (Math.Abs(dx3) < 0.000001 && Math.Abs(dy3) < 0.000001)
g = h = 0;
else
{
if (Math.Abs(den) < 0.000001) throw new InvalidOperationException("The selected corners do not form a usable page.");
g = (dx3 * dy2 - dx2 * dy3) / den;
h = (dx1 * dy3 - dx3 * dy1) / den;
}
a = p[1].X - p[0].X + g * p[1].X;
b = p[3].X - p[0].X + h * p[3].X;
c = p[0].X;
d = p[1].Y - p[0].Y + g * p[1].Y;
e = p[3].Y - p[0].Y + h * p[3].Y;
f = p[0].Y;
}
private static void SampleBilinear(byte[] src, int width, int height, int stride,
double x, double y, byte[] dst, int di)
{
x = Math.Max(0, Math.Min(width - 1, x));
y = Math.Max(0, Math.Min(height - 1, y));
int x0 = (int)Math.Floor(x), y0 = (int)Math.Floor(y);
int x1 = Math.Min(width - 1, x0 + 1), y1 = Math.Min(height - 1, y0 + 1);
double fx = x - x0, fy = y - y0;
for (int channel = 0; channel < 4; channel++)
{
double top = src[y0 * stride + x0 * 4 + channel] * (1 - fx) + src[y0 * stride + x1 * 4 + channel] * fx;
double bot = src[y1 * stride + x0 * 4 + channel] * (1 - fx) + src[y1 * stride + x1 * 4 + channel] * fx;
dst[di + channel] = (byte)Math.Round(top * (1 - fy) + bot * fy);
}
}
}
}
+65
View File
@@ -0,0 +1,65 @@
using System;
using System.Diagnostics;
using Microsoft.Win32;
namespace KillerPDF.Services
{
internal static class ProtocolRegistrar
{
internal const string Scheme = "killerpdf";
private const string RegistryPath = @"Software\Classes\killerpdf";
internal static void Register() => Register(Registry.CurrentUser, null);
// #183: machine-wide installs must register under HKLM so every user gets the handler,
// not just whoever ran the installer. Root is chosen by the caller; appPath defaults to
// the running executable (the per-user refresh path) but the elevated installer passes
// the Program Files copy explicitly since IT is the source exe at that moment.
internal static void Register(RegistryKey root, string? appPath)
{
try
{
appPath ??= Process.GetCurrentProcess().MainModule!.FileName;
using var protocol = root.CreateSubKey(RegistryPath);
if (protocol == null) return;
protocol.SetValue("", "URL:KillerPDF Protocol");
protocol.SetValue("URL Protocol", "");
using (var icon = protocol.CreateSubKey("DefaultIcon"))
icon?.SetValue("", $"\"{appPath}\",0");
using (var command = protocol.CreateSubKey(@"shell\open\command"))
command?.SetValue("", $"\"{appPath}\" \"%1\"");
}
catch (Exception ex) { Debug.WriteLine($"Failed to register KillerPDF protocol: {ex.Message}"); }
}
internal static void Unregister() => Unregister(Registry.CurrentUser);
internal static void Unregister(RegistryKey root)
{
try { root.DeleteSubKeyTree(RegistryPath, false); } catch { }
}
internal static bool TryGetTargetUrl(string? protocolUrl, out Uri? target)
{
target = null;
if (!Uri.TryCreate(protocolUrl, UriKind.Absolute, out var launch) ||
!launch.Scheme.Equals(Scheme, StringComparison.OrdinalIgnoreCase) ||
!launch.Host.Equals("open", StringComparison.OrdinalIgnoreCase)) return false;
string query = launch.Query.TrimStart('?');
foreach (string pair in query.Split('&'))
{
int equals = pair.IndexOf('=');
if (equals < 0) continue;
string name = Uri.UnescapeDataString(pair.Substring(0, equals).Replace("+", " "));
if (!name.Equals("url", StringComparison.OrdinalIgnoreCase)) continue;
string value = Uri.UnescapeDataString(pair.Substring(equals + 1).Replace("+", " "));
if (!Uri.TryCreate(value, UriKind.Absolute, out var parsed)) return false;
if (!parsed.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) return false;
target = parsed;
return true;
}
return false;
}
}
}
+91
View File
@@ -0,0 +1,91 @@
using UglyToad.PdfPig;
namespace KillerPDF.Services
{
internal sealed class SearchResult
{
public Dictionary<int, List<(double Left, double Bottom, double Right, double Top)>> PageRects { get; } = [];
public List<int> ResultPages { get; } = [];
public int TotalHits { get; set; }
}
internal sealed class SearchService
{
/// <summary>
/// Scans every page of <paramref name="filePath"/> for <paramref name="query"/> (case-insensitive).
/// Returns an empty result when query is blank or the file cannot be opened.
/// </summary>
public SearchResult Search(string filePath, string query)
{
var result = new SearchResult();
if (string.IsNullOrWhiteSpace(query) || string.IsNullOrWhiteSpace(filePath))
return result;
try
{
using var doc = PdfDocument.Open(filePath);
for (int pi = 0; pi < doc.NumberOfPages; pi++)
{
var page = doc.GetPage(pi + 1);
var hits = FindMatchesOnPage(page, query);
if (hits.Count > 0)
{
result.PageRects[pi] = hits;
result.ResultPages.Add(pi);
result.TotalHits += hits.Count;
}
}
}
catch { /* return whatever was collected so far */ }
return result;
}
internal static List<(double Left, double Bottom, double Right, double Top)> FindMatchesOnPage(
UglyToad.PdfPig.Content.Page page, string query)
{
var result = new List<(double, double, double, double)>();
var words = page.GetWords().ToList();
// A multi-word phrase can span adjacent words; a single token never does, so for single-token
// queries we only do the per-word substring match (the cross-word union box below would
// otherwise highlight whole runs of words leading up to the matching one).
bool isPhrase = query.Trim().IndexOf(' ') >= 0;
for (int i = 0; i < words.Count; i++)
{
if (words[i].Text.IndexOf(query, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
var bb = words[i].BoundingBox;
result.Add((bb.Left, bb.Bottom, bb.Right, bb.Top));
continue;
}
if (!isPhrase) continue;
// Multi-word match
string combined = words[i].Text;
for (int j = i + 1; j < words.Count && combined.Length < query.Length + 20; j++)
{
combined += " " + words[j].Text;
if (combined.IndexOf(query, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
double minX = double.MaxValue, minY = double.MaxValue;
double maxX = double.MinValue, maxY = double.MinValue;
for (int k = i; k <= j; k++)
{
var wbb = words[k].BoundingBox;
minX = Math.Min(minX, wbb.Left);
minY = Math.Min(minY, wbb.Bottom);
maxX = Math.Max(maxX, wbb.Right);
maxY = Math.Max(maxY, wbb.Top);
}
result.Add((minX, minY, maxX, maxY));
break;
}
}
}
return result;
}
}
}
+160
View File
@@ -0,0 +1,160 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace KillerPDF.Services
{
// ============================================================
// File-type (shell) icons - split out of FileOperations.cs
// (KillerUI refactor). Same name as the KillerUI kit's
// Services/ShellIcons.cs, which the family file picker uses, so
// the picker rollout lands on a familiar shape.
//
// Cached per extension. Uses SHGFI_USEFILEATTRIBUTES so the
// icon resolves from the extension alone - works even when the
// file is missing, and never touches the file on disk.
// ============================================================
internal static class ShellIcons
{
private static readonly Dictionary<string, ImageSource?> _shellIconCache = new(System.StringComparer.OrdinalIgnoreCase);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct SHFILEINFO
{
public IntPtr hIcon;
public int iIcon;
public uint dwAttributes;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szDisplayName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)] public string szTypeName;
}
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr SHGetFileInfo(string pszPath, uint dwFileAttributes, ref SHFILEINFO psfi, uint cbSizeFileInfo, uint uFlags);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool DestroyIcon(IntPtr hIcon);
internal static ImageSource? GetShellIcon(string path)
{
string ext = System.IO.Path.GetExtension(path) ?? "";
if (_shellIconCache.TryGetValue(ext, out var hit)) return hit;
const uint SHGFI_ICON = 0x000000100, SHGFI_LARGEICON = 0x000000000, SHGFI_USEFILEATTRIBUTES = 0x000000010;
const uint FILE_ATTRIBUTE_NORMAL = 0x80;
ImageSource? src = null;
try
{
var info = new SHFILEINFO();
IntPtr res = SHGetFileInfo("file" + ext, FILE_ATTRIBUTE_NORMAL, ref info,
(uint)Marshal.SizeOf<SHFILEINFO>(), SHGFI_ICON | SHGFI_LARGEICON | SHGFI_USEFILEATTRIBUTES);
if (res != IntPtr.Zero && info.hIcon != IntPtr.Zero)
{
src = Imaging.CreateBitmapSourceFromHIcon(info.hIcon, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
src.Freeze();
DestroyIcon(info.hIcon);
}
}
catch { /* no icon available - the row simply shows none */ }
_shellIconCache[ext] = src;
return src;
}
// ── File picker (Controls/FileDialog.xaml) ────────────────────────────────────────────
// Ported with the picker from Killendar, reusing the interop above rather than declaring
// a second copy of SHFILEINFO / SHGetFileInfo / DestroyIcon.
//
// Icons are cached by EXTENSION, so ten thousand .txt rows share one HICON conversion, and
// every HICON is destroyed after conversion - they are a limited USER handle, and leaking
// them eventually takes the whole desktop down, not just this app.
private const uint SHGFI_ICON_F = 0x000000100;
private const uint SHGFI_SMALLICON_F = 0x000000001;
private const uint SHGFI_LARGEICON_F = 0x000000000;
private const uint SHGFI_USEFILEATTRIBUTES_F = 0x000000010;
private const uint FILE_ATTRIBUTE_NORMAL_F = 0x00000080;
private const uint FILE_ATTRIBUTE_DIRECTORY_F = 0x00000010;
private static readonly Dictionary<string, ImageSource?> _small = new(System.StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, ImageSource?> _large = new(System.StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, ImageSource?> _places = new(System.StringComparer.OrdinalIgnoreCase);
private const string FolderKey = " dir";
/// <summary>16px icon for a list row. Null when the shell has nothing (caller falls back).</summary>
internal static ImageSource? Small(string path, bool isFolder) => GetSized(path, isFolder, true);
/// <summary>32px icon for the icon grid.</summary>
internal static ImageSource? Large(string path, bool isFolder) => GetSized(path, isFolder, false);
/// <summary>
/// 16px icon for a REAL path - the places rail. Deliberately NOT SHGFI_USEFILEATTRIBUTES:
/// the rail wants a drive's true icon (USB, network, optical) and a special folder's own,
/// which only a real-path query returns. That touches the disk, so it stays off the
/// per-row paths above and is cached by path.
/// </summary>
internal static ImageSource? Place(string path)
{
if (_places.TryGetValue(path, out var hit)) return hit;
ImageSource? img = null;
try
{
var info = new SHFILEINFO();
IntPtr res = SHGetFileInfo(path, 0, ref info, (uint)Marshal.SizeOf<SHFILEINFO>(),
SHGFI_ICON_F | SHGFI_SMALLICON_F);
if (res != IntPtr.Zero && info.hIcon != IntPtr.Zero)
{
try
{
img = Imaging.CreateBitmapSourceFromHIcon(info.hIcon, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
img.Freeze();
}
catch { img = null; }
finally { DestroyIcon(info.hIcon); }
}
}
catch { img = null; }
_places[path] = img;
return img;
}
private static ImageSource? GetSized(string path, bool isFolder, bool small)
{
string key = isFolder ? FolderKey : ExtKey(path);
var cache = small ? _small : _large;
if (cache.TryGetValue(key, out var hit)) return hit;
var img = LoadSized(isFolder ? "dir" : "x" + key, isFolder, small);
img?.Freeze(); // shared across threads and rows
cache[key] = img;
return img;
}
/// <summary>Extension including the dot, lowercased. Extensionless files share one key -
/// the shell gives them the same generic icon anyway.</summary>
private static string ExtKey(string path)
{
var ext = System.IO.Path.GetExtension(path);
return string.IsNullOrEmpty(ext) ? " noext" : ext.ToLowerInvariant();
}
private static BitmapSource? LoadSized(string fakeName, bool isFolder, bool small)
{
try
{
var info = new SHFILEINFO();
uint flags = SHGFI_ICON_F | SHGFI_USEFILEATTRIBUTES_F | (small ? SHGFI_SMALLICON_F : SHGFI_LARGEICON_F);
uint attrs = isFolder ? FILE_ATTRIBUTE_DIRECTORY_F : FILE_ATTRIBUTE_NORMAL_F;
IntPtr res = SHGetFileInfo(fakeName, attrs, ref info, (uint)Marshal.SizeOf<SHFILEINFO>(), flags);
if (res == IntPtr.Zero || info.hIcon == IntPtr.Zero) return null;
try
{
return Imaging.CreateBitmapSourceFromHIcon(info.hIcon, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
}
catch { return null; }
finally { DestroyIcon(info.hIcon); }
}
catch { return null; }
}
}
}
+231
View File
@@ -0,0 +1,231 @@
using System.Linq;
namespace KillerPDF
{
// ============================================================
// THE shortcut table. One source of truth for both views of the shortcuts overlay: the list
// (Shell/ShortcutsOverlay.cs) and the visual keyboard (Shell/KeyboardMapOverlay.cs).
//
// This was two hand maintained tables until 1.7.5 and they had drifted apart: Home and End read
// "first / last page" on the list but carried separate captions on the map, Alt+M was missing
// from the map entirely, and Ctrl+B was documented as bold in one section and as the sidebar in
// another while the code did neither consistently. Deriving both views from one array means a
// binding cannot be described two ways, and ShortcutTableTests holds that.
//
// Deliberately free of WPF and of MainWindow, so KillerPDF.Tests can link the file the way it
// links OcrCatalog.cs. Anything that needs a Brush or a Control belongs in the overlay files.
// ============================================================
/// <summary>Which modifier layer of the keyboard map a cap sits on.</summary>
internal enum KbLayer { Base, Ctrl, CtrlShift, Shift, Alt }
/// <summary>
/// One physical key the binding lights on the map. LabelKey overrides the row's description for
/// this cap's hover caption, because a row and a cap are different granularities: the row reads
/// "Home / End" while hovering Home should say "first page". Empty means inherit the row's.
/// </summary>
internal readonly record struct KsCap(KbLayer Layer, string Id, string LabelKey);
/// <summary>One row of the list, plus the caps it lights on the map.</summary>
internal readonly record struct KsBinding(string Keys, string LabelKey, string Cat, KsCap[] Caps);
internal static class ShortcutTable
{
// ── Key-name tokens (#230) ─────────────────────────────────────────────────────────────
// The key column used to be raw English: "Delete", "Home / End", "Wheel on logo". Italian
// users want Canc, Inizio and Fine, and they were right to ask - a keycap name is as much
// interface text as the description beside it.
//
// Only the NAMES are tokens. The chord syntax ("Ctrl+", "/", the F-numbers, letters and
// digits) stays in the table, so translators never hand-maintain "Ctrl+Shift+" across 69
// rows; they translate about twenty words and the composition takes care of itself.
//
// Ctrl and Alt are in here because German writes Strg. The gesture phrases are whole
// tokens rather than assembled from "Wheel" plus "on" plus "view", because word order in
// that phrase is not English's to dictate.
internal static readonly (string Token, string Key)[] KeyTokens =
[
("%ctrl%", "Str_Key_Ctrl"),
("%alt%", "Str_Key_Alt"),
("%shift%", "Str_Key_Shift"),
("%del%", "Str_Key_Delete"),
("%enter%", "Str_Key_Enter"),
("%esc%", "Str_Key_Esc"),
("%menu%", "Str_Key_Menu"),
("%home%", "Str_Key_Home"),
("%end%", "Str_Key_End"),
("%pgup%", "Str_Key_PgUp"),
("%pgdn%", "Str_Key_PgDn"),
("%tab%", "Str_Key_Tab"),
("%scroll%", "Str_Key_Scroll"),
("%click%", "Str_Key_Click"),
("%or%", "Str_Key_Or"),
("%wheelview%", "Str_Key_WheelView"),
("%wheellogo%", "Str_Key_WheelLogo"),
("%middledrag%", "Str_Key_MiddleDrag"),
("%spacedrag%", "Str_Key_SpaceDrag"),
];
/// <summary>Authoring helper: "F3", "Shift:F3", or either with a per-cap hover label.
/// Cap ids match KbRows in KeyboardMapOverlay.cs; bare ids are the base layer.</summary>
internal static KsCap Cap(string id, string labelKey = "")
{
int colon = id.IndexOf(':');
if (colon < 0) return new KsCap(KbLayer.Base, id, labelKey);
string layer = id.Substring(0, colon);
return new KsCap(
layer switch
{
"Ctrl" => KbLayer.Ctrl,
"CtrlShift" => KbLayer.CtrlShift,
"Shift" => KbLayer.Shift,
"Alt" => KbLayer.Alt,
_ => KbLayer.Base,
},
id.Substring(colon + 1), labelKey);
}
private static KsBinding B(string keys, string labelKey, string cat, params KsCap[] caps)
=> new(keys, labelKey, cat, caps);
// Declaration order is display order. Column and section title come from KsGroups below.
// A binding with no caps is a mouse gesture and correctly lights nothing on the board.
internal static readonly KsBinding[] KsAll =
[
B("%ctrl%+O", "Str_KS_Open", "File", Cap("Ctrl:O")),
B("%ctrl%+S", "Str_Lbl_Save", "File", Cap("Ctrl:S")),
B("%ctrl%+%shift%+S", "Str_KS_SaveAs", "File", Cap("CtrlShift:S")),
B("%ctrl%+W", "Str_KS_CloseFile", "File", Cap("Ctrl:W")),
B("%ctrl%+%shift%+W", "Str_KS_CloseOthers", "File", Cap("CtrlShift:W")),
B("%ctrl%+Q", "Str_KS_CloseAll", "File", Cap("Ctrl:Q")),
B("%ctrl%+N", "Str_KS_NewBlank", "File", Cap("Ctrl:N")),
B("%ctrl%+P", "Str_KS_Print", "File", Cap("Ctrl:P")),
B("%ctrl%+D / F4", "Str_KS_DocInfo", "File", Cap("Ctrl:D"), Cap("F4")),
B("%shift%+F4", "Str_KS_FileSize", "File", Cap("Shift:F4")),
B("V", "Str_Lbl_Select", "Tools", Cap("V")),
B("1 (%or% T)", "Str_Lbl_Text", "Tools", Cap("D1"), Cap("T")),
B("2 (%or% H)", "Str_Lbl_Highlight", "Tools", Cap("D2"), Cap("H")),
B("3 (%or% L %or% U)", "Str_Lbl_Line", "Tools", Cap("D3"), Cap("L"), Cap("U")),
B("4", "Str_Lbl_Shape", "Tools", Cap("D4")),
B("5 (%or% D)", "Str_Lbl_Draw", "Tools", Cap("D5"), Cap("D")),
B("6 (%or% I)", "Str_Lbl_Image", "Tools", Cap("D6"), Cap("I")),
B("7 (%or% G)", "Str_Lbl_Signature", "Tools", Cap("D7"), Cap("G")),
B("8 (%or% C)", "Str_Lbl_Crop", "Tools", Cap("D8"), Cap("C")),
B("9 (%or% R)", "Str_Lbl_Rotate", "Tools", Cap("D9"), Cap("R")),
B("0 (%or% S)", "Str_TT_StampTool", "Tools", Cap("D0"), Cap("S")),
B("%ctrl%+Z", "Str_KS_Undo", "Edit", Cap("Ctrl:Z")),
B("%ctrl%+Y", "Str_Ctx_Redo", "Edit", Cap("Ctrl:Y")),
B("%ctrl%+%shift%+Z", "Str_Ctx_Redo", "Edit", Cap("CtrlShift:Z")),
B("%ctrl%+C", "Str_KS_CopyText", "Edit", Cap("Ctrl:C")),
B("%ctrl%+V", "Str_KS_Paste", "Edit", Cap("Ctrl:V")),
// Real bindings as of 1.7.5. These were documented for years and never implemented;
// Ctrl+B collapsed the sidebar instead, which is why the sidebar moved to F9.
B("%ctrl%+B / I / U", "Str_KS_TextStyle", "Edit", Cap("Ctrl:B", "Str_Lbl_Bold"),
Cap("Ctrl:I", "Str_Lbl_Italic"),
Cap("Ctrl:U", "Str_Lbl_Underline")),
B("%del%", "Str_KS_DeleteAnnot", "Edit", Cap("Del")),
B("F2", "Str_Ctx_BmRename", "Edit", Cap("F2")),
B("%enter% / %esc%", "Str_KS_ConfirmCancel","Edit", Cap("Enter", "Str_Kb_Confirm"),
Cap("Esc", "Str_Kb_Cancel")),
B("%menu% / %shift%+F10", "Str_KS_ContextMenu", "Edit", Cap("Menu"), Cap("Shift:F10")),
B("F1 / %ctrl%+?", "Str_KS_ThisList", "Help", Cap("F1"), Cap("Ctrl:Slash")),
B("F12", "Str_KS_About", "Help", Cap("F12")),
B("← / → %or% %pgup%/%pgdn%", "Str_KS_PrevNext", "Nav", Cap("Left", "Str_Kb_PrevPage"),
Cap("Right", "Str_Kb_NextPage"),
Cap("PgUp", "Str_Kb_PrevPage"),
Cap("PgDn", "Str_Kb_NextPage")),
B("%home% / %end%", "Str_KS_FirstLast", "Nav", Cap("Home", "Str_Kb_FirstPage"),
Cap("End", "Str_Kb_LastPage")),
B("%alt%+← / %alt%+→", "Str_KS_BackForward", "Nav", Cap("Alt:Left", "Str_Kb_Back"),
Cap("Alt:Right", "Str_Kb_Forward")),
B("↑ / ↓", "Str_KS_ScrollView", "Nav", Cap("Up"), Cap("Down")),
B("%ctrl%+%scroll%", "Str_KS_ZoomCursor", "Nav"),
B("%ctrl%+%zin% / %ctrl%+%zout%", "Str_KS_ZoomInOut", "Nav", Cap("Ctrl:Equals", "Str_Lbl_ZoomIn"),
Cap("Ctrl:Minus", "Str_Lbl_ZoomOut")),
B("%ctrl%+0", "Str_KS_ResetZoom", "Nav", Cap("Ctrl:D0")),
B("%ctrl%+1/2/3", "Str_KS_ZoomPresets", "Nav", Cap("Ctrl:D1", "Str_Zoom_ActualSize"),
Cap("Ctrl:D2", "Str_Zoom_FitWidth"),
Cap("Ctrl:D3", "Str_Zoom_FitPage")),
B("%middledrag%", "Str_KS_PanView", "Nav"),
B("%spacedrag%", "Str_KS_PanView", "Nav", Cap("Space")),
B("F9", "Str_KS_ToggleSidebar","Nav", Cap("F9")),
B("%shift%+F9", "Str_KS_SidebarSide", "Nav", Cap("Shift:F9")),
B("%ctrl%+%tab%", "Str_KS_NextTab", "Nav", Cap("Ctrl:Tab")),
B("%ctrl%+%shift%+%tab%", "Str_KS_PrevTab", "Nav", Cap("CtrlShift:Tab")),
B("F5", "Str_View_Continuous", "View", Cap("F5")),
B("F6", "Str_View_Single", "View", Cap("F6")),
B("F7", "Str_View_TwoPage", "View", Cap("F7")),
B("B", "Str_View_BookMode", "View", Cap("B")), // #193: Two-Page only
B("F8", "Str_View_Grid", "View", Cap("F8")),
// Cycling lost its F9 when the sidebar took the key. F5-F8 still reach every mode
// directly, so the wheel gesture is the only thing that needed to survive.
B("%wheelview%", "Str_KS_CycleView", "View"),
B("F10", "Str_KS_SplitPane", "View", Cap("F10")),
// Esc belongs to Cancel on the base layer, so full screen only claims F11.
B("F11 / %esc%", "Str_KS_FullScreen", "View", Cap("F11")),
B("%alt%+M", "Str_Toolbar_Hide", "View", Cap("Alt:M")),
B("N", "Str_DocInvertSetting","View", Cap("N")),
B("%shift%+N", "Str_InvertImagesToo", "View", Cap("Shift:N")),
B("%ctrl%+%shift%+%zin% / %zout% / 0", "Str_KS_AppSize", "View", Cap("CtrlShift:Equals"),
Cap("CtrlShift:Minus"),
Cap("CtrlShift:D0")),
B("%wheellogo%", "Str_KS_AppSize", "View"),
// The toolbar appearance six, mirroring the bar's right-click menu top to bottom.
B("%ctrl%+%shift%+1..6", "Str_KS_ToolbarStyle", "View", Cap("CtrlShift:D1", "Str_Toolbar_SmallIcons"),
Cap("CtrlShift:D2", "Str_Toolbar_LargeIcons"),
Cap("CtrlShift:D3", "Str_Toolbar_TextNone"),
Cap("CtrlShift:D4", "Str_Toolbar_TextBeside"),
Cap("CtrlShift:D5", "Str_Toolbar_TextUnder"),
Cap("CtrlShift:D6", "Str_Toolbar_TextOnly")),
B("%ctrl%+%shift%+O", "Str_Ctx_OcrPage", "Ocr", Cap("CtrlShift:O")),
B("%ctrl%+%shift%+I", "Str_Ocr_Region", "Ocr", Cap("CtrlShift:I")),
B("%ctrl%+F", "Str_KS_Find", "Search", Cap("Ctrl:F")),
B("F3 / %shift%+F3", "Str_KS_NextPrevResult", "Search", Cap("F3", "Str_Kb_NextResult"),
Cap("Shift:F3", "Str_Kb_PrevResult")),
B("%enter% / %shift%+%enter%", "Str_KS_NextPrevResult", "Search", Cap("Shift:Enter", "Str_Kb_PrevResult")),
B("%ctrl%+A", "Str_KS_SelectAll", "Search", Cap("Ctrl:A")),
B("%shift%+%click%", "Str_KS_MultiSelect", "Search"),
];
// Section title and column for each category. Order here is the order sections appear.
internal static readonly (string Cat, string TitleKey, bool Right)[] KsGroups =
[
("File", "Str_KS_File", false),
("Tools", "Str_KS_Tools", false),
("Edit", "Str_KS_Editing", false),
("Help", "Str_KS_Help", false),
("Nav", "Str_KS_Navigation", true),
("View", "Str_KS_View", true),
("Ocr", "Str_KS_Ocr", true),
("Search", "Str_KS_SearchSelect", true),
];
/// <summary>The map's view of the table: layer -> cap id -> (category, label key). A cap's
/// own LabelKey wins over its row's, which is how one row serves two captions.</summary>
internal static Dictionary<KbLayer, Dictionary<string, (string Cat, string Label)>> BuildMap()
{
var map = new Dictionary<KbLayer, Dictionary<string, (string, string)>>();
foreach (KbLayer layer in System.Enum.GetValues(typeof(KbLayer)))
map[layer] = new Dictionary<string, (string, string)>();
foreach (var binding in KsAll)
foreach (var cap in binding.Caps)
map[cap.Layer][cap.Id] =
(binding.Cat, cap.LabelKey.Length > 0 ? cap.LabelKey : binding.LabelKey);
return map;
}
/// <summary>Every (layer, cap) the table claims, as "Layer:Id", including duplicates.
/// ShortcutTableTests uses this to prove no key is claimed twice, which is exactly how
/// Ctrl+B managed to mean two things for so long.</summary>
internal static IEnumerable<string> AllCapClaims() =>
KsAll.SelectMany(b => b.Caps).Select(c => c.Layer + ":" + c.Id);
}
}
+55
View File
@@ -0,0 +1,55 @@
using System.Text.Json;
namespace KillerPDF.Services
{
internal sealed class SignatureStore
{
private readonly string _dir;
private readonly string _file;
private static readonly string DefaultDir = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"KillerPDF");
public SignatureStore()
: this(DefaultDir, System.IO.Path.Combine(DefaultDir, "signatures.json")) { }
internal SignatureStore(string dir, string file)
{
_dir = dir;
_file = file;
}
private List<SavedSignature> _items = [];
public IReadOnlyList<SavedSignature> Signatures => _items;
public void Load()
{
try
{
if (System.IO.File.Exists(_file))
{
var json = System.IO.File.ReadAllText(_file);
_items = JsonSerializer.Deserialize<List<SavedSignature>>(json) ?? [];
}
}
catch { _items = []; }
}
public void Persist()
{
try
{
System.IO.Directory.CreateDirectory(_dir);
var json = JsonSerializer.Serialize(_items, new JsonSerializerOptions { WriteIndented = true });
System.IO.File.WriteAllText(_file, json);
}
catch { /* best effort */ }
}
public void Add(SavedSignature sig) => _items.Add(sig);
public void Remove(SavedSignature sig) => _items.Remove(sig);
}
}
+21
View File
@@ -0,0 +1,21 @@
using System.Security.Cryptography.X509Certificates;
namespace KillerPDF.Services.Signing
{
/// <summary>
/// A source of a signing certificate (with its private key). Kept as an interface so the only
/// Windows-only piece is the certificate-store provider; .pfx files (and, later, OS keychains)
/// keep the rest of the signing module portable for the planned Linux/Mac port.
/// </summary>
internal interface ICertificateProvider
{
/// <summary>Human-readable label for the UI (file name, or the cert's subject).</summary>
string DisplayName { get; }
/// <summary>
/// Returns the certificate (must have a usable private key). Throws on failure - a wrong
/// .pfx password, a missing private key, an unreadable file, etc.
/// </summary>
X509Certificate2 GetCertificate();
}
}
+67
View File
@@ -0,0 +1,67 @@
using System.IO;
using System.Security.Cryptography;
using System.Security.Cryptography.Pkcs;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using PdfSharp.Pdf.Signatures;
namespace KillerPDF.Services.Signing
{
/// <summary>
/// A PDF signer that assembles the PKCS#7 / CMS itself with .NET's <see cref="SignedCms"/> rather
/// than PDFsharp's PdfSharpDefaultSigner. SignedCms drives the certificate's private key through
/// the modern (CNG) provider, which is what makes cloud / token keys usable - notably Certum
/// SimplySign, where the default signer throws "An internal error occurred" because it cannot
/// reach a non-legacy key. It also signs plain software .pfx keys fine, so this is the single
/// signing path for every certificate source.
///
/// Implements PDFsharp 6.2's <c>IDigitalSigner</c>: PDFsharp hands us the ByteRange content to
/// sign and drops the returned DER bytes into the /Contents placeholder. A detached CMS over that
/// content is exactly an adbe.pkcs7.detached PDF signature.
/// </summary>
internal sealed class KillerCmsSigner(X509Certificate2 cert) : IDigitalSigner
{
private static readonly Oid Sha256 = new("2.16.840.1.101.3.4.2.1"); // id-sha256
public string CertificateName =>
cert.GetNameInfo(X509NameType.SimpleName, forIssuer: false) is { Length: > 0 } n
? n : cert.Subject;
// Reserve generous space in /Contents for the CMS (signer cert + full chain, SHA-256 RSA).
// No timestamp yet, so 16 KB sits comfortably above a typical 4-8 KB signature.
public Task<int> GetSignatureSizeAsync() => Task.FromResult(16384);
public Task<byte[]> GetSignatureAsync(Stream stream)
{
// PDFsharp hands us a RangedStream positioned at its END, so reading straight away yields
// zero bytes ("Cannot create CMS signature for empty content"). Its Seek() throws
// NotImplementedException ("Cannot seek in a RangedStream") even though CanSeek is true -
// but the Position setter IS implemented, so rewind with that. Then read SYNCHRONOUSLY;
// CopyToAsync / ReadAsync return nothing on this stream.
using var ms = new MemoryStream();
stream.Position = 0;
var buffer = new byte[81920];
int read;
while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
ms.Write(buffer, 0, read);
byte[] data = ms.ToArray();
if (data.Length == 0)
throw new CryptographicException("The content to sign was empty - the PDF stream could not be read.");
var content = new ContentInfo(data);
var signedCms = new SignedCms(content, detached: true);
var signer = new CmsSigner(cert)
{
DigestAlgorithm = Sha256,
IncludeOption = X509IncludeOption.WholeChain,
};
// silent:false lets a token / cloud KSP (SimplySign) surface a PIN or confirmation prompt
// if it needs one, instead of failing outright.
signedCms.ComputeSignature(signer, silent: false);
return Task.FromResult(signedCms.Encode());
}
}
}
+62
View File
@@ -0,0 +1,62 @@
using System;
using System.Security.Cryptography.X509Certificates;
using PdfSharp.Drawing;
using PdfSharp.Pdf;
using PdfSharp.Pdf.IO;
using PdfSharp.Pdf.Signatures;
namespace KillerPDF.Services.Signing
{
/// <summary>
/// Cryptographic (PAdES / PKCS#7) PDF signing, isolated from the rest of the app. Everything else
/// in KillerPDF uses PdfSharpCore; this module uses PDFsharp 6.2 (the <c>PdfSharp.*</c> namespace),
/// and the two coexist without clashing. There are deliberately no WPF or Windows-only types here,
/// so the whole module ports to Avalonia / Linux / Mac unchanged.
///
/// v1 milestone: an invisible-but-valid signature (Adobe still lists it in the Signatures panel),
/// SHA-256, no timestamp. The .NET Framework build of PDFsharp cannot timestamp; once the plumbing
/// is validated we swap the default signer for a Bouncy Castle IDigitalSigner to get portable
/// crypto plus timestamps/LTV. A visible signature appearance comes after that.
/// </summary>
internal sealed class PdfSigner
{
public sealed record SignInfo(string Reason, string Location, string Contact);
/// <summary>
/// Signs <paramref name="inputPath"/> with <paramref name="cert"/> and writes the signed copy
/// to <paramref name="outputPath"/>. Throws on failure.
/// </summary>
public void Sign(string inputPath, string outputPath, X509Certificate2 cert, SignInfo info)
{
if (cert is null) throw new ArgumentNullException(nameof(cert));
if (!cert.HasPrivateKey)
throw new InvalidOperationException(
"The selected certificate has no private key, so it cannot sign.");
// Open the finalized PDF. (NOTE: confirm the exact open-mode enum on first build - PDFsharp
// 6.2 may want PdfReadAccuracy / a different overload here.)
using PdfDocument document = PdfReader.Open(inputPath, PdfDocumentOpenMode.Modify);
var options = new DigitalSignatureOptions
{
ContactInfo = info.Contact,
Location = info.Location,
Reason = info.Reason,
// Invisible for v1: a zero rectangle and no AppearanceHandler. If PDFsharp requires a
// non-null AppearanceHandler, that is the first thing to add (a minimal drawn field).
Rectangle = new XRect(0, 0, 0, 0),
};
// Our own SignedCms-based signer: drives the cert's modern (CNG) key provider, so it
// works with cloud / token keys (Certum SimplySign) as well as software .pfx keys, where
// PdfSharpDefaultSigner throws "An internal error occurred". Same IDigitalSigner slot, so a
// Bouncy Castle variant can later swap in here for portability + timestamps.
var signer = new KillerCmsSigner(cert);
// Associates the signer + options with the document; the signature is produced on Save.
_ = DigitalSignatureHandler.ForDocument(document, signer, options);
document.Save(outputPath);
}
}
}
@@ -0,0 +1,20 @@
using System.IO;
using System.Security.Cryptography.X509Certificates;
namespace KillerPDF.Services.Signing
{
/// <summary>
/// Loads a signing certificate from a .pfx / .p12 file plus its password. Fully cross-platform,
/// so this is the default certificate source on every OS.
/// </summary>
internal sealed class PfxFileCertificateProvider(string path, string password) : ICertificateProvider
{
public string DisplayName => Path.GetFileName(path);
public X509Certificate2 GetCertificate()
// Exportable so a Bouncy Castle signer can later pull the private key to build the CMS.
// (EphemeralKeySet is intentionally not used - it does not exist on .NET Framework.)
=> new(path, password,
X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet);
}
}
@@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.Security.Cryptography.X509Certificates;
namespace KillerPDF.Services.Signing
{
/// <summary>
/// Windows-only helper that lists signing-capable certificates from the current user's personal
/// store, so the UI can offer a picker. Guard calls behind an OS check; on Linux/Mac the app
/// should fall back to file-based certificates (or a platform keychain) instead.
/// </summary>
internal static class WindowsCertificateStore
{
public static IReadOnlyList<X509Certificate2> ListSigningCertificates()
{
var result = new List<X509Certificate2>();
using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
foreach (var cert in store.Certificates)
{
if (cert.HasPrivateKey && CanSign(cert))
result.Add(cert);
}
return result;
}
// Accept a cert whose Key Usage permits digital signatures, or that declares no Key Usage
// restriction at all (which is permissive per the X.509 spec).
private static bool CanSign(X509Certificate2 cert)
{
foreach (var ext in cert.Extensions)
if (ext is X509KeyUsageExtension ku)
return (ku.KeyUsages &
(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.NonRepudiation)) != 0;
return true;
}
}
/// <summary>Wraps a certificate already chosen from the Windows store as an ICertificateProvider.</summary>
internal sealed class StoreCertificateProvider(X509Certificate2 cert) : ICertificateProvider
{
public string DisplayName =>
cert.GetNameInfo(X509NameType.SimpleName, forIssuer: false) is { Length: > 0 } name
? name : cert.Subject;
public X509Certificate2 GetCertificate() => cert;
}
}
+60
View File
@@ -0,0 +1,60 @@
using System;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;
namespace KillerPDF.Services
{
/// <summary>
/// Opt-in startup timing used by the release benchmark. Normal launches do no file I/O.
/// Set KILLERPDF_STARTUP_TRACE to an output path before starting the process.
/// </summary>
internal static class StartupTrace
{
private const string TraceEnvironmentVariable = "KILLERPDF_STARTUP_TRACE";
private static readonly object Gate = new object();
private static readonly Stopwatch Clock = Stopwatch.StartNew();
private static readonly string? OutputPath = Environment.GetEnvironmentVariable(TraceEnvironmentVariable);
private static bool _headerWritten;
internal static bool Enabled => !string.IsNullOrWhiteSpace(OutputPath);
internal static void Mark(string stage)
{
if (!Enabled) return;
try
{
lock (Gate)
{
var directory = Path.GetDirectoryName(OutputPath!);
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
var sb = new StringBuilder();
if (!_headerWritten)
{
_headerWritten = true;
var process = Process.GetCurrentProcess();
sb.Append("# KillerPDF startup trace | pid=")
.Append(process.Id)
.Append(" | processStartUtc=")
.Append(process.StartTime.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture))
.Append(" | traceStartUtc=")
.Append(DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture))
.AppendLine();
}
sb.Append(Clock.Elapsed.TotalMilliseconds.ToString("F3", CultureInfo.InvariantCulture))
.Append('\t')
.AppendLine(stage);
File.AppendAllText(OutputPath!, sb.ToString(), new UTF8Encoding(false));
}
}
catch
{
// Diagnostics must never make startup fail.
}
}
}
}
+345
View File
@@ -0,0 +1,345 @@
using System.IO;
using UglyToad.PdfPig;
namespace KillerPDF.Services
{
/// <summary>One selectable character on a page, in reading order. Coordinates are PDF space
/// (points, bottom-left origin), matching SearchService and ExtractTextFromRegion.</summary>
internal readonly struct RunChar
{
public readonly string Value; // PdfPig letters can be multi-char (ligatures)
public readonly double Left;
public readonly double Right;
public readonly int Word; // ordinal of the word this char belongs to (for word counts / spacing)
public readonly int Line; // ordinal of the line this char belongs to
public RunChar(string value, double left, double right, int word, int line)
{ Value = value; Left = left; Right = right; Word = word; Line = line; }
}
/// <summary>A visual line of text: a contiguous slice of the page's flattened char list plus its
/// vertical band. Caret positions run 0..N over the flattened chars; a line's End caret is the
/// next line's Start, so a selection ending at End stops cleanly at the line break.</summary>
internal sealed class RunLine
{
public int Start; // caret index of the line's first char
public int Count;
public double Top; // PDF space: Top > Bottom
public double Bottom;
public double Left;
public double Right;
public bool RightToLeft;
public int End => Start + Count;
}
/// <summary>Reading-order text geometry for one page.</summary>
internal sealed class PageTextRuns
{
public double PdfWidth;
public double PdfHeight;
public List<RunChar> Chars = [];
public List<RunLine> Lines = [];
}
/// <summary>
/// Builds and caches per-page reading-order character runs for flowing text selection (#127).
/// Word geometry comes from PdfPig's GetWords - the same source SearchService and the region
/// text extractor use - so selection quads land exactly where search highlights do. Words are
/// grouped into lines by vertical overlap and ordered top-to-bottom, then in each line's
/// detected reading direction.
/// Known shared limitation: like the search highlights, boxes ignore in-memory page rotation.
/// Column note: line grouping is by vertical band, so side-by-side columns join into one line;
/// good enough for v1, revisit with a segmenter if multi-column PDFs bite.
/// </summary>
internal sealed class TextRunService
{
// Keyed by (path, last-write ticks, page): a resave or temp-reload changes the key, so stale
// geometry can never serve a newer file. Nulls are cached too - a file PdfPig cannot open
// should not be re-parsed on every click.
private readonly Dictionary<(string Path, long Ticks, int Page), PageTextRuns?> _cache = [];
public PageTextRuns? GetPage(string path, int pageIdx)
{
if (string.IsNullOrEmpty(path) || pageIdx < 0) return null;
long ticks;
try { ticks = File.GetLastWriteTimeUtc(path).Ticks; }
catch { return null; }
var key = (path, ticks, pageIdx);
if (_cache.TryGetValue(key, out var hit)) return hit;
if (_cache.Count > 512) _cache.Clear(); // simple cap; entries are tiny but unbounded is unbounded
PageTextRuns? runs = null;
try
{
using var doc = PdfDocument.Open(path);
if (pageIdx < doc.NumberOfPages)
runs = Build(doc.GetPage(pageIdx + 1)); // PdfPig is 1-based
}
catch { /* encrypted/broken: selection just is not offered on this page */ }
_cache[key] = runs;
return runs;
}
// #185 helper: see the call site comment. Bands arrive top-to-bottom; the result is the
// same tuple shape, reordered so the flattened char order reads one column at a time.
private static List<(List<UglyToad.PdfPig.Content.Word> Words, double Top, double Bottom)>
OrderColumnAware(List<(List<UglyToad.PdfPig.Content.Word> Words, double Top, double Bottom)> bands)
{
if (bands.Count < 2) return bands;
double textL = double.MaxValue, textR = double.MinValue;
foreach (var (ws, _, _) in bands)
foreach (var w in ws)
{
if (w.BoundingBox.Left < textL) textL = w.BoundingBox.Left;
if (w.BoundingBox.Right > textR) textR = w.BoundingBox.Right;
}
double wideW = (textR - textL) * 0.62; // spans most of the text width = not a column line
var reordered = new List<(List<UglyToad.PdfPig.Content.Word>, double, double)>();
var pending = new List<(List<UglyToad.PdfPig.Content.Word> Words, double Top, double Bottom, double L, double R)>();
void Flush()
{
if (pending.Count == 0) return;
// Cluster segments into columns by X-interval overlap (>= half the narrower range).
var cols = new List<(double L, double R, List<int> Idx)>();
var byLeft = Enumerable.Range(0, pending.Count).OrderBy(i => pending[i].L).ToList();
foreach (int i in byLeft)
{
var seg = pending[i];
int hit = -1;
for (int c = 0; c < cols.Count && hit < 0; c++)
{
double ov = Math.Min(cols[c].R, seg.R) - Math.Max(cols[c].L, seg.L);
double minW = Math.Min(cols[c].R - cols[c].L, seg.R - seg.L);
if (minW > 0 && ov >= minW * 0.5) hit = c;
}
if (hit < 0) cols.Add((seg.L, seg.R, [i]));
else
{
var c0 = cols[hit];
c0.Idx.Add(i);
cols[hit] = (Math.Min(c0.L, seg.L), Math.Max(c0.R, seg.R), c0.Idx);
}
}
foreach (var col in cols.OrderBy(c => c.L))
foreach (int i in col.Idx.OrderByDescending(i => pending[i].Top))
reordered.Add((pending[i].Words, pending[i].Top, pending[i].Bottom));
pending.Clear();
}
foreach (var (ws, _, _) in bands)
{
var sorted = ws.OrderBy(w => w.BoundingBox.Left).ToList();
// Split threshold: well past a word space (~0.25em) but below a column gutter.
double tw = 0; int tn = 0;
foreach (var w in sorted) { tw += w.BoundingBox.Width; tn += Math.Max(1, w.Text.Length); }
double gapT = Math.Max(10, (tn > 0 ? tw / tn : 5) * 3);
var segs = new List<List<UglyToad.PdfPig.Content.Word>> { new() { sorted[0] } };
for (int i = 1; i < sorted.Count; i++)
{
if (sorted[i].BoundingBox.Left - sorted[i - 1].BoundingBox.Right > gapT)
segs.Add([]);
segs[^1].Add(sorted[i]);
}
foreach (var sws in segs)
{
double sT = double.MinValue, sB = double.MaxValue, sL = double.MaxValue, sR = double.MinValue;
foreach (var w in sws)
{
if (w.BoundingBox.Top > sT) sT = w.BoundingBox.Top;
if (w.BoundingBox.Bottom < sB) sB = w.BoundingBox.Bottom;
if (w.BoundingBox.Left < sL) sL = w.BoundingBox.Left;
if (w.BoundingBox.Right > sR) sR = w.BoundingBox.Right;
}
if (sR - sL >= wideW) { Flush(); reordered.Add((sws, sT, sB)); }
else pending.Add((sws, sT, sB, sL, sR));
}
}
Flush();
return reordered;
}
private static PageTextRuns Build(UglyToad.PdfPig.Content.Page page)
{
var result = new PageTextRuns { PdfWidth = page.Width, PdfHeight = page.Height };
var words = page.GetWords().ToList();
if (words.Count == 0) return result;
// Group words into lines: a word joins a line when its vertical band overlaps the line's
// band by at least half the smaller height. Bands grow as members join.
var lineWords = new List<(List<UglyToad.PdfPig.Content.Word> Words, double Top, double Bottom)>();
foreach (var w in words)
{
var bb = w.BoundingBox;
double wTop = bb.Top, wBottom = bb.Bottom;
int found = -1;
for (int i = 0; i < lineWords.Count; i++)
{
var (_, lTop, lBottom) = lineWords[i];
double overlap = Math.Min(lTop, wTop) - Math.Max(lBottom, wBottom);
double minH = Math.Min(lTop - lBottom, wTop - wBottom);
if (minH > 0 && overlap >= minH * 0.5) { found = i; break; }
}
if (found < 0)
lineWords.Add((new List<UglyToad.PdfPig.Content.Word> { w }, wTop, wBottom));
else
{
var entry = lineWords[found];
entry.Words.Add(w);
lineWords[found] = (entry.Words, Math.Max(entry.Top, wTop), Math.Min(entry.Bottom, wBottom));
}
}
// Reading order: lines top-to-bottom (PDF Y grows upward, so larger Top first).
// Each line chooses its own horizontal direction so mixed-language pages work too.
lineWords.Sort((a, b) => b.Top.CompareTo(a.Top));
// ---- #185: column-aware reading order ----------------------------------------------
// A Y band spans the whole page, so on a two-column layout every "line" mixed both
// columns and a drag down one column swept its neighbor. Split each band into segments
// at column-gutter-sized gaps, cluster narrow segments into columns by X overlap, and
// emit whole columns left-to-right (top-to-bottom inside each). Wide segments - titles
// and footers spanning the text width - close the open column section, so a
// title / two columns / footer page keeps a sane order. A single-column page yields
// one cluster and comes out in exactly the old order.
lineWords = OrderColumnAware(lineWords);
int wordOrdinal = 0;
for (int li = 0; li < lineWords.Count; li++)
{
var (ws, top, bottom) = lineWords[li];
bool rtl = IsRightToLeftText(ws.Select(w => w.Text));
ws.Sort(rtl
? (a, b) => b.BoundingBox.Right.CompareTo(a.BoundingBox.Right)
: (a, b) => a.BoundingBox.Left.CompareTo(b.BoundingBox.Left));
var line = new RunLine
{
Start = result.Chars.Count,
Top = top,
Bottom = bottom,
RightToLeft = rtl,
};
foreach (var w in ws)
{
var letters = w.Letters.ToList();
letters.Sort(rtl
? (a, b) => b.BoundingBox.Right.CompareTo(a.BoundingBox.Right)
: (a, b) => a.BoundingBox.Left.CompareTo(b.BoundingBox.Left));
foreach (var letter in letters)
{
var g = letter.BoundingBox;
result.Chars.Add(new RunChar(letter.Value, g.Left, g.Right, wordOrdinal, li));
}
wordOrdinal++;
}
line.Count = result.Chars.Count - line.Start;
if (line.Count == 0) continue;
line.Left = result.Chars.Skip(line.Start).Take(line.Count).Min(c => c.Left);
line.Right = result.Chars.Skip(line.Start).Take(line.Count).Max(c => c.Right);
result.Lines.Add(line);
}
return result;
}
internal static bool IsRightToLeftText(IEnumerable<string> values)
{
int rtl = 0, ltr = 0;
foreach (string value in values)
{
foreach (char c in value)
{
if ((c >= '\u0590' && c <= '\u08FF') ||
(c >= '\uFB1D' && c <= '\uFDFF') ||
(c >= '\uFE70' && c <= '\uFEFF')) rtl++;
else if (char.IsLetter(c)) ltr++;
}
}
return rtl > ltr;
}
/// <summary>True when the point sits ON text: inside a line's vertical band and within its
/// horizontal extent (small slop). This is the gate that decides flowing selection vs the
/// classic marquee - empty page areas must keep the marquee.</summary>
public static bool IsOverText(PageTextRuns runs, double x, double y)
{
const double slop = 2.0; // PDF points
foreach (var line in runs.Lines)
if (y <= line.Top + slop && y >= line.Bottom - slop &&
x >= line.Left - slop && x <= line.Right + slop)
return true;
return false;
}
/// <summary>Caret position (0..Chars.Count) nearest a point, browser-style clamping:
/// above the first line selects from the page start, below the last line to the page end,
/// between lines snaps to the closer line, beyond a line's ends clamps to its ends.</summary>
public static int CaretFromPoint(PageTextRuns runs, double x, double y)
{
if (runs.Lines.Count == 0) return 0;
RunLine? target = null;
double best = double.MaxValue;
foreach (var line in runs.Lines)
{
if (y <= line.Top && y >= line.Bottom) { target = line; best = 0; break; }
double d = y > line.Top ? y - line.Top : line.Bottom - y;
if (d < best) { best = d; target = line; }
}
// Above the first line entirely -> caret 0; below the last -> caret N.
var first = runs.Lines[0];
var last = runs.Lines[runs.Lines.Count - 1];
if (y > first.Top && target == first && x < first.Left) return 0;
if (y < last.Bottom && target == last && x > last.Right) return runs.Chars.Count;
if (target is null) return 0;
if (target.RightToLeft)
{
if (x >= target.Right) return target.Start;
if (x <= target.Left) return target.End;
for (int i = target.Start; i < target.End; i++)
{
var c = runs.Chars[i];
double mid = (c.Left + c.Right) / 2;
if (x > mid) return i;
}
return target.End;
}
if (x <= target.Left) return target.Start;
if (x >= target.Right) return target.End;
for (int i = target.Start; i < target.End; i++)
{
var c = runs.Chars[i];
double mid = (c.Left + c.Right) / 2;
if (x < mid) return i;
}
return target.End;
}
/// <summary>Text for the caret range [start, end): spaces between words, newlines between
/// lines. Also reports how many distinct words the range touches.</summary>
public static string TextForRange(PageTextRuns runs, int start, int end, out int wordCount)
{
wordCount = 0;
var sb = new System.Text.StringBuilder();
int lastWord = -1, lastLine = -1;
for (int i = Math.Max(0, start); i < Math.Min(end, runs.Chars.Count); i++)
{
var c = runs.Chars[i];
if (lastLine >= 0 && c.Line != lastLine) sb.Append('\n');
else if (lastWord >= 0 && c.Word != lastWord) sb.Append(' ');
if (c.Word != lastWord) wordCount++;
sb.Append(c.Value);
lastWord = c.Word;
lastLine = c.Line;
}
return sb.ToString();
}
}
}
+695
View File
@@ -0,0 +1,695 @@
using System;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Effects;
using System.Windows.Threading;
namespace KillerPDF.Services
{
internal enum Theme
{
Dark, Light, Black, SE98, Blood, Greed, Cyanotic, Ectoplasm, Decay,
Mourning, Sepulchre, Delirium, Malaise
}
// Accent-hue variants of the Dark theme. Green is the base Dark.xaml (no overlay); the
// others apply a small overlay dictionary that recolors only the accent-family keys.
internal enum DarkAccent { Green, Red, Blue, Purple, Orange, Teal }
internal static class ThemeManager
{
// ── P/Invoke ──────────────────────────────────────────────────────
[DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(
IntPtr hwnd, int attr, ref int attrValue, int attrSize);
private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
private const int DWMWA_BORDER_COLOR = 34;
// ── State ─────────────────────────────────────────────────────────
private static Theme _current = Theme.Dark;
// Dark, Light, and Black each remember their own accent independently.
private static DarkAccent _darkAccent = DarkAccent.Green;
private static DarkAccent _lightAccent = DarkAccent.Green;
private static DarkAccent _blackAccent = DarkAccent.Green;
// Match the shared KillerTools 98SE palette: classic Win98 navy is the default.
private static DarkAccent _se98Accent = DarkAccent.Blue;
public static Theme Current => _current;
public static DarkAccent DarkAccentChoice => _darkAccent;
public static DarkAccent LightAccentChoice => _lightAccent;
public static DarkAccent BlackAccentChoice => _blackAccent;
public static DarkAccent SE98AccentChoice => _se98Accent;
private static DarkAccent AccentFor(Theme t) =>
t == Theme.Light ? _lightAccent : t == Theme.Black ? _blackAccent : t == Theme.SE98 ? _se98Accent : _darkAccent;
public static DarkAccent AccentChoiceFor(Theme t) => AccentFor(t);
// True for the theme families that support accent variants.
private static bool HasAccents(Theme t) =>
t == Theme.Dark || t == Theme.Light || t == Theme.Black || t == Theme.SE98;
/// <summary>Fired after the theme dictionary has been updated.</summary>
public static event Action? ThemeChanged;
// ── Public API ───────────────────────────────────────────────────
/// <summary>
/// Call once at startup (before MainWindow is created) to restore the saved theme.
/// DWM title bar is applied later via ApplyDwm(hwnd) from SourceInitialized.
/// </summary>
public static void Initialize()
{
var saved = App.GetSetting("Theme");
// Back-compat: the Black theme's enum value was renamed from "HighContrast".
if (saved == "HighContrast") saved = nameof(Theme.Black);
_current = Enum.TryParse<Theme>(saved, out var t) ? t : Theme.Dark;
_darkAccent = Enum.TryParse<DarkAccent>(App.GetSetting("DarkAccent"), out var da) ? da : DarkAccent.Green;
_lightAccent = Enum.TryParse<DarkAccent>(App.GetSetting("LightAccent"), out var la) ? la : DarkAccent.Green;
_blackAccent = Enum.TryParse<DarkAccent>(App.GetSetting("BlackAccent"), out var ba) ? ba : DarkAccent.Green;
_se98Accent = Enum.TryParse<DarkAccent>(App.GetSetting("98SEAccent"), out var wa) ? wa : DarkAccent.Blue;
ApplyInternal(_current, applyDwm: false);
}
/// <summary>
/// Change a theme family's accent hue, persist it, and reapply if that family is active.
/// Dark and Light keep independent accents, so changing one never disturbs the other.
/// </summary>
public static void ApplyAccent(Theme family, DarkAccent accent)
{
if (family == Theme.Light) { _lightAccent = accent; App.SetSetting("LightAccent", accent.ToString()); }
else if (family == Theme.Black) { _blackAccent = accent; App.SetSetting("BlackAccent", accent.ToString()); }
else if (family == Theme.SE98) { _se98Accent = accent; App.SetSetting("98SEAccent", accent.ToString()); }
else { _darkAccent = accent; App.SetSetting("DarkAccent", accent.ToString()); }
if (_current == family)
{
LoadDict(_current);
ThemeChanged?.Invoke();
}
}
/// <summary>
/// Change to a new theme, persist the choice, and update DWM immediately.
/// </summary>
public static void Apply(Theme theme)
{
_current = theme;
App.SetSetting("Theme", theme.ToString());
ApplyInternal(theme, applyDwm: true);
ThemeChanged?.Invoke();
}
/// <summary>
/// Called from Window.SourceInitialized to set the native title bar color.
/// </summary>
public static void ApplyDwm(IntPtr hwnd)
{
SetDwm(hwnd, !UsesLightChrome(_current));
}
// ── Internal ─────────────────────────────────────────────────────
private static void ApplyInternal(Theme theme, bool applyDwm)
{
LoadDict(theme);
if (applyDwm)
{
var win = Application.Current?.MainWindow;
if (win != null)
{
var hwnd = new WindowInteropHelper(win).Handle;
if (hwnd != IntPtr.Zero)
SetDwm(hwnd, !UsesLightChrome(theme));
}
}
}
private static void LoadDict(Theme theme)
{
var uri = theme switch
{
Theme.Light => new Uri("pack://application:,,,/Themes/Light.xaml"),
Theme.Black => new Uri("pack://application:,,,/Themes/Black.xaml"),
Theme.SE98 => new Uri("pack://application:,,,/Themes/98SE.xaml"),
Theme.Blood => new Uri("pack://application:,,,/Themes/Blood.xaml"),
Theme.Greed => new Uri("pack://application:,,,/Themes/Greed.xaml"),
Theme.Cyanotic => new Uri("pack://application:,,,/Themes/Cyanotic.xaml"),
Theme.Ectoplasm => new Uri("pack://application:,,,/Themes/Ectoplasm.xaml"),
Theme.Decay => new Uri("pack://application:,,,/Themes/Decay.xaml"),
Theme.Mourning => new Uri("pack://application:,,,/Themes/Mourning.xaml"),
Theme.Sepulchre => new Uri("pack://application:,,,/Themes/Sepulchre.xaml"),
Theme.Delirium => new Uri("pack://application:,,,/Themes/Delirium.xaml"),
Theme.Malaise => new Uri("pack://application:,,,/Themes/Malaise.xaml"),
_ => new Uri("pack://application:,,,/Themes/Dark.xaml"),
};
var newDict = new ResourceDictionary { Source = uri };
// Recorded BEFORE CompleteAppPalette materializes it: whether the THEME FILE itself
// defines the tab ring (98SE does - classic chrome, not an accent derivation). After
// materialization the key always exists, so this is the only moment the distinction
// is readable, and the accent overlay below needs it to know whether re-deriving the
// ring from the overlay's accent would honor the theme or clobber it.
bool themeOwnsTabRing = newDict.Contains("TabActiveRingBrush");
// 98SE owns the entire classic button treatment. Other themes derive these roles from
// their accent; record ownership before CompleteAppPalette fills the fallback keys.
bool themeOwnsOutlineRest = newDict.Contains("OutlineRestBrush");
bool themeOwnsOutlineText = newDict.Contains("OutlineTextBrush");
bool themeOwnsOutlineHover = newDict.Contains("OutlineHoverBrush");
bool themeOwnsOutlineHoverText = newDict.Contains("OutlineHoverTextBrush");
CompleteAppPalette(newDict);
var merged = Application.Current.Resources.MergedDictionaries;
// In-place per-key update: fires a targeted notification for each changed key without
// structurally modifying MergedDictionaries. Structural add/remove fires a synchronous
// ResourcesChanged that can invoke FindResource() calls (e.g. in SwitchSidebarToPagesTab)
// before the new dict is fully in place, causing ResourceReferenceKeyNotFoundException.
if (merged.Count > 0)
{
var existing = merged[0];
foreach (object key in newDict.Keys)
existing[key] = newDict[key];
// The two effect keys can be NULL (98SE: no shadows), and a null-valued entry
// does not reliably survive the per-key copy above - the previous theme's effect
// then stays in the live dictionary and 98SE keeps casting pane shadows. Force
// them through explicitly, null included.
existing["PaneShadowEffect"] = newDict.Contains("PaneShadowEffect") ? newDict["PaneShadowEffect"] : null;
existing["BarShadowEffect"] = newDict.Contains("BarShadowEffect") ? newDict["BarShadowEffect"] : null;
}
else
{
merged.Add(newDict);
}
// Dark and Light families: overlay the chosen accent hue on top of the base green keys.
// Green is the base itself, so it needs no overlay (and re-applying the base above
// already restored green, so switching back from a colored accent works automatically).
// Each theme has its own tuned overlay (Dark = bright text on dark; Light = dark text
// on white), loaded from Accents/<Theme>/<Accent>.xaml.
var accent = AccentFor(theme);
if (HasAccents(theme) && accent != DarkAccent.Green)
{
// Dark overlays live in Accents/Dark/; Light in Accents/Light/; Black in Accents/Black/.
string sub = theme == Theme.Light ? "Light/" : theme == Theme.Black ? "Black/" : theme == Theme.SE98 ? "98SE/" : "Dark/";
var accentDict = new ResourceDictionary
{
Source = new Uri($"pack://application:,,,/Themes/Accents/{sub}{accent}.xaml")
};
var target = merged[0];
foreach (object key in accentDict.Keys)
target[key] = accentDict[key];
// Aliases derived from PrimaryBrush were materialized against the BASE palette
// (CompleteAppPalette runs before this overlay), so an overlay that recolors
// PrimaryBrush without carrying the alias left them on the base hue - the green
// wordmark on blue-accented 98SE. Re-point them at the overlay's accent.
if (accentDict.Contains("PrimaryBrush"))
foreach (string aliased in new[] { "AccentLogo", "InstallBtnBg", "SelectionAccent", "RadioAccent" })
if (!accentDict.Contains(aliased))
target[aliased] = accentDict["PrimaryBrush"];
// The outline-button roles were materialized against the BASE palette before this
// overlay ran. Re-derive every accent-led role together; otherwise the Install
// button gets (for example) a red outline with the base green text. A theme that
// explicitly owns a role (98SE's black-on-gray treatment) keeps it untouched.
object outlineAccent = accentDict.Contains("OutlineBtnBrush")
? accentDict["OutlineBtnBrush"]
: accentDict.Contains("PrimaryBrush") ? accentDict["PrimaryBrush"] : target["OutlineRestBrush"];
if (!themeOwnsOutlineRest && !accentDict.Contains("OutlineRestBrush"))
target["OutlineRestBrush"] = outlineAccent;
if (!themeOwnsOutlineText && !accentDict.Contains("OutlineTextBrush"))
target["OutlineTextBrush"] = outlineAccent;
if (!themeOwnsOutlineHover && !accentDict.Contains("OutlineHoverBrush"))
target["OutlineHoverBrush"] = outlineAccent;
if (!themeOwnsOutlineHoverText && !accentDict.Contains("OutlineHoverTextBrush"))
target["OutlineHoverTextBrush"] = target["OnPrimaryBrush"];
// TabActiveRingBrush is the same pre-overlay derivation (from SelectionAccent), so
// the active tab's ring and underline sat on the theme's base hue under every
// colored accent, on every theme. Re-derive from the overlay's SelectionAccent -
// already merged into target above - UNLESS the theme file defines the ring
// itself (98SE's classic chrome) or the overlay carries it explicitly.
if (!themeOwnsTabRing && !accentDict.Contains("TabActiveRingBrush"))
target["TabActiveRingBrush"] = target["SelectionAccent"];
}
// App.xaml owns startup fallbacks for these legacy aliases, and local application
// resources outrank merged dictionaries. Keep those fallbacks synchronized so 98SE's
// zero radius actually reaches panes, tabs, flyouts, and dialogs.
var appResources = Application.Current.Resources;
var liveResources = merged[0];
// One semantic role for the two window-like overlays. This is assigned after the
// palette and accent overlay are fully merged so gradient BackgroundBrush values are
// preserved instead of being flattened or replaced by MenuBackgroundBrush.
liveResources["OverlayWindowBrush"] = liveResources["BackgroundBrush"];
appResources["RadWindow"] = liveResources["WindowCornerRadius"];
appResources["RadCard"] = liveResources["PanelCornerRadius"];
appResources["RadControl"] = liveResources["ControlCornerRadius"];
appResources["UiFont"] = liveResources["UiFont"];
// One SystemIdle pass to nudge any elements whose effective value didn't auto-update
// (e.g. ControlTemplate trigger bindings with TargetName that missed the per-key signal).
Application.Current?.Dispatcher.BeginInvoke(DispatcherPriority.SystemIdle, (Action)RefreshIcons);
}
private static bool UsesLightChrome(Theme theme) => theme is Theme.Light or Theme.SE98;
private static void CompleteAppPalette(ResourceDictionary d)
{
object Pick(string key, string fallback) => d.Contains(key) ? d[key] : d[fallback];
void Alias(string key, string fallback)
{
if (!d.Contains(key)) d[key] = Pick(fallback, "PaneBrush");
}
Alias("BgRecentPanel", "SurfaceBrush");
Alias("BgFlyout", "MenuBackgroundBrush");
// Match KillerNotes: the inner About grouping panel uses the context-menu surface.
Alias("AboutPanelBrush", "MenuBackgroundBrush");
Alias("SelectionAccent", "PrimaryBrush");
Alias("AccentLogo", "PrimaryBrush");
Alias("InstallBtnBg", "PrimaryBrush");
Alias("DropBorder", "PaneBorderBrush");
Alias("SettingsOpenRowBg", "RowHoverBrush");
Alias("FilenameBrush", "MutedTextBrush");
Alias("BgDragHandle", "PaneBorderBrush");
Alias("DragLine", "InputBorderBrush");
Alias("SliderTrack", "InputBorderBrush");
Alias("RadioAccent", "PrimaryBrush");
Alias("BgCanvas", "PaneBrush");
Alias("FocusedPaneBrush", "BgCanvas");
// Keep a solid fallback for controls that cannot use the full-window gradient.
if (!d.Contains("SolidBackgroundBrush"))
{
if (d["BackgroundBrush"] is LinearGradientBrush gradient && gradient.GradientStops.Count > 0)
{
var solid = new SolidColorBrush(gradient.GradientStops[0].Color);
solid.Freeze();
d["SolidBackgroundBrush"] = solid;
}
else
{
Alias("SolidBackgroundBrush", "BackgroundBrush");
}
}
// Inactive tabs belong to the strip behind them and use the theme background.
// Replacing this with the first gradient stop creates visibly unrelated color blocks.
Alias("TabInactiveBrush", "BackgroundBrush");
Alias("RadioWellBrush", "BgCanvas");
// App.xaml owns a local UiFont fallback, and local application resources outrank
// merged theme dictionaries. Materialize this key in every palette so LoadDict can
// copy the active value into that higher-precedence slot (98SE supplies Microsoft
// Sans Serif; the other themes deliberately return to the family Segoe stack).
if (!d.Contains("UiFont"))
d["UiFont"] = new FontFamily("Segoe UI, Microsoft JhengHei UI, Nirmala UI");
Alias("ComboFieldBrush", "PaneBrush");
Alias("ComboFieldHoverBrush", "RowHoverBrush");
Alias("ComboPopupBrush", "PaneBrush");
// The chevron sits directly on the combo field: no button face by default, so the
// arrow does not read as a separate boxed control. 98SE sets ComboButtonBrush
// explicitly (#c0c0c0) and keeps its raised Win98 drop-down button.
if (!d.Contains("ComboButtonBrush")) d["ComboButtonBrush"] = Brushes.Transparent;
Alias("ComboButtonHoverBrush", "RowHoverBrush");
// These must be materialized into every completed palette. Theme dictionaries are
// copied into the live dictionary in place, so a missing key would otherwise retain
// the previously selected theme's caption (most visibly 98SE green on Blood).
// Keep the titlebar continuous with the surrounding app chrome. Themes that need
// distinct titlebar treatment (notably 98SE and the gradient themes) provide an
// explicit TitleBarBrush and are therefore left untouched by this fallback.
Alias("TitleBarBrush", "BackgroundBrush");
Alias("DialogTitleBarBrush", "TitleBarBrush");
Alias("KeyboardKeyBrush", "PaneBrush");
if (!d.Contains("DangerRed")) d["DangerRed"] = new SolidColorBrush(Color.FromRgb(0xef, 0x44, 0x44));
if (!d.Contains("BgOverlay")) d["BgOverlay"] = new SolidColorBrush(Color.FromArgb(0xbb, 0, 0, 0));
if (!d.Contains("HeaderShadowOpacity")) d["HeaderShadowOpacity"] = 0.5;
if (!d.Contains("WordmarkShadowOpacity")) d["WordmarkShadowOpacity"] = 0.45;
if (!d.Contains("AboutShadowOpacity")) d["AboutShadowOpacity"] = d.Contains("FlyoutShadowOpacity") ? d["FlyoutShadowOpacity"] : 0.6;
if (!d.Contains("AboutIconShadowOpacity")) d["AboutIconShadowOpacity"] = 0.6;
if (!d.Contains("AboutCaptionVisibility")) d["AboutCaptionVisibility"] = Visibility.Collapsed;
if (!d.Contains("AboutModernCloseVisibility")) d["AboutModernCloseVisibility"] = Visibility.Visible;
if (!d.Contains("ShortcutShadowOpacity")) d["ShortcutShadowOpacity"] = d.Contains("FlyoutShadowOpacity") ? d["FlyoutShadowOpacity"] : 0.6;
if (!d.Contains("ShortcutHeaderShadowOpacity")) d["ShortcutHeaderShadowOpacity"] = 0.55;
// Every *ShadowOpacity key needs a materialized default: 98SE defines all ten as
// zeroes, and the in-place merge keeps them zeroed for any later theme that omits
// the key - which is how visiting 98SE once stripped the pane shadows from every
// theme that relied on lookup fallbacks. Defaults mirror Black.xaml.
if (!d.Contains("PaneShadowOpacity")) d["PaneShadowOpacity"] = 0.60;
if (!d.Contains("BarShadowOpacity")) d["BarShadowOpacity"] = 0.38;
if (!d.Contains("FlyoutShadowOpacity")) d["FlyoutShadowOpacity"] = 0.55;
if (!d.Contains("ThemeRadioShadowOpacity")) d["ThemeRadioShadowOpacity"] = 0.5;
if (!d.Contains("ShortcutCaptionVisibility")) d["ShortcutCaptionVisibility"] = Visibility.Collapsed;
if (!d.Contains("ShortcutModernHeaderVisibility")) d["ShortcutModernHeaderVisibility"] = Visibility.Visible;
if (!d.Contains("KsCatTools")) d["KsCatTools"] = new SolidColorBrush(Color.FromRgb(0xff, 0xd3, 0x19));
if (!d.Contains("KsCatOcr")) d["KsCatOcr"] = new SolidColorBrush(Color.FromRgb(0xff, 0x90, 0x1f));
if (!d.Contains("WindowFramePadding")) d["WindowFramePadding"] = new Thickness(0);
if (!d.Contains("FrameOuterLightThickness")) d["FrameOuterLightThickness"] = new Thickness(0);
if (!d.Contains("FrameOuterDarkThickness")) d["FrameOuterDarkThickness"] = new Thickness(0);
if (!d.Contains("FrameInnerLightThickness")) d["FrameInnerLightThickness"] = new Thickness(0);
if (!d.Contains("FrameInnerDarkThickness")) d["FrameInnerDarkThickness"] = new Thickness(0);
if (!d.Contains("FrameInnerMargin")) d["FrameInnerMargin"] = new Thickness(0);
if (!d.Contains("FrameOuterLightBrush")) d["FrameOuterLightBrush"] = Brushes.Transparent;
if (!d.Contains("FrameOuterDarkBrush")) d["FrameOuterDarkBrush"] = Brushes.Transparent;
if (!d.Contains("FrameInnerLightBrush")) d["FrameInnerLightBrush"] = Brushes.Transparent;
if (!d.Contains("FrameInnerDarkBrush")) d["FrameInnerDarkBrush"] = Brushes.Transparent;
if (!d.Contains("WindowFrameBrush")) d["WindowFrameBrush"] = Pick("SurfaceBrush", "PaneBrush");
// Dialogs carry the same outline as the main window (AppBorderBrush - what DWM paints
// on the main frame), not the neutral menu hairline they had drifted to. 98SE disables
// this uniform outline because its directional frame rings supply the classic bevel.
if (!d.Contains("DialogFrameBrush")) d["DialogFrameBrush"] = Pick("AppBorderBrush", "MenuBorderBrush");
if (!d.Contains("DialogFrameThickness")) d["DialogFrameThickness"] = new Thickness(1);
if (!d.Contains("DialogFramePadding")) d["DialogFramePadding"] = new Thickness(0);
if (!d.Contains("DialogWindowFrameThickness")) d["DialogWindowFrameThickness"] = new Thickness(0);
if (!d.Contains("DialogWindowFramePadding")) d["DialogWindowFramePadding"] = new Thickness(0);
if (!d.Contains("ButtonBevelLightThickness")) d["ButtonBevelLightThickness"] = d.Contains("BevelLightThickness") ? d["BevelLightThickness"] : new Thickness(0);
if (!d.Contains("ButtonBevelDarkThickness")) d["ButtonBevelDarkThickness"] = d.Contains("BevelDarkThickness") ? d["BevelDarkThickness"] : new Thickness(0);
// Only 98SE defines these; unmaterialized they resolve to nothing and the picker's
// footer buttons lose their borders on every other theme. Family standard: the confirm
// button rests with the accent outline; the neutral button gets the menu hairline.
// Text input fill. Only 98SE defined it (classic white), so the picker's fields
// rendered with no background on every other theme. BgCanvas matches the Document
// Info dialog's fields - the reference look.
if (!d.Contains("TextFieldBrush")) d["TextFieldBrush"] = Pick("BgCanvas", "PaneBrush");
// No theme dictionary carries these two keys, so on the fresh dict this method runs
// against, plain assignment and if-absent are equivalent - assignment states the
// intent. NOTE this still runs BEFORE the accent overlay: overlay-time re-derivation
// (LoadDict) is what keeps them on the chosen accent, not this line - the green
// Install outline on teal-accent Black was fixed THERE.
if (!d.Contains("OutlineRestBrush")) d["OutlineRestBrush"] = Pick("OutlineBtnBrush", "PrimaryBrush");
if (!d.Contains("ButtonEdgeBrush")) d["ButtonEdgeBrush"] = Pick("MenuBorderBrush", "PaneBrush");
// The confirm button remains accent-led in modern themes. 98SE supplies classic gray
// face/text/hover values so Open and Save never become a blue selection rectangle.
if (!d.Contains("OutlineFaceBrush")) d["OutlineFaceBrush"] = Brushes.Transparent;
if (!d.Contains("OutlineTextBrush")) d["OutlineTextBrush"] = Pick("OutlineBtnBrush", "PrimaryBrush");
if (!d.Contains("OutlineHoverBrush")) d["OutlineHoverBrush"] = Pick("OutlineBtnBrush", "PrimaryBrush");
if (!d.Contains("OutlineHoverTextBrush")) d["OutlineHoverTextBrush"] = Pick("OnPrimaryBrush", "TextBrush");
// Circle swatches by default; 98SE's own 0 makes them squares. Materialized so 98SE's
// square cannot leak into a later theme through the in-place merge.
if (!d.Contains("AccentSwatchCornerRadius")) d["AccentSwatchCornerRadius"] = new CornerRadius(9);
// The checked picker row's label while HOVERED. Defaults to the normal checked color
// (RadioAccent) so nothing changes; a theme whose accent equals its hover fill
// (Sepulchre) overrides it, or the selected label vanishes into its own highlight.
Alias("RadioHoverFgBrush", "RadioAccent");
// Overlay (About / shortcuts) close button, KillerScan reference: a bare muted X that
// turns red on hover. 98SE overrides all of these in its own xaml for the classic
// MDL2 glyph and metrics.
if (!d.Contains("AboutCloseGlyph")) d["AboutCloseGlyph"] = ((char)0x2715).ToString(); // multiplication X, by codepoint so the file stays ASCII-clean
if (!d.Contains("AboutCloseFont")) d["AboutCloseFont"] = new FontFamily("Segoe UI");
if (!d.Contains("AboutCloseWidth")) d["AboutCloseWidth"] = 28.0;
if (!d.Contains("AboutCloseHeight")) d["AboutCloseHeight"] = 26.0;
if (!d.Contains("AboutCloseMargin")) d["AboutCloseMargin"] = new Thickness(0, 6, 6, 0);
if (!d.Contains("AboutCloseFg")) d["AboutCloseFg"] = new SolidColorBrush(Color.FromRgb(0x88, 0x88, 0x88));
if (!d.Contains("AboutCloseHoverFg")) d["AboutCloseHoverFg"] = new SolidColorBrush(Color.FromRgb(0xe0, 0x44, 0x44));
if (!d.Contains("CheckSunkenDarkThickness")) d["CheckSunkenDarkThickness"] = new Thickness(0);
if (!d.Contains("CheckSunkenLightThickness")) d["CheckSunkenLightThickness"] = new Thickness(0);
// 98SE opts into the compact native-style caption and removes the shadow halo. These
// values must exist in every completed palette because the live dictionary is updated
// in place; otherwise its caption geometry survives after selecting another theme.
if (!d.Contains("UseDialogCaption")) d["UseDialogCaption"] = false;
if (!d.Contains("DialogTitleBarHeight")) d["DialogTitleBarHeight"] = 40.0;
if (!d.Contains("DialogHaloMargin")) d["DialogHaloMargin"] = new Thickness(12);
if (!d.Contains("TitleBarHeight")) d["TitleBarHeight"] = 36.0;
if (!d.Contains("FooterHeight")) d["FooterHeight"] = 24.0;
if (!d.Contains("FooterStatusPadding")) d["FooterStatusPadding"] = new Thickness(16, 0, 16, 0);
if (!d.Contains("FooterStatusFont")) d["FooterStatusFont"] = new FontFamily("Segoe UI");
if (!d.Contains("FooterMetaFont")) d["FooterMetaFont"] = new FontFamily("Consolas");
if (!d.Contains("FooterPadding")) d["FooterPadding"] = new Thickness(16, 0, 16, 0);
if (!d.Contains("FooterCellMargin")) d["FooterCellMargin"] = new Thickness(0);
if (!d.Contains("FooterCellPadding")) d["FooterCellPadding"] = new Thickness(0);
if (!d.Contains("RootBorderThickness")) d["RootBorderThickness"] = new Thickness(1);
if (!d.Contains("PlainTitleVisibility")) d["PlainTitleVisibility"] = Visibility.Collapsed;
if (!d.Contains("WordmarkVisibility")) d["WordmarkVisibility"] = Visibility.Visible;
if (!d.Contains("GripDotsVisibility")) d["GripDotsVisibility"] = Visibility.Visible;
if (!d.Contains("GripHatchVisibility")) d["GripHatchVisibility"] = Visibility.Collapsed;
if (!d.Contains("ComboButtonSize")) d["ComboButtonSize"] = 18.0;
// Width and height are separate: modern themes use a compact square chevron face,
// while 98SE keeps the native narrow button but stretches it to the field's height.
if (!d.Contains("ComboButtonHeight")) d["ComboButtonHeight"] = d["ComboButtonSize"];
if (!d.Contains("ZoomBoxHeight")) d["ZoomBoxHeight"] = 28.0;
if (!d.Contains("RetroTabJoinVisibility")) d["RetroTabJoinVisibility"] = Visibility.Collapsed;
if (!d.Contains("RetroActiveTabOutlineVisibility")) d["RetroActiveTabOutlineVisibility"] = Visibility.Collapsed;
if (!d.Contains("TabBandHeight")) d["TabBandHeight"] = double.NaN;
if (!d.Contains("CaptionButtonWidth")) d["CaptionButtonWidth"] = 46.0;
if (!d.Contains("CaptionButtonHeight")) d["CaptionButtonHeight"] = 36.0;
if (!d.Contains("CaptionButtonMargin")) d["CaptionButtonMargin"] = new Thickness(0);
if (!d.Contains("CaptionCloseGap")) d["CaptionCloseGap"] = new Thickness(0);
if (!d.Contains("CaptionButtonsMargin")) d["CaptionButtonsMargin"] = new Thickness(0);
bool compactDialogCaption = d["UseDialogCaption"] is bool useDialogCaption && useDialogCaption;
if (!d.Contains("DialogCloseWidth"))
d["DialogCloseWidth"] = compactDialogCaption ? d["CaptionButtonWidth"] : 28.0;
if (!d.Contains("DialogCloseHeight"))
d["DialogCloseHeight"] = compactDialogCaption ? d["CaptionButtonHeight"] : 26.0;
if (!d.Contains("DialogCaptionButtonsMargin"))
{
var captionButtonsMargin = d["CaptionButtonsMargin"] is Thickness margin
? margin
: new Thickness(0);
d["DialogCaptionButtonsMargin"] = new Thickness(0, 0, captionButtonsMargin.Right, 0);
}
if (!d.Contains("CaptionButtonBrush")) d["CaptionButtonBrush"] = Brushes.Transparent;
if (!d.Contains("CaptionGlyphBrush")) d["CaptionGlyphBrush"] = Pick("TextBrush", "PaneBrush");
if (!d.Contains("CaptionHoverBrush")) d["CaptionHoverBrush"] = Pick("RowHoverBrush", "PaneBrush");
if (!d.Contains("CaptionCloseBrush")) d["CaptionCloseBrush"] = Pick("DangerRed", "TextBrush");
if (!d.Contains("CaptionCloseHoverBrush")) d["CaptionCloseHoverBrush"] = Pick("DangerRed", "TextBrush");
if (!d.Contains("CaptionCloseHoverFgBrush")) d["CaptionCloseHoverFgBrush"] = Brushes.White;
bool classicCaption = d["PlainTitleVisibility"] is Visibility titleVisibility
&& titleVisibility == Visibility.Visible;
d["CaptionGlyphWeight"] = classicCaption ? FontWeights.Bold : FontWeights.Normal;
d["CaptionFontGlyphVisibility"] = classicCaption ? Visibility.Collapsed : Visibility.Visible;
d["CaptionDrawnGlyphVisibility"] = classicCaption ? Visibility.Visible : Visibility.Collapsed;
if (!d.Contains("ChromeFontFamily")) d["ChromeFontFamily"] = new FontFamily("Tahoma");
if (!d.Contains("TitleIconSize")) d["TitleIconSize"] = 25.0;
if (!d.Contains("TitleIconMargin")) d["TitleIconMargin"] = new Thickness(0, 0, 7, 0);
if (!d.Contains("TitleBarPadding")) d["TitleBarPadding"] = new Thickness(12, 0, 0, 0);
if (!d.Contains("FooterBevelDarkBrush")) d["FooterBevelDarkBrush"] = Brushes.Transparent;
if (!d.Contains("FooterBevelLightBrush")) d["FooterBevelLightBrush"] = Brushes.Transparent;
if (!d.Contains("FooterCellLightThickness")) d["FooterCellLightThickness"] = new Thickness(0);
if (!d.Contains("FooterCellDarkThickness")) d["FooterCellDarkThickness"] = new Thickness(0);
if (!d.Contains("BevelLightBrush")) d["BevelLightBrush"] = Brushes.Transparent;
if (!d.Contains("BevelDarkBrush")) d["BevelDarkBrush"] = Brushes.Transparent;
if (!d.Contains("BevelLightThickness")) d["BevelLightThickness"] = new Thickness(0);
if (!d.Contains("BevelDarkThickness")) d["BevelDarkThickness"] = new Thickness(0);
if (!d.Contains("PaneBevelLightBrush")) d["PaneBevelLightBrush"] = Brushes.Transparent;
if (!d.Contains("PaneBevelDarkBrush")) d["PaneBevelDarkBrush"] = Brushes.Transparent;
if (!d.Contains("PaneBevelLightThickness")) d["PaneBevelLightThickness"] = new Thickness(0);
if (!d.Contains("PaneBevelDarkThickness")) d["PaneBevelDarkThickness"] = new Thickness(0);
if (!d.Contains("PaneBevelDark2Brush")) d["PaneBevelDark2Brush"] = Brushes.Transparent;
if (!d.Contains("PaneBevelLight2Brush")) d["PaneBevelLight2Brush"] = Brushes.Transparent;
if (!d.Contains("PaneBevel2LightThickness")) d["PaneBevel2LightThickness"] = new Thickness(0);
if (!d.Contains("PaneBevel2DarkThickness")) d["PaneBevel2DarkThickness"] = new Thickness(0);
if (!d.Contains("PaneBevelInnerMargin")) d["PaneBevelInnerMargin"] = new Thickness(0);
if (!d.Contains("SidebarPanelMargin")) d["SidebarPanelMargin"] = new Thickness(0);
if (!d.Contains("SidebarInnerDarkThickness")) d["SidebarInnerDarkThickness"] = new Thickness(0);
if (!d.Contains("SidebarInnerDarkVisibility")) d["SidebarInnerDarkVisibility"] = Visibility.Collapsed;
if (!d.Contains("SplitHostMargin")) d["SplitHostMargin"] = new Thickness(0, 0, 8, 0);
if (!d.Contains("SplitPaneGutterWidth")) d["SplitPaneGutterWidth"] = 8.0;
if (!d.Contains("ContentPaneMargin")) d["ContentPaneMargin"] = new Thickness(0, 0, 8, 0);
if (!d.Contains("FileDialogPaneBrush")) d["FileDialogPaneBrush"] = d.Contains("PaneBrush") ? d["PaneBrush"] : Brushes.White;
// Theme dictionaries are copied into the live dictionary in place, so a key that is
// absent from the next theme keeps the previous theme's value. 98SE sets this to zero;
// materialize the modern default so switching away from 98SE restores both edge fades.
if (!d.Contains("EdgeFadeOpacity")) d["EdgeFadeOpacity"] = 1.0;
// Match KillerNotes and KillerShell: modern sidebars do not paint a second surface;
// the themed app background continues through them. 98SE explicitly overrides this
// with its white recessed client pane.
if (!d.Contains("SidebarPaneBrush")) d["SidebarPaneBrush"] = Brushes.Transparent;
if (!d.Contains("SidebarRailBrush")) d["SidebarRailBrush"] = Brushes.Transparent;
if (!d.Contains("PaneEdgeBrush")) d["PaneEdgeBrush"] = Pick("PaneBorderBrush", "CardBorderBrush");
if (!d.Contains("BarEdgeBrush")) d["BarEdgeBrush"] = Pick("PaneBorderBrush", "CardBorderBrush");
if (!d.Contains("BarEdgeThickness")) d["BarEdgeThickness"] = new Thickness(1, 0, 1, 1);
if (!d.Contains("BarEdgeDarkBrush")) d["BarEdgeDarkBrush"] = Brushes.Transparent;
if (!d.Contains("BarEdgeDarkThickness")) d["BarEdgeDarkThickness"] = new Thickness(0);
if (!d.Contains("BarPadding")) d["BarPadding"] = new Thickness(4);
if (!d.Contains("MenuBevelLightBrush")) d["MenuBevelLightBrush"] = Brushes.Transparent;
if (!d.Contains("MenuBevelDarkBrush")) d["MenuBevelDarkBrush"] = Brushes.Transparent;
if (!d.Contains("MenuBevel2LightBrush")) d["MenuBevel2LightBrush"] = Brushes.Transparent;
if (!d.Contains("MenuBevel2DarkBrush")) d["MenuBevel2DarkBrush"] = Brushes.Transparent;
if (!d.Contains("MenuBevelLightThickness")) d["MenuBevelLightThickness"] = new Thickness(0);
if (!d.Contains("MenuBevelDarkThickness")) d["MenuBevelDarkThickness"] = new Thickness(0);
if (!d.Contains("MenuBevel2LightThickness")) d["MenuBevel2LightThickness"] = new Thickness(0);
if (!d.Contains("MenuBevel2DarkThickness")) d["MenuBevel2DarkThickness"] = new Thickness(0);
if (!d.Contains("MenuBevelInnerMargin")) d["MenuBevelInnerMargin"] = new Thickness(0);
if (!d.Contains("TabInactiveBevelDarkThickness")) d["TabInactiveBevelDarkThickness"] = new Thickness(0);
if (!d.Contains("TabActiveBevelDarkThickness")) d["TabActiveBevelDarkThickness"] = new Thickness(0);
if (!d.Contains("TabBevelMargin")) d["TabBevelMargin"] = new Thickness(0);
if (!d.Contains("TabActiveInnerBevelBrush")) d["TabActiveInnerBevelBrush"] = Brushes.Transparent;
if (!d.Contains("TabActiveInnerBevelThickness")) d["TabActiveInnerBevelThickness"] = new Thickness(0);
if (!d.Contains("TabActiveInnerBevelMargin")) d["TabActiveInnerBevelMargin"] = new Thickness(0);
if (!d.Contains("TabMargin")) d["TabMargin"] = new Thickness(0, 3, 0, 1);
if (!d.Contains("TabInactiveFirstMargin")) d["TabInactiveFirstMargin"] = d["TabMargin"];
if (!d.Contains("TabInactiveLastMargin")) d["TabInactiveLastMargin"] = d["TabMargin"];
if (!d.Contains("TabActiveFirstMargin")) d["TabActiveFirstMargin"] = new Thickness(0, 3, 0, 0);
if (!d.Contains("TabActiveLastMargin")) d["TabActiveLastMargin"] = new Thickness(0, 3, 0, 0);
if (!d.Contains("TabActiveOnlyMargin")) d["TabActiveOnlyMargin"] = new Thickness(0, 3, 0, 0);
if (!d.Contains("TabPadding")) d["TabPadding"] = new Thickness(12, 4, 5, 5);
if (!d.Contains("TabActiveBevelDarkMargin")) d["TabActiveBevelDarkMargin"] = d["TabBevelMargin"];
if (!d.Contains("TabActivePadding")) d["TabActivePadding"] = new Thickness(12, 1, 5, 5);
if (!d.Contains("TabStripeThickness")) d["TabStripeThickness"] = new Thickness(0, 3, 0, 0);
if (!d.Contains("TabSeamPatchBrush")) d["TabSeamPatchBrush"] = Brushes.Transparent;
if (!d.Contains("TabActiveRingBrush")) d["TabActiveRingBrush"] = Pick("SelectionAccent", "PrimaryBrush");
if (!d.Contains("TabFocusThickness")) d["TabFocusThickness"] = new Thickness(1, 3, 1, 0);
if (!d.Contains("TabFocusPadding")) d["TabFocusPadding"] = new Thickness(11, 1, 4, 5);
if (!d.Contains("TabFocusFirstThickness")) d["TabFocusFirstThickness"] = new Thickness(0, 3, 1, 0);
if (!d.Contains("TabFocusFirstPadding")) d["TabFocusFirstPadding"] = new Thickness(12, 1, 4, 5);
if (!d.Contains("TabFocusLastThickness")) d["TabFocusLastThickness"] = new Thickness(1, 3, 0, 0);
if (!d.Contains("TabFocusLastPadding")) d["TabFocusLastPadding"] = new Thickness(11, 1, 5, 5);
if (!d.Contains("TabFocusOnlyThickness")) d["TabFocusOnlyThickness"] = new Thickness(0, 3, 0, 0);
if (!d.Contains("TabFocusOnlyPadding")) d["TabFocusOnlyPadding"] = new Thickness(12, 1, 5, 5);
if (!d.Contains("FlyoutCornerRadius")) d["FlyoutCornerRadius"] = new CornerRadius(6);
// Annotation bars attach directly beneath the main toolbar: their top edge is always
// square, while the exposed bottom corners follow the theme. 98SE's flyout radius is
// zero, so this naturally squares all four corners there.
var flyoutRadius = d["FlyoutCornerRadius"] is CornerRadius fr ? fr : new CornerRadius(6);
d["AnnotationBarCornerRadius"] = new CornerRadius(
0, 0, flyoutRadius.BottomRight, flyoutRadius.BottomLeft);
if (!d.Contains("MenuFontFamily")) d["MenuFontFamily"] = new FontFamily("Segoe UI");
if (!d.Contains("MenuFontSize")) d["MenuFontSize"] = 12.0;
if (!d.Contains("MenuItemPadding")) d["MenuItemPadding"] = new Thickness(8, 6, 10, 6);
if (!d.Contains("ComboButtonMinWidth")) d["ComboButtonMinWidth"] = 22.0;
if (!d.Contains("ComboChevGlyph")) d["ComboChevGlyph"] = "\uE70D";
if (!d.Contains("ComboChevFont")) d["ComboChevFont"] = new FontFamily("Segoe MDL2 Assets");
if (!d.Contains("ComboChevMargin")) d["ComboChevMargin"] = new Thickness(0);
if (!d.Contains("ComboHighlightTextBrush")) d["ComboHighlightTextBrush"] = Pick("SelectionFg", "TextBrush");
if (!d.Contains("ScrollBarThickness")) d["ScrollBarThickness"] = 12.0;
if (!d.Contains("ScrollArrowSize")) d["ScrollArrowSize"] = 0.0;
if (!d.Contains("ScrollArrowTopBevelMargin")) d["ScrollArrowTopBevelMargin"] = new Thickness(0);
if (!d.Contains("ScrollThumbRadius")) d["ScrollThumbRadius"] = new CornerRadius(3);
if (!d.Contains("ScrollThumbMargin")) d["ScrollThumbMargin"] = new Thickness(4, 0, 4, 0);
if (!d.Contains("ScrollTrackBrush")) d["ScrollTrackBrush"] = Brushes.Transparent;
if (!d.Contains("ScrollTrackBevelDark")) d["ScrollTrackBevelDark"] = Brushes.Transparent;
if (!d.Contains("ScrollTrackBevelLight")) d["ScrollTrackBevelLight"] = Brushes.Transparent;
if (!d.Contains("WindowCornerRadius")) d["WindowCornerRadius"] = new CornerRadius(7);
if (!d.Contains("PanelCornerRadius")) d["PanelCornerRadius"] = new CornerRadius(6);
if (!d.Contains("ControlCornerRadius")) d["ControlCornerRadius"] = new CornerRadius(3);
d["RadWindow"] = d["WindowCornerRadius"];
d["RadCard"] = d["PanelCornerRadius"];
d["RadControl"] = d["ControlCornerRadius"];
var windowRadius = d["WindowCornerRadius"] is CornerRadius wr ? wr.TopLeft : 7;
d["TitleBarCornerRadius"] = new CornerRadius(windowRadius, windowRadius, 0, 0);
d["FooterCornerRadius"] = new CornerRadius(0, 0, windowRadius, windowRadius);
if (!d.Contains("TabCornerRadius"))
{
var panelRadius = d["PanelCornerRadius"] is CornerRadius radius ? radius.TopLeft : 6;
d["TabCornerRadius"] = new CornerRadius(panelRadius, panelRadius, 0, 0);
}
if (!d.Contains("TabStripFadeBrush"))
{
var end = (Pick("PaneBrush", "SurfaceBrush") as SolidColorBrush)?.Color ?? Colors.Transparent;
d["TabStripFadeBrush"] = new LinearGradientBrush
{
StartPoint = new Point(0, 0), EndPoint = new Point(0, 1),
GradientStops = { new GradientStop(Colors.Transparent, 0), new GradientStop(end, 1) }
};
}
// KillerShell's selected-tab elevation is a centered, palette-driven shadow.
// A null resource on 98SE removes the effect completely instead of rasterizing
// classic text through a zero-opacity effect.
if (!d.Contains("BarShadowEffect"))
{
double opacity = d["BarShadowOpacity"] is double value ? value : 0;
if (opacity > 0)
{
var shadow = new DropShadowEffect
{
Color = Colors.Black,
BlurRadius = 9,
ShadowDepth = 0,
Opacity = opacity
};
shadow.Freeze();
d["BarShadowEffect"] = shadow;
}
else
{
d["BarShadowEffect"] = null;
}
}
// The document pane's drop shadow, same pattern as BarShadowEffect above: the App.xaml
// PaneShadow effect is an app-level Freezable, so its DynamicResource opacity froze at
// startup and 98SE's zero never reached it - the shadow stayed on. Build the effect
// per theme instead; null removes it completely.
if (!d.Contains("PaneShadowEffect"))
{
double paneOp = d["PaneShadowOpacity"] is double pv ? pv : 0.60;
if (paneOp > 0)
{
var paneShadow = new System.Windows.Media.Effects.DropShadowEffect
{
Color = Colors.Black,
BlurRadius = 16,
ShadowDepth = 5, // family standard: downward cast over the footer
Direction = 270,
Opacity = paneOp,
RenderingBias = System.Windows.Media.Effects.RenderingBias.Quality,
};
paneShadow.Freeze();
d["PaneShadowEffect"] = paneShadow;
}
else
{
d["PaneShadowEffect"] = null;
}
}
d[SystemColors.HighlightBrushKey] = Pick("SelectionBg", "PrimaryBrush");
d[SystemColors.HighlightTextBrushKey] = Pick("SelectionFg", "OnPrimaryBrush");
d[SystemColors.InactiveSelectionHighlightBrushKey] = Pick("SelectionBg", "PrimaryBrush");
d[SystemColors.InactiveSelectionHighlightTextBrushKey] = Pick("SelectionFg", "OnPrimaryBrush");
}
/// <summary>
/// Call from MainWindow.ContentRendered to fix icon colors on initial load
/// when the theme was restored from settings (no switch event fires).
/// </summary>
public static void RefreshIcons()
{
if (Application.Current == null) return;
foreach (Window w in Application.Current.Windows)
ForceRender(w);
}
private static void ForceRender(DependencyObject node)
{
if (node is System.Windows.Controls.Primitives.ToggleButton tb)
{
// ClearValue + InvalidateProperty forces style-setter DynamicResources to
// re-resolve from the updated dictionary without firing Checked/Unchecked
// event handlers (which would re-trigger Apply and cause an infinite loop).
tb.ClearValue(Control.ForegroundProperty);
tb.InvalidateProperty(Control.ForegroundProperty);
}
if (node is Control ctrl)
{
ctrl.InvalidateProperty(Control.ForegroundProperty);
ctrl.InvalidateProperty(Control.BackgroundProperty);
ctrl.InvalidateProperty(Control.BorderBrushProperty);
}
if (node is UIElement el) el.InvalidateVisual();
int count = VisualTreeHelper.GetChildrenCount(node);
for (int i = 0; i < count; i++)
ForceRender(VisualTreeHelper.GetChild(node, i));
}
private static void SetDwm(IntPtr hwnd, bool dark)
{
try
{
int value = dark ? 1 : 0;
DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ref value, sizeof(int));
// Tint the Win11 1px frame border to the theme's pane border so the
// window outline follows the palette instead of staying system gray.
// AppBorderBrush lets a theme override the tone (family standard).
if ((Application.Current?.TryFindResource("AppBorderBrush")
?? Application.Current?.TryFindResource("PaneBorderBrush")) is SolidColorBrush b)
{
// COLORREF is 0x00BBGGRR
int colorref = b.Color.R | (b.Color.G << 8) | (b.Color.B << 16);
DwmSetWindowAttribute(hwnd, DWMWA_BORDER_COLOR, ref colorref, sizeof(int));
}
}
catch { /* DWMWA not supported on older Windows builds */ }
}
}
}
+55
View File
@@ -0,0 +1,55 @@
namespace KillerPDF.Services
{
/// <summary>
/// Separates fast in-page wheel scrolling from page navigation at the edge. Momentum events
/// immediately following a content scroll are ignored; after that, two standard wheel notches
/// in the same direction confirm that the user intends to change pages (#205).
/// </summary>
internal sealed class WheelPageFlipGate
{
private static readonly TimeSpan MomentumQuietPeriod = TimeSpan.FromMilliseconds(250);
private static readonly TimeSpan ConfirmationWindow = TimeSpan.FromMilliseconds(650);
private const int ConfirmationDelta = 240;
private DateTime _blockUntilUtc;
private DateTime _lastEdgeWheelUtc;
private int _direction;
private int _accumulatedDelta;
internal void NoteContentScroll(DateTime nowUtc)
{
_blockUntilUtc = nowUtc + MomentumQuietPeriod;
ResetConfirmation();
}
internal bool TryConfirm(int delta, DateTime nowUtc)
{
if (delta == 0 || nowUtc < _blockUntilUtc)
{
ResetConfirmation();
return false;
}
int direction = Math.Sign(delta);
if (_direction != direction || nowUtc - _lastEdgeWheelUtc > ConfirmationWindow)
{
_direction = direction;
_accumulatedDelta = 0;
}
_lastEdgeWheelUtc = nowUtc;
_accumulatedDelta += Math.Abs(delta);
if (_accumulatedDelta < ConfirmationDelta) return false;
ResetConfirmation();
return true;
}
private void ResetConfirmation()
{
_lastEdgeWheelUtc = default;
_direction = 0;
_accumulatedDelta = 0;
}
}
}