Skip to content

Commit 8443fe6

Browse files
committed
Added multifont support to shape light. Refactored ShapedText and ShapedLightText to a common base class
1 parent 3100288 commit 8443fe6

9 files changed

Lines changed: 494 additions & 144 deletions

File tree

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
/*************************************************************************************************
2+
Required Notice: Copyright (C) EPPlus Software AB.
3+
This software is licensed under PolyForm Noncommercial License 1.0.0
4+
and may only be used for noncommercial purposes
5+
https://polyformproject.org/licenses/noncommercial/1.0.0/
6+
7+
A commercial license to use this software can be purchased at https://epplussoftware.com
8+
*************************************************************************************************
9+
Date Author Change
10+
*************************************************************************************************
11+
03/18/2026 EPPlus Software AB ShapeLight multi-font tests
12+
*************************************************************************************************/
13+
using EPPlus.Fonts.OpenType.TextShaping;
14+
using Microsoft.VisualStudio.TestTools.UnitTesting;
15+
using OfficeOpenXml.Interfaces.Fonts;
16+
using System;
17+
using System.Diagnostics;
18+
using System.Linq;
19+
20+
namespace EPPlus.Fonts.OpenType.Tests.TextShaping
21+
{
22+
[TestClass]
23+
public class ShapeLightTests : FontTestBase
24+
{
25+
public override TestContext? TestContext { get; set; }
26+
27+
[TestMethod]
28+
public void ShapeLight_SimpleText_ReturnsSameGlyphCountAsShape()
29+
{
30+
// Arrange
31+
var font = OpenTypeFonts.LoadFont("Roboto");
32+
var shaper = new TextShaper(font);
33+
34+
// Act
35+
var full = shaper.Shape("Hello");
36+
shaper.ResetFontTracking();
37+
var light = shaper.ShapeLight("Hello");
38+
39+
// Assert
40+
Assert.AreEqual(full.Glyphs.Length, light.Glyphs.Length,
41+
"ShapeLight should produce same number of glyphs as Shape");
42+
}
43+
44+
[TestMethod]
45+
public void ShapeLight_SimpleText_HasFontUnitsPerEm()
46+
{
47+
// Arrange
48+
var font = OpenTypeFonts.LoadFont("Roboto");
49+
var shaper = new TextShaper(font);
50+
51+
// Act
52+
var result = shaper.ShapeLight("Hello");
53+
54+
// Assert
55+
Assert.IsNotNull(result.FontUnitsPerEm, "Should have FontUnitsPerEm");
56+
Assert.AreEqual(1, result.FontUnitsPerEm.Length, "Single font should have 1 entry");
57+
Assert.AreEqual(font.HeadTable.UnitsPerEm, result.FontUnitsPerEm[0]);
58+
}
59+
60+
[TestMethod]
61+
public void ShapeLight_EmojiOnly_UsesFallbackFont()
62+
{
63+
// Arrange
64+
var font = OpenTypeFonts.LoadFont("Roboto");
65+
var shaper = new TextShaper(font);
66+
67+
// Act
68+
var result = shaper.ShapeLight("😀😁😂");
69+
70+
// Assert
71+
Assert.AreEqual(3, result.Glyphs.Length, "Should have 3 glyphs for 3 emojis");
72+
Assert.IsNotNull(result.FontUnitsPerEm);
73+
Assert.IsTrue(result.FontUnitsPerEm.Length >= 1, "Should have at least one font");
74+
75+
// All glyphs should have FontId 0 (the only used font is emoji fallback)
76+
foreach (var glyph in result.Glyphs)
77+
{
78+
Assert.AreEqual(0, glyph.FontId, "All emoji glyphs should be FontId 0");
79+
Assert.IsTrue(glyph.XAdvance > 0, "Emoji glyphs should have positive width");
80+
}
81+
}
82+
83+
[TestMethod]
84+
public void ShapeLight_MixedTextAndEmoji_HasMultipleFonts()
85+
{
86+
// Arrange
87+
var font = OpenTypeFonts.LoadFont("Roboto");
88+
var shaper = new TextShaper(font);
89+
90+
// Act
91+
var result = shaper.ShapeLight("Hi 😀 there");
92+
93+
// Assert
94+
Assert.IsNotNull(result.FontUnitsPerEm);
95+
Assert.AreEqual(2, result.FontUnitsPerEm.Length,
96+
"Should have 2 fonts (primary + emoji fallback)");
97+
98+
// Verify emoji glyph has different FontId than text glyphs
99+
var textFontId = result.Glyphs[0].FontId;
100+
var emojiFontId = result.Glyphs.First(g => g.ClusterIndex == 3).FontId; // '😀' starts at index 3
101+
Assert.AreNotEqual(textFontId, emojiFontId,
102+
"Emoji should use different font than text");
103+
}
104+
105+
[TestMethod]
106+
public void ShapeLight_GetWidthInPoints_ConsistentWithShape()
107+
{
108+
// Arrange
109+
var font = OpenTypeFonts.LoadFont("Roboto");
110+
var shaper = new TextShaper(font);
111+
float fontSize = 12f;
112+
113+
// Act
114+
var full = shaper.Shape("Hello World");
115+
float fullWidth = full.GetWidthInPoints(fontSize);
116+
117+
shaper.ResetFontTracking();
118+
var light = shaper.ShapeLight("Hello World");
119+
float lightWidth = light.GetWidthInPoints(fontSize);
120+
121+
// Assert — ShapeLight uses simplified kerning so allow small difference
122+
Assert.AreEqual(fullWidth, lightWidth, fullWidth * 0.05f,
123+
"ShapeLight width should be within 5% of Shape width");
124+
}
125+
126+
[TestMethod]
127+
public void ShapeLight_FillCharWidths_ProducesCorrectWidths()
128+
{
129+
// Arrange
130+
var font = OpenTypeFonts.LoadFont("Roboto");
131+
var shaper = new TextShaper(font);
132+
string text = "ABC";
133+
float fontSize = 12f;
134+
var charWidths = new double[text.Length];
135+
136+
// Act
137+
var result = shaper.ShapeLight(text);
138+
result.FillCharWidths(fontSize, charWidths, text.Length);
139+
140+
// Assert
141+
for (int i = 0; i < text.Length; i++)
142+
{
143+
Assert.IsTrue(charWidths[i] > 0,
144+
$"Character '{text[i]}' at index {i} should have positive width");
145+
}
146+
147+
// Total of char widths should match GetWidthInPoints
148+
double totalCharWidths = charWidths.Sum();
149+
float shapedWidth = result.GetWidthInPoints(fontSize);
150+
Assert.AreEqual(shapedWidth, totalCharWidths, 0.01,
151+
"Sum of char widths should match total shaped width");
152+
}
153+
154+
[TestMethod]
155+
public void ShapeLight_FillCharWidths_MixedEmoji_CorrectPerGlyphScale()
156+
{
157+
// Arrange
158+
var font = OpenTypeFonts.LoadFont("Roboto");
159+
var shaper = new TextShaper(font);
160+
string text = "A😀B";
161+
float fontSize = 12f;
162+
var charWidths = new double[text.Length];
163+
164+
// Act
165+
var result = shaper.ShapeLight(text);
166+
result.FillCharWidths(fontSize, charWidths, text.Length);
167+
168+
// Assert
169+
Assert.IsTrue(charWidths[0] > 0, "'A' should have positive width");
170+
// charWidths[1] is high surrogate of emoji — should have the emoji width
171+
Assert.IsTrue(charWidths[1] > 0, "Emoji (at surrogate position) should have positive width");
172+
// charWidths[2] is low surrogate — typically 0 (width is on the high surrogate)
173+
// charWidths[3] is 'B'
174+
Assert.IsTrue(charWidths[text.Length - 1] > 0, "'B' should have positive width");
175+
176+
// Total should match
177+
double totalCharWidths = charWidths.Sum();
178+
float shapedWidth = result.GetWidthInPoints(fontSize);
179+
Assert.AreEqual(shapedWidth, totalCharWidths, 0.01,
180+
"Sum of char widths should match total shaped width");
181+
}
182+
183+
[TestMethod]
184+
public void ShapeLight_EmptyString_ReturnsEmptyResult()
185+
{
186+
// Arrange
187+
var font = OpenTypeFonts.LoadFont("Roboto");
188+
var shaper = new TextShaper(font);
189+
190+
// Act
191+
var result = shaper.ShapeLight("");
192+
193+
// Assert
194+
Assert.IsNotNull(result);
195+
Assert.AreEqual(0, result.Glyphs.Length);
196+
Assert.IsNotNull(result.FontUnitsPerEm);
197+
Assert.AreEqual(0f, result.GetWidthInPoints(12f));
198+
}
199+
200+
[TestMethod]
201+
public void ShapeLight_TextEmojiAndMath_UsesThreeFonts()
202+
{
203+
// Arrange
204+
var font = OpenTypeFonts.LoadFont("Roboto");
205+
var shaper = new TextShaper(font);
206+
207+
// U+1D400 = 𝐀 (Mathematical Bold Capital A) — not in Roboto or Noto Emoji,
208+
// should fall back to Noto Sans Math.
209+
// If U+1D400 isn't covered, try U+2200 (∀) or U+222B (∫).
210+
string mathChar = "\u2200";
211+
string text = "Hi😀" + mathChar;
212+
float fontSize = 12f;
213+
214+
// Act
215+
var result = shaper.ShapeLight(text);
216+
217+
// Assert — three distinct fonts
218+
Assert.IsNotNull(result.FontUnitsPerEm);
219+
Assert.AreEqual(3, result.FontUnitsPerEm.Length,
220+
$"Expected 3 fonts (primary + emoji + math), got {result.FontUnitsPerEm.Length}");
221+
222+
// Verify three distinct FontIds are present
223+
var distinctFontIds = result.Glyphs.Select(g => g.FontId).Distinct().OrderBy(id => id).ToArray();
224+
Assert.AreEqual(3, distinctFontIds.Length,
225+
$"Expected 3 distinct FontIds, got [{string.Join(", ", distinctFontIds)}]");
226+
227+
// All glyphs should have valid (non-zero) advance widths
228+
foreach (var glyph in result.Glyphs)
229+
{
230+
Assert.IsTrue(glyph.XAdvance > 0,
231+
$"Glyph at ClusterIndex {glyph.ClusterIndex} (FontId={glyph.FontId}) should have positive width");
232+
}
233+
234+
// FillCharWidths should still sum correctly
235+
var charWidths = new double[text.Length];
236+
result.FillCharWidths(fontSize, charWidths, text.Length);
237+
double totalCharWidths = charWidths.Sum();
238+
float shapedWidth = result.GetWidthInPoints(fontSize);
239+
Assert.AreEqual(shapedWidth, totalCharWidths, 0.01,
240+
"Sum of char widths should match total shaped width across all three fonts");
241+
}
242+
}
243+
}

src/EPPlus.Fonts.OpenType/Integration/TextLayoutEngine.Helpers.cs

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,10 @@ private void PrepareLineBuilder(int textLength)
2525

2626
private double[] CalculateCharacterWidths(string text, float fontSize, ShapingOptions options)
2727
{
28-
var glyphs = _shaper.ShapeLight(text, options);
28+
var shaped = _shaper.ShapeLight(text, options);
2929
var charWidths = GetCharWidthBuffer(text.Length);
3030
Array.Clear(charWidths, 0, text.Length);
31-
32-
double scaleFactor = fontSize / _shaper.UnitsPerEm;
33-
34-
foreach (var glyph in glyphs)
35-
{
36-
int charIndex = glyph.ClusterIndex;
37-
if (charIndex >= 0 && charIndex < text.Length)
38-
{
39-
charWidths[charIndex] += glyph.XAdvance * scaleFactor;
40-
}
41-
}
42-
31+
shaped.FillCharWidths(fontSize, charWidths, text.Length);
4332
return charWidths;
4433
}
4534

src/EPPlus.Fonts.OpenType/Integration/TextLayoutEngine.RichText.cs

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -128,19 +128,15 @@ private void ProcessFragment(
128128
{
129129
var shaper = GetShaperForFont(fragment.Font);
130130
var options = fragment.Options ?? ShapingOptions.Default;
131-
132131
int len = fragment.Text.Length;
133-
134132
var charWidths = GetCharWidthBuffer(len);
133+
Array.Clear(charWidths, 0, len);
135134

136135
// ShapeLight applies only kerning (sufficient for line-breaking).
137136
// Full Shape() runs SingleAdjustment + Kerning + MarkToBase which
138137
// is ~250x slower and irrelevant for wrapping decisions.
139-
var glyphWidths = shaper.ShapeLight(fragment.Text, options);
140-
double scale = fragment.Font.Size / shaper.UnitsPerEm;
141-
142-
Array.Clear(charWidths, 0, len);
143-
FillCharWidths(glyphWidths, scale, len, charWidths);
138+
var shaped = shaper.ShapeLight(fragment.Text, options);
139+
shaped.FillCharWidths(fragment.Font.Size, charWidths, len);
144140

145141
//Store for after everything is done
146142
fragment.AscentPoints = shaper.GetAscentInPoints(fragment.Font.Size);
@@ -159,7 +155,6 @@ private void ProcessFragment(
159155
{
160156
HandleLineBreak(lineBuilder, state);
161157
SkipLineBreakChars(fragment.Text, ref i);
162-
163158
state.CurrentLineWidth = 0;
164159
state.CurrentWordWidth = 0;
165160
state.WordStart = -1;
@@ -170,7 +165,6 @@ private void ProcessFragment(
170165
state.CurrentLineWidth += charWidths[i];
171166
state.CurrentWordWidth += charWidths[i];
172167
state.LineFrag.Width += charWidths[i];
173-
174168
lineBuilder.Append(c);
175169

176170
if (c == ' ')
@@ -182,10 +176,11 @@ private void ProcessFragment(
182176
{
183177
WrapCurrentLine(lineBuilder, state, maxWidthPoints, charWidths[i]);
184178
}
179+
185180
i++;
186181
}
187182

188-
if(state.LineFrag.Width > 0)
183+
if (state.LineFrag.Width > 0)
189184
{
190185
state.CurrentTextLine.LineFragments.Add(state.LineFrag);
191186
}

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

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -621,17 +621,19 @@ public MultiLineMetrics MeasureLines(string text, float fontSize, ShapingOptions
621621
/// <summary>
622622
/// Shapes text into lightweight GlyphWidth structs optimized for text measurement.
623623
/// </summary>
624-
public GlyphWidth[] ShapeLight(string text, ShapingOptions options = null)
624+
public ShapedLightText ShapeLight(string text, ShapingOptions options = null)
625625
{
626626
if (string.IsNullOrEmpty(text))
627627
{
628-
return new GlyphWidth[0];
628+
return new ShapedLightText
629+
{
630+
Glyphs = new GlyphWidth[0],
631+
FontUnitsPerEm = new ushort[] { _primaryFont.HeadTable.UnitsPerEm }
632+
};
629633
}
630634

631635
if (options == null)
632-
{
633636
options = ShapingOptions.Default;
634-
}
635637

636638
var glyphs = MapToGlyphs(text);
637639

@@ -645,7 +647,21 @@ public GlyphWidth[] ShapeLight(string text, ShapingOptions options = null)
645647
ApplyKerningOnly(glyphs);
646648
}
647649

648-
return ExtractGlyphWidths(glyphs);
650+
return new ShapedLightText
651+
{
652+
Glyphs = ExtractGlyphWidths(glyphs),
653+
FontUnitsPerEm = BuildFontUnitsPerEm()
654+
};
655+
}
656+
657+
658+
/// <summary>
659+
/// Gets the UnitsPerEm for each font used in the last shaping operation.
660+
/// Indexed by FontId. Must be called after Shape/ShapeLight and before ResetFontTracking.
661+
/// </summary>
662+
public ushort[] GetFontUnitsPerEm()
663+
{
664+
return BuildFontUnitsPerEm();
649665
}
650666

651667
private void ApplyKerningOnly(List<ShapedGlyph> glyphs)
@@ -681,7 +697,8 @@ private GlyphWidth[] ExtractGlyphWidths(List<ShapedGlyph> glyphs)
681697
{
682698
XAdvance = (ushort)g.XAdvance,
683699
ClusterIndex = g.ClusterIndex,
684-
CharCount = g.CharCount
700+
CharCount = g.CharCount,
701+
FontId = g.FontId
685702
};
686703
}
687704

src/EPPlus.Interfaces/Fonts/GlyphWidth.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,11 @@ public struct GlyphWidth
4646
/// </summary>
4747
public byte CharCount;
4848

49-
// 3 bytes padding to align to 8 bytes total
49+
/// <summary>
50+
/// Which font produced this glyph (0 = first used font, 1+ = fallbacks).
51+
/// Needed for correct point conversion when fonts have different UnitsPerEm.
52+
/// </summary>
53+
public byte FontId;
5054

51-
// Total size: 8 bytes (perfectly aligned for 64-bit systems)
5255
}
5356
}

0 commit comments

Comments
 (0)