Skip to content

Commit 2595f04

Browse files
swmalswmal
andauthored
Added OpenTypeFonts.GetTextShaper method (#2319)
Co-authored-by: swmal <{ID}+username}@users.noreply.github.com>
1 parent aeb39a5 commit 2595f04

File tree

5 files changed

+85
-34
lines changed

5 files changed

+85
-34
lines changed

src/EPPlus.Export.Pdf/PdfLayout/PdfCatalogLayout.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,6 @@ private static void LayoutAndShapeText(PdfPageSettings pageSettings, PdfDictiona
410410
fd.FontIdMap = fontIdMap;
411411
fd.UsedFonts = usedFonts;
412412
text.TextFormats[i] = fd;
413-
shaper.ResetFontTracking();
414413
}
415414
//saker här sen
416415
if (text is PdfCellContentLayout ccl)

src/EPPlus.Fonts.OpenType.Tests/TextShaping/ShapeLightTests.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ public void ShapeLight_SimpleText_ReturnsSameGlyphCountAsShape()
3333

3434
// Act
3535
var full = shaper.Shape("Hello");
36-
shaper.ResetFontTracking();
3736
var light = shaper.ShapeLight("Hello");
3837

3938
// Assert
@@ -114,7 +113,6 @@ public void ShapeLight_GetWidthInPoints_ConsistentWithShape()
114113
var full = shaper.Shape("Hello World");
115114
float fullWidth = full.GetWidthInPoints(fontSize);
116115

117-
shaper.ResetFontTracking();
118116
var light = shaper.ShapeLight("Hello World");
119117
float lightWidth = light.GetWidthInPoints(fontSize);
120118

src/EPPlus.Fonts.OpenType.Tests/TextShaping/TextShaperTests.cs

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,7 @@ public void Shape_WithSpace_IncludesSpaceGlyph()
118118
public void Shape_WithKerning_ReducesWidth()
119119
{
120120
// Arrange
121-
var font = OpenTypeFonts.LoadFont("Roboto", FontSubFamily.Regular);
122-
var shaper = new TextShaper(font);
121+
var shaper = OpenTypeFonts.GetTextShaper("Roboto", FontSubFamily.Regular, FontFolders);
123122

124123
// Act
125124
var withKerning = shaper.Shape("WAVE", ShapingOptions.Default);
@@ -177,8 +176,7 @@ public void Debug_GposKerningFormat()
177176
public void Shape_AVPair_HasNegativeKerning()
178177
{
179178
// Arrange
180-
var font = OpenTypeFonts.LoadFont("Roboto");
181-
var shaper = new TextShaper(font);
179+
var shaper = OpenTypeFonts.GetTextShaper("Roboto", FontSubFamily.Regular, FontFolders);
182180

183181
// Act
184182
var withKerning = shaper.Shape("AV");
@@ -197,8 +195,7 @@ public void Shape_AVPair_HasNegativeKerning()
197195
public void Shape_FastOption_StillAppliesKerning()
198196
{
199197
// Arrange
200-
var font = OpenTypeFonts.LoadFont("Roboto");
201-
var shaper = new TextShaper(font);
198+
var shaper = OpenTypeFonts.GetTextShaper("Roboto", FontSubFamily.Regular, FontFolders);
202199

203200
// Act
204201
var fast = shaper.Shape("WAVE", ShapingOptions.Fast);
@@ -440,8 +437,7 @@ public void MeasureLines_TwoLines_HeightIsDoubleLineHeight()
440437
public void GetLineHeightInPoints_ReturnsPositiveValue()
441438
{
442439
// Arrange
443-
var font = OpenTypeFonts.LoadFont("Roboto");
444-
var shaper = new TextShaper(font);
440+
var shaper = OpenTypeFonts.GetTextShaper("Roboto", FontSubFamily.Regular, FontFolders);
445441

446442
// Act
447443
float lineHeight = shaper.GetLineHeightInPoints(12);
@@ -456,8 +452,7 @@ public void GetLineHeightInPoints_ReturnsPositiveValue()
456452
public void GetFontHeightInPoints_ReturnsPositiveValue()
457453
{
458454
// Arrange
459-
var font = OpenTypeFonts.LoadFont("Roboto");
460-
var shaper = new TextShaper(font);
455+
var shaper = OpenTypeFonts.GetTextShaper("Roboto", FontSubFamily.Regular, FontFolders);
461456

462457
// Act
463458
float fontHeight = shaper.GetFontHeightInPoints(12);
@@ -472,8 +467,7 @@ public void GetFontHeightInPoints_ReturnsPositiveValue()
472467
public void GetLineHeight_IsGreaterThanFontHeight()
473468
{
474469
// Arrange
475-
var font = OpenTypeFonts.LoadFont("Roboto");
476-
var shaper = new TextShaper(font);
470+
var shaper = OpenTypeFonts.GetTextShaper("Roboto", FontSubFamily.Regular, FontFolders);
477471

478472
// Act
479473
float lineHeight = shaper.GetLineHeightInPoints(12);
@@ -488,8 +482,7 @@ public void GetLineHeight_IsGreaterThanFontHeight()
488482
public void GetLineHeight_ScalesWithFontSize()
489483
{
490484
// Arrange
491-
var font = OpenTypeFonts.LoadFont("Roboto");
492-
var shaper = new TextShaper(font);
485+
var shaper = OpenTypeFonts.GetTextShaper("Roboto", FontSubFamily.Regular, FontFolders);
493486

494487
// Act
495488
float height12 = shaper.GetLineHeightInPoints(12);
@@ -642,8 +635,7 @@ public void Shape_Ligature_PreservesClusterIndex()
642635
public void Shape_DecomposedUnicode_PositionsAccent()
643636
{
644637
// Arrange
645-
var font = OpenTypeFonts.LoadFont("Roboto");
646-
var shaper = new TextShaper(font);
638+
var shaper = OpenTypeFonts.GetTextShaper("Roboto", FontSubFamily.Regular, FontFolders);
647639

648640
// Act
649641
// U+0065 = 'e', U+0301 = combining acute accent
@@ -671,8 +663,7 @@ public void Shape_DecomposedUnicode_PositionsAccent()
671663
public void Shape_PrecomposedVsDecomposed_SimilarWidth()
672664
{
673665
// Arrange
674-
var font = OpenTypeFonts.LoadFont("Roboto");
675-
var shaper = new TextShaper(font);
666+
var shaper = OpenTypeFonts.GetTextShaper("Roboto", FontSubFamily.Regular, FontFolders);
676667

677668
// Act
678669
var precomposed = shaper.Shape("\u00e9"); // é (single codepoint)
@@ -695,8 +686,7 @@ public void Shape_PrecomposedVsDecomposed_SimilarWidth()
695686
public void Shape_SourceSans3_SingleMark_PositionsCorrectly()
696687
{
697688
// Arrange
698-
var font = OpenTypeFonts.LoadFont("SourceSans3");
699-
var shaper = new TextShaper(font);
689+
var shaper = OpenTypeFonts.GetTextShaper("SourceSans3", fontDirectories: FontFolders);
700690

701691
// Act - Single combining mark
702692
var result = shaper.Shape("e\u0301"); // e + combining acute (é)
@@ -724,8 +714,7 @@ public void Shape_SourceSans3_SingleMark_PositionsCorrectly()
724714
public void Shape_Cafe_HandlesDecomposed()
725715
{
726716
// Arrange
727-
var font = OpenTypeFonts.LoadFont("SourceSans3");
728-
var shaper = new TextShaper(font);
717+
var shaper = OpenTypeFonts.GetTextShaper("SourceSans3", fontDirectories: FontFolders);
729718

730719
// Act - "café" with decomposed é
731720
var result = shaper.Shape("cafe\u0301");
@@ -745,7 +734,7 @@ public void Shape_Cafe_HandlesDecomposed()
745734
[TestMethod]
746735
public void Debug_OpenSans_MarkFeature()
747736
{
748-
var font = OpenTypeFonts.LoadFont("OpenSans", FontSubFamily.Regular);
737+
var font = OpenTypeFonts.LoadFont("OpenSans", FontSubFamily.Regular, FontFolders);
749738

750739
foreach (var featureRecord in font.GposTable.FeatureList.FeatureRecords)
751740
{

src/EPPlus.Fonts.OpenType/OpenTypeFonts.cs

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ Date Author Change
1313
01/23/2026 EPPlus Software AB Improved thread-safety with per-font locking
1414
02/26/2026 EPPlus Software AB Moved caching from DefaultFontResolver to here
1515
02/27/2026 EPPlus Software AB Replaced Configure overloads with IEpplusFontConfiguration
16+
03/20/2026 EPPlus Software AB Added thread-local TextShaper cache
1617
*************************************************************************************************/
1718
using EPPlus.Fonts.OpenType.FontCache;
1819
using EPPlus.Fonts.OpenType.FontResolver;
1920
using EPPlus.Fonts.OpenType.Scanner;
21+
using EPPlus.Fonts.OpenType.TextShaping;
2022
using OfficeOpenXml.Interfaces.Drawing.Text;
2123
using OfficeOpenXml.Interfaces.Fonts;
2224
using System;
@@ -38,6 +40,14 @@ public static class OpenTypeFonts
3840
// Singleton configuration instance. Internal events wired up in the static constructor.
3941
private static readonly EpplusFontConfiguration _configuration;
4042

43+
// Thread-local TextShaper cache — each thread gets its own dictionary of shapers,
44+
// one per unique (fontName, subFamily) combination. The underlying OpenTypeFont instances
45+
// are shared via the global font cache, but TextShaper is not thread-safe to share.
46+
// NOTE: [ThreadStatic] field initializers only run on the primary thread. All other
47+
// threads will see null here — the null-check in GetTextShaper() handles this.
48+
[ThreadStatic]
49+
private static Dictionary<string, TextShaper> _threadLocalShaperCache;
50+
4151
static OpenTypeFonts()
4252
{
4353
_configuration = new EpplusFontConfiguration();
@@ -99,7 +109,48 @@ public static void Configure(Action<IEpplusFontConfiguration> configure)
99109
}
100110

101111
/// <summary>
102-
/// Clears all cached fonts and font locks.
112+
/// Gets a TextShaper for the given font, reusing a thread-local cached instance.
113+
/// The underlying OpenTypeFont is shared globally, but each thread gets its own
114+
/// TextShaper instance, ensuring thread safety without locking.
115+
/// Returns null if the font cannot be resolved.
116+
/// </summary>
117+
/// <param name="fontName">Font family name</param>
118+
/// <param name="subFamily">Font subfamily (Regular, Bold, Italic, etc.)</param>
119+
/// <param name="fontDirectories">Additional directories to search. If null, uses globally configured resolver.</param>
120+
/// <param name="searchSystemDirectories">Whether to search system font directories</param>
121+
public static TextShaper GetTextShaper(
122+
string fontName,
123+
FontSubFamily subFamily = FontSubFamily.Regular,
124+
IEnumerable<string> fontDirectories = null,
125+
bool searchSystemDirectories = true)
126+
{
127+
if (fontName == null)
128+
throw new ArgumentNullException("fontName");
129+
130+
// [ThreadStatic] fields are null on all threads except the primary — initialize on first use.
131+
if (_threadLocalShaperCache == null)
132+
_threadLocalShaperCache = new Dictionary<string, TextShaper>();
133+
134+
// Use the same cache key logic as LoadFont to avoid collisions between
135+
// calls with and without explicit font directories.
136+
string key = BuildCacheKey(fontName, subFamily, fontDirectories, searchSystemDirectories);
137+
138+
TextShaper shaper;
139+
if (!_threadLocalShaperCache.TryGetValue(key, out shaper))
140+
{
141+
var font = LoadFont(fontName, subFamily, fontDirectories, searchSystemDirectories);
142+
if (font == null)
143+
return null;
144+
145+
shaper = new TextShaper(font);
146+
_threadLocalShaperCache[key] = shaper;
147+
}
148+
149+
return shaper;
150+
}
151+
152+
/// <summary>
153+
/// Clears all cached fonts, font locks and thread-local TextShaper cache.
103154
/// Thread-safe operation.
104155
/// </summary>
105156
public static void ClearFontCache()
@@ -110,6 +161,10 @@ public static void ClearFontCache()
110161
FontScannerCache.Clear();
111162
_fontLocks.Clear();
112163
}
164+
165+
// Clear the TextShaper cache for the calling thread. Other threads will
166+
// lazily rebuild their own caches on next call to GetTextShaper().
167+
_threadLocalShaperCache = null;
113168
}
114169

115170
// -----------------------------------------------------------------------------------------

src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Date Author Change
1111
01/15/2025 EPPlus Software AB Initial implementation
1212
01/19/2026 EPPlus Software AB Added Single Adjustment support (GPOS Type 1)
1313
02/05/2026 EPPlus Software AB Added IFontProvider support for fallback fonts
14+
03/20/2026 EPPlus Software AB ResetFontTracking made private, called automatically
1415
*************************************************************************************************/
1516
using EPPlus.Fonts.OpenType.TextShaping.Contextual;
1617
using EPPlus.Fonts.OpenType.TextShaping.Kerning;
@@ -54,6 +55,9 @@ public ushort UnitsPerEm
5455

5556
/// <summary>
5657
/// Creates a TextShaper with automatic emoji fallback (DefaultFontProvider).
58+
/// NOTE: In most cases, prefer OpenTypeFonts.GetTextShaper() over creating
59+
/// instances directly. It provides a thread-local cached instance and avoids
60+
/// duplicate caches across the codebase.
5761
/// </summary>
5862
public TextShaper(OpenTypeFont font)
5963
: this(new DefaultFontProvider(font))
@@ -62,6 +66,9 @@ public TextShaper(OpenTypeFont font)
6266

6367
/// <summary>
6468
/// Creates a TextShaper with custom font provider.
69+
/// NOTE: In most cases, prefer OpenTypeFonts.GetTextShaper() over creating
70+
/// instances directly. It provides a thread-local cached instance and avoids
71+
/// duplicate caches across the codebase.
6572
/// </summary>
6673
public TextShaper(IFontProvider fontProvider)
6774
{
@@ -96,10 +103,10 @@ public IEnumerable<OpenTypeFont> GetUsedFonts()
96103
}
97104

98105
/// <summary>
99-
/// Clears font tracking between different texts.
100-
/// Call this if you're reusing the same TextShaper for multiple unrelated texts.
106+
/// Resets font tracking state. Called automatically at the start of each
107+
/// shaping operation — Shape(), ExtractCharWidths(), ShapeLight().
101108
/// </summary>
102-
public void ResetFontTracking()
109+
private void ResetFontTracking()
103110
{
104111
_usedFonts.Clear();
105112
_fontToIdMap.Clear();
@@ -147,6 +154,8 @@ public ShapedText Shape(string text)
147154
/// <returns>Shaped text with positioned glyphs</returns>
148155
public ShapedText Shape(string text, ShapingOptions options)
149156
{
157+
ResetFontTracking();
158+
150159
if (string.IsNullOrEmpty(text))
151160
{
152161
return new ShapedText
@@ -234,10 +243,12 @@ public void ExtractCharWidths(string text, float fontSize, ShapingOptions option
234243
/// <summary>
235244
/// Core implementation that extracts char widths into provided buffer.
236245
/// OPTIMIZED: Avoids creating ShapedText object and copying glyphs to array.
237-
/// Works directly with List<ShapedGlyph> for better memory efficiency.
246+
/// Works directly with List&lt;ShapedGlyph&gt; for better memory efficiency.
238247
/// </summary>
239248
private void ExtractCharWidthsCore(string text, float fontSize, ShapingOptions options, double[] targetArray)
240249
{
250+
ResetFontTracking();
251+
241252
// Clear only the portion we will use
242253
Array.Clear(targetArray, 0, text.Length);
243254

@@ -623,6 +634,8 @@ public MultiLineMetrics MeasureLines(string text, float fontSize, ShapingOptions
623634
/// </summary>
624635
public ShapedLightText ShapeLight(string text, ShapingOptions options = null)
625636
{
637+
ResetFontTracking();
638+
626639
if (string.IsNullOrEmpty(text))
627640
{
628641
return new ShapedLightText
@@ -654,7 +667,6 @@ public ShapedLightText ShapeLight(string text, ShapingOptions options = null)
654667
};
655668
}
656669

657-
658670
/// <summary>
659671
/// Gets the UnitsPerEm for each font used in the last shaping operation.
660672
/// Indexed by FontId. Must be called after Shape/ShapeLight and before ResetFontTracking.
@@ -744,8 +756,6 @@ public float GetFontHeightInPoints(float fontSize)
744756
/// <summary>
745757
/// Calculates the distance from the top of the font's bounding box to the baseline.
746758
/// </summary>
747-
/// <param name="fontSize">The font size, in points, for which to calculate the baseline position. Must be a positive value.</param>
748-
/// <returns>The distance, in points, from the top of the font's bounding box to the baseline for the given font size.</returns>
749759
public float GetAscentInPoints(float fontSize)
750760
{
751761
var ascent = _primaryFont.Os2Table.UseTypoMetrics

0 commit comments

Comments
 (0)