diff --git a/src/EPPlus.Fonts.OpenType.Tests/Logging/CollectingFontLogger.cs b/src/EPPlus.Fonts.OpenType.Tests/Logging/CollectingFontLogger.cs new file mode 100644 index 000000000..5c942ee31 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/Logging/CollectingFontLogger.cs @@ -0,0 +1,62 @@ +using OfficeOpenXml.Interfaces.Fonts; +using System.Collections.Generic; + +namespace EPPlus.Fonts.OpenType.Tests +{ + /// + /// Test logger that keeps every event it is asked to log. Thread-safe. + /// + public class CollectingFontLogger : IFontLogger + { + private readonly object _lock = new object(); + private readonly List _events = new List(); + private readonly FontLogSeverity _minimumSeverity; + + public CollectingFontLogger() + : this(FontLogSeverity.Debug) + { + } + + public CollectingFontLogger(FontLogSeverity minimumSeverity) + { + _minimumSeverity = minimumSeverity; + } + + public bool IsEnabled(FontLogSeverity severity) + { + return severity >= _minimumSeverity; + } + + public void Log(FontLogEvent logEvent) + { + lock (_lock) + { + _events.Add(logEvent); + } + } + + /// A snapshot of all events logged so far, in order. + public IList Events + { + get + { + lock (_lock) + { + return new List(_events); + } + } + } + + /// A snapshot of the events of one type, in order. + public IList GetEvents(FontLogEventType type) + { + var result = new List(); + foreach (var e in Events) + { + if (e.Type == type) + result.Add(e); + } + return result; + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType.Tests/Logging/FontLoggingTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Logging/FontLoggingTests.cs new file mode 100644 index 000000000..a6004cd23 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/Logging/FontLoggingTests.cs @@ -0,0 +1,174 @@ +using EPPlus.Fonts.OpenType.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml.Interfaces.Fonts; +using System; +using System.IO; +using System.Linq; + +namespace EPPlus.Fonts.OpenType.Tests +{ + /// + /// The tests use no installed fonts: system directories are not searched, so the outcome is + /// the same on every machine. Only the embedded fonts (Archivo Narrow, Noto Emoji) are used. + /// Every test creates its own engine and logger, so they are safe to run in parallel. + /// + [TestClass] + public class FontLoggingTests + { + private static OpenTypeFontEngine CreateEngine(IFontLogger logger) + { + return new OpenTypeFontEngine(c => + { + c.SearchSystemDirectories = false; + c.Logger = logger; + }); + } + + private static FontLogEvent Single(CollectingFontLogger log, FontLogEventType type) + { + var events = log.GetEvents(type); + Assert.AreEqual(1, events.Count, "Expected exactly one " + type + " event."); + return events[0]; + } + + [TestMethod] + public void UnknownFont_LogsLastResort() + { + var log = new CollectingFontLogger(); + using (var engine = CreateEngine(log)) + { + Assert.IsNotNull(engine.GetTextShaper("NoSuchFont_EpplusLogTest")); + } + + var e = Single(log, FontLogEventType.FontLastResort); + Assert.AreEqual(FontLogSeverity.Warning, e.Severity); + Assert.AreEqual("NoSuchFont_EpplusLogTest", e.RequestedFont); + Assert.AreEqual("Archivo Narrow", e.ResolvedFont); + } + + [TestMethod] + public void LastResortFontRequestedExplicitly_LogsResolvedNotLastResort() + { + var log = new CollectingFontLogger(); + using (var engine = CreateEngine(log)) + { + Assert.IsNotNull(engine.GetTextShaper("Archivo Narrow")); + } + + Assert.AreEqual(0, log.GetEvents(FontLogEventType.FontLastResort).Count); + Single(log, FontLogEventType.FontResolved); + } + + [TestMethod] + public void MissingGlyph_IsReportedOncePerCodePoint() + { + var log = new CollectingFontLogger(); + using (var engine = CreateEngine(log)) + { + var font = engine.LoadFont("Archivo Narrow"); + var provider = new DefaultFontProvider(engine, font); + OpenTypeFont used; + ushort glyphId; + + // U+4F60 is Han. Archivo Narrow lacks it and no CJK font is available. + Assert.IsFalse(provider.TryGetGlyphFont(0x4F60, out used, out glyphId)); + Assert.IsFalse(provider.TryGetGlyphFont(0x4F60, out used, out glyphId)); + } + + var missing = Single(log, FontLogEventType.GlyphMissing); + Assert.AreEqual(FontLogSeverity.Warning, missing.Severity); + Assert.AreEqual(0x4F60u, missing.CodePoint.Value); + Assert.AreEqual(UnicodeScript.Han, missing.Script.Value); + StringAssert.Contains(missing.Message, "U+4F60"); + + // The chain is resolved once, and reported once, however many glyphs ask for it. + var chain = Single(log, FontLogEventType.ScriptChainResolved); + Assert.AreEqual(UnicodeScript.Han, chain.Script.Value); + StringAssert.Contains(chain.Message, "Microsoft YaHei [not found]"); + } + + [TestMethod] + public void EmojiFallback_IsReportedOnFirstUseOfTheFont() + { + var log = new CollectingFontLogger(); + using (var engine = CreateEngine(log)) + { + var font = engine.LoadFont("Archivo Narrow"); + var provider = new DefaultFontProvider(engine, font); + OpenTypeFont used; + ushort glyphId; + + Assert.IsTrue(provider.TryGetGlyphFont(0x1F600, out used, out glyphId)); + Assert.IsTrue(provider.TryGetGlyphFont(0x1F601, out used, out glyphId)); + } + + var first = Single(log, FontLogEventType.ScriptFallbackUsed); + Assert.AreEqual(0x1F600u, first.CodePoint.Value); + StringAssert.Contains(first.ResolvedFont, "Noto"); + + // Debug level also follows each code point routed to the fallback. + Assert.AreEqual(2, log.GetEvents(FontLogEventType.GlyphFallback).Count); + } + + [TestMethod] + public void MinimumSeverity_FiltersDebugEvents() + { + var log = new CollectingFontLogger(FontLogSeverity.Warning); + using (var engine = CreateEngine(log)) + { + engine.GetTextShaper("NoSuchFont_EpplusLogTest"); + engine.GetTextShaper("Archivo Narrow"); + } + + Assert.IsTrue(log.Events.Count > 0); + Assert.IsTrue(log.Events.All(e => e.Severity >= FontLogSeverity.Warning)); + } + + [TestMethod] + public void LoggerThatThrows_DoesNotBreakFontResolution() + { + using (var engine = CreateEngine(new ThrowingLogger())) + { + var shaper = engine.GetTextShaper("NoSuchFont_EpplusLogTest"); + Assert.IsNotNull(shaper); + } + } + + [TestMethod] + public void TextFileLogger_WritesOneLinePerEvent() + { + var path = Path.Combine(Path.GetTempPath(), "epplus_fontlog_" + Guid.NewGuid().ToString("N") + ".txt"); + try + { + var logger = FontLoggerFactory.CreateTextFileLogger(new FileInfo(path)); + using (var engine = CreateEngine(logger)) + { + engine.GetTextShaper("NoSuchFont_EpplusLogTest"); + } + + var lines = File.ReadAllLines(path); + Assert.IsTrue( + lines.Any(l => l.StartsWith("WRN ") && l.Contains("NoSuchFont_EpplusLogTest")), + "Expected a warning line naming the unknown font."); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } + + private sealed class ThrowingLogger : IFontLogger + { + public bool IsEnabled(FontLogSeverity severity) + { + return true; + } + + public void Log(FontLogEvent logEvent) + { + throw new InvalidOperationException("The logger failed on purpose."); + } + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/DefaultFontProvider.cs b/src/EPPlus.Fonts.OpenType/DefaultFontProvider.cs index a483581c5..32d9632d1 100644 --- a/src/EPPlus.Fonts.OpenType/DefaultFontProvider.cs +++ b/src/EPPlus.Fonts.OpenType/DefaultFontProvider.cs @@ -11,9 +11,11 @@ Date Author Change 10/07/2025 EPPlus Software AB EPPlus.Fonts.OpenType 1.0 02/24/2026 EPPlus Software AB Dynamic fallback chain with lazy loading 05/20/2026 EPPlus Software AB Script-classified fallback via engine reference + 10/08/2026 EPPlus Software AB Font logging *************************************************************************************************/ using EPPlus.Fonts.OpenType.FontCache; using EPPlus.Fonts.OpenType.FontResolver; +using EPPlus.Fonts.OpenType.Logging; using OfficeOpenXml.Interfaces.Drawing.Text; using OfficeOpenXml.Interfaces.Fonts; using OfficeOpenXml.Interfaces.RichText; @@ -35,9 +37,18 @@ namespace EPPlus.Fonts.OpenType /// /// Per-script chains and their fonts are lazy-loaded the first time a code point in /// that script is encountered, then cached for the lifetime of this provider. + /// + /// The decisions are reported to the logger configured on the font source, if any. A glyph + /// found in the primary font is never logged, as that is the hot path. Everything else is + /// reported once: the resolution of a script's chain, the first use of each fallback font, + /// each code point routed to a fallback (debug level) and each code point no font can supply. /// public class DefaultFontProvider : IFontProvider { + // Upper bound on distinct missing code points reported by one provider, so a document + // full of unsupported characters cannot flood the log. + private const int MaxReportedMissing = 100; + private readonly OpenTypeFont _primaryFont; private readonly IFontSource _fontSource; @@ -57,6 +68,12 @@ private readonly Dictionary> _resolvedScriptCh // matters for subsetting and PDF embedding. private readonly HashSet _usedFallbacks = new HashSet(); + // Log de-duplication. Allocated on first use, and only when a logger asks for the events, + // so a provider without a logger pays nothing. Guarded by _lock. + private HashSet _reportedMissing; + private HashSet _reportedFallbackGlyphs; + private bool _missingCapReported; + private readonly object _lock = new object(); /// @@ -98,6 +115,11 @@ internal DefaultFontProvider(IFontSource fontSource, OpenTypeFont primaryFont) _notoMath = new LazyFallbackFont(EmbeddedFonts.LoadNotoMath); } + private IFontLogger Logger + { + get { return _fontSource.Logger; } + } + /// public bool TryGetGlyphFont(uint codePoint, out OpenTypeFont font, out ushort glyphId) { @@ -114,12 +136,12 @@ public bool TryGetGlyphFont(uint codePoint, out OpenTypeFont font, out ushort gl switch (script) { case UnicodeScript.Emoji: - if (TryGlyphInLazyFallback(_notoEmoji, codePoint, out font, out glyphId)) + if (TryGlyphInLazyFallback(_notoEmoji, script, codePoint, out font, out glyphId)) return true; break; case UnicodeScript.Math: - if (TryGlyphInLazyFallback(_notoMath, codePoint, out font, out glyphId)) + if (TryGlyphInLazyFallback(_notoMath, script, codePoint, out font, out glyphId)) return true; break; @@ -134,6 +156,7 @@ public bool TryGetGlyphFont(uint codePoint, out OpenTypeFont font, out ushort gl } // 3. Nothing found — return primary with .notdef. + LogGlyphMissing(codePoint, script); font = _primaryFont; glyphId = 0; return false; @@ -165,6 +188,7 @@ public IEnumerable GetAllFonts() /// private bool TryGlyphInLazyFallback( LazyFallbackFont lazy, + UnicodeScript script, uint codePoint, out OpenTypeFont font, out ushort glyphId) @@ -173,7 +197,7 @@ private bool TryGlyphInLazyFallback( if (fallbackFont.CmapTable.TryGetGlyphId(codePoint, out glyphId)) { font = fallbackFont; - MarkUsed(fallbackFont); + MarkUsed(fallbackFont, script, codePoint); return true; } @@ -199,7 +223,7 @@ private bool TryGlyphInScriptChain( if (candidate.CmapTable.TryGetGlyphId(codePoint, out glyphId)) { font = candidate; - MarkUsed(candidate); + MarkUsed(candidate, script, codePoint); return true; } } @@ -216,28 +240,64 @@ private bool TryGlyphInScriptChain( /// private List GetOrResolveScriptChain(UnicodeScript script) { + List resolved; + var events = new List(); + lock (_lock) { - List resolved; if (_resolvedScriptChains.TryGetValue(script, out resolved)) return resolved; - resolved = ResolveScriptChain(script); + resolved = ResolveScriptChain(script, events); _resolvedScriptChains[script] = resolved; - return resolved; } + + // Reported after the lock is released, so a slow logger cannot block other threads + // that are shaping text with this provider. + var logger = Logger; + foreach (var logEvent in events) + { + FontLog.Write(logger, logEvent); + } + + return resolved; } /// /// Reads the configured chain for a script from the engine and loads each named font. + /// Events describing the outcome are added to for the caller + /// to report; nothing is logged from here, as the caller holds a lock. /// - private List ResolveScriptChain(UnicodeScript script) + private List ResolveScriptChain(UnicodeScript script, List events) { var result = new List(); + var logger = Logger; + var wantInformation = FontLog.IsEnabled(logger, FontLogSeverity.Information); + var wantWarning = FontLog.IsEnabled(logger, FontLogSeverity.Warning); + var primary = wantInformation || wantWarning ? FontLog.Describe(_primaryFont) : null; + var chainNames = _fontSource.GetScriptFallback(script); if (chainNames == null || chainNames.Length == 0) + { + if (wantInformation) + { + events.Add(new FontLogEvent + { + Type = FontLogEventType.ScriptChainResolved, + Severity = FontLogSeverity.Information, + Script = script, + RequestedFont = primary, + Message = string.Format( + "Script chain {0} (primary {1}): {2}.", + script, primary, + chainNames == null ? "no chain configured" : "fallback disabled (empty chain)") + }); + } return result; + } + + var status = wantInformation ? new List() : null; foreach (var fontName in chainNames) { @@ -249,31 +309,232 @@ private List ResolveScriptChain(UnicodeScript script) // availability check rather than blindly loading. var availability = _fontSource.GetFontAvailability(fontName, FontSubFamily.Regular); if (availability != FontAvailability.Exact) + { + if (status != null) + { + status.Add(fontName + (availability == FontAvailability.FamilyOnly + ? " [family only, not exact]" + : " [not found]")); + } continue; + } try { var font = _fontSource.LoadFont(fontName, FontSubFamily.Regular); if (font != null) + { result.Add(font); + if (status != null) + status.Add(fontName + " [ok]"); + } + else if (status != null) + { + status.Add(fontName + " [load returned null]"); + } } - catch + catch (Exception ex) { - // If a named fallback fails to load for any reason, skip it silently. + // If a named fallback fails to load for any reason, skip it. // The chain is best-effort — we never want a fallback font's loading - // error to break primary text rendering. + // error to break primary text rendering. The failure is reported, though. + if (status != null) + status.Add(fontName + " [load failed]"); + + if (wantWarning) + { + events.Add(new FontLogEvent + { + Type = FontLogEventType.ScriptFontLoadFailed, + Severity = FontLogSeverity.Warning, + Script = script, + RequestedFont = fontName, + Exception = ex, + Message = string.Format( + "Script chain {0}: font '{1}' is installed but could not be loaded ({2}: {3}).", + script, fontName, ex.GetType().Name, ex.Message) + }); + } } } + if (wantInformation) + { + events.Add(new FontLogEvent + { + Type = FontLogEventType.ScriptChainResolved, + Severity = FontLogSeverity.Information, + Script = script, + RequestedFont = primary, + Message = string.Format( + "Script chain {0} (primary {1}): {2}.", + script, primary, string.Join(", ", status.ToArray())) + }); + } + return result; } - private void MarkUsed(OpenTypeFont font) + /// + /// Records that a fallback font supplied a glyph. The first time a font is used, and the + /// first time a code point is routed to a fallback, are reported. + /// + private void MarkUsed(OpenTypeFont font, UnicodeScript script, uint codePoint) + { + bool firstUseOfFont; + lock (_lock) + { + firstUseOfFont = _usedFallbacks.Add(font); + } + + var logger = Logger; + + if (firstUseOfFont && FontLog.IsEnabled(logger, FontLogSeverity.Information)) + { + var primary = FontLog.Describe(_primaryFont); + var used = FontLog.Describe(font); + FontLog.Write(logger, new FontLogEvent + { + Type = FontLogEventType.ScriptFallbackUsed, + Severity = FontLogSeverity.Information, + Script = script, + CodePoint = codePoint, + RequestedFont = primary, + ResolvedFont = used, + Message = string.Format( + "Glyph fallback: {0} lacks {1} ({2}); using {3}.", + primary, FontLog.FormatCodePoint(codePoint), script, used) + }); + } + + if (FontLog.IsEnabled(logger, FontLogSeverity.Debug)) + { + bool firstUseOfCodePoint; + lock (_lock) + { + if (_reportedFallbackGlyphs == null) + _reportedFallbackGlyphs = new HashSet(); + firstUseOfCodePoint = _reportedFallbackGlyphs.Add(codePoint); + } + + if (firstUseOfCodePoint) + { + var primary = FontLog.Describe(_primaryFont); + var used = FontLog.Describe(font); + FontLog.Write(logger, new FontLogEvent + { + Type = FontLogEventType.GlyphFallback, + Severity = FontLogSeverity.Debug, + Script = script, + CodePoint = codePoint, + RequestedFont = primary, + ResolvedFont = used, + Message = string.Format( + "{0} ({1}) -> {2}.", + FontLog.FormatCodePoint(codePoint), script, used) + }); + } + } + } + + /// + /// Reports a code point that no candidate font could supply. Each code point is reported + /// once per provider, up to distinct code points. + /// + private void LogGlyphMissing(uint codePoint, UnicodeScript script) { + var logger = Logger; + if (!FontLog.IsEnabled(logger, FontLogSeverity.Warning)) + return; + + var capReached = false; lock (_lock) { - _usedFallbacks.Add(font); + if (_reportedMissing == null) + _reportedMissing = new HashSet(); + + if (_reportedMissing.Contains(codePoint)) + return; + + if (_reportedMissing.Count >= MaxReportedMissing) + { + if (_missingCapReported) + return; + _missingCapReported = true; + capReached = true; + } + else + { + _reportedMissing.Add(codePoint); + } + } + + var primary = FontLog.Describe(_primaryFont); + + if (capReached) + { + FontLog.Write( + logger, + FontLogSeverity.Warning, + FontLogEventType.GlyphMissing, + string.Format( + "More than {0} distinct glyphs are missing from {1} and its fallbacks; further ones are not reported.", + MaxReportedMissing, primary), + primary, + null); + return; + } + + FontLog.Write(logger, new FontLogEvent + { + Type = FontLogEventType.GlyphMissing, + Severity = FontLogSeverity.Warning, + Script = script, + CodePoint = codePoint, + RequestedFont = primary, + Message = string.Format( + "Glyph missing: {0} ({1}) is not in {2}; {3}.", + FontLog.FormatCodePoint(codePoint), script, primary, DescribeWhyMissing(script)) + }); + } + + /// + /// Explains why a code point of the given script ended up without a glyph. Called only + /// when a logger wants the event, and never from inside the lock. + /// + private string DescribeWhyMissing(UnicodeScript script) + { + switch (script) + { + case UnicodeScript.Unknown: + return "the code point has no script classification, so no fallback applies"; + + case UnicodeScript.Emoji: + return "the bundled Noto Emoji has no glyph for it either"; + + case UnicodeScript.Math: + return "the bundled Noto Math has no glyph for it either"; + } + + var configured = _fontSource.GetScriptFallback(script); + if (configured == null) + return "no fallback chain is configured for this script"; + if (configured.Length == 0) + return "fallback is disabled for this script"; + + var resolved = GetOrResolveScriptChain(script); + if (resolved.Count == 0) + { + return "no font in the chain (" + FontLog.JoinNames(configured) + + ") is installed and loadable"; + } + + var names = new List(); + foreach (var f in resolved) + { + names.Add(FontLog.Describe(f)); } + return "none of the loaded chain fonts (" + string.Join(", ", names.ToArray()) + ") has it"; } /// diff --git a/src/EPPlus.Fonts.OpenType/EpplusFontConfiguration.cs b/src/EPPlus.Fonts.OpenType/EpplusFontConfiguration.cs index 7aa1d2789..6251d00ab 100644 --- a/src/EPPlus.Fonts.OpenType/EpplusFontConfiguration.cs +++ b/src/EPPlus.Fonts.OpenType/EpplusFontConfiguration.cs @@ -11,7 +11,9 @@ Date Author Change 02/27/2026 EPPlus Software AB Replaces FontResolutionConfig 05/06/2026 EPPlus Software AB Property-based transactional configuration 05/20/2026 EPPlus Software AB Added per-script glyph fallback configuration + 10/08/2026 EPPlus Software AB Added Logger for font and glyph selection diagnostics *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Logging; using OfficeOpenXml.Interfaces.Fonts; using System; using System.Collections.Generic; @@ -28,8 +30,9 @@ namespace EPPlus.Fonts.OpenType.FontResolver /// properties at different times: and /// are read once while the resolver is built, so /// changing them after the callback returns has no effect, whereas - /// , the per-script chains and - /// are read on each font resolution and so do take effect. + /// , the per-script chains, + /// and are read on each font resolution + /// and so do take effect. /// Callers should not rely on either behaviour; treat the configuration as fixed once the /// callback returns and create a new engine to change it. /// @@ -64,6 +67,18 @@ public IList FontDirectories /// public IFontResolver FontResolver { get; set; } + /// + public IFontLogger Logger { get; set; } + + /// + /// The configured logger, or a logger that is never enabled when none is configured. + /// Never null, so callers can use it without a null check. + /// + internal IFontLogger ActiveLogger + { + get { return Logger ?? NullFontLogger.Instance; } + } + /// public void OnFontEmbedding(Func callback) { @@ -115,6 +130,7 @@ public void Reset() ApplyDefaultScriptFallbacks(); _webFontSubstitutions.Clear(); ApplyDefaultWebFontSubstitutions(); + Logger = null; } // ----------------------------------------------------------------------------------------- diff --git a/src/EPPlus.Fonts.OpenType/FontResolver/DefaultFontResolver.cs b/src/EPPlus.Fonts.OpenType/FontResolver/DefaultFontResolver.cs index 813b8baca..775ee0552 100644 --- a/src/EPPlus.Fonts.OpenType/FontResolver/DefaultFontResolver.cs +++ b/src/EPPlus.Fonts.OpenType/FontResolver/DefaultFontResolver.cs @@ -13,7 +13,9 @@ Date Author Change 03/02/2026 EPPlus Software AB TTC support: extract individual font from collection 05/06/2026 EPPlus Software AB Built-in fallback chains for common Office fonts 05/06/2026 EPPlus Software AB Extracted IFontScanner and IFontFileReader for testability + 10/08/2026 EPPlus Software AB Font logging *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Logging; using EPPlus.Fonts.OpenType.Scanner; using OfficeOpenXml.Interfaces.Fonts; using System; @@ -28,9 +30,14 @@ namespace EPPlus.Fonts.OpenType.FontResolver /// Supports fallback font chains via EpplusFontConfiguration as well as a built-in /// metric-aware fallback chain for common Office and system fonts. /// TTC (TrueType Collection) files are handled transparently by the IFontFileReader. + /// + /// Each resolution reports which step decided the outcome to the logger configured on + /// , if any. /// internal class DefaultFontResolver : IFontResolver, IFontAvailabilityProvider { + private const string LastResortFamily = "Archivo Narrow"; + private readonly IEnumerable _fontDirectories; private readonly bool _searchSystemDirectories; private readonly EpplusFontConfiguration _config; @@ -83,9 +90,12 @@ public FontAvailability GetFontAvailability(string fontName, FontSubFamily subFa public byte[] ResolveFont(string fontName, FontSubFamily subFamily) { + var logger = _config != null ? _config.ActiveLogger : NullFontLogger.Instance; + // 1. special case for Archivo Narrow which is distributed as last-resort-font with EPPlus if (string.Equals("archivo narrow", fontName, StringComparison.OrdinalIgnoreCase)) { + LogResolved(logger, fontName, subFamily, "it is the embedded last-resort font"); return EmbeddedFonts.LoadArchivoNarrow(subFamily).RawData; } @@ -94,17 +104,25 @@ public byte[] ResolveFont(string fontName, FontSubFamily subFamily) _fontDirectories, fontName, subFamily, _searchSystemDirectories); if (face != null && face.IsExactMatch) + { + LogResolved(logger, fontName, subFamily, "exact match"); return _fileReader.ReadFontBytes(face); + } // 3. No exact match — try user-configured fallback chain + string[] userFallbacks = null; if (_config != null) { - var userFallbacks = _config.GetFallbacks(fontName); + userFallbacks = _config.GetFallbacks(fontName); if (userFallbacks != null) { - var resolved = TryResolveFromChain(userFallbacks, subFamily); + string matched; + var resolved = TryResolveFromChain(userFallbacks, subFamily, out matched); if (resolved != null) + { + LogFallback(logger, fontName, subFamily, matched, "user-configured chain", userFallbacks); return resolved; + } } } @@ -114,13 +132,18 @@ public byte[] ResolveFont(string fontName, FontSubFamily subFamily) var builtinFallbacks = BuiltinFontFallbackChains.GetFallbacks(fontName); if (builtinFallbacks != null) { - var resolved = TryResolveFromChain(builtinFallbacks, subFamily); + string matched; + var resolved = TryResolveFromChain(builtinFallbacks, subFamily, out matched); if (resolved != null) + { + LogFallback(logger, fontName, subFamily, matched, "built-in chain", builtinFallbacks); return resolved; + } } // 5. No match found — fall back to built-in Archivo Narrow. // Only applies when using DefaultFontResolver (i.e. no custom resolver installed). + LogLastResort(logger, fontName, subFamily, face != null ? face.FamilyName : null, userFallbacks, builtinFallbacks); return EmbeddedFonts.LoadArchivoNarrow(subFamily).RawData; } @@ -129,8 +152,9 @@ public byte[] ResolveFont(string fontName, FontSubFamily subFamily) /// Returns the bytes of the first chain entry that produces an exact match, or null if /// no entry resolves. Each entry is required to match the requested subFamily — falling /// back from a Bold request to a Regular face would defeat the purpose of fallback. + /// receives the chain entry that matched, or null. /// - private byte[] TryResolveFromChain(IEnumerable chain, FontSubFamily subFamily) + private byte[] TryResolveFromChain(IEnumerable chain, FontSubFamily subFamily, out string matchedName) { foreach (var fallbackName in chain) { @@ -138,9 +162,88 @@ private byte[] TryResolveFromChain(IEnumerable chain, FontSubFamily subF _fontDirectories, fallbackName, subFamily, _searchSystemDirectories); if (fallbackFace != null && fallbackFace.IsExactMatch) + { + matchedName = fallbackName; return _fileReader.ReadFontBytes(fallbackFace); + } } + + matchedName = null; return null; } + + // ----------------------------------------------------------------------------------------- + // Logging + // ----------------------------------------------------------------------------------------- + + private static void LogResolved(IFontLogger logger, string fontName, FontSubFamily subFamily, string reason) + { + if (!FontLog.IsEnabled(logger, FontLogSeverity.Debug)) + return; + + FontLog.Write( + logger, + FontLogSeverity.Debug, + FontLogEventType.FontResolved, + string.Format("Requested font '{0}' {1} was resolved: {2}.", fontName, subFamily, reason), + fontName, + fontName); + } + + private static void LogFallback( + IFontLogger logger, + string fontName, + FontSubFamily subFamily, + string matchedName, + string chainKind, + string[] chain) + { + if (!FontLog.IsEnabled(logger, FontLogSeverity.Information)) + return; + + FontLog.Write( + logger, + FontLogSeverity.Information, + FontLogEventType.FontFallback, + string.Format( + "Requested font '{0}' {1} has no exact match; resolved using fallback font '{2}' ({3}: {4}).", + fontName, subFamily, matchedName, chainKind, FontLog.JoinNames(chain)), + fontName, + matchedName); + } + + private static void LogLastResort( + IFontLogger logger, + string fontName, + FontSubFamily subFamily, + string closestFamily, + string[] userFallbacks, + string[] builtinFallbacks) + { + if (!FontLog.IsEnabled(logger, FontLogSeverity.Warning)) + return; + + var message = string.Format( + "Requested font '{0}' {1} has no exact match and no fallback was found; using last-resort font '{2}' (user chain: {3}; built-in chain: {4}).", + fontName, subFamily, LastResortFamily, + FontLog.JoinNames(userFallbacks), FontLog.JoinNames(builtinFallbacks)); + + // The scanner can return a face that is not an exact match, for instance the right + // family in another style. It is rejected on purpose, which is worth knowing. + if (closestFamily != null) + { + message += string.Format( + " Closest face found, '{0}', was rejected because it is not an exact match.", + closestFamily); + } + + FontLog.Write( + logger, + FontLogSeverity.Warning, + FontLogEventType.FontLastResort, + message, + fontName, + LastResortFamily); + } } } \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/FontStore.cs b/src/EPPlus.Fonts.OpenType/FontStore.cs index 35e2e6e2a..e9d899f71 100644 --- a/src/EPPlus.Fonts.OpenType/FontStore.cs +++ b/src/EPPlus.Fonts.OpenType/FontStore.cs @@ -9,8 +9,10 @@ This software is licensed under PolyForm Noncommercial License 1.0.0 Date Author Change ************************************************************************************************* 09/02/2026 EPPlus Software AB Extracted from OpenTypeFontEngine + 10/08/2026 EPPlus Software AB Font logging *************************************************************************************************/ using EPPlus.Fonts.OpenType.FontResolver; +using EPPlus.Fonts.OpenType.Logging; using OfficeOpenXml.Interfaces.Fonts; using System; using System.Collections.Generic; @@ -32,6 +34,10 @@ internal class FontStore : IFontSource private readonly IFontResolver _resolver; private readonly EpplusFontConfiguration _configuration; + // DefaultFontResolver logs the reason for each decision itself. A custom resolver does + // not, so for those the store reports a substitution it can observe from the outside. + private readonly bool _resolverExplainsItself; + private bool _disposed; internal FontStore(IFontResolver resolver, EpplusFontConfiguration configuration) @@ -43,6 +49,13 @@ internal FontStore(IFontResolver resolver, EpplusFontConfiguration configuration _resolver = resolver; _configuration = configuration; + _resolverExplainsItself = resolver is DefaultFontResolver; + } + + /// + public IFontLogger Logger + { + get { return _configuration.ActiveLogger; } } // ----------------------------------------------------------------------------------------- @@ -58,7 +71,11 @@ internal OpenTypeFont LoadFont(string fontName, FontSubFamily subFamily, bool ig ThrowIfDisposed(); if (ignoreCache) - return ResolveAndCreate(_resolver, fontName, subFamily); + { + var uncached = ResolveAndCreate(_resolver, fontName, subFamily); + LogLoaded(fontName, subFamily, uncached); + return uncached; + } string lockKey = BuildCacheKey(fontName, subFamily); object fontLock; @@ -83,6 +100,11 @@ internal OpenTypeFont LoadFont(string fontName, FontSubFamily subFamily, bool ig _fontCache.BeginCache(lockKey); var font = ResolveAndCreate(_resolver, fontName, subFamily); + + // Only reached on a cache miss, so this reports each font once per engine. + // Held under the per-font lock, which only blocks other loads of the same font. + LogLoaded(fontName, subFamily, font); + if (font == null) { // BeginCache left a not-loaded placeholder. Nothing will ever complete it, @@ -168,6 +190,63 @@ private void ThrowIfDisposed() throw new ObjectDisposedException("OpenTypeFontEngine"); } + // ----------------------------------------------------------------------------------------- + // Logging + // ----------------------------------------------------------------------------------------- + + /// + /// Reports the outcome of a resolver call. An unresolved font is a warning. A resolved font + /// is debug output, except when a custom resolver returned a different family than was + /// asked for, which nothing else would explain. + /// + private void LogLoaded(string fontName, FontSubFamily subFamily, OpenTypeFont font) + { + var logger = Logger; + + if (font == null) + { + if (FontLog.IsEnabled(logger, FontLogSeverity.Warning)) + { + FontLog.Write( + logger, + FontLogSeverity.Warning, + FontLogEventType.FontNotResolved, + string.Format("Requested font '{0}' {1} could not be resolved: the font resolver returned no font.", fontName, subFamily), + fontName, + null); + } + return; + } + + if (!FontLog.IsEnabled(logger, FontLogSeverity.Debug) + && (_resolverExplainsItself || !FontLog.IsEnabled(logger, FontLogSeverity.Information))) + { + return; + } + + var resolvedFamily = FontLog.FamilyOf(font); + var substituted = resolvedFamily != null + && !string.Equals(fontName, resolvedFamily, StringComparison.OrdinalIgnoreCase); + + var severity = substituted && !_resolverExplainsItself + ? FontLogSeverity.Information + : FontLogSeverity.Debug; + + if (!FontLog.IsEnabled(logger, severity)) + return; + + // For DefaultFontResolver the preceding FontFallback event explains the substitution. + // A custom resolver does not, so say here that it was the resolver that substituted. + var message = substituted + ? string.Format( + "Requested font '{0}' {1} was loaded using font '{2}'{3}.", + fontName, subFamily, FontLog.Describe(font), + _resolverExplainsItself ? string.Empty : " (substituted by the font resolver)") + : string.Format("Requested font '{0}' {1} was loaded.", fontName, subFamily); + + FontLog.Write(logger, severity, FontLogEventType.FontLoaded, message, fontName, resolvedFamily); + } + // ----------------------------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------------------------- diff --git a/src/EPPlus.Fonts.OpenType/IFontSource.cs b/src/EPPlus.Fonts.OpenType/IFontSource.cs index 384832610..d749f9ac2 100644 --- a/src/EPPlus.Fonts.OpenType/IFontSource.cs +++ b/src/EPPlus.Fonts.OpenType/IFontSource.cs @@ -9,14 +9,15 @@ This software is licensed under PolyForm Noncommercial License 1.0.0 Date Author Change ************************************************************************************************* 09/02/2026 EPPlus Software AB Extracted from OpenTypeFontEngine + 10/08/2026 EPPlus Software AB Added Logger *************************************************************************************************/ using OfficeOpenXml.Interfaces.Fonts; namespace EPPlus.Fonts.OpenType.FontCache { /// - /// The font-loading surface a font provider depends on: resolution, availability, and the - /// configured per-script fallback chains. Nothing else. + /// The font-loading surface a font provider depends on: resolution, availability, the + /// configured per-script fallback chains, and the logger. Nothing else. /// /// It exists so does not depend on /// . A glyph provider has no business reaching the engine's @@ -24,6 +25,13 @@ namespace EPPlus.Fonts.OpenType.FontCache /// internal interface IFontSource { + /// + /// The logger that receives font and glyph selection events. Never null — when no logger + /// is configured this is a logger that is never enabled. Read on each use, so a logger + /// attached later is picked up. + /// + IFontLogger Logger { get; } + /// /// The configured fallback chain for a Unicode script, or null if none is configured. /// An empty array means fallback is explicitly disabled for that script. diff --git a/src/EPPlus.Fonts.OpenType/Logging/FontLog.cs b/src/EPPlus.Fonts.OpenType/Logging/FontLog.cs new file mode 100644 index 000000000..fd73c81d8 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType/Logging/FontLog.cs @@ -0,0 +1,112 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 10/08/2026 EPPlus Software AB Font logging + *************************************************************************************************/ +using OfficeOpenXml.Interfaces.Fonts; +using System; + +namespace EPPlus.Fonts.OpenType.Logging +{ + /// + /// Helpers shared by everything that raises font log events. All calls into a logger go + /// through here, so an exception in a user-supplied logger never reaches the caller. + /// + internal static class FontLog + { + internal static bool IsEnabled(IFontLogger logger, FontLogSeverity severity) + { + if (logger == null) + return false; + try + { + return logger.IsEnabled(severity); + } + catch + { + return false; + } + } + + internal static void Write(IFontLogger logger, FontLogEvent logEvent) + { + if (logger == null || logEvent == null) + return; + try + { + logger.Log(logEvent); + } + catch + { + // Logging must never break font resolution or rendering. + } + } + + internal static void Write( + IFontLogger logger, + FontLogSeverity severity, + FontLogEventType type, + string message, + string requestedFont, + string resolvedFont) + { + Write(logger, new FontLogEvent + { + Type = type, + Severity = severity, + Message = message, + RequestedFont = requestedFont, + ResolvedFont = resolvedFont + }); + } + + /// Family and subfamily of a font, for messages. + internal static string Describe(OpenTypeFont font) + { + if (font == null) + return "(none)"; + try + { + return font.GetEnglishFontFamilyName() + " " + font.SubFamily; + } + catch + { + return "(unknown)"; + } + } + + /// Family name of a font, or null when it cannot be read. + internal static string FamilyOf(OpenTypeFont font) + { + if (font == null) + return null; + try + { + return font.GetEnglishFontFamilyName(); + } + catch + { + return null; + } + } + + internal static string JoinNames(string[] names) + { + if (names == null || names.Length == 0) + return "none"; + return string.Join(", ", names); + } + + internal static string FormatCodePoint(uint codePoint) + { + return "U+" + codePoint.ToString("X4"); + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/Logging/FontLoggerFactory.cs b/src/EPPlus.Fonts.OpenType/Logging/FontLoggerFactory.cs new file mode 100644 index 000000000..2ebac20e3 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType/Logging/FontLoggerFactory.cs @@ -0,0 +1,51 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 10/08/2026 EPPlus Software AB Font logging + *************************************************************************************************/ +using OfficeOpenXml.Interfaces.Fonts; +using System.IO; + +namespace EPPlus.Fonts.OpenType.Logging +{ + /// + /// Creates ready-made implementations. + /// + /// + /// workbook.ConfigureFonts(c => + /// { + /// c.Logger = FontLoggerFactory.CreateTextFileLogger(new FileInfo(@"c:\fontlog.txt")); + /// }); + /// + public static class FontLoggerFactory + { + /// + /// Creates a logger that writes one line per event to , from + /// and up. An existing file is overwritten. + /// + /// The file to write to. + public static IFontLogger CreateTextFileLogger(FileInfo logfile) + { + return CreateTextFileLogger(logfile, FontLogSeverity.Information); + } + + /// + /// Creates a logger that writes one line per event to . + /// An existing file is overwritten. + /// + /// The file to write to. + /// Events below this severity are not written. + /// Use to follow every exact match and every glyph routed to a fallback. + public static IFontLogger CreateTextFileLogger(FileInfo logfile, FontLogSeverity minimumSeverity) + { + return new TextFileFontLogger(logfile, minimumSeverity); + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/Logging/NullFontLogger.cs b/src/EPPlus.Fonts.OpenType/Logging/NullFontLogger.cs new file mode 100644 index 000000000..3eedb5a95 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType/Logging/NullFontLogger.cs @@ -0,0 +1,37 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 10/08/2026 EPPlus Software AB Font logging + *************************************************************************************************/ +using OfficeOpenXml.Interfaces.Fonts; + +namespace EPPlus.Fonts.OpenType.Logging +{ + /// + /// Used when no logger is configured. Never enabled, so no event is ever built. + /// + internal sealed class NullFontLogger : IFontLogger + { + internal static readonly NullFontLogger Instance = new NullFontLogger(); + + private NullFontLogger() + { + } + + public bool IsEnabled(FontLogSeverity severity) + { + return false; + } + + public void Log(FontLogEvent logEvent) + { + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/Logging/TextFileFontLogger.cs b/src/EPPlus.Fonts.OpenType/Logging/TextFileFontLogger.cs new file mode 100644 index 000000000..3e4f30bb1 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType/Logging/TextFileFontLogger.cs @@ -0,0 +1,79 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 10/08/2026 EPPlus Software AB Font logging + *************************************************************************************************/ +using OfficeOpenXml.Interfaces.Fonts; +using System; +using System.IO; +using System.Text; + +namespace EPPlus.Fonts.OpenType.Logging +{ + /// + /// Writes as one line per event to a text file. + /// The file is opened for each write and closed again, so no handle is held between events + /// and the log can be read, moved or deleted while rendering is in progress. This logger + /// therefore needs no disposal. + /// + internal sealed class TextFileFontLogger : IFontLogger + { + private readonly string _path; + private readonly FontLogSeverity _minimumSeverity; + private readonly object _lock = new object(); + + internal TextFileFontLogger(FileInfo logfile, FontLogSeverity minimumSeverity) + { + if (logfile == null) + throw new ArgumentNullException("logfile"); + + _path = logfile.FullName; + _minimumSeverity = minimumSeverity; + + // Start a fresh log, and fail here rather than during rendering if the path is unusable. + using (new FileStream(_path, FileMode.Create, FileAccess.Write, FileShare.ReadWrite)) + { + } + } + + public bool IsEnabled(FontLogSeverity severity) + { + return severity >= _minimumSeverity; + } + + public void Log(FontLogEvent logEvent) + { + if (logEvent == null || !IsEnabled(logEvent.Severity)) + return; + + var line = Prefix(logEvent.Severity) + " " + logEvent.Message; + + lock (_lock) + { + using (var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) + using (var writer = new StreamWriter(stream, new UTF8Encoding(false))) + { + writer.WriteLine(line); + } + } + } + + private static string Prefix(FontLogSeverity severity) + { + switch (severity) + { + case FontLogSeverity.Debug: return "DBG"; + case FontLogSeverity.Information: return "INF"; + case FontLogSeverity.Warning: return "WRN"; + default: return "ERR"; + } + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs b/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs index 7cdbced44..cdf14be03 100644 --- a/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs +++ b/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs @@ -10,10 +10,12 @@ Date Author Change ************************************************************************************************* 05/13/2026 EPPlus Software AB Per-instance font engine. Replaces static OpenTypeFonts. 09/02/2026 EPPlus Software AB Extracted FontStore and ShaperCache; added measurement shaper + 10/08/2026 EPPlus Software AB Font logging; replaced Debug.WriteLine in ResolveEmbeddingDecision *************************************************************************************************/ using EPPlus.Fonts.OpenType.FontCache; using EPPlus.Fonts.OpenType.FontResolver; using EPPlus.Fonts.OpenType.Integration; +using EPPlus.Fonts.OpenType.Logging; using EPPlus.Fonts.OpenType.Scanner; using EPPlus.Fonts.OpenType.TextShaping; using OfficeOpenXml.Interfaces.Drawing.Text; @@ -21,7 +23,6 @@ Date Author Change using OfficeOpenXml.Interfaces.RichText; using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; namespace EPPlus.Fonts.OpenType @@ -43,6 +44,11 @@ public class OpenTypeFontEngine : IDisposable private readonly FontStore _fontStore; private readonly ShaperCache _shaperCache = new ShaperCache(); + // Web substitutions already reported, so the log carries one line per font rather than + // one per call. GetFamilyForTarget runs for every measurement. Guarded by _logLock. + private readonly object _logLock = new object(); + private HashSet _reportedWebSubstitutions; + private bool _disposed; /// @@ -116,6 +122,15 @@ internal FontStore FontStore get { return _fontStore; } } + /// + /// The configured logger, or one that is never enabled. Read on each use, so a logger + /// assigned after construction is picked up. + /// + private IFontLogger Logger + { + get { return _configuration.ActiveLogger; } + } + // ----------------------------------------------------------------------------------------- // Shapers — this is the policy // ----------------------------------------------------------------------------------------- @@ -320,28 +335,50 @@ private void ThrowIfDisposed() internal FontEmbeddingDecision ResolveEmbeddingDecision(OpenTypeFont font) { + var logger = Logger; + var restriction = font.Os2Table != null ? font.Os2Table.GetEmbeddingRestriction() : FontEmbeddingRestriction.None; + var fontName = font.NameTable != null ? font.NameTable.GetFullFontName() : null; + if (font.NameTable != null && EmbeddedFonts.IsBundledFamily(font.GetEnglishFontFamilyName())) + { + LogEmbedding(logger, FontLogSeverity.Debug, fontName, restriction, + FontEmbeddingDecision.Subset, "bundled font"); return FontEmbeddingDecision.Subset; + } - var fontName = font.NameTable != null ? font.NameTable.GetFullFontName() : null; var callback = _configuration.GetEmbeddingCallback(); if (callback != null) { var decision = callback(new FontEmbeddingInfo(fontName, restriction)); if (decision != FontEmbeddingDecision.Default) + { + LogEmbedding(logger, FontLogSeverity.Information, fontName, restriction, + decision, "decided by the OnFontEmbedding callback"); return decision; // user override wins + } } - Debug.WriteLine($"ResolveEmbeddingDecision: {fontName} restriction={restriction} callback={(callback != null)}"); - // No callback, or callback returned Default → derive from the restriction. switch (restriction) { case FontEmbeddingRestriction.NoEmbedding: + if (FontLog.IsEnabled(logger, FontLogSeverity.Error)) + { + FontLog.Write( + logger, + FontLogSeverity.Error, + FontLogEventType.EmbeddingDecision, + string.Format( + "Embedding '{0}' refused: fsType declares a restricted license and no OnFontEmbedding callback permitted it.", + DisplayName(fontName)), + fontName, + null); + } + // Default policy: fail loud. User must opt in via the callback. throw new InvalidOperationException( string.Format( @@ -350,8 +387,12 @@ internal FontEmbeddingDecision ResolveEmbeddingDecision(OpenTypeFont font) "EmbedWhole from IEpplusFontConfiguration.OnFontEmbedding.", string.IsNullOrWhiteSpace(fontName) ? "(unknown)" : fontName)); case FontEmbeddingRestriction.NoSubsetting: + LogEmbedding(logger, FontLogSeverity.Information, fontName, restriction, + FontEmbeddingDecision.EmbedWhole, "fsType forbids subsetting"); return FontEmbeddingDecision.EmbedWhole; default: + LogEmbedding(logger, FontLogSeverity.Information, fontName, restriction, + FontEmbeddingDecision.Subset, "default policy"); return FontEmbeddingDecision.Subset; } } @@ -392,6 +433,7 @@ private ITextShaper CreateMeasurementShaper(string key, string fontName, FontSub GenericFontTextShaper alwaysShaper; if (GenericFontTextShaper.TryCreate(fontName, FontSubFamilyConverter.ToStyles(subFamily), out alwaysShaper)) { + LogMetricsUsed(fontName, subFamily, "MetricsFallback is set to Always"); return alwaysShaper; } // No metrics for this family. Fall through to normal resolution rather than @@ -415,6 +457,12 @@ private ITextShaper CreateMeasurementShaper(string key, string fontName, FontSub GenericFontTextShaper metricsShaper; if (GenericFontTextShaper.TryCreate(fontName, FontSubFamilyConverter.ToStyles(subFamily), out metricsShaper)) { + LogMetricsUsed( + fontName, + subFamily, + font == null + ? "the font resolver returned no font" + : "font resolution ended at the last-resort font " + FontLog.Describe(font)); return metricsShaper; } } @@ -496,9 +544,82 @@ public string GetFamilyForTarget(string fontName, FontRenderTarget target) if (_configuration.WebFontSubstitutions.TryGetValue(fontName, out substitute) && string.IsNullOrEmpty(substitute) == false) { + LogWebSubstitution(fontName, substitute); return substitute; } return fontName; } + + // ----------------------------------------------------------------------------------------- + // Logging + // ----------------------------------------------------------------------------------------- + + private void LogMetricsUsed(string fontName, FontSubFamily subFamily, string reason) + { + var logger = Logger; + if (!FontLog.IsEnabled(logger, FontLogSeverity.Information)) + return; + + FontLog.Write( + logger, + FontLogSeverity.Information, + FontLogEventType.MeasurementMetricsUsed, + string.Format( + "Font '{0}' {1}: measured from serialized font metrics, not a font file ({2}).", + fontName, subFamily, reason), + fontName, + null); + } + + private void LogWebSubstitution(string fontName, string substitute) + { + var logger = Logger; + if (!FontLog.IsEnabled(logger, FontLogSeverity.Debug)) + return; + + lock (_logLock) + { + if (_reportedWebSubstitutions == null) + _reportedWebSubstitutions = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (!_reportedWebSubstitutions.Add(fontName)) + return; + } + + FontLog.Write( + logger, + FontLogSeverity.Debug, + FontLogEventType.WebFontSubstitution, + string.Format("Font '{0}' -> '{1}' for the web render target.", fontName, substitute), + fontName, + substitute); + } + + private static void LogEmbedding( + IFontLogger logger, + FontLogSeverity severity, + string fontName, + FontEmbeddingRestriction restriction, + FontEmbeddingDecision decision, + string reason) + { + if (!FontLog.IsEnabled(logger, severity)) + return; + + FontLog.Write( + logger, + severity, + FontLogEventType.EmbeddingDecision, + string.Format( + "Embedding '{0}': fsType restriction {1} -> {2} ({3}).", + DisplayName(fontName), restriction, decision, reason), + fontName, + null); + } + + private static string DisplayName(string fontName) + { + return string.IsNullOrEmpty(fontName) ? "(unknown)" : fontName; + } } } \ No newline at end of file diff --git a/src/EPPlus.Interfaces/Fonts/FontLogEvent.cs b/src/EPPlus.Interfaces/Fonts/FontLogEvent.cs new file mode 100644 index 000000000..90df5145e --- /dev/null +++ b/src/EPPlus.Interfaces/Fonts/FontLogEvent.cs @@ -0,0 +1,54 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 10/08/2026 EPPlus Software AB Font logging + *************************************************************************************************/ +using System; + +namespace OfficeOpenXml.Interfaces.Fonts +{ + /// + /// A diagnostic event describing a font or glyph selection decision. + /// is a ready-made line of text; the other properties carry the same + /// information in structured form for loggers that filter or assert on it. + /// + public sealed class FontLogEvent + { + /// The kind of decision. + public FontLogEventType Type { get; set; } + + /// The severity of the event. + public FontLogSeverity Severity { get; set; } + + /// A human-readable description. + public string Message { get; set; } + + /// The font that was asked for, when applicable. For glyph events, the primary font. + public string RequestedFont { get; set; } + + /// The font that was chosen, when applicable. + public string ResolvedFont { get; set; } + + /// The Unicode script involved, for glyph and script events. + public UnicodeScript? Script { get; set; } + + /// The code point involved, for glyph events. + public uint? CodePoint { get; set; } + + /// The exception that caused the event, when there is one. + public Exception Exception { get; set; } + + /// + public override string ToString() + { + return Message; + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Interfaces/Fonts/FontLogEventType.cs b/src/EPPlus.Interfaces/Fonts/FontLogEventType.cs new file mode 100644 index 000000000..cdbe83468 --- /dev/null +++ b/src/EPPlus.Interfaces/Fonts/FontLogEventType.cs @@ -0,0 +1,47 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 10/08/2026 EPPlus Software AB Font logging + *************************************************************************************************/ +namespace OfficeOpenXml.Interfaces.Fonts +{ + /// + /// The kind of decision a describes. + /// + public enum FontLogEventType + { + /// A requested font was found as an exact match. + FontResolved, + /// A requested font was replaced by an entry in a user-configured or built-in fallback chain. + FontFallback, + /// No exact match or chain entry was found; the embedded last-resort font is used. + FontLastResort, + /// The font resolver returned no font for the request. + FontNotResolved, + /// A font was loaded from the resolver (cache miss). Reports a substitution made by a custom resolver. + FontLoaded, + /// Text is measured from serialized font metrics instead of a font file. + MeasurementMetricsUsed, + /// A font is replaced by a web substitute for . + WebFontSubstitution, + /// The embedding policy was decided for a font. + EmbeddingDecision, + /// The fallback chain of a script was resolved, with the outcome per entry. + ScriptChainResolved, + /// A font in a script fallback chain could not be loaded. + ScriptFontLoadFailed, + /// A fallback font supplied a glyph for the first time. + ScriptFallbackUsed, + /// A code point was routed to a fallback font (reported once per code point). + GlyphFallback, + /// No candidate font has a glyph for a code point (reported once per code point). + GlyphMissing + } +} \ No newline at end of file diff --git a/src/EPPlus.Interfaces/Fonts/FontLogSeverity.cs b/src/EPPlus.Interfaces/Fonts/FontLogSeverity.cs new file mode 100644 index 000000000..2d9498547 --- /dev/null +++ b/src/EPPlus.Interfaces/Fonts/FontLogSeverity.cs @@ -0,0 +1,39 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 10/08/2026 EPPlus Software AB Font logging + *************************************************************************************************/ +namespace OfficeOpenXml.Interfaces.Fonts +{ + /// + /// Severity of a font diagnostic event. + /// + public enum FontLogSeverity + { + /// + /// Detailed decisions, such as every exact match or every glyph routed to a fallback font. + /// Can be verbose. + /// + Debug = 0, + /// + /// Decisions worth following, such as a font substituted by a fallback chain. + /// + Information = 1, + /// + /// The output may differ from what was requested, such as the last-resort font being used + /// or a glyph being missing from every candidate font. + /// + Warning = 2, + /// + /// An operation was refused or failed, such as a font that may not be embedded. + /// + Error = 3 + } +} \ No newline at end of file diff --git a/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs b/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs index 39bfb38b3..ca5947dd6 100644 --- a/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs +++ b/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs @@ -11,6 +11,7 @@ Date Author Change 02/27/2026 EPPlus Software AB Initial implementation 05/06/2026 EPPlus Software AB Property-based transactional configuration 05/20/2026 EPPlus Software AB Added per-script glyph fallback configuration + 10/08/2026 EPPlus Software AB Added Logger for font and glyph selection diagnostics *************************************************************************************************/ using System; using System.Collections.Generic; @@ -71,17 +72,6 @@ public interface IEpplusFontConfiguration /// Ordered list of font names to try. void SetScriptFallback(UnicodeScript script, params string[] fallbackFontNames); - /// - /// Restores all settings to factory defaults: - /// - /// Clears . - /// Sets to true. - /// Restores the default (with Archivo Narrow built-in fallback). - /// Clears . - /// Restores the default per-script glyph fallback chains. - /// - /// - /// /// Whether text measurement may fall back to serialized font metrics when the requested /// font is not available as a font file. Defaults to @@ -92,6 +82,18 @@ public interface IEpplusFontConfiguration /// MetricsFallbackMode MetricsFallback { get; set; } + /// + /// Restores all settings to factory defaults: + /// + /// Clears . + /// Sets to true. + /// Restores the default (with Archivo Narrow built-in fallback). + /// Clears . + /// Restores the default per-script glyph fallback chains. + /// Restores and to their defaults. + /// Sets to null. + /// + /// void Reset(); /// @@ -116,6 +118,24 @@ public interface IEpplusFontConfiguration /// Set a value to null or an empty string to keep the original font. /// IDictionary WebFontSubstitutions { get; } + + /// + /// Receives diagnostic events that explain which fonts are chosen and why: font-level + /// fallbacks, script and glyph fallbacks, glyphs that no font can supply, metrics-based + /// measurement and embedding decisions. Defaults to null, which disables logging. + /// + /// + /// The logger is read each time a decision is made, so it can be set or replaced at any + /// time. Fonts that are already cached are not resolved again, so set it inside the + /// ConfigureFonts callback to get the complete picture. The logger is called from + /// whichever thread triggers the work and must be thread-safe; an exception it throws is + /// swallowed. EPPlus never disposes a logger — a logger the caller creates is the + /// caller's to clean up. + /// + /// events can be + /// numerous. Use to filter them out. + /// + IFontLogger Logger { get; set; } } } \ No newline at end of file diff --git a/src/EPPlus.Interfaces/Fonts/IFontLogger.cs b/src/EPPlus.Interfaces/Fonts/IFontLogger.cs new file mode 100644 index 000000000..a9ad39036 --- /dev/null +++ b/src/EPPlus.Interfaces/Fonts/IFontLogger.cs @@ -0,0 +1,37 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 10/08/2026 EPPlus Software AB Font logging + *************************************************************************************************/ +namespace OfficeOpenXml.Interfaces.Fonts +{ + /// + /// Receives diagnostic events describing font and glyph selection. + /// Attach an implementation via . + /// + /// + /// Implementations must be thread-safe: events are raised on whichever thread triggers font + /// resolution or text shaping. Exceptions thrown by an implementation are swallowed, so a + /// faulty logger can never break rendering. + /// + public interface IFontLogger + { + /// + /// Called before an event is built. Return false to skip events of this severity, which + /// avoids the cost of formatting messages that would be discarded. + /// + bool IsEnabled(FontLogSeverity severity); + + /// + /// Called for each event whose severity accepted. + /// + void Log(FontLogEvent logEvent); + } +} \ No newline at end of file