- Services/PdfRedactText.cs: strip text whose origin falls inside a CoverAnnotation from the page content stream at save time, hooked into PdfBurn.DrawAnnotationsIntoDoc. Fixes edited values staying recoverable by text extraction. - Themes/MMD.xaml replaces all thirteen themes; picker and accent strip removed; no dark mode. - Rename KillerPDF -> MMD PDF across code, resources, packaging and locale strings; new icon. - Remove the upstream author credit and the in-app install button.
161 lines
8.4 KiB
C#
161 lines
8.4 KiB
C#
namespace MmdPdf.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];
|
|
}
|
|
}
|
|
}
|
|
}
|