Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/EPPlus.Export.Pdf/PdfLayout/PdfCatalogLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ public void ShapeLight_SimpleText_ReturnsSameGlyphCountAsShape()

// Act
var full = shaper.Shape("Hello");
shaper.ResetFontTracking();
var light = shaper.ShapeLight("Hello");

// Assert
Expand Down Expand Up @@ -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);

Expand Down
35 changes: 12 additions & 23 deletions src/EPPlus.Fonts.OpenType.Tests/TextShaping/TextShaperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 (é)
Expand Down Expand Up @@ -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");
Expand All @@ -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)
{
Expand Down
57 changes: 56 additions & 1 deletion src/EPPlus.Fonts.OpenType/OpenTypeFonts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, TextShaper> _threadLocalShaperCache;

static OpenTypeFonts()
{
_configuration = new EpplusFontConfiguration();
Expand Down Expand Up @@ -99,7 +109,48 @@ public static void Configure(Action<IEpplusFontConfiguration> configure)
}

/// <summary>
/// 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.
/// </summary>
/// <param name="fontName">Font family name</param>
/// <param name="subFamily">Font subfamily (Regular, Bold, Italic, etc.)</param>
/// <param name="fontDirectories">Additional directories to search. If null, uses globally configured resolver.</param>
/// <param name="searchSystemDirectories">Whether to search system font directories</param>
public static TextShaper GetTextShaper(
string fontName,
FontSubFamily subFamily = FontSubFamily.Regular,
IEnumerable<string> 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<string, TextShaper>();

// 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;
}

/// <summary>
/// Clears all cached fonts, font locks and thread-local TextShaper cache.
/// Thread-safe operation.
/// </summary>
public static void ClearFontCache()
Expand All @@ -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;
}

// -----------------------------------------------------------------------------------------
Expand Down
24 changes: 17 additions & 7 deletions src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,6 +55,9 @@ public ushort UnitsPerEm

/// <summary>
/// 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.
/// </summary>
public TextShaper(OpenTypeFont font)
: this(new DefaultFontProvider(font))
Expand All @@ -62,6 +66,9 @@ public TextShaper(OpenTypeFont font)

/// <summary>
/// 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.
/// </summary>
public TextShaper(IFontProvider fontProvider)
{
Expand Down Expand Up @@ -96,10 +103,10 @@ public IEnumerable<OpenTypeFont> GetUsedFonts()
}

/// <summary>
/// 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().
/// </summary>
public void ResetFontTracking()
private void ResetFontTracking()
{
_usedFonts.Clear();
_fontToIdMap.Clear();
Expand Down Expand Up @@ -147,6 +154,8 @@ public ShapedText Shape(string text)
/// <returns>Shaped text with positioned glyphs</returns>
public ShapedText Shape(string text, ShapingOptions options)
{
ResetFontTracking();

if (string.IsNullOrEmpty(text))
{
return new ShapedText
Expand Down Expand Up @@ -234,10 +243,12 @@ public void ExtractCharWidths(string text, float fontSize, ShapingOptions option
/// <summary>
/// Core implementation that extracts char widths into provided buffer.
/// OPTIMIZED: Avoids creating ShapedText object and copying glyphs to array.
/// Works directly with List<ShapedGlyph> for better memory efficiency.
/// Works directly with List&lt;ShapedGlyph&gt; for better memory efficiency.
/// </summary>
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);

Expand Down Expand Up @@ -623,6 +634,8 @@ public MultiLineMetrics MeasureLines(string text, float fontSize, ShapingOptions
/// </summary>
public ShapedLightText ShapeLight(string text, ShapingOptions options = null)
{
ResetFontTracking();

if (string.IsNullOrEmpty(text))
{
return new ShapedLightText
Expand Down Expand Up @@ -654,7 +667,6 @@ public ShapedLightText ShapeLight(string text, ShapingOptions options = null)
};
}


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