diff --git a/src/EPPlus.Export.Pdf.Tests/EPPlus.Export.Pdf.Tests.csproj b/src/EPPlus.Export.Pdf.Tests/EPPlus.Export.Pdf.Tests.csproj index fe5001f3a0..c6feaff043 100644 --- a/src/EPPlus.Export.Pdf.Tests/EPPlus.Export.Pdf.Tests.csproj +++ b/src/EPPlus.Export.Pdf.Tests/EPPlus.Export.Pdf.Tests.csproj @@ -28,6 +28,9 @@ + + PreserveNewest + PreserveNewest diff --git a/src/EPPlus.Export.Pdf.Tests/Fonts/BIZUDGothic-Regular.ttf b/src/EPPlus.Export.Pdf.Tests/Fonts/BIZUDGothic-Regular.ttf new file mode 100644 index 0000000000..030a7c96fd Binary files /dev/null and b/src/EPPlus.Export.Pdf.Tests/Fonts/BIZUDGothic-Regular.ttf differ diff --git a/src/EPPlus.Export.Pdf.Tests/VariationSequenceReproTests.cs b/src/EPPlus.Export.Pdf.Tests/VariationSequenceReproTests.cs new file mode 100644 index 0000000000..1f24b5e6af --- /dev/null +++ b/src/EPPlus.Export.Pdf.Tests/VariationSequenceReproTests.cs @@ -0,0 +1,146 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 09/09/2026 EPPlus Software AB Unicode Variation Sequence visual repro sheet + *************************************************************************************************/ +using EPPlus.Export.Pdf.Settings; +using EPPlus.Export.Pdf.Tests; +using EPPlus.Fonts.OpenType; +using OfficeOpenXml; +using OfficeOpenXml.Export.PdfExport; +using OfficeOpenXml.Style; +using System; +using System.IO; +using System.Text; + +namespace EPPlus.Export.Pdf.Tests +{ + /// + /// VISUAL repro for cmap format 14 (Unicode Variation Sequences) actually being consulted + /// during shaping, and preserved through subsetting and embedding - not an automated + /// regression test, since the point is to look at the two glyph shapes with your own eyes. + /// The three unit-test files already cover the automated side of this. + /// + /// Uses BIZ UDGothic, a real Japanese font bundled with the test suite that has genuine + /// format-14 data - verified directly against the font's own bytes (not assumed): U+585A + /// (塚) has a registered NON-DEFAULT variation sequence under VS01 (U+FE00). The base + /// character alone resolves to glyph 3363 in this font; U+585A + VS01 resolves to glyph + /// 1399 - a different, hand-drawn glyph, not a font-side no-op. + /// + /// What to look for in the exported PDF: + /// + /// A2 "塚" The plain base character - glyph 3363, the font's default form. + /// A3 "塚" + VS01 The variation sequence - should render glyph 1399: a VISIBLY + /// different shape for the top of the right-hand component (this is + /// one of the standard textbook examples of a Japanese IVS/ + /// hanyo-denshi variant pair). + /// + /// If A2 and A3 look IDENTICAL, the variation sequence is silently falling back to the base + /// glyph somewhere in the pipeline - shaping, subsetting, or serialization. + /// + [TestClass] + public class VariationSequenceReproTests : PdfTestBase + { + // BIZUDGothic-Regular.ttf lives in the Fonts subfolder of the test project and is + // copied next to the test assembly at build time - no dependency on BIZ UDGothic being + // installed as a system font. + private static string FontsFolder => Path.Combine(AppContext.BaseDirectory, "Fonts"); + + private const string ReproFontFamily = "BIZ UDGothic"; + private const float ReproFontSize = 72f; + + // U+585A (塚): base char alone -> glyph 3363, base char + VS01 (U+FE00) -> glyph 1399. + // Both values read directly out of BIZUDGothic-Regular.ttf's own cmap tables. + private const string BaseChar = "\u585A"; + private const string BaseCharPlusVs01 = "\u585A\uFE00"; + + private static OpenTypeFontEngine CreateEngine() + { + return new OpenTypeFontEngine(cfg => + { + cfg.FontDirectories.Add(FontsFolder); + cfg.SearchSystemDirectories = false; + }); + } + + [TestMethod] + public void VariationSequence_BizUdGothic_ReproSheet() + { + using (var package = OpenPackage("VariationSequenceRepro.xlsx", true)) + { + var sheet = package.Workbook.Worksheets.Add("VariationSequence"); + BuildReproSheet(sheet); + + var engine = CreateEngine(); + var settings = new PdfPageSettings(engine); + + // Written to disk for visual inspection - this is the whole point of the test. + SaveAsPdf(sheet, "VariationSequenceRepro", settings); + + // Also export to a stream so the test fails loudly if the export itself breaks, + // rather than silently writing an unreadable file. + using (var stream = new MemoryStream()) + { + new PdfCatalog(settings, sheet).Save(stream); + AssertLooksLikePdf(stream.ToArray()); + } + + SaveWorkbook("VariationSequenceRepro.xlsx", package); + } + } + + private static void BuildReproSheet(ExcelWorksheet sheet) + { + sheet.Cells["A1"].Value = "Compare A2 (plain) vs A3 (base + VS01) - look for a different top-right stroke shape"; + StyleHeader(sheet.Cells["A1"]); + + sheet.Cells["A2"].Value = BaseChar; + sheet.Cells["A3"].Value = BaseCharPlusVs01; + + StyleReference(sheet.Cells["A2:A3"]); + sheet.Cells.AutoFitColumns(); + + for (int row = 1; row <= 3; row++) + { + sheet.Row(row).CustomHeight = true; + sheet.Row(row).Height = 100; + } + } + + private static void StyleReference(ExcelRange range) + { + range.Style.Font.Name = ReproFontFamily; + range.Style.Font.Size = ReproFontSize; + range.Style.HorizontalAlignment = ExcelHorizontalAlignment.Left; + range.Style.VerticalAlignment = ExcelVerticalAlignment.Bottom; + range.Style.Indent = 0; + range.Style.WrapText = false; + } + + private static void StyleHeader(ExcelRange range) + { + range.Style.Font.Name = ReproFontFamily; + range.Style.Font.Size = 11f; + range.Style.Font.Bold = true; + } + + private static void AssertLooksLikePdf(byte[] bytes) + { + Assert.IsTrue(bytes.Length > 0, "PDF output is empty."); + + string head = Encoding.ASCII.GetString(bytes, 0, Math.Min(8, bytes.Length)); + Assert.IsTrue(head.StartsWith("%PDF-"), $"Missing PDF header. Got: '{head}'"); + + int tailLength = Math.Min(8, bytes.Length); + string tail = Encoding.ASCII.GetString(bytes, bytes.Length - tailLength, tailLength); + Assert.IsTrue(tail.Contains("%%EOF"), "Missing %%EOF trailer marker."); + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType.Tests/Serialization/GPosSerializationTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Serialization/GPosSerializationTests.cs index 2966165736..c52afb8947 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/Serialization/GPosSerializationTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/Serialization/GPosSerializationTests.cs @@ -17,11 +17,7 @@ Date Author Change using EPPlus.Fonts.OpenType.Tables.Gpos.Data.Lookups.LookupType1; using EPPlus.Fonts.OpenType.Tables.Gpos.Data.Lookups.LookupType2; using EPPlus.Fonts.OpenType.Tables.Gpos.Data.Lookups.LookupType4; -using EPPlus.Fonts.OpenType.Tests.Helpers; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System.Collections.Generic; using System.Diagnostics; -using System.IO; namespace EPPlus.Fonts.OpenType.Tests.Serialization { diff --git a/src/EPPlus.Fonts.OpenType.Tests/Subsetting/VariationSequenceSubsettingParityTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Subsetting/VariationSequenceSubsettingParityTests.cs new file mode 100644 index 0000000000..d0a0f7dbc8 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/Subsetting/VariationSequenceSubsettingParityTests.cs @@ -0,0 +1,111 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 09/09/2026 EPPlus Software AB Unicode Variation Sequence subsetting parity test + *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Tables.Cmap; +using EPPlus.Fonts.OpenType.TextShaping; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml.Interfaces.Fonts; +using System.Collections.Generic; + +namespace EPPlus.Fonts.OpenType.Tests.Subsetting +{ + /// + /// Parity test analogous to the measurement/render width-parity checks (see WP1's kerning + /// fix): verifies that shaping against the FULL font (the measurement path, + /// GetCellCollectionFromRange) and shaping against the ROUND-TRIPPED SUBSET font (the render + /// path - what actually ends up embedded in the PDF) agree on a Unicode Variation Sequence. + /// + /// Glyph IDs are renumbered by subsetting, so this can't compare raw glyph IDs across the two + /// fonts. Instead it compares BEHAVIOR: does each font's own shaping still tell the + /// (base, selector) pair apart from the plain base character on its own terms? + /// + /// "Round-tripped" means serialized to bytes and reloaded - exactly like a font that has gone + /// through PDF embedding. This exercises CmapSubsetProcessor's discovery/rewrite of the + /// variation-sequence data AND CmapTable's serialization of the format-14 subtable, both of + /// which are currently missing. + /// + [TestClass] + public class VariationSequenceSubsettingParityTests : FontTestBase + { + public override TestContext? TestContext { get; set; } + + private const uint Vs01 = 0xFE00; // VARIATION SELECTOR-1 (BMP) + + /// + /// Loads a private (non-cached) Roboto instance and registers 'A' + Vs01 as a variation + /// sequence resolving to whatever glyph 'B' already maps to (a distinct, real, existing + /// glyph - not a made-up one). + /// + private OpenTypeFont LoadFullFontWithSyntheticVariationSequence(out ushort glyphIdA, out ushort glyphIdB) + { + var font = TestFolderEngine.LoadFont("Roboto", FontSubFamily.Regular, true); + + Assert.IsTrue(font.CmapTable.TryGetGlyphId('A', out glyphIdA), "Test font is expected to contain 'A'."); + Assert.IsTrue(font.CmapTable.TryGetGlyphId('B', out glyphIdB), "Test font is expected to contain 'B'."); + Assert.AreNotEqual(glyphIdA, glyphIdB, "Test relies on 'A' and 'B' mapping to different glyphs."); + + var subtable14 = new CmapSubtable14(); + subtable14.VariationSelectors.Add(new VariationSelector + { + VarSelector = Vs01, + NonDefaultUvsTable = new NonDefaultUvsTable + { + Mappings = new List + { + new UvsMapping { UnicodeValue = 'A', GlyphId = glyphIdB } + } + } + }); + font.CmapTable.SubTables.Add(subtable14); + + return font; + } + + [TestMethod] + public void Shape_RoundTrippedSubsetFont_StillDistinguishesVariationSequenceFromPlainBaseChar() + { + const string textWithSequence = "A\uFE00"; + + var fullFont = LoadFullFontWithSyntheticVariationSequence(out ushort glyphIdA, out ushort glyphIdB); + + // --- Measurement path: shape against the full font (GetCellCollectionFromRange today + // shapes here) --- + var fullShaper = new TextShaper(TestFolderEngine, fullFont); + var fullPlain = fullShaper.Shape("A"); + var fullSequence = fullShaper.Shape(textWithSequence); + + Assert.AreEqual(1, fullSequence.Glyphs.Length); + Assert.AreEqual(2, fullSequence.Glyphs[0].CharCount, "Sanity check: the full font must consume the pair as one glyph."); + Assert.AreNotEqual(fullPlain.Glyphs[0].GlyphId, fullSequence.Glyphs[0].GlyphId, + "Sanity check: the variation sequence must select a different glyph than plain 'A' in the full font."); + + // --- Render path: subset for exactly this text, then round-trip (serialize + reload) - + // exactly what happens when the subset is embedded in, and later read back from, a PDF. --- + var subsetFont = fullFont.CreateSubset(textWithSequence); + var subsetBytes = subsetFont.Serialize(); + var roundTrippedSubsetFont = new OpenTypeFont(subsetBytes); + + var subsetShaper = new TextShaper(TestFolderEngine, roundTrippedSubsetFont); + var subsetPlain = subsetShaper.Shape("A"); + var subsetSequence = subsetShaper.Shape(textWithSequence); + + Assert.AreEqual(1, subsetSequence.Glyphs.Length); + Assert.AreEqual(2, subsetSequence.Glyphs[0].CharCount, + "The round-tripped SUBSET font must still consume the pair as one glyph. Today it silently " + + "falls back to the base glyph, because CmapSubsetProcessor doesn't discover/preserve format-14 " + + "data for the subset, and CmapTable.Serialize unconditionally drops format-14 subtables."); + Assert.AreNotEqual(subsetPlain.Glyphs[0].GlyphId, subsetSequence.Glyphs[0].GlyphId, + "The render path (round-tripped subset) must select a DIFFERENT glyph for the variation " + + "sequence than for plain 'A' - matching what the measurement path (full font) does above."); + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType.Tests/Tables/Cmap/CmapVariationSequenceTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Tables/Cmap/CmapVariationSequenceTests.cs new file mode 100644 index 0000000000..0f8b25dd52 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/Tables/Cmap/CmapVariationSequenceTests.cs @@ -0,0 +1,147 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 09/09/2026 EPPlus Software AB Unicode Variation Sequence lookup tests + *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Tables.Cmap; +using EPPlus.Fonts.OpenType.Tables.Cmap.Mappings; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; + +namespace EPPlus.Fonts.OpenType.Tests.Tables.Cmap +{ + /// + /// Tests the new CmapTable.TryGetGlyphId(uint baseCodePoint, uint variationSelector, out ushort glyphId) + /// overload, which looks a (base char, variation selector) pair up against the format-14 + /// subtable that is already parsed but currently never consulted. + /// + /// All tables here are built by hand so the tests are deterministic and don't depend on any + /// specific font shipping real Unicode Variation Sequence data. + /// + [TestClass] + public class CmapVariationSequenceTests + { + public TestContext? TestContext { get; set; } + + // Variation selectors (see Unicode Standard Annex #38 / ISO 10646). + private const uint Vs01 = 0xFE00; // VARIATION SELECTOR-1 (BMP) + private const uint Vs17 = 0xE0100; // VARIATION SELECTOR-17 (supplementary plane) + private const uint UnknownSelector = 0xFE0F; // VARIATION SELECTOR-16 - not registered in our synthetic table + + // Arbitrary CJK base characters used only as stand-ins in the synthetic table. + private const uint BaseCharNonDefault = 0x4E00; // 一 + private const uint BaseCharDefault = 0x4E01; // 丁 + private const uint BaseCharUnmapped = 0x4E02; // 丂 - not registered under any selector + + private const ushort NonDefaultGlyphId = 500; + private const ushort DefaultCmapGlyphId = 42; + + /// + /// Builds a CmapTable with an ordinary Format 4 subtable (for the "default UVS" fallback + /// to have something to fall back to) plus a Format 14 subtable registering: + /// - BaseCharNonDefault + Vs01 -> explicit override glyph (NonDefaultUvsTable) + /// - BaseCharDefault + Vs17 -> "use the base character's ordinary glyph" (DefaultUvsTable) + /// + private static CmapTable BuildSyntheticCmapTable() + { + var cmap = new CmapTable(); + + var baseMapping = new Dictionary { { BaseCharDefault, DefaultCmapGlyphId } }; + cmap.SubTables.Add(CmapFormat4.CreateFromMappings(baseMapping)); + + var subtable14 = new CmapSubtable14(); + + var nonDefaultSelector = new VariationSelector + { + VarSelector = Vs01, + NonDefaultUvsTable = new NonDefaultUvsTable + { + Mappings = new List + { + new UvsMapping { UnicodeValue = BaseCharNonDefault, GlyphId = NonDefaultGlyphId } + } + } + }; + subtable14.VariationSelectors.Add(nonDefaultSelector); + + var defaultSelector = new VariationSelector + { + VarSelector = Vs17, + DefaultUvsTable = new DefaultUvsTable + { + Ranges = new List + { + new UnicodeRange { StartUnicodeValue = BaseCharDefault, AdditionalCount = 0 } + } + } + }; + subtable14.VariationSelectors.Add(defaultSelector); + + cmap.SubTables.Add(subtable14); + + return cmap; + } + + [TestMethod] + public void TryGetGlyphId_NonDefaultUvs_ReturnsExplicitOverrideGlyph() + { + var cmap = BuildSyntheticCmapTable(); + + bool found = cmap.TryGetGlyphId(BaseCharNonDefault, Vs01, out ushort glyphId); + + Assert.IsTrue(found, "A registered non-default variation sequence should resolve."); + Assert.AreEqual(NonDefaultGlyphId, glyphId); + } + + [TestMethod] + public void TryGetGlyphId_DefaultUvs_FallsBackToOrdinaryCmapGlyph() + { + var cmap = BuildSyntheticCmapTable(); + + bool found = cmap.TryGetGlyphId(BaseCharDefault, Vs17, out ushort glyphId); + + Assert.IsTrue(found, "A registered default variation sequence should still resolve (it's a valid, known sequence)."); + Assert.AreEqual(DefaultCmapGlyphId, glyphId, + "Default UVS entries don't carry their own glyph id - they mean 'use the base character's ordinary cmap glyph'."); + } + + [TestMethod] + public void TryGetGlyphId_SelectorRegisteredButBaseCharIsNot_ReturnsFalse() + { + var cmap = BuildSyntheticCmapTable(); + + bool found = cmap.TryGetGlyphId(BaseCharUnmapped, Vs01, out ushort glyphId); + + Assert.IsFalse(found, "A base character absent from both the default and non-default UVS tables is not a registered sequence."); + Assert.AreEqual(0, glyphId); + } + + [TestMethod] + public void TryGetGlyphId_UnknownVariationSelector_ReturnsFalse() + { + var cmap = BuildSyntheticCmapTable(); + + bool found = cmap.TryGetGlyphId(BaseCharNonDefault, UnknownSelector, out ushort glyphId); + + Assert.IsFalse(found, "A variation selector with no entry at all in the format-14 subtable is not a registered sequence."); + } + + [TestMethod] + public void TryGetGlyphId_NoFormat14Subtable_ReturnsFalse() + { + var cmap = new CmapTable(); + cmap.SubTables.Add(CmapFormat4.CreateFromMappings(new Dictionary { { BaseCharDefault, DefaultCmapGlyphId } })); + + bool found = cmap.TryGetGlyphId(BaseCharDefault, Vs01, out ushort glyphId); + + Assert.IsFalse(found, "Without a format-14 subtable there is no Unicode Variation Sequence data to consult at all."); + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/Ligatures/LigatureFeatureTagTests.cs b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/Ligatures/LigatureFeatureTagTests.cs index da108f1f7f..f7a1e31489 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/Ligatures/LigatureFeatureTagTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/Ligatures/LigatureFeatureTagTests.cs @@ -10,6 +10,7 @@ Date Author Change ************************************************************************************************* 09/07/2026 EPPlus Software AB Ligature feature-tag plumbing (WP3/WP4) *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Tables; using EPPlus.Fonts.OpenType.Tables.Common.Layout.Coverage; using EPPlus.Fonts.OpenType.Tables.Common.Layout.Features; using EPPlus.Fonts.OpenType.Tables.Common.Layout.Lookups; @@ -122,7 +123,7 @@ private static GsubTable BuildDligOnlyGsubTable() var lookup = new LookupTable { LookupType = 4, - SubTables = new List { ligatureSubtable } + SubTables = new List { ligatureSubtable } }; var featureList = new FeatureListTable @@ -164,7 +165,7 @@ private static GsubTable BuildExtensionWrappedLigaGsubTable() var lookup = new LookupTable { LookupType = 7, - SubTables = new List { extensionWrapper } + SubTables = new List { extensionWrapper } }; var featureList = new FeatureListTable diff --git a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ScriptAwareFeatureLookupTests.cs b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ScriptAwareFeatureLookupTests.cs index aeb38d7820..af105ca900 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ScriptAwareFeatureLookupTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ScriptAwareFeatureLookupTests.cs @@ -10,6 +10,7 @@ Date Author Change ************************************************************************************************* 09/07/2026 EPPlus Software AB Script-aware feature lookup *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Tables; using EPPlus.Fonts.OpenType.Tables.Common.Layout.Coverage; using EPPlus.Fonts.OpenType.Tables.Common.Layout.Features; using EPPlus.Fonts.OpenType.Tables.Common.Layout.Lookups; @@ -114,8 +115,8 @@ private static GposTable BuildSyntheticGposTable() { Lookups = new List { - new LookupTable { LookupType = 1, SubTables = new List { arabSubtable } }, - new LookupTable { LookupType = 1, SubTables = new List { latinSubtable } } + new LookupTable { LookupType = 1, SubTables = new List { arabSubtable } }, + new LookupTable { LookupType = 1, SubTables = new List { latinSubtable } } } }; diff --git a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ScriptAwareGsubTests.cs b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ScriptAwareGsubTests.cs index 2112baa355..a4d130d454 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ScriptAwareGsubTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ScriptAwareGsubTests.cs @@ -10,6 +10,7 @@ Date Author Change ************************************************************************************************* 09/07/2026 EPPlus Software AB Script-aware feature lookup *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Tables; using EPPlus.Fonts.OpenType.Tables.Common.Layout.Coverage; using EPPlus.Fonts.OpenType.Tables.Common.Layout.Features; using EPPlus.Fonts.OpenType.Tables.Common.Layout.Lookups; @@ -143,11 +144,11 @@ private static GsubTable BuildLigatureGsubTable() } }; - var latinLookup = new LookupTable { LookupType = 4, SubTables = new List() }; + var latinLookup = new LookupTable { LookupType = 4, SubTables = new List() }; var arabLookup = new LookupTable { LookupType = 4, - SubTables = new List { arabLigature } + SubTables = new List { arabLigature } }; return new GsubTable @@ -185,16 +186,16 @@ private static GsubTable BuildChainingContextualGsubTable() } }; - var latinLookup = new LookupTable { LookupType = 6, SubTables = new List() }; + var latinLookup = new LookupTable { LookupType = 6, SubTables = new List() }; var arabLookup = new LookupTable { LookupType = 6, - SubTables = new List { contextualRule } + SubTables = new List { contextualRule } }; var singleSubstLookup = new LookupTable { LookupType = 1, - SubTables = new List { singleSubst } + SubTables = new List { singleSubst } }; return new GsubTable @@ -258,7 +259,7 @@ private static GsubTable BuildExtensionWrappedSingleSubstGsubTable() var lookup = new LookupTable { LookupType = 7, - SubTables = new List { extensionWrapper } + SubTables = new List { extensionWrapper } }; var featureList = new FeatureListTable @@ -293,11 +294,11 @@ private static GsubTable BuildSingleSubstGsubTable() SubstituteGlyphIDs = new ushort[] { SubstituteGlyph } }; - var latinLookup = new LookupTable { LookupType = 1, SubTables = new List() }; + var latinLookup = new LookupTable { LookupType = 1, SubTables = new List() }; var arabLookup = new LookupTable { LookupType = 1, - SubTables = new List { arabSubst } + SubTables = new List { arabSubst } }; return new GsubTable diff --git a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ScriptAwareKerningAndMarkTests.cs b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ScriptAwareKerningAndMarkTests.cs index 0af10c8eaa..8e4e15cf97 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ScriptAwareKerningAndMarkTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ScriptAwareKerningAndMarkTests.cs @@ -10,6 +10,7 @@ Date Author Change ************************************************************************************************* 09/07/2026 EPPlus Software AB Script-aware feature lookup *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Tables; using EPPlus.Fonts.OpenType.Tables.Common.Layout.Coverage; using EPPlus.Fonts.OpenType.Tables.Common.Layout.Features; using EPPlus.Fonts.OpenType.Tables.Common.Layout.Lookups; @@ -133,8 +134,8 @@ private static GposTable BuildKerningGposTable() { Lookups = new List { - new LookupTable { LookupType = 2, SubTables = new List { latinPair } }, - new LookupTable { LookupType = 2, SubTables = new List { arabPair } } + new LookupTable { LookupType = 2, SubTables = new List { latinPair } }, + new LookupTable { LookupType = 2, SubTables = new List { arabPair } } } }; @@ -226,8 +227,8 @@ private static GposTable BuildMarkGposTable() { Lookups = new List { - new LookupTable { LookupType = 4, SubTables = new List { latinMark } }, - new LookupTable { LookupType = 4, SubTables = new List { arabMark } } + new LookupTable { LookupType = 4, SubTables = new List { latinMark } }, + new LookupTable { LookupType = 4, SubTables = new List { arabMark } } } }; diff --git a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/VariationSequenceShapingTests.cs b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/VariationSequenceShapingTests.cs new file mode 100644 index 0000000000..a44bed25cf --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/VariationSequenceShapingTests.cs @@ -0,0 +1,140 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 09/09/2026 EPPlus Software AB Unicode Variation Sequence shaping tests + *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Tables.Cmap; +using EPPlus.Fonts.OpenType.TextShaping; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml.Interfaces.Fonts; +using System.Collections.Generic; + +namespace EPPlus.Fonts.OpenType.Tests.TextShaping +{ + /// + /// Verifies that TextShaper actually consults cmap format 14 (Unicode Variation Sequences) + /// during shaping - today the format-14 subtable is parsed but MapToGlyphs never looks at it. + /// + /// The variation-sequence data is injected synthetically into a real, otherwise-unmodified + /// test font, so the tests don't depend on any specific font shipping real UVS data. + /// + /// IMPORTANT: TestFolderEngine.LoadFont caches and freezes (IsReadOnly) the fonts it returns, + /// and that cache is shared across the whole test run. These tests load with ignoreCache: true + /// so the synthetic format-14 subtable is only ever added to a private, per-test font instance. + /// + [TestClass] + public class VariationSequenceShapingTests : FontTestBase + { + public override TestContext? TestContext { get; set; } + + private const uint Vs01 = 0xFE00; // VARIATION SELECTOR-1 (BMP, 1 UTF-16 char) + private const uint Vs17Supplementary = 0xE0100; // VARIATION SELECTOR-17 (supplementary plane, surrogate pair) + + /// + /// Loads a private (non-cached) instance of Roboto and adds a synthetic format-14 + /// subtable registering 'A' + Vs01 and 'A' + Vs17Supplementary as variation sequences + /// that both resolve to the glyph 'B' already maps to (a visibly different, existing + /// glyph). 'B' itself is left completely untouched. + /// + private OpenTypeFont LoadFontWithSyntheticVariationSequence(out ushort glyphIdA, out ushort glyphIdB) + { + var font = TestFolderEngine.LoadFont("Roboto", FontSubFamily.Regular, true); + + Assert.IsTrue(font.CmapTable.TryGetGlyphId('A', out glyphIdA), "Test font is expected to contain 'A'."); + Assert.IsTrue(font.CmapTable.TryGetGlyphId('B', out glyphIdB), "Test font is expected to contain 'B'."); + Assert.AreNotEqual(glyphIdA, glyphIdB, "Test relies on 'A' and 'B' mapping to different glyphs."); + + var subtable14 = new CmapSubtable14(); + + subtable14.VariationSelectors.Add(new VariationSelector + { + VarSelector = Vs01, + NonDefaultUvsTable = new NonDefaultUvsTable + { + Mappings = new List + { + new UvsMapping { UnicodeValue = 'A', GlyphId = glyphIdB } + } + } + }); + + subtable14.VariationSelectors.Add(new VariationSelector + { + VarSelector = Vs17Supplementary, + NonDefaultUvsTable = new NonDefaultUvsTable + { + Mappings = new List + { + new UvsMapping { UnicodeValue = 'A', GlyphId = glyphIdB } + } + } + }); + + font.CmapTable.SubTables.Add(subtable14); + + return font; + } + + [TestMethod] + public void Shape_BaseCharPlusBmpVariationSelector_ConsumesBothIntoOneVariantGlyph() + { + var font = LoadFontWithSyntheticVariationSequence(out ushort glyphIdA, out ushort glyphIdB); + var shaper = new TextShaper(TestFolderEngine, font); + + var shaped = shaper.Shape("A\uFE00"); + + Assert.AreEqual(1, shaped.Glyphs.Length, "The base char and the variation selector should collapse into a single glyph."); + Assert.AreEqual(glyphIdB, shaped.Glyphs[0].GlyphId, "Should resolve to the variant glyph registered in cmap format 14, not glyphIdA."); + Assert.AreEqual(2, shaped.Glyphs[0].CharCount, "Should consume both UTF-16 chars (1-char base + 1-char selector)."); + Assert.AreEqual(0, shaped.Glyphs[0].ClusterIndex); + } + + [TestMethod] + public void Shape_BaseCharPlusSupplementaryPlaneVariationSelector_ConsumesAllThreeChars() + { + var font = LoadFontWithSyntheticVariationSequence(out ushort glyphIdA, out ushort glyphIdB); + var shaper = new TextShaper(TestFolderEngine, font); + + string selector = char.ConvertFromUtf32((int)Vs17Supplementary); // 2 UTF-16 chars (surrogate pair) + var shaped = shaper.Shape("A" + selector); + + Assert.AreEqual(1, shaped.Glyphs.Length, "The base char and the surrogate-pair selector should collapse into a single glyph."); + Assert.AreEqual(glyphIdB, shaped.Glyphs[0].GlyphId); + Assert.AreEqual(3, shaped.Glyphs[0].CharCount, "Should consume base (1 char) + selector (2 chars, surrogate pair)."); + } + + [TestMethod] + public void Shape_UnregisteredBaseCharPlusVariationSelector_DoesNotConsumeSelector() + { + var font = LoadFontWithSyntheticVariationSequence(out ushort glyphIdA, out ushort glyphIdB); + var shaper = new TextShaper(TestFolderEngine, font); + + // 'B' has no entry under Vs01 in our synthetic table, so this sequence isn't registered. + var shaped = shaper.Shape("B\uFE00"); + + Assert.AreEqual(2, shaped.Glyphs.Length, "An unregistered sequence must fall back to two separate glyphs, not be silently consumed."); + Assert.AreEqual(glyphIdB, shaped.Glyphs[0].GlyphId, "'B' should still resolve to its own ordinary glyph."); + Assert.AreEqual(1, shaped.Glyphs[0].CharCount); + } + + [TestMethod] + public void Shape_PlainCharWithoutSelector_IsUnaffected() + { + var font = LoadFontWithSyntheticVariationSequence(out ushort glyphIdA, out ushort glyphIdB); + var shaper = new TextShaper(TestFolderEngine, font); + + var shaped = shaper.Shape("A"); + + Assert.AreEqual(1, shaped.Glyphs.Length); + Assert.AreEqual(glyphIdA, shaped.Glyphs[0].GlyphId, "Without a following selector, 'A' must still map to its own glyph."); + Assert.AreEqual(1, shaped.Glyphs[0].CharCount); + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/Subsetting/CmapSubsetProcessor.cs b/src/EPPlus.Fonts.OpenType/Subsetting/CmapSubsetProcessor.cs index 8296d95b82..d11326ab21 100644 --- a/src/EPPlus.Fonts.OpenType/Subsetting/CmapSubsetProcessor.cs +++ b/src/EPPlus.Fonts.OpenType/Subsetting/CmapSubsetProcessor.cs @@ -39,6 +39,42 @@ public void Discover(FontSubsettingContext context) } } + // --- Unicode Variation Sequences (cmap format 14) --- + // context.UsedCodePoints is a flat set of code points with no notion of "this selector + // followed this base char in the text" - CodePointUtil.ExtractCodePoints just decodes + // UTF-16 to scalar values, so a base char and a variation selector that appeared + // together in the text are indistinguishable here from two that never did. So for every + // variation selector actually present in the used code points, every OTHER used code + // point is checked against the original font's format-14 table, and any pair that IS + // registered there is kept. A pair that's registered in the font but never actually + // adjacent in the real text is a harmless false positive - a few extra bytes/glyphs in + // the subset - because TextShaper only ever looks up a pair it finds truly adjacent, so + // an over-included pair is simply never queried at render time. + var subtable14 = FindFormat14Subtable(context.OriginalFont); + if (subtable14 != null) + { + foreach (var selector in subtable14.VariationSelectors) + { + if (!context.UsedCodePoints.Contains(selector.VarSelector)) + continue; + + foreach (uint baseCodePoint in context.UsedCodePoints) + { + if (baseCodePoint == selector.VarSelector) + continue; + + ushort variantGid; + if (context.OriginalFont.CmapTable.TryGetGlyphId(baseCodePoint, selector.VarSelector, out variantGid)) + { + if (!context.IncludedGlyphs.Contains(variantGid)) + { + context.IncludedGlyphs.Add(variantGid); + } + } + } + } + } + // Ensure GID 0 (.notdef) is always included if (!context.IncludedGlyphs.Contains(0)) { @@ -121,9 +157,113 @@ public void Rewrite(FontSubsettingContext context) newCmap.NumTables = 2; } + // --- Unicode Variation Sequences (cmap format 14) --- + // Preserve the (base, selector) pairs that Discover found registered in the original + // font and that are actually used in this subset, remapped to the subset's new glyph + // IDs. Without this, TextShaper's format-14 lookahead (which runs against whichever + // font it's actually shaping - full or subset) would silently fall back to the base + // character's default glyph when shaping against the embedded subset, even though the + // full font correctly picked a variant. + var originalSubtable14 = FindFormat14Subtable(context.OriginalFont); + if (originalSubtable14 != null) + { + var newSubtable14 = BuildSubsetFormat14Subtable(originalSubtable14, context); + if (newSubtable14 != null) + { + // (0,5) - Unicode Variation Sequences: the platform/encoding combination the + // OpenType spec registers for format 14. + EncodingRecord uvsRecord = new EncodingRecord(Platforms.Unicode, 5, 0); + uvsRecord.Subtable = newSubtable14; + newCmap.EncodingRecords.Add(uvsRecord); + newCmap.SubTables.Add(newSubtable14); + newCmap.NumTables++; + } + } + context.SubsetFont.AddOrReplaceTable(newCmap); } + private static CmapSubtable14 FindFormat14Subtable(OpenTypeFont font) + { + foreach (var subtable in font.CmapTable.SubTables) + { + if (subtable.Format == 14) + return subtable as CmapSubtable14; + } + return null; + } + + /// + /// Rebuilds a format-14 subtable containing only the variation selectors, base characters + /// and glyph IDs that are both registered in AND actually + /// present in this subset's used code points / retained glyph mapping. Returns null if + /// nothing survives the filter (e.g. the text used no variation sequences at all). + /// + private CmapSubtable14 BuildSubsetFormat14Subtable(CmapSubtable14 original, FontSubsettingContext context) + { + var newSubtable14 = new CmapSubtable14(); + + foreach (var selector in original.VariationSelectors) + { + if (!context.UsedCodePoints.Contains(selector.VarSelector)) + continue; + + NonDefaultUvsTable newNonDefault = null; + if (selector.NonDefaultUvsTable != null) + { + foreach (var mapping in selector.NonDefaultUvsTable.Mappings) + { + ushort newGid; + if (context.UsedCodePoints.Contains(mapping.UnicodeValue) && + context.OldToNewGlyphId.TryGetValue(mapping.GlyphId, out newGid)) + { + if (newNonDefault == null) + newNonDefault = new NonDefaultUvsTable { Mappings = new List() }; + + newNonDefault.Mappings.Add(new UvsMapping { UnicodeValue = mapping.UnicodeValue, GlyphId = newGid }); + } + } + } + + DefaultUvsTable newDefault = null; + if (selector.DefaultUvsTable != null) + { + foreach (var range in selector.DefaultUvsTable.Ranges) + { + // A default-UVS range can span many code points; only the ones actually used + // in this subset are kept, each re-emitted as its own single-value range + // (AdditionalCount = 0). This produces more, smaller ranges than the original + // font might use, but keeps the logic simple and correct - re-compacting + // adjacent surviving code points back into wider ranges isn't worth the + // complexity here. + uint rangeEnd = range.StartUnicodeValue + (uint)range.AdditionalCount; + for (uint cp = range.StartUnicodeValue; cp <= rangeEnd; cp++) + { + if (context.UsedCodePoints.Contains(cp)) + { + if (newDefault == null) + newDefault = new DefaultUvsTable { Ranges = new List() }; + + newDefault.Ranges.Add(new UnicodeRange { StartUnicodeValue = cp, AdditionalCount = 0 }); + } + } + } + } + + if (newNonDefault == null && newDefault == null) + continue; + + newSubtable14.VariationSelectors.Add(new VariationSelector + { + VarSelector = selector.VarSelector, + NonDefaultUvsTable = newNonDefault, + DefaultUvsTable = newDefault + }); + } + + return newSubtable14.VariationSelectors.Count == 0 ? null : newSubtable14; + } + private CmapSubtable12 CreateFormat12Subtable(Dictionary mapping) { var subtable = new CmapSubtable12(); diff --git a/src/EPPlus.Fonts.OpenType/Tables/Cmap/CmapTable.cs b/src/EPPlus.Fonts.OpenType/Tables/Cmap/CmapTable.cs index 3790758cff..d1fae6dba2 100644 --- a/src/EPPlus.Fonts.OpenType/Tables/Cmap/CmapTable.cs +++ b/src/EPPlus.Fonts.OpenType/Tables/Cmap/CmapTable.cs @@ -70,29 +70,40 @@ internal override void SerializeInternal(FontsBinaryWriter writer, FontSerializa writer.Write(new byte[8]); // placeholder } - // Precompute offsets for unique subtables + // Precompute offsets for unique subtables. Deduplication is keyed by the SUBTABLE + // INSTANCE, not by SubtableOffset: for a freshly-built cmap (e.g. a subset's cmap), + // every EncodingRecord starts out with the same placeholder SubtableOffset (0), so + // keying on that value would wrongly alias two DIFFERENT subtables (e.g. a subset's + // format 4 and format 12 tables) onto the same bytes the first time this ran with two + // fresh tables sharing that placeholder. Keying by the actual object reference only + // dedups encoding records that genuinely point at the SAME subtable (e.g. (3,1) and + // (0,3) both referencing one shared Unicode BMP subtable), which is what this is for. var subtableOffsetsMap = new Dictionary(); var subTableStartIndex = writer.BaseStream.Position; var encRecordsToSerialize = EncodingRecords.OrderBy(er => er.SubtableOffset); - var usedSubtables = new Dictionary(); - foreach(var encRecord in encRecordsToSerialize) + foreach (var encRecord in encRecordsToSerialize) { - // Skip format 14 and any explicitly marked skipped records - if (encRecord.IsSkipped || (encRecord.Subtable?.Format == 14)) + // Skip any explicitly marked skipped records. Format 14 (Unicode Variation + // Sequences) subtables ARE serialized like any other format now - they used to be + // unconditionally dropped here, which silently discarded variation-sequence data + // from every embedded font. + if (encRecord.IsSkipped) { continue; } - if (usedSubtables.ContainsKey(encRecord.SubtableOffset)) + uint existingOffset; + if (encRecord.Subtable != null && subtableOffsetsMap.TryGetValue(encRecord.Subtable, out existingOffset)) { - encRecord.SubtableOffset = usedSubtables[encRecord.SubtableOffset]; + encRecord.SubtableOffset = existingOffset; continue; } var subTableBytes = encRecord.Subtable.Serialize(); writer.Write(subTableBytes); - usedSubtables.Add(encRecord.SubtableOffset, (uint)subTableStartIndex); + if (encRecord.Subtable != null) + subtableOffsetsMap[encRecord.Subtable] = (uint)subTableStartIndex; encRecord.SubtableOffset = (uint)subTableStartIndex; subTableStartIndex += subTableBytes.Length; - + } // Go back and write encoding records with correct offsets @@ -219,7 +230,75 @@ public bool TryGetGlyphId(uint codePoint, out ushort glyphId) return false; } + /// + /// Looks up a Unicode Variation Sequence - a (base character, variation selector) pair - + /// against the font's cmap format 14 subtable (Unicode Variation Sequences, see the + /// OpenType spec's "Format 14" section). Returns true only if the sequence is actually + /// registered in the font: + /// - a "non-default" entry supplies an explicit override glyph for the base character, or + /// - a "default" entry means the sequence is registered but carries no glyph of its own - + /// the base character's ordinary glyph (as + /// would return) should be used. + /// Returns false when there is no format 14 subtable at all, the variation selector isn't + /// registered in it, or the selector is registered but this particular base character is not + /// listed under it. In every false case the pair is not a known variation sequence, and the + /// caller should fall back to treating the base character on its own. + /// + public bool TryGetGlyphId(uint baseCodePoint, uint variationSelector, out ushort glyphId) + { + glyphId = 0; + + CmapSubtable14 subtable14 = null; + foreach (var subtable in SubTables) + { + if (subtable.Format == 14) + { + subtable14 = subtable as CmapSubtable14; + break; + } + } + if (subtable14 == null) + return false; + + foreach (var selector in subtable14.VariationSelectors) + { + if (selector.VarSelector != variationSelector) + continue; + + if (selector.NonDefaultUvsTable != null) + { + foreach (var mapping in selector.NonDefaultUvsTable.Mappings) + { + if (mapping.UnicodeValue == baseCodePoint) + { + glyphId = mapping.GlyphId; + return true; + } + } + } + + if (selector.DefaultUvsTable != null) + { + foreach (var range in selector.DefaultUvsTable.Ranges) + { + uint rangeEnd = range.StartUnicodeValue + (uint)range.AdditionalCount; + if (baseCodePoint >= range.StartUnicodeValue && baseCodePoint <= rangeEnd) + { + // A "default" entry carries no glyph of its own - it just confirms the + // sequence is registered, so fall back to the base character's ordinary glyph. + return TryGetGlyphId(baseCodePoint, out glyphId); + } + } + } + + // The selector itself is registered in this font, but this base character is not + // listed under it in either table - not a known sequence. + return false; + } + // No entry at all for this variation selector. + return false; + } public CmapSubtableBase GetPreferredSubtable() { @@ -275,4 +354,4 @@ public ushort GetGlyphId(char ch) return gid; } } -} +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/Tables/Cmap/CmapTableLoader.cs b/src/EPPlus.Fonts.OpenType/Tables/Cmap/CmapTableLoader.cs index 471d2288b3..2e10794821 100644 --- a/src/EPPlus.Fonts.OpenType/Tables/Cmap/CmapTableLoader.cs +++ b/src/EPPlus.Fonts.OpenType/Tables/Cmap/CmapTableLoader.cs @@ -94,20 +94,10 @@ protected override CmapTable LoadInternal() break; case 14: - // Skip format 14 (Unicode Variation Sequences) - var dummySubtable = new CmapSubtable14(); - enc.IsSkipped = true; - enc.Subtable = dummySubtable; - subtableCache[enc.SubtableOffset] = dummySubtable; - - _reader.BaseStream.Position = currentPos + 6; - uint length = _reader.ReadUInt32BigEndian(); - long nextTablePos = currentPos + length; - if (nextTablePos > _reader.BaseStream.Length || nextTablePos < currentPos) - { - nextTablePos = _reader.BaseStream.Length; - } - _reader.BaseStream.Position = nextTablePos; + var sub14 = new CmapSubtable14Deserializer(_reader).Deserialize(currentPos); + table.SubTables.Add(sub14); + subtableCache[enc.SubtableOffset] = sub14; + enc.Subtable = sub14; break; default: @@ -119,4 +109,4 @@ protected override CmapTable LoadInternal() return table; } } -} +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/Tables/Cmap/Serialization/CmapSubtable14Deserializer.cs b/src/EPPlus.Fonts.OpenType/Tables/Cmap/Serialization/CmapSubtable14Deserializer.cs index 70880f90dc..79054b2266 100644 --- a/src/EPPlus.Fonts.OpenType/Tables/Cmap/Serialization/CmapSubtable14Deserializer.cs +++ b/src/EPPlus.Fonts.OpenType/Tables/Cmap/Serialization/CmapSubtable14Deserializer.cs @@ -54,6 +54,12 @@ internal CmapSubtable14 Deserialize(uint startIndex) uint defaultUVSOffset = _reader.ReadUInt32BigEndian(); uint nonDefaultUVSOffset = _reader.ReadUInt32BigEndian(); + // The selector records are a contiguous sequential array, but reading a UVS table + // below seeks elsewhere in the subtable (its data lives after all the records). + // Save the position right after THIS record - where the NEXT selector's record + // begins - so it can be restored once this selector's tables have been read. + long nextSelectorRecordPosition = _reader.BaseStream.Position; + var selector = new VariationSelector { VarSelector = varSelector, @@ -102,9 +108,14 @@ internal CmapSubtable14 Deserialize(uint startIndex) } subtable.VariationSelectors.Add(selector); + + // Restore the position to right after this selector's own record, so the next + // loop iteration reads the next selector's record instead of whatever happens to + // be at the tail end of this selector's UVS table data. + _reader.BaseStream.Position = nextSelectorRecordPosition; } return subtable; } } -} +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs b/src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs index 338b9e9659..4e51a7c197 100644 --- a/src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs +++ b/src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs @@ -317,44 +317,40 @@ private List MapToGlyphs(string text) { uint codePoint; int charCount; + DecodeCodePoint(text, i, out codePoint, out charCount); - // Check if this is a surrogate pair - if (i < text.Length - 1 && char.IsHighSurrogate(text[i])) + // Use font provider to find glyph (with fallback support) + OpenTypeFont font; + ushort glyphId; + _fontProvider.TryGetGlyphFont(codePoint, out font, out glyphId); + + // Unicode Variation Sequence lookahead: if the next code point is a variation + // selector, check whether (codePoint, selector) is a registered sequence in the + // SAME font that resolved the base character. Variation sequences are font-specific + // data (cmap format 14), so this is checked directly against that font's own + // CmapTable rather than routed back through the fallback provider. + int nextIndex = i + charCount; + if (nextIndex < text.Length) { - // Potential surrogate pair: 2 chars → 1 Unicode code point - char high = text[i]; - char low = text[i + 1]; + uint nextCodePoint; + int nextCharCount; + DecodeCodePoint(text, nextIndex, out nextCodePoint, out nextCharCount); - if (char.IsLowSurrogate(low)) - { - // Valid pair - convert to code point - codePoint = (uint)char.ConvertToUtf32(high, low); - charCount = 2; - } - else + if (IsVariationSelector(nextCodePoint)) { - // Invalid surrogate pair - treat as .notdef and skip high surrogate - codePoint = 0; - charCount = 1; + ushort variantGlyphId; + if (font.CmapTable.TryGetGlyphId(codePoint, nextCodePoint, out variantGlyphId)) + { + // Registered sequence: consume both the base character and the + // selector into this single glyph. + glyphId = variantGlyphId; + charCount += nextCharCount; + } + // Not a registered sequence: leave the selector unconsumed - it is mapped on + // its own in the next loop iteration (normally to .notdef, since variation + // selectors have no ordinary cmap entry of their own). } } - else if (char.IsSurrogate(text[i])) - { - // Lone surrogate (invalid) - treat as .notdef - codePoint = 0; - charCount = 1; - } - else - { - // Normal BMP character - codePoint = text[i]; - charCount = 1; - } - - // Use font provider to find glyph (with fallback support) - OpenTypeFont font; - ushort glyphId; - _fontProvider.TryGetGlyphFont(codePoint, out font, out glyphId); // Get font ID for multi-font tracking byte fontId = GetOrRegisterFontId(font); @@ -382,6 +378,58 @@ private List MapToGlyphs(string text) return glyphs; } + /// + /// Decodes the Unicode code point starting at , correctly combining + /// a valid UTF-16 surrogate pair into a single supplementary-plane code point. Lone/invalid + /// surrogates decode as .notdef (code point 0) and consume 1 char - matching MapToGlyphs' + /// original surrogate handling exactly, so this is a pure refactor of that logic, reusable + /// for lookahead. + /// + private static void DecodeCodePoint(string text, int index, out uint codePoint, out int charCount) + { + if (index < text.Length - 1 && char.IsHighSurrogate(text[index])) + { + char high = text[index]; + char low = text[index + 1]; + + if (char.IsLowSurrogate(low)) + { + // Valid pair - convert to code point + codePoint = (uint)char.ConvertToUtf32(high, low); + charCount = 2; + } + else + { + // Invalid surrogate pair - treat as .notdef and skip high surrogate + codePoint = 0; + charCount = 1; + } + } + else if (char.IsSurrogate(text[index])) + { + // Lone surrogate (invalid) - treat as .notdef + codePoint = 0; + charCount = 1; + } + else + { + // Normal BMP character + codePoint = text[index]; + charCount = 1; + } + } + + /// + /// True if is a Unicode variation selector - either in the BMP + /// block (U+FE00-FE0F) or the supplementary-plane block (U+E0100-E01EF, always encoded as a + /// surrogate pair in UTF-16). + /// + private static bool IsVariationSelector(uint codePoint) + { + return (codePoint >= 0xFE00 && codePoint <= 0xFE0F) + || (codePoint >= 0xE0100 && codePoint <= 0xE01EF); + } + #endregion #region Phase 2: GSUB Substitutions