Skip to content

Commit 30afb0c

Browse files
committed
Read, serialize and subset vhea and vmtx tables. Textshaping of vertical text
2 parents 3a26671 + 2db5879 commit 30afb0c

37 files changed

Lines changed: 2352 additions & 329 deletions
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*************************************************************************************************
2+
Font Provider Unit Tests
3+
Tests for automatic emoji fallback functionality
4+
*************************************************************************************************/
5+
using EPPlus.Fonts.OpenType.TextShaping;
6+
using Microsoft.VisualStudio.TestTools.UnitTesting;
7+
using System.Linq;
8+
9+
namespace EPPlus.Fonts.OpenType.Tests.FallbackFonts
10+
{
11+
[TestClass]
12+
public class FontProviderTests : FontTestBase
13+
{
14+
public override TestContext? TestContext { get; set; }
15+
16+
private OpenTypeFont _robotoFont;
17+
18+
[TestInitialize]
19+
public void TestSetup()
20+
{
21+
_robotoFont = OpenTypeFonts.GetFontData(FontFolders, "Roboto", FontSubFamily.Regular);
22+
}
23+
24+
[TestMethod]
25+
public void DefaultFontProvider_EmojiGlyph_ShouldUseFallbackFont()
26+
{
27+
// Arrange
28+
var shaper = new TextShaper(_robotoFont);
29+
30+
// Act
31+
var shaped = shaper.Shape("😀");
32+
var usedFonts = shaper.GetUsedFonts().ToList();
33+
34+
// Assert
35+
Assert.AreEqual(1, shaped.Glyphs.Length, "Should have 1 glyph");
36+
Assert.AreNotEqual((ushort)0, shaped.Glyphs[0].GlyphId, "Emoji should not be .notdef");
37+
Assert.AreEqual((byte)0, shaped.Glyphs[0].FontId, "Emoji is the only font used (FontId=0)");
38+
39+
// Verify it's NOT the primary font
40+
Assert.AreEqual(1, usedFonts.Count, "Should only use one font (emoji fallback)");
41+
Assert.AreNotEqual(_robotoFont, usedFonts[0], "Should be emoji font, not Roboto");
42+
}
43+
44+
[TestMethod]
45+
public void DefaultFontProvider_LatinText_ShouldUsePrimaryFont()
46+
{
47+
// Arrange
48+
var shaper = new TextShaper(_robotoFont);
49+
50+
// Act
51+
var shaped = shaper.Shape("Hello World");
52+
53+
// Assert
54+
foreach (var glyph in shaped.Glyphs)
55+
{
56+
Assert.AreEqual((byte)0, glyph.FontId, "All glyphs should be from primary font");
57+
}
58+
}
59+
60+
[TestMethod]
61+
public void DefaultFontProvider_MixedTextAndEmoji_ShouldUseMultipleFonts()
62+
{
63+
// Arrange
64+
var shaper = new TextShaper(_robotoFont);
65+
66+
// Act
67+
var shaped = shaper.Shape("Hello 😀 World");
68+
var usedFonts = shaper.GetUsedFonts().ToList();
69+
70+
// Assert
71+
Assert.AreEqual(2, usedFonts.Count, "Should use 2 fonts (primary + emoji fallback)");
72+
Assert.AreEqual(_robotoFont, usedFonts[0], "First font should be primary");
73+
Assert.AreNotEqual(_robotoFont, usedFonts[1], "Second font should be emoji fallback");
74+
}
75+
76+
[TestMethod]
77+
public void TextShaper_SurrogatePair_ShouldMapToSingleGlyph()
78+
{
79+
// Arrange
80+
var shaper = new TextShaper(_robotoFont);
81+
string text = "😀"; // U+1F600 = 2 chars in UTF-16
82+
83+
// Act
84+
var shaped = shaper.Shape(text);
85+
86+
// Assert
87+
Assert.AreEqual(2, text.Length, "Emoji should be 2 chars in .NET");
88+
Assert.AreEqual(1, shaped.Glyphs.Length, "Should map to 1 glyph");
89+
Assert.AreEqual((byte)2, shaped.Glyphs[0].CharCount, "Glyph should span 2 chars");
90+
Assert.AreEqual((ushort)0, shaped.Glyphs[0].ClusterIndex, "Should start at char 0");
91+
}
92+
93+
[TestMethod]
94+
public void TextShaper_MultipleEmoji_ShouldMapCorrectly()
95+
{
96+
// Arrange
97+
var shaper = new TextShaper(_robotoFont);
98+
string text = "😀😁😂"; // 3 emoji = 6 chars in UTF-16
99+
100+
// Act
101+
var shaped = shaper.Shape(text);
102+
103+
// Assert
104+
Assert.AreEqual(6, text.Length, "3 emoji = 6 chars");
105+
Assert.AreEqual(3, shaped.Glyphs.Length, "Should map to 3 glyphs");
106+
107+
Assert.AreEqual((ushort)0, shaped.Glyphs[0].ClusterIndex, "First emoji at char 0");
108+
Assert.AreEqual((byte)2, shaped.Glyphs[0].CharCount, "First emoji spans 2 chars");
109+
110+
Assert.AreEqual((ushort)2, shaped.Glyphs[1].ClusterIndex, "Second emoji at char 2");
111+
Assert.AreEqual((byte)2, shaped.Glyphs[1].CharCount, "Second emoji spans 2 chars");
112+
113+
Assert.AreEqual((ushort)4, shaped.Glyphs[2].ClusterIndex, "Third emoji at char 4");
114+
Assert.AreEqual((byte)2, shaped.Glyphs[2].CharCount, "Third emoji spans 2 chars");
115+
}
116+
}
117+
}

src/EPPlus.Fonts.OpenType.Tests/FontMeasurerPerformanceTest.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,8 @@ public class FontMeasurerPerformanceTest : FontTestBase
3636
/// Performance test for text wrapping.
3737
/// Fixed kerning pairs major bottle-neck.
3838
/// </summary>
39-
[TestMethod]
40-
//[TestMethod, Ignore("This test should not run in a multithreaded test run. If we want to keep it, it should be moved to a separate benchmark project.")]
41-
//[TestCategory("Benchmark")]
39+
[TestMethod, Ignore("This test should not run in a multithreaded test run. If we want to keep it, it should be moved to a separate benchmark project.")]
40+
[TestCategory("Benchmark")]
4241
public void Wrap20Paragraphs100Times()
4342
{
4443
List<string> longTexts = new List<string>();

src/EPPlus.Fonts.OpenType.Tests/FontTestBase.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ protected static void DeleteOutputFont(string fileName)
112112
[TestInitialize]
113113
public void ClearAllCaches()
114114
{
115-
OpenTypeFonts.ClearFontCache();
115+
//OpenTypeFonts.ClearFontCache();
116116
}
117117

118118
[ClassInitialize(InheritanceBehavior.BeforeEachDerivedClass)]

src/EPPlus.Fonts.OpenType.Tests/Integration/MeasurerComparisonTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ public void Compare_MeasureMultiLineText_NewImplementationFixesBugs()
119119
Debug.WriteLine($"Line '{line}': {lineResult.Width}");
120120
}
121121

122-
double expectedHeight = shaper.GetLineHeightInPoints(11.0) * lines.Length;
122+
double expectedHeight = shaper.GetLineHeightInPoints(11.0f) * lines.Length;
123123

124124
// Assert
125125
Debug.WriteLine("");
@@ -164,7 +164,7 @@ public void Compare_GetSingleLineSpacing_ShouldMatch()
164164

165165
// Act
166166
var oldSpacing = oldMeasurer.GetSingleLineSpacing();
167-
var newSpacing = shaper.GetLineHeightInPoints(11.0);
167+
var newSpacing = shaper.GetLineHeightInPoints(11.0f);
168168

169169
// Assert
170170
Debug.WriteLine($"Old Spacing: {oldSpacing}, New Spacing: {newSpacing}");
@@ -185,7 +185,7 @@ public void Compare_GetBaseLine_ShouldMatch()
185185

186186
// Act
187187
var oldBaseline = oldMeasurer.GetBaseLine();
188-
var newBaseline = shaper.GetBaseLineInPoints(11.0);
188+
var newBaseline = shaper.GetBaseLineInPoints(11.0f);
189189

190190
// Assert
191191
Debug.WriteLine($"Old Baseline: {oldBaseline}, New Baseline: {newBaseline}");
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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+
02/19/2026 EPPlus Software AB Vertical subsetting tests (vhea/vmtx)
12+
*************************************************************************************************/
13+
using EPPlus.Fonts.OpenType.FontValidation;
14+
using EPPlus.Fonts.OpenType.Tests.Helpers;
15+
16+
namespace EPPlus.Fonts.OpenType.Tests.Subsetting
17+
{
18+
[TestClass]
19+
public class VerticalSubsettingTests : FontTestBase
20+
{
21+
public override TestContext TestContext { get; set; }
22+
23+
[ClassInitialize]
24+
public static void ClassInitialize(TestContext ctx)
25+
{
26+
FontDirectoriesTestHelper.ClassInitialize(ctx);
27+
}
28+
29+
#region vhea/vmtx presence tests
30+
31+
[TestMethod]
32+
public void Subset_CjkFont_SubsetContainsVheaTable()
33+
{
34+
// Arrange
35+
var font = OpenTypeFonts.GetFontDataOpen(FontFolders, "BIZ UDGothic", FontSubFamily.Regular, true);
36+
Assert.IsNotNull(font.VheaTable, "BIZ UDGothic should have a vhea table");
37+
38+
// Act
39+
var subset = font.CreateSubset("日本語");
40+
var bytes = subset.Serialize();
41+
var parsed = new OpenTypeFont(bytes, font.Format);
42+
43+
SaveFontForCurrentTest(parsed);
44+
45+
// Assert
46+
Assert.IsNotNull(parsed.VheaTable, "Subset should contain vhea table");
47+
}
48+
49+
[TestMethod]
50+
public void Subset_CjkFont_SubsetContainsVmtxTable()
51+
{
52+
// Arrange
53+
var font = OpenTypeFonts.GetFontDataOpen(FontFolders, "BIZ UDGothic", FontSubFamily.Regular, true);
54+
Assert.IsNotNull(font.VmtxTable, "BIZ UDGothic should have a vmtx table");
55+
56+
// Act
57+
var subset = font.CreateSubset("日本語");
58+
var bytes = subset.Serialize();
59+
var parsed = new OpenTypeFont(bytes, font.Format);
60+
61+
SaveFontForCurrentTest(parsed);
62+
63+
// Assert
64+
Assert.IsNotNull(parsed.VmtxTable, "Subset should contain vmtx table");
65+
}
66+
67+
[TestMethod]
68+
public void Subset_FontWithoutVmtx_SubsetDoesNotContainVmtxTable()
69+
{
70+
// Arrange - Roboto has no vmtx/vhea
71+
var font = OpenTypeFonts.GetFontDataOpen(FontFolders, "Roboto", FontSubFamily.Regular, true);
72+
Assert.IsNull(font.VmtxTable, "Roboto should not have a vmtx table");
73+
74+
// Act
75+
var subset = font.CreateSubset("ABC");
76+
var bytes = subset.Serialize();
77+
var parsed = new OpenTypeFont(bytes, font.Format);
78+
79+
// Assert - vmtx should not be introduced by subsetting
80+
Assert.IsNull(parsed.VmtxTable, "Subset of font without vmtx should not contain vmtx table");
81+
}
82+
83+
#endregion
84+
85+
#region vhea correctness tests
86+
87+
[TestMethod]
88+
public void Subset_CjkFont_VheaNumberOfVMetricsMatchesGlyphCount()
89+
{
90+
// Arrange
91+
var font = OpenTypeFonts.GetFontDataOpen(FontFolders, "BIZ UDGothic", FontSubFamily.Regular, true);
92+
93+
// Act
94+
var subset = font.CreateSubset("日本語");
95+
var bytes = subset.Serialize();
96+
var parsed = new OpenTypeFont(bytes, font.Format);
97+
98+
SaveFontForCurrentTest(parsed);
99+
100+
// Assert - NumberOfVMetrics must equal numGlyphs (same simplification as hmtx)
101+
Assert.AreEqual(
102+
parsed.MaxpTable.numGlyphs,
103+
parsed.VheaTable.NumberOfVMetrics,
104+
"vhea.NumberOfVMetrics should equal numGlyphs in subset");
105+
}
106+
107+
#endregion
108+
109+
#region vmtx correctness tests
110+
111+
[TestMethod]
112+
public void Subset_CjkFont_VmtxAdvanceHeightPreservedForSubsettedGlyphs()
113+
{
114+
// Arrange
115+
var font = OpenTypeFonts.GetFontDataOpen(FontFolders, "BIZ UDGothic", FontSubFamily.Regular, true);
116+
var text = "日";
117+
118+
// Get original glyph ID and advance height before subsetting
119+
ushort originalGlyphId;
120+
font.CmapTable.TryGetGlyphId('日', out originalGlyphId);
121+
var originalAdvanceHeight = font.VmtxTable.GetAdvanceHeight(originalGlyphId);
122+
123+
// Act
124+
var subset = font.CreateSubset(text);
125+
var bytes = subset.Serialize();
126+
var parsed = new OpenTypeFont(bytes, font.Format);
127+
128+
SaveFontForCurrentTest(parsed);
129+
130+
// Assert - resolve new glyph ID in subset and verify advance height is preserved
131+
ushort subsetGlyphId;
132+
parsed.CmapTable.TryGetGlyphId('日', out subsetGlyphId);
133+
var subsetAdvanceHeight = parsed.VmtxTable.GetAdvanceHeight(subsetGlyphId);
134+
135+
Assert.AreEqual(originalAdvanceHeight, subsetAdvanceHeight,
136+
$"AdvanceHeight for '日' should be preserved after subsetting " +
137+
$"(original={originalAdvanceHeight}, subset={subsetAdvanceHeight})");
138+
}
139+
140+
[TestMethod]
141+
public void Subset_CjkFont_VmtxEntryCountMatchesGlyphCount()
142+
{
143+
// Arrange
144+
var font = OpenTypeFonts.GetFontDataOpen(FontFolders, "BIZ UDGothic", FontSubFamily.Regular, true);
145+
146+
// Act
147+
var subset = font.CreateSubset("東京");
148+
var bytes = subset.Serialize();
149+
var parsed = new OpenTypeFont(bytes, font.Format);
150+
151+
SaveFontForCurrentTest(parsed);
152+
153+
// Assert - VMetrics count must equal numGlyphs
154+
Assert.AreEqual(
155+
parsed.MaxpTable.numGlyphs,
156+
parsed.VmtxTable.VMetrics.Count,
157+
"vmtx.VMetrics.Count should equal numGlyphs in subset");
158+
}
159+
160+
[TestMethod]
161+
public void Subset_CjkFont_PassesValidationAfterSubsetting()
162+
{
163+
// Arrange
164+
var font = OpenTypeFonts.GetFontDataOpen(FontFolders, "BIZ UDGothic", FontSubFamily.Regular, true);
165+
166+
// Act
167+
var subset = font.CreateSubset("日本語テスト");
168+
var bytes = subset.Serialize();
169+
var parsed = new OpenTypeFont(bytes, font.Format);
170+
171+
SaveFontForCurrentTest(parsed);
172+
173+
// Assert - full font validation should pass without errors
174+
FontTestHelper.AssertFontValid(parsed, FontValidationSeverity.Warning);
175+
}
176+
177+
#endregion
178+
}
179+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using EPPlus.Fonts.OpenType.TextShaping;
2+
using OfficeOpenXml.Interfaces.Drawing.Text;
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Diagnostics;
6+
using System.Linq;
7+
using System.Text;
8+
using System.Threading.Tasks;
9+
10+
namespace EPPlus.Fonts.OpenType.Tests.TextShaping
11+
{
12+
[TestClass]
13+
public class MarkToBaseTests : FontTestBase
14+
{
15+
public override TestContext? TestContext { get; set; }
16+
17+
[TestMethod]
18+
public void MarkToBaseTest()
19+
{
20+
var font = OpenTypeFonts.GetFontData(null, "Roboto", FontSubFamily.Regular, true, true);
21+
Debug.WriteLine("=== MarkToBaseTest ===");
22+
Debug.WriteLine($"Font instance: {font.GetHashCode()}");
23+
Debug.WriteLine($"CmapTable instance: {font.CmapTable.GetHashCode()}");
24+
Debug.WriteLine($"SubTables count: {font.CmapTable.SubTables.Count}");
25+
for (int i = 0; i < font.CmapTable.SubTables.Count; i++)
26+
Debug.WriteLine($" SubTable[{i}]: Format={font.CmapTable.SubTables[i].Format} HashCode={font.CmapTable.SubTables[i].GetHashCode()}");
27+
var shaper = new TextShaper(font);
28+
29+
string test = "A\u0302\u0309";
30+
// Lägg till lite synchronization för att verifiera
31+
lock (typeof(MarkToBaseTests))
32+
{
33+
var shaped = shaper.Shape(test, ShapingOptions.Full);
34+
35+
foreach (var g in shaped.Glyphs)
36+
{
37+
Debug.WriteLine($"GID={g.GlyphId,-4} XAdv={g.XAdvance,-5} YOff={g.YOffset,-4}");
38+
}
39+
40+
Debug.WriteLine($"GPOS null? {font.GposTable == null}");
41+
Debug.WriteLine($"FullyLoaded? {font.FullyLoaded}"); //
42+
43+
Assert.IsTrue(shaped.Glyphs.Any(x => x.YOffset > 0),
44+
$"Expected YOffset > 0. Got: {string.Join(", ", shaped.Glyphs.Select(g => $"Y={g.YOffset}"))}");
45+
}
46+
}
47+
}
48+
}

0 commit comments

Comments
 (0)