diff --git a/src/EPPlus.Export.Pdf/PdfLayout/PdfCatalogLayout.cs b/src/EPPlus.Export.Pdf/PdfLayout/PdfCatalogLayout.cs index 4f23e3f6d..0b4b83bbb 100644 --- a/src/EPPlus.Export.Pdf/PdfLayout/PdfCatalogLayout.cs +++ b/src/EPPlus.Export.Pdf/PdfLayout/PdfCatalogLayout.cs @@ -410,7 +410,6 @@ private static void LayoutAndShapeText(PdfPageSettings pageSettings, PdfDictiona fd.FontIdMap = fontIdMap; fd.UsedFonts = usedFonts; text.TextFormats[i] = fd; - shaper.ResetFontTracking(); } //saker här sen if (text is PdfCellContentLayout ccl) diff --git a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ShapeLightTests.cs b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ShapeLightTests.cs index 1219ccd6e..5bc571010 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ShapeLightTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ShapeLightTests.cs @@ -33,7 +33,6 @@ public void ShapeLight_SimpleText_ReturnsSameGlyphCountAsShape() // Act var full = shaper.Shape("Hello"); - shaper.ResetFontTracking(); var light = shaper.ShapeLight("Hello"); // Assert @@ -114,7 +113,6 @@ public void ShapeLight_GetWidthInPoints_ConsistentWithShape() var full = shaper.Shape("Hello World"); float fullWidth = full.GetWidthInPoints(fontSize); - shaper.ResetFontTracking(); var light = shaper.ShapeLight("Hello World"); float lightWidth = light.GetWidthInPoints(fontSize); diff --git a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/TextShaperTests.cs b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/TextShaperTests.cs index 46a05f3ab..26c69eaba 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/TextShaperTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/TextShaperTests.cs @@ -118,8 +118,7 @@ public void Shape_WithSpace_IncludesSpaceGlyph() public void Shape_WithKerning_ReducesWidth() { // Arrange - var font = OpenTypeFonts.LoadFont("Roboto", FontSubFamily.Regular); - var shaper = new TextShaper(font); + var shaper = OpenTypeFonts.GetTextShaper("Roboto", FontSubFamily.Regular, FontFolders); // Act var withKerning = shaper.Shape("WAVE", ShapingOptions.Default); @@ -177,8 +176,7 @@ public void Debug_GposKerningFormat() public void Shape_AVPair_HasNegativeKerning() { // Arrange - var font = OpenTypeFonts.LoadFont("Roboto"); - var shaper = new TextShaper(font); + var shaper = OpenTypeFonts.GetTextShaper("Roboto", FontSubFamily.Regular, FontFolders); // Act var withKerning = shaper.Shape("AV"); @@ -197,8 +195,7 @@ public void Shape_AVPair_HasNegativeKerning() public void Shape_FastOption_StillAppliesKerning() { // Arrange - var font = OpenTypeFonts.LoadFont("Roboto"); - var shaper = new TextShaper(font); + var shaper = OpenTypeFonts.GetTextShaper("Roboto", FontSubFamily.Regular, FontFolders); // Act var fast = shaper.Shape("WAVE", ShapingOptions.Fast); @@ -440,8 +437,7 @@ public void MeasureLines_TwoLines_HeightIsDoubleLineHeight() public void GetLineHeightInPoints_ReturnsPositiveValue() { // Arrange - var font = OpenTypeFonts.LoadFont("Roboto"); - var shaper = new TextShaper(font); + var shaper = OpenTypeFonts.GetTextShaper("Roboto", FontSubFamily.Regular, FontFolders); // Act float lineHeight = shaper.GetLineHeightInPoints(12); @@ -456,8 +452,7 @@ public void GetLineHeightInPoints_ReturnsPositiveValue() public void GetFontHeightInPoints_ReturnsPositiveValue() { // Arrange - var font = OpenTypeFonts.LoadFont("Roboto"); - var shaper = new TextShaper(font); + var shaper = OpenTypeFonts.GetTextShaper("Roboto", FontSubFamily.Regular, FontFolders); // Act float fontHeight = shaper.GetFontHeightInPoints(12); @@ -472,8 +467,7 @@ public void GetFontHeightInPoints_ReturnsPositiveValue() public void GetLineHeight_IsGreaterThanFontHeight() { // Arrange - var font = OpenTypeFonts.LoadFont("Roboto"); - var shaper = new TextShaper(font); + var shaper = OpenTypeFonts.GetTextShaper("Roboto", FontSubFamily.Regular, FontFolders); // Act float lineHeight = shaper.GetLineHeightInPoints(12); @@ -488,8 +482,7 @@ public void GetLineHeight_IsGreaterThanFontHeight() public void GetLineHeight_ScalesWithFontSize() { // Arrange - var font = OpenTypeFonts.LoadFont("Roboto"); - var shaper = new TextShaper(font); + var shaper = OpenTypeFonts.GetTextShaper("Roboto", FontSubFamily.Regular, FontFolders); // Act float height12 = shaper.GetLineHeightInPoints(12); @@ -642,8 +635,7 @@ public void Shape_Ligature_PreservesClusterIndex() public void Shape_DecomposedUnicode_PositionsAccent() { // Arrange - var font = OpenTypeFonts.LoadFont("Roboto"); - var shaper = new TextShaper(font); + var shaper = OpenTypeFonts.GetTextShaper("Roboto", FontSubFamily.Regular, FontFolders); // Act // U+0065 = 'e', U+0301 = combining acute accent @@ -671,8 +663,7 @@ public void Shape_DecomposedUnicode_PositionsAccent() public void Shape_PrecomposedVsDecomposed_SimilarWidth() { // Arrange - var font = OpenTypeFonts.LoadFont("Roboto"); - var shaper = new TextShaper(font); + var shaper = OpenTypeFonts.GetTextShaper("Roboto", FontSubFamily.Regular, FontFolders); // Act var precomposed = shaper.Shape("\u00e9"); // é (single codepoint) @@ -695,8 +686,7 @@ public void Shape_PrecomposedVsDecomposed_SimilarWidth() public void Shape_SourceSans3_SingleMark_PositionsCorrectly() { // Arrange - var font = OpenTypeFonts.LoadFont("SourceSans3"); - var shaper = new TextShaper(font); + var shaper = OpenTypeFonts.GetTextShaper("SourceSans3", fontDirectories: FontFolders); // Act - Single combining mark var result = shaper.Shape("e\u0301"); // e + combining acute (é) @@ -724,8 +714,7 @@ public void Shape_SourceSans3_SingleMark_PositionsCorrectly() public void Shape_Cafe_HandlesDecomposed() { // Arrange - var font = OpenTypeFonts.LoadFont("SourceSans3"); - var shaper = new TextShaper(font); + var shaper = OpenTypeFonts.GetTextShaper("SourceSans3", fontDirectories: FontFolders); // Act - "café" with decomposed é var result = shaper.Shape("cafe\u0301"); @@ -745,7 +734,7 @@ public void Shape_Cafe_HandlesDecomposed() [TestMethod] public void Debug_OpenSans_MarkFeature() { - var font = OpenTypeFonts.LoadFont("OpenSans", FontSubFamily.Regular); + var font = OpenTypeFonts.LoadFont("OpenSans", FontSubFamily.Regular, FontFolders); foreach (var featureRecord in font.GposTable.FeatureList.FeatureRecords) { diff --git a/src/EPPlus.Fonts.OpenType/OpenTypeFonts.cs b/src/EPPlus.Fonts.OpenType/OpenTypeFonts.cs index 712fca93d..43e9c41c4 100644 --- a/src/EPPlus.Fonts.OpenType/OpenTypeFonts.cs +++ b/src/EPPlus.Fonts.OpenType/OpenTypeFonts.cs @@ -13,10 +13,12 @@ Date Author Change 01/23/2026 EPPlus Software AB Improved thread-safety with per-font locking 02/26/2026 EPPlus Software AB Moved caching from DefaultFontResolver to here 02/27/2026 EPPlus Software AB Replaced Configure overloads with IEpplusFontConfiguration + 03/20/2026 EPPlus Software AB Added thread-local TextShaper cache *************************************************************************************************/ using EPPlus.Fonts.OpenType.FontCache; using EPPlus.Fonts.OpenType.FontResolver; using EPPlus.Fonts.OpenType.Scanner; +using EPPlus.Fonts.OpenType.TextShaping; using OfficeOpenXml.Interfaces.Drawing.Text; using OfficeOpenXml.Interfaces.Fonts; using System; @@ -38,6 +40,14 @@ public static class OpenTypeFonts // Singleton configuration instance. Internal events wired up in the static constructor. private static readonly EpplusFontConfiguration _configuration; + // Thread-local TextShaper cache — each thread gets its own dictionary of shapers, + // one per unique (fontName, subFamily) combination. The underlying OpenTypeFont instances + // are shared via the global font cache, but TextShaper is not thread-safe to share. + // NOTE: [ThreadStatic] field initializers only run on the primary thread. All other + // threads will see null here — the null-check in GetTextShaper() handles this. + [ThreadStatic] + private static Dictionary _threadLocalShaperCache; + static OpenTypeFonts() { _configuration = new EpplusFontConfiguration(); @@ -99,7 +109,48 @@ public static void Configure(Action configure) } /// - /// Clears all cached fonts and font locks. + /// Gets a TextShaper for the given font, reusing a thread-local cached instance. + /// The underlying OpenTypeFont is shared globally, but each thread gets its own + /// TextShaper instance, ensuring thread safety without locking. + /// Returns null if the font cannot be resolved. + /// + /// Font family name + /// Font subfamily (Regular, Bold, Italic, etc.) + /// Additional directories to search. If null, uses globally configured resolver. + /// Whether to search system font directories + public static TextShaper GetTextShaper( + string fontName, + FontSubFamily subFamily = FontSubFamily.Regular, + IEnumerable fontDirectories = null, + bool searchSystemDirectories = true) + { + if (fontName == null) + throw new ArgumentNullException("fontName"); + + // [ThreadStatic] fields are null on all threads except the primary — initialize on first use. + if (_threadLocalShaperCache == null) + _threadLocalShaperCache = new Dictionary(); + + // Use the same cache key logic as LoadFont to avoid collisions between + // calls with and without explicit font directories. + string key = BuildCacheKey(fontName, subFamily, fontDirectories, searchSystemDirectories); + + TextShaper shaper; + if (!_threadLocalShaperCache.TryGetValue(key, out shaper)) + { + var font = LoadFont(fontName, subFamily, fontDirectories, searchSystemDirectories); + if (font == null) + return null; + + shaper = new TextShaper(font); + _threadLocalShaperCache[key] = shaper; + } + + return shaper; + } + + /// + /// Clears all cached fonts, font locks and thread-local TextShaper cache. /// Thread-safe operation. /// public static void ClearFontCache() @@ -110,6 +161,10 @@ public static void ClearFontCache() FontScannerCache.Clear(); _fontLocks.Clear(); } + + // Clear the TextShaper cache for the calling thread. Other threads will + // lazily rebuild their own caches on next call to GetTextShaper(). + _threadLocalShaperCache = null; } // ----------------------------------------------------------------------------------------- diff --git a/src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs b/src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs index fc4479d99..f31f42da4 100644 --- a/src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs +++ b/src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs @@ -11,6 +11,7 @@ Date Author Change 01/15/2025 EPPlus Software AB Initial implementation 01/19/2026 EPPlus Software AB Added Single Adjustment support (GPOS Type 1) 02/05/2026 EPPlus Software AB Added IFontProvider support for fallback fonts + 03/20/2026 EPPlus Software AB ResetFontTracking made private, called automatically *************************************************************************************************/ using EPPlus.Fonts.OpenType.TextShaping.Contextual; using EPPlus.Fonts.OpenType.TextShaping.Kerning; @@ -54,6 +55,9 @@ public ushort UnitsPerEm /// /// Creates a TextShaper with automatic emoji fallback (DefaultFontProvider). + /// NOTE: In most cases, prefer OpenTypeFonts.GetTextShaper() over creating + /// instances directly. It provides a thread-local cached instance and avoids + /// duplicate caches across the codebase. /// public TextShaper(OpenTypeFont font) : this(new DefaultFontProvider(font)) @@ -62,6 +66,9 @@ public TextShaper(OpenTypeFont font) /// /// Creates a TextShaper with custom font provider. + /// NOTE: In most cases, prefer OpenTypeFonts.GetTextShaper() over creating + /// instances directly. It provides a thread-local cached instance and avoids + /// duplicate caches across the codebase. /// public TextShaper(IFontProvider fontProvider) { @@ -96,10 +103,10 @@ public IEnumerable GetUsedFonts() } /// - /// Clears font tracking between different texts. - /// Call this if you're reusing the same TextShaper for multiple unrelated texts. + /// Resets font tracking state. Called automatically at the start of each + /// shaping operation — Shape(), ExtractCharWidths(), ShapeLight(). /// - public void ResetFontTracking() + private void ResetFontTracking() { _usedFonts.Clear(); _fontToIdMap.Clear(); @@ -147,6 +154,8 @@ public ShapedText Shape(string text) /// Shaped text with positioned glyphs public ShapedText Shape(string text, ShapingOptions options) { + ResetFontTracking(); + if (string.IsNullOrEmpty(text)) { return new ShapedText @@ -234,10 +243,12 @@ public void ExtractCharWidths(string text, float fontSize, ShapingOptions option /// /// Core implementation that extracts char widths into provided buffer. /// OPTIMIZED: Avoids creating ShapedText object and copying glyphs to array. - /// Works directly with List for better memory efficiency. + /// Works directly with List<ShapedGlyph> for better memory efficiency. /// private void ExtractCharWidthsCore(string text, float fontSize, ShapingOptions options, double[] targetArray) { + ResetFontTracking(); + // Clear only the portion we will use Array.Clear(targetArray, 0, text.Length); @@ -623,6 +634,8 @@ public MultiLineMetrics MeasureLines(string text, float fontSize, ShapingOptions /// public ShapedLightText ShapeLight(string text, ShapingOptions options = null) { + ResetFontTracking(); + if (string.IsNullOrEmpty(text)) { return new ShapedLightText @@ -654,7 +667,6 @@ public ShapedLightText ShapeLight(string text, ShapingOptions options = null) }; } - /// /// Gets the UnitsPerEm for each font used in the last shaping operation. /// Indexed by FontId. Must be called after Shape/ShapeLight and before ResetFontTracking. @@ -744,8 +756,6 @@ public float GetFontHeightInPoints(float fontSize) /// /// Calculates the distance from the top of the font's bounding box to the baseline. /// - /// The font size, in points, for which to calculate the baseline position. Must be a positive value. - /// The distance, in points, from the top of the font's bounding box to the baseline for the given font size. public float GetAscentInPoints(float fontSize) { var ascent = _primaryFont.Os2Table.UseTypoMetrics