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+ }
0 commit comments