diff --git a/src/EPPlus.DrawingRenderer.Tests/Fonts/WebFontSubstitutionSvgTests.cs b/src/EPPlus.DrawingRenderer.Tests/Fonts/WebFontSubstitutionSvgTests.cs
new file mode 100644
index 000000000..07fc67c59
--- /dev/null
+++ b/src/EPPlus.DrawingRenderer.Tests/Fonts/WebFontSubstitutionSvgTests.cs
@@ -0,0 +1,250 @@
+using System.Text.RegularExpressions;
+using EPPlus.DrawingRenderer.Svg;
+using EPPlus.Fonts.OpenType;
+using OfficeOpenXml;
+using OfficeOpenXml.Drawing;
+using OfficeOpenXml.Drawing.Chart;
+using OfficeOpenXml.Interfaces.Fonts;
+
+namespace EPPlus.Export.ImageRenderer.Tests.Fonts
+{
+ ///
+ /// Verifies that web font substitution reaches the SVG output. The font engine does not search
+ /// system directories, so the output is independent of the fonts installed on the machine.
+ ///
+ [TestClass]
+ public class WebFontSubstitutionSvgTests : TestBase
+ {
+ private const string SampleText = "This is a line chart with data from the table below. The chart is exported as SVG when exporting to HTML.";
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project");
+ }
+
+ private static ExcelPackage CreatePackage(Action? configure = null)
+ {
+ var p = new ExcelPackage();
+ p.Workbook.UseFontEngine(new OpenTypeFontEngine(cfg =>
+ {
+ cfg.SearchSystemDirectories = false;
+ if (configure != null)
+ {
+ configure(cfg);
+ }
+ }));
+ return p;
+ }
+
+ private static ExcelShape AddShapeWithText(ExcelPackage p, string fontName)
+ {
+ var ws = p.Workbook.Worksheets.Add("Sheet1");
+ var shape = ws.Drawings.AddShape("Shape1", eShapeStyle.Rect);
+ shape.SetSize(300, 200);
+ var paragraph = shape.TextBody.Paragraphs.Add(SampleText);
+ paragraph.TextRuns[0].SetFromFont(fontName, 11);
+ return shape;
+ }
+
+ ///
+ /// Returns the first family of the font-family attribute on every text run (tspan) in the svg.
+ /// The paragraph level (text element) carries the paragraph default font, which runs may override.
+ ///
+ private static List GetRunFontFamilies(string svg)
+ {
+ var result = new List();
+ foreach (Match m in Regex.Matches(svg, "]*?font-family=\"([^\"]*)\""))
+ {
+ var first = m.Groups[1].Value.Split(',')[0].Trim().Trim('\'', '"');
+ result.Add(first);
+ }
+ return result;
+ }
+
+ private static void AssertAllFamilies(string svg, string expectedFamily)
+ {
+ var matches = Regex.Matches(svg, "]*?font-family=\"([^\"]*)\"[^>]*>([^<]*)");
+ Assert.IsTrue(matches.Count > 0, "No text run with a font-family was rendered.");
+ foreach (Match m in matches)
+ {
+ var family = m.Groups[1].Value.Split(',')[0].Trim().Trim('\'', '"');
+ Assert.AreEqual(expectedFamily, family, "Text run '" + m.Groups[2].Value + "'");
+ }
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Shapes
+ // -----------------------------------------------------------------------------------------
+
+ [TestMethod]
+ public void Shape_OfficeCloudFont_IsSubstituted()
+ {
+ using (var p = CreatePackage())
+ {
+ var svg = AddShapeWithText(p, "Aptos Narrow").ToSvg();
+
+ AssertAllFamilies(svg, "Calibri");
+ Assert.IsFalse(svg.Contains("Aptos Narrow"), "The original font name leaked into the svg.");
+ }
+ }
+
+ [TestMethod]
+ public void Shape_FontNotInTable_IsUnchanged()
+ {
+ using (var p = CreatePackage())
+ {
+ var svg = AddShapeWithText(p, "Arial").ToSvg();
+
+ AssertAllFamilies(svg, "Arial");
+ }
+ }
+
+ [TestMethod]
+ public void Shape_DocumentTarget_KeepsOriginalFont()
+ {
+ using (var p = CreatePackage())
+ {
+ var svg = AddShapeWithText(p, "Aptos Narrow").ToSvg(o => o.FontTarget = FontRenderTarget.Document);
+
+ AssertAllFamilies(svg, "Aptos Narrow");
+ }
+ }
+
+ [TestMethod]
+ public void Shape_UserSubstitution_IsUsed()
+ {
+ using (var p = CreatePackage(cfg => cfg.WebFontSubstitutions["Aptos Narrow"] = "Verdana"))
+ {
+ var svg = AddShapeWithText(p, "Aptos Narrow").ToSvg();
+
+ AssertAllFamilies(svg, "Verdana");
+ }
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Charts
+ // -----------------------------------------------------------------------------------------
+
+ [TestMethod]
+ public void Chart_OfficeCloudFont_IsSubstitutedInAllText()
+ {
+ using (var p = CreatePackage())
+ {
+ var ws = p.Workbook.Worksheets.Add("Sheet1");
+ LoadItemData(ws);
+ var chart = ws.Drawings.AddChart("Chart1", eChartType.Line);
+ chart.Series.Add(ws.Cells["N2:N11"], ws.Cells["K2:K11"]);
+ chart.SetSize(600, 400);
+
+ chart.Title.Text = "Chart title";
+ chart.Title.Font.LatinFont = "Aptos Narrow";
+ chart.XAxis.Font.LatinFont = "Aptos Narrow";
+ chart.YAxis.Font.LatinFont = "Aptos Narrow";
+ chart.Legend.Font.LatinFont = "Aptos Narrow";
+
+ var svg = chart.ToSvg();
+
+ //Covers title, axis labels and legend. A failure here points to a text path
+ //that bypasses the render context's target.
+ AssertAllFamilies(svg, "Calibri");
+ Assert.IsFalse(svg.Contains("Aptos Narrow"), "The original font name leaked into the svg.");
+ }
+ }
+
+ ///
+ /// Measurement is independent of installed fonts: no system fonts and metrics always preferred.
+ ///
+ private static void Deterministic(IEpplusFontConfiguration cfg)
+ {
+ cfg.MetricsFallback = MetricsFallbackMode.Always;
+ }
+
+ ///
+ /// Returns the text of every text run (tspan) in document order. For wrapped text each run is a line.
+ ///
+ private static List GetRunTexts(string svg)
+ {
+ var result = new List();
+ foreach (Match m in Regex.Matches(svg, "]*>([^<]*)"))
+ {
+ result.Add(m.Groups[1].Value);
+ }
+ return result;
+ }
+
+ private static ExcelChart AddChartWithFont(ExcelPackage p, string fontName)
+ {
+ var ws = p.Workbook.Worksheets.Add("Sheet1");
+ LoadItemData(ws);
+ var chart = ws.Drawings.AddChart("Chart1", eChartType.Line);
+ chart.Series.Add(ws.Cells["N2:N11"], ws.Cells["K2:K11"]);
+ chart.SetSize(400, 300);
+
+ chart.Title.Text = "A long chart title that should wrap into more than one line";
+ chart.Title.Font.LatinFont = fontName;
+ chart.XAxis.Font.LatinFont = fontName;
+ chart.YAxis.Font.LatinFont = fontName;
+ chart.Legend.Font.LatinFont = fontName;
+ return chart;
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Measurement follows the substitution
+ // -----------------------------------------------------------------------------------------
+
+ [TestMethod]
+ public void Shape_WebSubstitution_WrapsAsSubstituteFont()
+ {
+ string webSvg, documentSvg;
+ using (var p = CreatePackage(Deterministic))
+ {
+ webSvg = AddShapeWithText(p, "Aptos Narrow").ToSvg();
+ }
+ using (var p = CreatePackage(Deterministic))
+ {
+ documentSvg = AddShapeWithText(p, "Calibri").ToSvg(o => o.FontTarget = FontRenderTarget.Document);
+ }
+
+ var webLines = GetRunTexts(webSvg);
+ Assert.IsTrue(webLines.Count > 1, "The text must wrap for the test to verify line breaking.");
+ CollectionAssert.AreEqual(GetRunTexts(documentSvg), webLines);
+ }
+
+ [TestMethod]
+ public void Shape_WrapComparison_DetectsDifferentFont()
+ {
+ //Control for the test above: a clearly wider font must break differently,
+ //otherwise the comparison would pass regardless of which font is measured.
+ string webSvg, documentSvg;
+ using (var p = CreatePackage(Deterministic))
+ {
+ webSvg = AddShapeWithText(p, "Aptos Narrow").ToSvg();
+ }
+ using (var p = CreatePackage(Deterministic))
+ {
+ documentSvg = AddShapeWithText(p, "Verdana").ToSvg(o => o.FontTarget = FontRenderTarget.Document);
+ }
+
+ CollectionAssert.AreNotEqual(GetRunTexts(documentSvg), GetRunTexts(webSvg));
+ }
+
+ [TestMethod]
+ public void Chart_WebSubstitution_LaysOutAsSubstituteFont()
+ {
+ string webSvg, documentSvg;
+ using (var p = CreatePackage(Deterministic))
+ {
+ webSvg = AddChartWithFont(p, "Aptos Narrow").ToSvg();
+ }
+ using (var p = CreatePackage(Deterministic))
+ {
+ documentSvg = AddChartWithFont(p, "Calibri").ToSvg(o => o.FontTarget = FontRenderTarget.Document);
+ }
+
+ //Covers title wrapping, axis label selection and legend. Any text path that measures
+ //with the original font instead of the substitute makes the sequences differ.
+ CollectionAssert.AreEqual(GetRunTexts(documentSvg), GetRunTexts(webSvg));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EPPlus.DrawingRenderer/RenderContext.cs b/src/EPPlus.DrawingRenderer/RenderContext.cs
index fa2ebd031..666ad65b8 100644
--- a/src/EPPlus.DrawingRenderer/RenderContext.cs
+++ b/src/EPPlus.DrawingRenderer/RenderContext.cs
@@ -1,4 +1,6 @@
using EPPlus.Fonts.OpenType;
+using OfficeOpenXml.Interfaces.Fonts;
+using OfficeOpenXml.Interfaces.RichText;
namespace EPPlus.DrawingRenderer
{
@@ -11,21 +13,53 @@ namespace EPPlus.DrawingRenderer
///
public class RenderContext : IDisposable
{
- private readonly object _lock = new object();
+ private readonly object _lock;
private readonly Func _engineFactory;
- private OpenTypeFontEngine? _fontEngine;
+ private readonly RenderContext _root;
+ private volatile OpenTypeFontEngine _fontEngine;
public RenderContext(Func engineFactory)
{
if (engineFactory == null)
throw new ArgumentNullException("engineFactory");
_engineFactory = engineFactory;
+ _lock = new object();
+ Target = FontRenderTarget.Document;
+ }
+
+ ///
+ /// Creates a view of the root context for another render target. Shares the root's font engine.
+ ///
+ private RenderContext(RenderContext root, FontRenderTarget target)
+ {
+ _root = root;
+ Target = target;
+ }
+
+ ///
+ /// The output kind text is laid out for. Decides whether web font substitution applies.
+ ///
+ public FontRenderTarget Target { get; private set; }
+
+ ///
+ /// Returns a context for the given target that shares this context's font engine and cache.
+ ///
+ public RenderContext ForTarget(FontRenderTarget target)
+ {
+ if (target == Target)
+ return this;
+
+ var root = _root ?? this;
+ return target == root.Target ? root : new RenderContext(root, target);
}
public OpenTypeFontEngine FontEngine
{
get
{
+ if (_root != null)
+ return _root.FontEngine;
+
if (_fontEngine == null)
{
lock (_lock)
@@ -38,12 +72,35 @@ public OpenTypeFontEngine FontEngine
}
}
+ ///
+ /// Returns the font family to lay out and output text with for this context's target.
+ ///
+ public string GetFamilyForTarget(string fontName)
+ {
+ return FontEngine.GetFamilyForTarget(fontName, Target);
+ }
+
+ ///
+ /// Gets a measurement shaper for the font, with the family substituted for this context's target.
+ /// All text measurement in drawing rendering should go through here.
+ ///
+ public ITextShaper GetMeasurementShaper(IFontFormatBase font)
+ {
+ if (font == null)
+ throw new ArgumentNullException("font");
+ return FontEngine.GetMeasurementShaper(GetFamilyForTarget(font.Family), font.SubFamily);
+ }
public void Dispose()
{
- if (_fontEngine != null)
+ // A view does not own the engine.
+ if (_root != null)
+ return;
+
+ var engine = _fontEngine;
+ if (engine != null)
{
- try { _fontEngine.Dispose(); } catch { /* best effort */ }
+ try { engine.Dispose(); } catch { /* best effort */ }
_fontEngine = null;
}
}
diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs
index 3b54e7326..09e18a74f 100644
--- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs
+++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs
@@ -96,11 +96,26 @@ public abstract class ParagraphRenderItem : RenderItem
public bool AutoSize = false;
- public FontFormatBase DefaultParagraphFont;
+ private FontFormatBase _defaultParagraphFont;
+
+ ///
+ /// The default font of the paragraph. The family is substituted for the render target on assignment.
+ ///
+ public FontFormatBase DefaultParagraphFont
+ {
+ get { return _defaultParagraphFont; }
+ set
+ {
+ _defaultParagraphFont = value;
+ ApplyFontTarget(value);
+ }
+ }
protected double ParentMaxWidth;
protected double ParentMaxHeight;
+
+
protected RenderTextBody ParentTextBody { get; set; }
protected double? _lsMultiplier = null;
@@ -226,6 +241,17 @@ TextLineCollection WrapFragmentsToLines(List? fragments = nul
return _layoutSystem.Wrap(maxWidthInPoints);
}
+ ///
+ /// Substitutes the font family for the render target. Every font that reaches measurement must pass
+ /// through here, since text runs output the same font object they were measured with.
+ ///
+ protected void ApplyFontTarget(IFontFormatBase font)
+ {
+ if (font == null || RenderContext == null)
+ return;
+ font.Family = RenderContext.GetFamilyForTarget(font.Family);
+ }
+
private void AddRichTextBase(IRichTextFormatSimple rt)
{
if (_textFragments == null)
@@ -235,10 +261,12 @@ private void AddRichTextBase(IRichTextFormatSimple rt)
if (string.IsNullOrEmpty(rt.Text) == false)
{
+ ApplyFontTarget(rt);
_textFragments.Add(rt);
}
}
+
protected void AddDefaultTextFragment(string text)
{
var defaults = new RichTextFormatSimple();
diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/TextRunRenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/TextRunRenderItem.cs
index 0b04d4f66..0421f49cf 100644
--- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/TextRunRenderItem.cs
+++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/TextRunRenderItem.cs
@@ -172,7 +172,9 @@ internal protected void InitializeBase(IFontFormatBase font)
//Should be ascent-only?
Bounds.Height = font.Size;
FontSizeInPixels = ((double)font.Size).PointToPixel(true);
- _measurementFont = font;
+ //Copy, as callers pass shared instances (e.g. the paragraph default font) and the
+ //run adjusts its own size for sub- and superscript.
+ _measurementFont = new FontFormatBase(font.Family, font.SubFamily, font.Size);
}
///
diff --git a/src/EPPlus.DrawingRenderer/Svg/Options/SvgRenderOptions.cs b/src/EPPlus.DrawingRenderer/Svg/Options/SvgRenderOptions.cs
index 542a83d64..e0dc2457f 100644
--- a/src/EPPlus.DrawingRenderer/Svg/Options/SvgRenderOptions.cs
+++ b/src/EPPlus.DrawingRenderer/Svg/Options/SvgRenderOptions.cs
@@ -10,6 +10,8 @@ Date Author Change
*************************************************************************************************
01/27/2020 EPPlus Software AB Initial release EPPlus 5
*************************************************************************************************/
+using OfficeOpenXml.Interfaces.Fonts;
+
namespace EPPlus.DrawingRenderer.Svg
{
///
@@ -31,6 +33,14 @@ public class SvgRenderOptions
/// Sets the width and height of the svg image. If not set, the size will be calculated based on the drawings dimensions.
///
public SvgSize SvgSize { get; } = new SvgSize();
+
+ ///
+ /// The render target used for text layout. Defaults to , which
+ /// substitutes fonts unlikely to be available to a browser, both when measuring and in the output.
+ /// Use when the fonts are made available to the consumer
+ /// by other means, e.g. embedded.
+ ///
+ public FontRenderTarget FontTarget { get; set; } = FontRenderTarget.Web;
}
}
\ No newline at end of file
diff --git a/src/EPPlus.Fonts.OpenType.Tests/FontResolver/WebFontSubstitutionTests.cs b/src/EPPlus.Fonts.OpenType.Tests/FontResolver/WebFontSubstitutionTests.cs
new file mode 100644
index 000000000..cfdb93af7
--- /dev/null
+++ b/src/EPPlus.Fonts.OpenType.Tests/FontResolver/WebFontSubstitutionTests.cs
@@ -0,0 +1,195 @@
+/*************************************************************************************************
+ 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
+ *************************************************************************************************
+ 09/28/2026 EPPlus Software AB Web font substitution tests
+ *************************************************************************************************/
+using OfficeOpenXml.Interfaces.Fonts;
+
+namespace EPPlus.Fonts.OpenType.Tests.FontResolver
+{
+ ///
+ /// Tests for web font substitution via .
+ /// None of these tests resolve or load fonts, so they are independent of installed fonts.
+ ///
+ [TestClass]
+ public class WebFontSubstitutionTests : FontTestBase
+ {
+ public override TestContext? TestContext { get; set; }
+
+ ///
+ /// Creates an isolated engine for tests that change the configuration.
+ /// The shared engines in must not be reconfigured.
+ ///
+ private static OpenTypeFontEngine CreateEngine(Action? configure = null)
+ {
+ return new OpenTypeFontEngine(cfg =>
+ {
+ cfg.SearchSystemDirectories = false;
+ if (configure != null)
+ {
+ configure(cfg);
+ }
+ });
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Render target
+ // -----------------------------------------------------------------------------------------
+
+ [TestMethod]
+ public void DocumentTarget_NeverSubstitutes()
+ {
+ var family = TestFolderEngine.GetFamilyForTarget("Aptos Narrow", FontRenderTarget.Document);
+
+ Assert.AreEqual("Aptos Narrow", family);
+ }
+
+ [TestMethod]
+ public void WebTarget_UnknownFont_IsUnchanged()
+ {
+ var family = TestFolderEngine.GetFamilyForTarget("Arial", FontRenderTarget.Web);
+
+ Assert.AreEqual("Arial", family);
+ }
+
+ [TestMethod]
+ public void WebTarget_NullOrEmptyFontName_IsReturnedAsIs()
+ {
+ Assert.IsNull(TestFolderEngine.GetFamilyForTarget(null!, FontRenderTarget.Web));
+ Assert.AreEqual("", TestFolderEngine.GetFamilyForTarget("", FontRenderTarget.Web));
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Default substitution table
+ // -----------------------------------------------------------------------------------------
+
+ [DataTestMethod]
+ [DataRow("Aptos", "Arial")]
+ [DataRow("Aptos Narrow", "Calibri")]
+ [DataRow("Aptos Display", "Arial")]
+ [DataRow("Aptos Serif", "Cambria")]
+ [DataRow("Aptos Mono", "Consolas")]
+ [DataRow("Grandview", "Calibri")]
+ [DataRow("Seaford", "Segoe UI")]
+ [DataRow("Tenorite", "Segoe UI")]
+ [DataRow("Bierstadt", "Arial")]
+ [DataRow("Skeena", "Segoe UI")]
+ public void WebTarget_OfficeCloudFont_IsSubstitutedByDefault(string requested, string expected)
+ {
+ var family = TestFolderEngine.GetFamilyForTarget(requested, FontRenderTarget.Web);
+
+ Assert.AreEqual(expected, family);
+ }
+
+ [DataTestMethod]
+ [DataRow("aptos narrow")]
+ [DataRow("APTOS NARROW")]
+ [DataRow("Aptos NARROW")]
+ public void WebTarget_Lookup_IsCaseInsensitive(string requested)
+ {
+ var family = TestFolderEngine.GetFamilyForTarget(requested, FontRenderTarget.Web);
+
+ Assert.AreEqual("Calibri", family);
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // User configuration
+ // -----------------------------------------------------------------------------------------
+
+ [TestMethod]
+ public void WebTarget_UserSubstitution_OverridesDefault()
+ {
+ using (var engine = CreateEngine(cfg => cfg.WebFontSubstitutions["Aptos Narrow"] = "Arial"))
+ {
+ Assert.AreEqual("Arial", engine.GetFamilyForTarget("Aptos Narrow", FontRenderTarget.Web));
+ }
+ }
+
+ [TestMethod]
+ public void WebTarget_UserSubstitution_ForNonDefaultFont_IsApplied()
+ {
+ using (var engine = CreateEngine(cfg => cfg.WebFontSubstitutions["Goudy Stout"] = "Georgia"))
+ {
+ Assert.AreEqual("Georgia", engine.GetFamilyForTarget("Goudy Stout", FontRenderTarget.Web));
+ }
+ }
+
+ [TestMethod]
+ public void WebTarget_UserSubstitution_DoesNotAffectDocumentTarget()
+ {
+ using (var engine = CreateEngine(cfg => cfg.WebFontSubstitutions["Goudy Stout"] = "Georgia"))
+ {
+ Assert.AreEqual("Goudy Stout", engine.GetFamilyForTarget("Goudy Stout", FontRenderTarget.Document));
+ }
+ }
+
+ [DataTestMethod]
+ [DataRow("")]
+ [DataRow(null)]
+ public void WebTarget_EmptySubstitute_KeepsOriginalFont(string? substitute)
+ {
+ using (var engine = CreateEngine(cfg => cfg.WebFontSubstitutions["Aptos Narrow"] = substitute!))
+ {
+ Assert.AreEqual("Aptos Narrow", engine.GetFamilyForTarget("Aptos Narrow", FontRenderTarget.Web));
+ }
+ }
+
+ [TestMethod]
+ public void WebTarget_RemovedDefault_KeepsOriginalFont()
+ {
+ using (var engine = CreateEngine(cfg => cfg.WebFontSubstitutions.Remove("Aptos Narrow")))
+ {
+ Assert.AreEqual("Aptos Narrow", engine.GetFamilyForTarget("Aptos Narrow", FontRenderTarget.Web));
+ }
+ }
+
+ [TestMethod]
+ public void WebTarget_Substitution_IsNotChained()
+ {
+ using (var engine = CreateEngine(cfg =>
+ {
+ cfg.WebFontSubstitutions["Font A"] = "Font B";
+ cfg.WebFontSubstitutions["Font B"] = "Font C";
+ }))
+ {
+ Assert.AreEqual("Font B", engine.GetFamilyForTarget("Font A", FontRenderTarget.Web));
+ }
+ }
+
+ [TestMethod]
+ public void Reset_RestoresDefaultSubstitutions()
+ {
+ using (var engine = CreateEngine(cfg =>
+ {
+ cfg.WebFontSubstitutions.Clear();
+ cfg.WebFontSubstitutions["Goudy Stout"] = "Georgia";
+ cfg.Reset();
+ }))
+ {
+ Assert.AreEqual("Calibri", engine.GetFamilyForTarget("Aptos Narrow", FontRenderTarget.Web));
+ Assert.AreEqual("Goudy Stout", engine.GetFamilyForTarget("Goudy Stout", FontRenderTarget.Web));
+ }
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Lifecycle
+ // -----------------------------------------------------------------------------------------
+
+ [TestMethod]
+ [ExpectedException(typeof(ObjectDisposedException))]
+ public void GetFamilyForTarget_OnDisposedEngine_Throws()
+ {
+ var engine = CreateEngine();
+ engine.Dispose();
+
+ engine.GetFamilyForTarget("Aptos Narrow", FontRenderTarget.Web);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EPPlus.Fonts.OpenType.Tests/FontScanning/SystemFontsTests.cs b/src/EPPlus.Fonts.OpenType.Tests/FontScanning/SystemFontsTests.cs
new file mode 100644
index 000000000..ed234d3cd
--- /dev/null
+++ b/src/EPPlus.Fonts.OpenType.Tests/FontScanning/SystemFontsTests.cs
@@ -0,0 +1,23 @@
+using OfficeOpenXml.Interfaces.Fonts;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace EPPlus.Fonts.OpenType.Tests.FontScanning
+{
+ [TestClass]
+ public class SystemFontsTests : FontTestBase
+ {
+ public override TestContext? TestContext { get; set; }
+
+ [TestMethod]
+ public void AptosNarrowTest1()
+ {
+ RequireFont(SystemFontsEngine, "Aptos Narrow", FontSubFamily.Regular);
+ var font = SystemFontsEngine.LoadFont("Aptos Narrow", FontSubFamily.Regular);
+ Assert.AreEqual("Aptos Narrow", font.FullName);
+ }
+ }
+}
diff --git a/src/EPPlus.Fonts.OpenType/EpplusFontConfiguration.cs b/src/EPPlus.Fonts.OpenType/EpplusFontConfiguration.cs
index cb38f651a..7aa1d2789 100644
--- a/src/EPPlus.Fonts.OpenType/EpplusFontConfiguration.cs
+++ b/src/EPPlus.Fonts.OpenType/EpplusFontConfiguration.cs
@@ -49,6 +49,7 @@ public EpplusFontConfiguration()
SearchSystemDirectories = true;
MetricsFallback = MetricsFallbackMode.WhenFontMissing;
ApplyDefaultScriptFallbacks();
+ ApplyDefaultWebFontSubstitutions();
}
///
@@ -112,6 +113,8 @@ public void Reset()
_scriptFallbacks.Clear();
MetricsFallback = MetricsFallbackMode.WhenFontMissing;
ApplyDefaultScriptFallbacks();
+ _webFontSubstitutions.Clear();
+ ApplyDefaultWebFontSubstitutions();
}
// -----------------------------------------------------------------------------------------
@@ -195,5 +198,36 @@ private void ApplyDefaultScriptFallbacks()
"Mangal", "Nirmala UI", "Noto Sans Devanagari"
};
}
+
+ private readonly Dictionary _webFontSubstitutions =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+
+
+ ///
+ public IDictionary WebFontSubstitutions
+ {
+ get { return _webFontSubstitutions; }
+ }
+
+ ///
+ /// Office cloud fonts are delivered through Office's font service and cached per user, never
+ /// installed system-wide. Browsers cannot see them, even on machines where Office has them.
+ /// Substitutes are fonts shipped with Windows that have metric-compatible open alternatives
+ /// (Calibri/Carlito, Cambria/Caladea), so server-side measurement stays reproducible.
+ ///
+ private void ApplyDefaultWebFontSubstitutions()
+ {
+ _webFontSubstitutions["Aptos"] = "Arial";
+ _webFontSubstitutions["Aptos Narrow"] = "Calibri";
+ _webFontSubstitutions["Aptos Display"] = "Arial";
+ _webFontSubstitutions["Aptos Serif"] = "Cambria";
+ _webFontSubstitutions["Aptos Mono"] = "Consolas";
+ _webFontSubstitutions["Grandview"] = "Calibri";
+ _webFontSubstitutions["Seaford"] = "Segoe UI";
+ _webFontSubstitutions["Tenorite"] = "Segoe UI";
+ _webFontSubstitutions["Bierstadt"] = "Arial";
+ _webFontSubstitutions["Skeena"] = "Segoe UI";
+ }
}
}
\ No newline at end of file
diff --git a/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs b/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs
index 0cf3acc98..7cdbced44 100644
--- a/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs
+++ b/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs
@@ -480,5 +480,25 @@ private static bool IsLastResortFamily(string fontName)
{
return string.Equals("archivo narrow", fontName, StringComparison.OrdinalIgnoreCase);
}
+
+ ///
+ /// Returns the font family to use for the given render target. For
+ /// the requested family is always returned unchanged. For a
+ /// configured substitute is returned when one exists. Substitution is a single step and is not chained.
+ ///
+ public string GetFamilyForTarget(string fontName, FontRenderTarget target)
+ {
+ ThrowIfDisposed();
+ if (target != FontRenderTarget.Web || string.IsNullOrEmpty(fontName))
+ return fontName;
+
+ string substitute;
+ if (_configuration.WebFontSubstitutions.TryGetValue(fontName, out substitute)
+ && string.IsNullOrEmpty(substitute) == false)
+ {
+ return substitute;
+ }
+ return fontName;
+ }
}
}
\ No newline at end of file
diff --git a/src/EPPlus/Drawing/SvgRenderOptions.cs b/src/EPPlus.Interfaces/Fonts/FontRenderTarget.cs
similarity index 58%
rename from src/EPPlus/Drawing/SvgRenderOptions.cs
rename to src/EPPlus.Interfaces/Fonts/FontRenderTarget.cs
index a984fa65d..10be1519d 100644
--- a/src/EPPlus/Drawing/SvgRenderOptions.cs
+++ b/src/EPPlus.Interfaces/Fonts/FontRenderTarget.cs
@@ -8,19 +8,23 @@ This software is licensed under PolyForm Noncommercial License 1.0.0
*************************************************************************************************
Date Author Change
*************************************************************************************************
- 01/27/2020 EPPlus Software AB Initial release EPPlus 5
+ 10/07/2026 EPPlus Software AB EPPlus.Fonts.OpenType 1.0
*************************************************************************************************/
-namespace OfficeOpenXml.Drawing.Svg
+namespace OfficeOpenXml.Interfaces.Fonts
{
///
- /// Options for rendering drawings to svg.
+ /// The kind of output text is being laid out for. Decides whether web font substitution applies.
///
- public class SvgRenderOptions
+ public enum FontRenderTarget
{
///
- /// The size of the svg image, if you want to override the default size.
- ///
- public SvgSize Size { get; } = new SvgSize();
+ /// Output where EPPlus controls the fonts, e.g. PDF with embedded fonts. No substitution.
+ ///
+ Document,
+ ///
+ /// Output rendered by a browser using its own fonts, e.g. SVG and HTML.
+ /// Fonts unlikely to be available to a browser are substituted.
+ ///
+ Web
}
-
}
\ No newline at end of file
diff --git a/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs b/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs
index f2d3f2b3c..39bfb38b3 100644
--- a/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs
+++ b/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs
@@ -107,6 +107,15 @@ public interface IEpplusFontConfiguration
/// owner. Only one callback is active; a later call replaces the earlier one.
///
void OnFontEmbedding(Func callback);
+
+ ///
+ /// Font substitutions applied when laying out text for .
+ /// The substitute is used both for measurement and in the output, so the browser renders with
+ /// the same font the text was measured with. Pre-populated with Office cloud fonts, which are
+ /// never installed system-wide and therefore not visible to browsers. Lookup is case-insensitive.
+ /// Set a value to null or an empty string to keep the original font.
+ ///
+ IDictionary WebFontSubstitutions { get; }
}
}
\ No newline at end of file
diff --git a/src/EPPlus/Drawing/ExcelShape.cs b/src/EPPlus/Drawing/ExcelShape.cs
index 741cb735c..d4e8eba92 100644
--- a/src/EPPlus/Drawing/ExcelShape.cs
+++ b/src/EPPlus/Drawing/ExcelShape.cs
@@ -97,7 +97,7 @@ internal override void DeleteMe()
}
public string ToSvg(SvgRenderOptions options)
{
- var sr = new ShapeRenderer(this);
+ var sr = new ShapeRenderer(this, options.FontTarget);
var sb = new StringBuilder();
var svg = new SvgShapeRenderer(this.GetBoundingBox(), sb, options);
svg.Render(sr.RenderItems);
diff --git a/src/EPPlus/Drawing/Renderer/Chart/Axis/CategoryAxisScaleCalculator.cs b/src/EPPlus/Drawing/Renderer/Chart/Axis/CategoryAxisScaleCalculator.cs
index 1e4fb9561..507f55c95 100644
--- a/src/EPPlus/Drawing/Renderer/Chart/Axis/CategoryAxisScaleCalculator.cs
+++ b/src/EPPlus/Drawing/Renderer/Chart/Axis/CategoryAxisScaleCalculator.cs
@@ -6,6 +6,7 @@
using OfficeOpenXml.FormulaParsing.Excel.Functions.Information;
using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions;
using OfficeOpenXml.Interfaces.Drawing.Text;
+using OfficeOpenXml.Interfaces.Fonts;
using OfficeOpenXml.Utils.DateUtils;
using System;
using System.Collections.Generic;
@@ -17,27 +18,24 @@ namespace EPPlus.Export.ImageRenderer.Svg.Chart.Util
internal class CategoryAxisScaleCalculator
{
- internal static AxisScale CalculateHorizontalAxisByWidth(ref List