From a4018f8b03617968e64ea420e83c8e5c9f255ef9 Mon Sep 17 00:00:00 2001
From: swmal <897655+swmal@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:51:44 +0200
Subject: [PATCH 1/2] Added font-logging
---
.../Logging/CollectingFontLogger.cs | 62 ++++
.../Logging/FontLoggingTests.cs | 174 +++++++++++
.../DefaultFontProvider.cs | 287 +++++++++++++++++-
.../EpplusFontConfiguration.cs | 20 +-
.../FontResolver/DefaultFontResolver.cs | 111 ++++++-
src/EPPlus.Fonts.OpenType/FontStore.cs | 76 ++++-
src/EPPlus.Fonts.OpenType/IFontSource.cs | 12 +-
src/EPPlus.Fonts.OpenType/Logging/FontLog.cs | 112 +++++++
.../Logging/FontLoggerFactory.cs | 51 ++++
.../Logging/NullFontLogger.cs | 37 +++
.../Logging/TextFileFontLogger.cs | 79 +++++
.../OpenTypeFontEngine.cs | 129 +++++++-
src/EPPlus.Interfaces/Fonts/FontLogEvent.cs | 54 ++++
.../Fonts/FontLogEventType.cs | 47 +++
.../Fonts/FontLogSeverity.cs | 39 +++
.../Fonts/IEpplusFontConfiguration.cs | 42 ++-
src/EPPlus.Interfaces/Fonts/IFontLogger.cs | 37 +++
17 files changed, 1332 insertions(+), 37 deletions(-)
create mode 100644 src/EPPlus.Fonts.OpenType.Tests/Logging/CollectingFontLogger.cs
create mode 100644 src/EPPlus.Fonts.OpenType.Tests/Logging/FontLoggingTests.cs
create mode 100644 src/EPPlus.Fonts.OpenType/Logging/FontLog.cs
create mode 100644 src/EPPlus.Fonts.OpenType/Logging/FontLoggerFactory.cs
create mode 100644 src/EPPlus.Fonts.OpenType/Logging/NullFontLogger.cs
create mode 100644 src/EPPlus.Fonts.OpenType/Logging/TextFileFontLogger.cs
create mode 100644 src/EPPlus.Interfaces/Fonts/FontLogEvent.cs
create mode 100644 src/EPPlus.Interfaces/Fonts/FontLogEventType.cs
create mode 100644 src/EPPlus.Interfaces/Fonts/FontLogSeverity.cs
create mode 100644 src/EPPlus.Interfaces/Fonts/IFontLogger.cs
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..c98a9c6b8 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, "the embedded last-resort font was requested");
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("Font '{0}' {1}: {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(
+ "Font '{0}' {1} -> '{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(
+ "Font '{0}' {1} -> '{2}' (last resort: no exact match; 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..00823682d 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,58 @@ 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("Font '{0}' {1}: 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;
+
+ var message = substituted
+ ? string.Format("Font '{0}' {1} was loaded as '{2}'.", fontName, subFamily, FontLog.Describe(font))
+ : string.Format("Font '{0}' {1} 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
From e472998ec945afdf61075a81501990509d804b65 Mon Sep 17 00:00:00 2001
From: swmal <{ID}+username}@users.noreply.github.com>
Date: Fri, 9 Oct 2026 15:21:05 +0200
Subject: [PATCH 2/2] Improved font logging messages
---
.../FontResolver/DefaultFontResolver.cs | 8 ++++----
src/EPPlus.Fonts.OpenType/FontStore.cs | 11 ++++++++---
2 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/src/EPPlus.Fonts.OpenType/FontResolver/DefaultFontResolver.cs b/src/EPPlus.Fonts.OpenType/FontResolver/DefaultFontResolver.cs
index c98a9c6b8..775ee0552 100644
--- a/src/EPPlus.Fonts.OpenType/FontResolver/DefaultFontResolver.cs
+++ b/src/EPPlus.Fonts.OpenType/FontResolver/DefaultFontResolver.cs
@@ -95,7 +95,7 @@ public byte[] ResolveFont(string fontName, FontSubFamily subFamily)
// 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, "the embedded last-resort font was requested");
+ LogResolved(logger, fontName, subFamily, "it is the embedded last-resort font");
return EmbeddedFonts.LoadArchivoNarrow(subFamily).RawData;
}
@@ -185,7 +185,7 @@ private static void LogResolved(IFontLogger logger, string fontName, FontSubFami
logger,
FontLogSeverity.Debug,
FontLogEventType.FontResolved,
- string.Format("Font '{0}' {1}: {2}.", fontName, subFamily, reason),
+ string.Format("Requested font '{0}' {1} was resolved: {2}.", fontName, subFamily, reason),
fontName,
fontName);
}
@@ -206,7 +206,7 @@ private static void LogFallback(
FontLogSeverity.Information,
FontLogEventType.FontFallback,
string.Format(
- "Font '{0}' {1} -> '{2}' ({3}: {4}).",
+ "Requested font '{0}' {1} has no exact match; resolved using fallback font '{2}' ({3}: {4}).",
fontName, subFamily, matchedName, chainKind, FontLog.JoinNames(chain)),
fontName,
matchedName);
@@ -224,7 +224,7 @@ private static void LogLastResort(
return;
var message = string.Format(
- "Font '{0}' {1} -> '{2}' (last resort: no exact match; user chain: {3}; built-in chain: {4}).",
+ "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));
diff --git a/src/EPPlus.Fonts.OpenType/FontStore.cs b/src/EPPlus.Fonts.OpenType/FontStore.cs
index 00823682d..e9d899f71 100644
--- a/src/EPPlus.Fonts.OpenType/FontStore.cs
+++ b/src/EPPlus.Fonts.OpenType/FontStore.cs
@@ -211,7 +211,7 @@ private void LogLoaded(string fontName, FontSubFamily subFamily, OpenTypeFont fo
logger,
FontLogSeverity.Warning,
FontLogEventType.FontNotResolved,
- string.Format("Font '{0}' {1}: the font resolver returned no font.", fontName, subFamily),
+ string.Format("Requested font '{0}' {1} could not be resolved: the font resolver returned no font.", fontName, subFamily),
fontName,
null);
}
@@ -235,9 +235,14 @@ private void LogLoaded(string fontName, FontSubFamily subFamily, OpenTypeFont fo
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("Font '{0}' {1} was loaded as '{2}'.", fontName, subFamily, FontLog.Describe(font))
- : string.Format("Font '{0}' {1} loaded.", fontName, subFamily);
+ ? 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);
}