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/LigatureDligReproTests.cs b/src/EPPlus.Export.Pdf.Tests/LigatureDligReproTests.cs index a5ae7b1e8c..bdb2ad147e 100644 --- a/src/EPPlus.Export.Pdf.Tests/LigatureDligReproTests.cs +++ b/src/EPPlus.Export.Pdf.Tests/LigatureDligReproTests.cs @@ -65,8 +65,8 @@ public void Th_EBGaramond48_DligOff_DoesNotLigate() var sheet = BuildReproSheet(workbook); - // Default GsubFeatures is Liga | Clig - "dlig" is not requested, so this is the - // "before" state: "Th" renders as two separate glyphs. + // Default GsubFeatures is Liga | Clig | Calt - "dlig" is not requested, so this + // is the "before" state: "Th" renders as two separate glyphs. SaveAsPdf(sheet, "WP3DligRepro_Off"); SaveWorkbook("WP3DligRepro_Off.xlsx", package); diff --git a/src/EPPlus.Export.Pdf.Tests/MultipleSubstitutionReproTests.cs b/src/EPPlus.Export.Pdf.Tests/MultipleSubstitutionReproTests.cs new file mode 100644 index 0000000000..1d05cb028d --- /dev/null +++ b/src/EPPlus.Export.Pdf.Tests/MultipleSubstitutionReproTests.cs @@ -0,0 +1,219 @@ +/************************************************************************************************* + 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 GSUB Multiple Substitution visual repro sheet + *************************************************************************************************/ +using EPPlus.Export.Pdf.Settings; +using EPPlus.Export.Pdf.Tests; +using EPPlus.Fonts.OpenType; +using EPPlus.Fonts.OpenType.Tables; +using EPPlus.Fonts.OpenType.Tables.Common.Layout.Coverage; +using EPPlus.Fonts.OpenType.Tables.Common.Layout.Lookups; +using EPPlus.Fonts.OpenType.Tables.Common.Layout.Scripts; +using EPPlus.Fonts.OpenType.Tables.Gsub.Data.Lookups; +using OfficeOpenXml; +using OfficeOpenXml.Export.PdfExport; +using OfficeOpenXml.Interfaces.Fonts; +using OfficeOpenXml.Style; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace EPPlus.Export.Pdf.Tests +{ + /// + /// VISUAL repro for GSUB Lookup Type 2 (Multiple Substitution) actually being applied during + /// shaping and preserved through subsetting/embedding - not an automated regression test, + /// the point is to look at the page. The unit tests already cover the automated side. + /// + /// A synthetic Multiple Substitution lookup is appended to Roboto's own existing "liga" + /// feature: the digit '5' is made to expand into the glyphs for 'H' and 'i'. That's a + /// deliberately unmistakable effect - if it works, a cell containing the single character + /// "5" should render as "Hi", identical in appearance to a cell where "Hi" was typed + /// directly. + /// + /// What to look for in the exported PDF: + /// + /// A2 "Hi" Typed directly, two ordinary characters - the reference. + /// A3 "5" A single digit in the underlying cell text. If Multiple Substitution works + /// end to end (shaping -> subsetting -> embedding), this renders identically to + /// A2. If it silently falls back, this renders as a plain "5". + /// + [TestClass] + public class MultipleSubstitutionReproTests : PdfTestBase + { + private static string FontsFolder => Path.Combine(AppContext.BaseDirectory, "Fonts"); + + private const string ReproFontFamily = "Roboto"; + private const float ReproFontSize = 48f; + + private static OpenTypeFontEngine CreateEngine() + { + return new OpenTypeFontEngine(cfg => + { + cfg.FontDirectories.Add(FontsFolder); + cfg.SearchSystemDirectories = false; + }); + } + + /// + /// Loads Roboto through the given engine's normal (cached) path and appends a synthetic + /// Multiple Substitution lookup - '5' -> the glyphs for 'H' and 'i' - to its existing + /// "liga" feature. Uses the cached load path deliberately, so the SAME mutated font + /// instance is what the PDF export pipeline resolves later through this engine. + /// + private static void InjectDigitFiveExpandsToHi(OpenTypeFontEngine engine) + { + var font = engine.LoadFont(ReproFontFamily, FontSubFamily.Regular); + + if (!font.CmapTable.TryGetGlyphId('5', out ushort glyphIdFive)) + Assert.Fail("Test font is expected to contain '5'."); + if (!font.CmapTable.TryGetGlyphId('H', out ushort glyphIdH)) + Assert.Fail("Test font is expected to contain 'H'."); + if (!font.CmapTable.TryGetGlyphId('i', out ushort glyphIdI)) + Assert.Fail("Test font is expected to contain 'i'."); + + + var multiSubst = new MultipleSubstSubTable + { + SubtableFormat = 1, + Coverage = CoverageTableFormat2.CreateCoverageFormat2(new List { glyphIdFive }), + Sequences = new List { new[] { glyphIdH, glyphIdI } } + }; + + var newLookup = new LookupTable + { + LookupType = 2, + LookupFlag = 0, + SubTables = new List { multiSubst } + }; + + int newLookupIndex = font.GsubTable.LookupList.Lookups.Count; + font.GsubTable.LookupList.Lookups.Add(newLookup); + + AppendLookupToActiveLigaFeature(font, newLookupIndex); + } + + /// + /// Returns the index of a "liga" FeatureRecord that is actually REACHABLE from the + /// "latn" script, and appends to it. + /// + /// This matters: Roboto defines "liga" three times (once per script grouping), and only + /// ONE of those FeatureRecords is listed in the latn LangSys. Picking the first "liga" + /// by tag alone lands on a record the script-aware feature resolver correctly filters + /// out, so the injected lookup would never run - the test would fail for a reason that + /// has nothing to do with the code under test. + /// + private static void AppendLookupToActiveLigaFeature(OpenTypeFont font, int lookupIndex) + { + var activeIndices = ScriptFeatureResolver.GetActiveFeatureIndices( + font.GsubTable.ScriptList, "latn", null); + Assert.IsNotNull(activeIndices, "Test font is expected to have a ScriptList."); + + var records = font.GsubTable.FeatureList.FeatureRecords; + int ligaIndex = -1; + for (int i = 0; i < records.Count; i++) + { + if (records[i].FeatureTag.Value == "liga" && activeIndices.Contains(i)) + { + ligaIndex = i; + break; + } + } + + Assert.IsTrue(ligaIndex >= 0, "Test font is expected to define a 'liga' feature reachable from the latn script."); + + var featureTable = records[ligaIndex].FeatureTable; + var oldIndices = featureTable.LookupListIndices ?? new ushort[0]; + var newIndices = new ushort[oldIndices.Length + 1]; + oldIndices.CopyTo(newIndices, 0); + newIndices[oldIndices.Length] = (ushort)lookupIndex; + featureTable.LookupListIndices = newIndices; + featureTable.LookupCount = (ushort)newIndices.Length; + } + + + [TestMethod] + public void MultipleSubstitution_Roboto_ReproSheet() + { + using (var package = OpenPackage("MultipleSubstitutionRepro.xlsx", true)) + { + var sheet = package.Workbook.Worksheets.Add("MultipleSubstitution"); + BuildReproSheet(sheet); + + var engine = CreateEngine(); + InjectDigitFiveExpandsToHi(engine); + + var settings = new PdfPageSettings(engine); + + // Written to disk for visual inspection - this is the whole point of the test. + SaveAsPdf(sheet, "MultipleSubstitutionRepro", 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("MultipleSubstitutionRepro.xlsx", package); + } + } + + private static void BuildReproSheet(ExcelWorksheet sheet) + { + sheet.Cells["A1"].Value = "Compare A2 (typed \"Hi\") vs A3 (typed \"5\") - both should look the same"; + StyleHeader(sheet.Cells["A1"]); + + sheet.Cells["A2"].Value = "Hi"; + sheet.Cells["A3"].Value = "5"; + + StyleReference(sheet.Cells["A2:A3"]); + sheet.Cells.AutoFitColumns(); + + for (int row = 1; row <= 3; row++) + { + sheet.Row(row).CustomHeight = true; + sheet.Row(row).Height = 70; + } + } + + 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.Export.Pdf.Tests/PdfTextShaperShapingOptionsTests.cs b/src/EPPlus.Export.Pdf.Tests/PdfTextShaperShapingOptionsTests.cs index dc7603fb24..3533f764d5 100644 --- a/src/EPPlus.Export.Pdf.Tests/PdfTextShaperShapingOptionsTests.cs +++ b/src/EPPlus.Export.Pdf.Tests/PdfTextShaperShapingOptionsTests.cs @@ -45,13 +45,13 @@ private static PdfPageSettings CreateSettings() public void BuildShapingOptions_Default_AppliesBothAndUsesDefaultTags() { var settings = CreateSettings(); - // Defaults per PdfPageSettings: GsubFeatures = Liga|Clig, GposFeatures = Kern|Mark. + // Defaults per PdfPageSettings: GsubFeatures = Liga|Clig|Calt|Ccmp, GposFeatures = Kern|Mark. var options = PdfTextShaper.BuildShapingOptions(settings); Assert.IsTrue(options.ApplySubstitutions); Assert.IsTrue(options.ApplyPositioning); - CollectionAssert.AreEquivalent(new[] { "liga", "clig" }, options.GsubFeatures.ToList()); + CollectionAssert.AreEquivalent(new[] { "liga", "clig", "calt", "ccmp" }, options.GsubFeatures.ToList()); CollectionAssert.AreEquivalent(new[] { "kern", "mark" }, options.GposFeatures.ToList()); } 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.Export.Pdf/DocumentObjects/PdfContentStream.cs b/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs index 078ac09264..5188463148 100644 --- a/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs +++ b/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs @@ -120,7 +120,7 @@ public void AddText(PdfCellContentLayout cell, Vector2 position, double textRota var line = cell.TextLines[k]; double lineOffsetX = 0d; if (isVertical) - { + { lineOffsetX = (stackWidth - line.Width) / 2d; } else @@ -133,7 +133,7 @@ public void AddText(PdfCellContentLayout cell, Vector2 position, double textRota case ExcelHorizontalAlignment.Center: case ExcelHorizontalAlignment.CenterContinuous: case ExcelHorizontalAlignment.Distributed: - lineOffsetX = (line0Width - line.Width) / 2d; + lineOffsetX = (line0Width - line.Width) / 2d; break; } } @@ -252,9 +252,19 @@ public void AddText(PdfCellContentLayout cell, Vector2 position, double textRota for (int j = glyphStart; j < shapedText.ShapedText.Glyphs.Length; j++) { - if (charsRendered >= fragmentCharCount) - break; var glyph = shapedText.ShapedText.Glyphs[j]; + + // Stop once this fragment's characters are all accounted for - but only + // at a glyph that actually consumes a character. A glyph with CharCount 0 + // is a CONTINUATION of the preceding glyph's cluster (GSUB Multiple + // Substitution expands one character into several glyphs, and the whole + // CharCount sits on the first of them). Breaking on it would drop the + // tail of the cluster whenever the expansion falls at the end of a + // fragment - e.g. rendering only the first glyph of a decomposed + // character. + if (charsRendered >= fragmentCharCount && glyph.CharCount > 0) + break; + if (glyph.FontId != currentFontId) { // Close TJ array, switch font, open new TJ array diff --git a/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs b/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs index 158b29dc61..82d91c8e88 100644 --- a/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs +++ b/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs @@ -199,8 +199,8 @@ public PdfScaling Scaling /// /// GSUB (glyph substitution) features to request when shaping text for this export, e.g. - /// ligatures. Defaults to | , - /// matching the shaping engine's own default. + /// ligatures and contextual alternates. Defaults to | + /// | | . /// /// /// This setting applies to the whole export - there is currently no per-cell or @@ -208,12 +208,11 @@ public PdfScaling Scaling /// on the font actually defining it; see for what each flag /// means and which fonts typically support it. /// - public GsubFeature GsubFeatures { get; set; } = GsubFeature.Liga | GsubFeature.Clig; + public GsubFeature GsubFeatures { get; set; } = GsubFeature.Liga | GsubFeature.Clig | GsubFeature.Calt | GsubFeature.Ccmp; /// /// GPOS (glyph positioning) features to request when shaping text for this export, e.g. - /// kerning and mark attachment. Defaults to | , - /// matching the shaping engine's own default. + /// kerning and mark attachment. Defaults to | . /// /// /// This setting applies to the whole export - there is currently no per-cell or diff --git a/src/EPPlus.Fonts.OpenType.Tests/Integration/TextLayoutEngineTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Integration/TextLayoutEngineTests.cs index 039e2a368f..54fa2a8e40 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/Integration/TextLayoutEngineTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/Integration/TextLayoutEngineTests.cs @@ -84,7 +84,7 @@ public void WrapText_TestWhenOnExactWrapPlusSpaces2() //'sit amet non' is EXACTLY 72 pixels (54 points) in excel at 100% size/display //So an added space should push 'non' over the edge to the next line var text = "sit amet non lacus."; - var comparison = new List() {"sit amet", "non lacus."}; + var comparison = new List() { "sit amet", "non lacus." }; var maxWidthPoints = 54d; @@ -94,7 +94,7 @@ public void WrapText_TestWhenOnExactWrapPlusSpaces2() text, 11f, maxWidthPoints, - ShapingOptions.Full + ShapingOptions.Default ); Assert.IsTrue(comparison.SequenceEqual(wrappedLines)); @@ -116,7 +116,7 @@ public void WrapText_TestWhenOnExactWrap() text, 11f, 54, - ShapingOptions.Full + ShapingOptions.Default ); Assert.IsTrue(comparison.SequenceEqual(wrappedLines)); @@ -139,7 +139,7 @@ public void WrapText_TestFragments() Lorem20Str, 11f, 54, - ShapingOptions.Full + ShapingOptions.Default ); List faultyStrings = new(); @@ -165,7 +165,7 @@ public void WrapText_TestFragments() Assert.AreEqual(0, faultyStrings.Count); } - + [TestMethod] public void WrapText_WithPreExistingWidth_AccountsForIt() { @@ -513,22 +513,22 @@ public void MeasureBigGoudy() var wrappedLines = layout.WrapRichTextLines(fragments, maxSizeInPoints); - var txtWidthsingle = shaper.MeasureTextInPixels("E", 16, 96, ShapingOptions.Full); - var txtWidth = shaper.MeasureTextInPixels("EEEEEEEEEE", 16, 96, ShapingOptions.Full); + var txtWidthsingle = shaper.MeasureTextInPixels("E", 16, 96, ShapingOptions.Default); + var txtWidth = shaper.MeasureTextInPixels("EEEEEEEEEE", 16, 96, ShapingOptions.Default); var txtWidthAlt = shaper.MeasureTextInPixels("EEEEEEEEEE", 16, 96, ShapingOptions.Fast); var txtWidthLowSize = shaper.MeasureTextInPixels("E", 11, 96, ShapingOptions.Fast); - var txtWidthHighSize= shaper.MeasureTextInPixels("E", 72, 96, ShapingOptions.Fast); + var txtWidthHighSize = shaper.MeasureTextInPixels("E", 72, 96, ShapingOptions.Fast); var txtWidthHighSize10 = shaper.MeasureTextInPixels("EEEEEEEEEE", 72, 96, ShapingOptions.Fast); var pts16 = shaper.MeasureTextInPoints("E", 16); var pts = shaper.MeasureTextInPoints("E", 72); var pts2 = shaper.MeasureTextInPoints("E", 96); - var txtWidthMaxSizeSingle = shaper.MeasureTextInPixels("E", 96, 72, ShapingOptions.Full); + var txtWidthMaxSizeSingle = shaper.MeasureTextInPixels("E", 96, 72, ShapingOptions.Default); var txtWidthMaxSizeSingleFast = shaper.MeasureTextInPixels("E", 96, 72, ShapingOptions.Fast); - var txtWidthMaxSizeSingle96 = shaper.MeasureTextInPixels("E", 96, 96, ShapingOptions.Full); + var txtWidthMaxSizeSingle96 = shaper.MeasureTextInPixels("E", 96, 96, ShapingOptions.Default); var txtWidthMaxSizeSingleFast96 = shaper.MeasureTextInPixels("E", 96, 96, ShapingOptions.Fast); //Assert.AreEqual(16, txtWidthLowSize); @@ -551,7 +551,7 @@ public void EnsureRichTextLineWrappingSameAsNonRichWhenNoWrap() RequireFont(SystemFontsEngine, "Aptos Narrow", FontSubFamily.Regular); RequireFont(SystemFontsEngine, "Goudy Stout", FontSubFamily.Regular); - List comparatorLst = new() { "Strike", "Goudy size"}; + List comparatorLst = new() { "Strike", "Goudy size" }; var font = SystemFontsEngine.LoadFont("Aptos Narrow", FontSubFamily.Regular); var shaper = new TextShaper(SystemFontsEngine, font); var points1 = shaper.MeasureTextInPoints(comparatorLst[0], 11); @@ -573,7 +573,7 @@ public void EnsureRichTextLineWrappingSameAsNonRichWhenNoWrap() StrikeType = 1 }; - + var font22 = new RichTextFormatSimple() { @@ -655,11 +655,11 @@ public void EnsureRichTextLineWrappingSameAsNonRichWhenNoWrapAndSpaceTrail() [TestMethod] public void EnsureLineFragmentsAreMeasuredCorrectlyWhenWrapping() { - RequireFont(SystemFontsEngine,"Aptos Narrow", FontSubFamily.Regular); + RequireFont(SystemFontsEngine, "Aptos Narrow", FontSubFamily.Regular); RequireFont(SystemFontsEngine, "Aptos Narrow", FontSubFamily.Bold); RequireFont(SystemFontsEngine, "Goudy Stout", FontSubFamily.Regular); - List lstOfRichText = new() { "TextBox2", "ra underline", "La Strike", "Goudy size 16"}; + List lstOfRichText = new() { "TextBox2", "ra underline", "La Strike", "Goudy size 16" }; var font2 = new MeasurementFont() { FontFamily = "Aptos Narrow", @@ -689,12 +689,12 @@ public void EnsureLineFragmentsAreMeasuredCorrectlyWhenWrapping() }; - List fonts = new() { font2, font3, font4, font5}; + List fonts = new() { font2, font3, font4, font5 }; var fragments = new List(); for (int i = 0; i < lstOfRichText.Count(); i++) { - var currentFrag = new TextFragment() { Text = lstOfRichText[i]}; + var currentFrag = new TextFragment() { Text = lstOfRichText[i] }; currentFrag.RichTextOptions.SetFont(fonts[i]); fragments.Add(currentFrag); } @@ -713,9 +713,9 @@ public void EnsureLineFragmentsAreMeasuredCorrectlyWhenWrapping() List smallestTextFragments = new List(); //Ensure each linefragment can get correct text - foreach(var line in wrappedLines) + foreach (var line in wrappedLines) { - foreach(var lf in line.InternalLineFragments) + foreach (var lf in line.InternalLineFragments) { var text = line.GetLineFragmentText(lf); smallestTextFragments.Add(text); @@ -923,7 +923,7 @@ public void EnsureRTCharIdxBecomesCorrectWhenBreaking() public void WrapRichTextDifficultCaseCompare() { List lstOfRichText = new() { "TextBox\r\na\r\n", "TextBox2", "ra underline", "La Strike", "Goudy size 16", "SvgSize 24" }; - + RequireFont(SystemFontsEngine, "Aptos Narrow", FontSubFamily.Regular); RequireFont(SystemFontsEngine, "Aptos Narrow", FontSubFamily.Bold); RequireFont(SystemFontsEngine, "Goudy Stout", FontSubFamily.Regular); @@ -1205,7 +1205,7 @@ public void WrapText_Continous_Long_Word() longWord, 11f, 54, - ShapingOptions.Full + ShapingOptions.Default ); Assert.AreEqual("pellentesqu", wrappedLines[0]); @@ -1219,7 +1219,7 @@ public void WrapRichText_MeasureCorrectly() // Arrange var font = SystemFontsEngine.LoadFont("Aptos Narrow", FontSubFamily.Regular); - var shaper = new TextShaper(SystemFontsEngine,font); + var shaper = new TextShaper(SystemFontsEngine, font); var layout = new TextLayoutEngine(SystemFontsEngine, shaper); var fragments = new List 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/MultipleSubstitutionSubsettingParityTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Subsetting/MultipleSubstitutionSubsettingParityTests.cs new file mode 100644 index 0000000000..2fafda2c73 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/Subsetting/MultipleSubstitutionSubsettingParityTests.cs @@ -0,0 +1,145 @@ +/************************************************************************************************* + 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 GSUB Multiple Substitution subsetting parity test + *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Tables.Common.Layout.Coverage; +using EPPlus.Fonts.OpenType.Tables; +using EPPlus.Fonts.OpenType.Tables.Common.Layout.Lookups; +using EPPlus.Fonts.OpenType.Tables.Common.Layout.Scripts; +using EPPlus.Fonts.OpenType.Tables.Gsub.Data.Lookups; +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 cmap format-14 subsetting parity test: verifies that shaping + /// against the FULL font (the measurement path) and shaping against the ROUND-TRIPPED SUBSET + /// font (the render path - what actually ends up embedded in the PDF) agree on a GSUB Multiple + /// Substitution (Lookup Type 2). + /// + /// "Round-tripped" means serialized to bytes and reloaded - exactly like a font that has gone + /// through PDF embedding. This exercises MultipleSubstHandler's discovery/rewrite AND + /// GsubTableLoader's parsing of Lookup Type 2, both of which are new. + /// + [TestClass] + public class MultipleSubstitutionSubsettingParityTests : FontTestBase + { + public override TestContext? TestContext { get; set; } + + private OpenTypeFont LoadFullFontWithSyntheticMultipleSubst(out ushort glyphIdX, out ushort glyphIdA, out ushort glyphIdB) + { + var font = TestFolderEngine.LoadFont("Roboto", FontSubFamily.Regular, true); + + Assert.IsTrue(font.CmapTable.TryGetGlyphId('X', out glyphIdX)); + Assert.IsTrue(font.CmapTable.TryGetGlyphId('A', out glyphIdA)); + Assert.IsTrue(font.CmapTable.TryGetGlyphId('B', out glyphIdB)); + + + var multiSubst = new MultipleSubstSubTable + { + SubtableFormat = 1, + Coverage = CoverageTableFormat2.CreateCoverageFormat2(new List { glyphIdX }), + Sequences = new List { new[] { glyphIdA, glyphIdB } } + }; + + var newLookup = new LookupTable + { + LookupType = 2, + LookupFlag = 0, + SubTables = new List { multiSubst } + }; + + int newLookupIndex = font.GsubTable.LookupList.Lookups.Count; + font.GsubTable.LookupList.Lookups.Add(newLookup); + + AppendLookupToActiveLigaFeature(font, newLookupIndex); + + return font; + } + + /// + /// Returns the index of a "liga" FeatureRecord that is actually REACHABLE from the + /// "latn" script, and appends to it. + /// + /// This matters: Roboto defines "liga" three times (once per script grouping), and only + /// ONE of those FeatureRecords is listed in the latn LangSys. Picking the first "liga" + /// by tag alone lands on a record the script-aware feature resolver correctly filters + /// out, so the injected lookup would never run - the test would fail for a reason that + /// has nothing to do with the code under test. + /// + private static void AppendLookupToActiveLigaFeature(OpenTypeFont font, int lookupIndex) + { + var activeIndices = ScriptFeatureResolver.GetActiveFeatureIndices( + font.GsubTable.ScriptList, "latn", null); + Assert.IsNotNull(activeIndices, "Test font is expected to have a ScriptList."); + + var records = font.GsubTable.FeatureList.FeatureRecords; + int ligaIndex = -1; + for (int i = 0; i < records.Count; i++) + { + if (records[i].FeatureTag.Value == "liga" && activeIndices.Contains(i)) + { + ligaIndex = i; + break; + } + } + + Assert.IsTrue(ligaIndex >= 0, "Test font is expected to define a 'liga' feature reachable from the latn script."); + + var featureTable = records[ligaIndex].FeatureTable; + var oldIndices = featureTable.LookupListIndices ?? new ushort[0]; + var newIndices = new ushort[oldIndices.Length + 1]; + oldIndices.CopyTo(newIndices, 0); + newIndices[oldIndices.Length] = (ushort)lookupIndex; + featureTable.LookupListIndices = newIndices; + featureTable.LookupCount = (ushort)newIndices.Length; + } + + [TestMethod] + public void Shape_RoundTrippedSubsetFont_StillExpandsMultipleSubstitution() + { + const string text = "X"; + + var fullFont = LoadFullFontWithSyntheticMultipleSubst(out ushort glyphIdX, out ushort glyphIdA, out ushort glyphIdB); + + // --- Measurement path: shape against the full font --- + var fullShaper = new TextShaper(TestFolderEngine, fullFont); + var fullShaped = fullShaper.Shape(text); + + Assert.AreEqual(2, fullShaped.Glyphs.Length, "Sanity check: the full font must expand 'X' into two glyphs."); + Assert.AreEqual(glyphIdA, fullShaped.Glyphs[0].GlyphId); + Assert.AreEqual(glyphIdB, fullShaped.Glyphs[1].GlyphId); + + // --- 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(text); + var subsetBytes = subsetFont.Serialize(); + var roundTrippedSubsetFont = new OpenTypeFont(subsetBytes); + + var subsetShaper = new TextShaper(TestFolderEngine, roundTrippedSubsetFont); + var subsetShaped = subsetShaper.Shape(text); + + Assert.AreEqual(2, subsetShaped.Glyphs.Length, + "The round-tripped SUBSET font must still expand 'X' into two glyphs - today the Multiple " + + "Substitution lookup isn't discovered/preserved by subsetting, and Lookup Type 2 isn't even " + + "parsed back when the subset font is reloaded, so this silently collapses to one glyph."); + + // Glyph ids are renumbered by subsetting, so compare relative identity, not raw ids: + // the two resulting glyphs in the subset must still be DIFFERENT from each other, and + // different from whatever "X" alone would resolve to without the substitution. + Assert.AreNotEqual(subsetShaped.Glyphs[0].GlyphId, subsetShaped.Glyphs[1].GlyphId, + "The two expanded glyphs must remain distinct after subsetting."); + } + } +} \ No newline at end of file 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/Tables/Gsub/MultipleSubstSubTableTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Tables/Gsub/MultipleSubstSubTableTests.cs new file mode 100644 index 0000000000..7551fbf71e --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/Tables/Gsub/MultipleSubstSubTableTests.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 GSUB Multiple Substitution (Type 2) tests + *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Tables.Common.Layout.Coverage; +using EPPlus.Fonts.OpenType.Tables.Gsub.Data.Lookups; +using EPPlus.Fonts.OpenType.Tables.Gsub.IO; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; +using System.IO; + +namespace EPPlus.Fonts.OpenType.Tests.Tables.Gsub +{ + /// + /// Tests the new GSUB Lookup Type 2 (Multiple Substitution) data model: + /// MultipleSubstSubTable and MultipleSubstSubTableDeserializer. Unlike Lookup + /// Type 1 (one glyph -> one glyph) or Type 4/Ligature (many glyphs -> one), Type 2 maps ONE + /// input glyph to a SEQUENCE of output glyphs - the direction "ccmp" typically needs for + /// decomposing a precomposed glyph into a base + combining marks. + /// + /// All tables here are built by hand so the tests are deterministic and don't depend on any + /// specific font shipping real Multiple Substitution data. + /// + [TestClass] + public class MultipleSubstSubTableTests + { + public TestContext? TestContext { get; set; } + + // Arbitrary glyph ids used only as stand-ins in the synthetic table. + private const ushort InputGidA = 10; // covered, decomposes into two glyphs + private const ushort InputGidB = 20; // covered, decomposes into three glyphs + private const ushort UncoveredGid = 30; // never listed in Coverage + + private static readonly ushort[] SequenceForA = { 100, 101 }; + private static readonly ushort[] SequenceForB = { 200, 201, 202 }; + + private static MultipleSubstSubTable BuildSyntheticSubtable() + { + return new MultipleSubstSubTable + { + SubtableFormat = 1, + Coverage = CoverageTableFormat2.CreateCoverageFormat2(new List { InputGidA, InputGidB }), + Sequences = new List { SequenceForA, SequenceForB } + }; + } + + [TestMethod] + public void GetSubstitution_CoveredGlyph_ReturnsItsSequence() + { + var subtable = BuildSyntheticSubtable(); + + CollectionAssert.AreEqual(SequenceForA, subtable.GetSubstitution(InputGidA)); + CollectionAssert.AreEqual(SequenceForB, subtable.GetSubstitution(InputGidB)); + } + + [TestMethod] + public void GetSubstitution_UncoveredGlyph_ReturnsNull() + { + var subtable = BuildSyntheticSubtable(); + + Assert.IsNull(subtable.GetSubstitution(UncoveredGid)); + } + + [TestMethod] + public void SerializeThenDeserialize_RoundTrips_SameCoverageAndSequences() + { + var original = BuildSyntheticSubtable(); + + byte[] bytes = original.Serialize(); + + using (var stream = new MemoryStream(bytes)) + { + var reader = new FontsBinaryReader(stream); + var roundTripped = new MultipleSubstSubTableDeserializer(reader).Deserialize(0); + + Assert.AreEqual(1, roundTripped.SubtableFormat); + CollectionAssert.AreEqual(SequenceForA, roundTripped.GetSubstitution(InputGidA)); + CollectionAssert.AreEqual(SequenceForB, roundTripped.GetSubstitution(InputGidB)); + Assert.IsNull(roundTripped.GetSubstitution(UncoveredGid)); + } + } + + [TestMethod] + public void SerializeThenDeserialize_PreservesCoverageOrderIndependence() + { + // Coverage index order must line up with Sequences order - verify both covered + // glyphs still resolve correctly after a round trip, not just the first one (which + // would still "work" even if the sequence-offset array were parsed incorrectly, as + // long as the coverage table itself parsed fine). + var original = BuildSyntheticSubtable(); + byte[] bytes = original.Serialize(); + + using (var stream = new MemoryStream(bytes)) + { + var reader = new FontsBinaryReader(stream); + var roundTripped = new MultipleSubstSubTableDeserializer(reader).Deserialize(0); + + var coveredGlyphs = roundTripped.Coverage.GetCoveredGlyphs(); + CollectionAssert.AreEqual(new ushort[] { InputGidA, InputGidB }, coveredGlyphs); + } + } + } +} \ 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/MarkToBasePositioningTests.cs b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/MarkToBasePositioningTests.cs index be6a0e177d..f67c7210db 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/MarkToBasePositioningTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/MarkToBasePositioningTests.cs @@ -9,11 +9,13 @@ This software is licensed under PolyForm Noncommercial License 1.0.0 Date Author Change ************************************************************************************************* 09/07/2026 EPPlus Software AB Mark-to-base offset convention + 09/09/2026 EPPlus Software AB Shape without ccmp to keep the sequence decomposed *************************************************************************************************/ using EPPlus.Fonts.OpenType.TextShaping; using EPPlus.Fonts.OpenType.TextShaping.Positioning; using OfficeOpenXml.Interfaces.Fonts; using System.Collections.Generic; +using System.Linq; namespace EPPlus.Fonts.OpenType.Tests.TextShaping { @@ -170,13 +172,27 @@ public void MarkOffset_UsesKernedAdvance_NotRawAdvance() /// /// Shapes a base character followed by a combining acute and returns the two glyphs. + /// + /// Shaping runs WITHOUT "ccmp" on purpose. These tests are about the offset convention in + /// - they need the base and the mark to reach mark + /// positioning as the two glyphs the expected anchor values were read from. Fonts commonly + /// use ccmp to swap a combining mark for a cap-height variant after an uppercase letter + /// (Mulish does exactly this for the acute), which is correct rendering but silently + /// changes which anchors mark positioning uses - the mark would then sit at cap height and + /// YOffset would come out 0 instead of the lifted value these tests assert. + /// + /// The other features are left at their defaults; only ccmp is dropped, since that is the + /// one that rewrites this particular sequence. /// private static List ShapeBaseAndMark(string family, char baseChar) { var font = TestFolderEngine.LoadFont(family, FontSubFamily.Regular); Assert.IsNotNull(font, $"{family} must be present in the test font folder"); - var shaped = new TextShaper(TestFolderEngine, font!).Shape($"{baseChar}{CombiningAcute}"); + var options = ShapingOptions.Default; + options.GsubFeatures = options.GsubFeatures.Where(t => t != "ccmp").ToList(); + + var shaped = new TextShaper(TestFolderEngine, font!).Shape($"{baseChar}{CombiningAcute}", options); Assert.AreEqual( 2, diff --git a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/MarkToBaseTests.cs b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/MarkToBaseTests.cs index 6dab819653..2e65d7c57c 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/MarkToBaseTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/MarkToBaseTests.cs @@ -19,7 +19,7 @@ public class MarkToBaseTests : FontTestBase [TestInitialize] public void TestSetup() { - + } [TestMethod] @@ -27,14 +27,14 @@ public void MarkToBaseTest() { var font = TestFolderEngine.LoadFont("EB Garamond", FontSubFamily.Regular, ignoreCache: true); - var shaper = new TextShaper(TestFolderEngine,font); + var shaper = new TextShaper(TestFolderEngine, font); string test = "e\u0301"; // e + combining acute - var shaped = shaper.Shape(test, ShapingOptions.Full); + var shaped = shaper.Shape(test, ShapingOptions.Default); Assert.IsTrue(shaped.Glyphs.Any(x => x.XOffset != 0), $"Expected XOffset != 0. Got: {string.Join(", ", shaped.Glyphs.Select(g => $"X={g.XOffset}"))}"); } - + } -} +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/MultipleSubstitutionShapingTests.cs b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/MultipleSubstitutionShapingTests.cs new file mode 100644 index 0000000000..169ecd8b09 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/MultipleSubstitutionShapingTests.cs @@ -0,0 +1,173 @@ +/************************************************************************************************* + 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 GSUB Multiple Substitution shaping tests + *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Tables.Common.Layout.Coverage; +using EPPlus.Fonts.OpenType.Tables; +using EPPlus.Fonts.OpenType.Tables.Common.Layout.Lookups; +using EPPlus.Fonts.OpenType.Tables.Common.Layout.Scripts; +using EPPlus.Fonts.OpenType.Tables.Gsub.Data.Lookups; +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 applies GSUB Lookup Type 2 (Multiple Substitution) - + /// today the engine has handlers for Type 1 (single), Type 4 (ligature) and Type 6 (chaining + /// contextual), but nothing expands one input glyph into several output glyphs, which is + /// what most real-world "ccmp" decomposition relies on. + /// + /// The substitution data is injected synthetically into a real, otherwise-unmodified test + /// font, so the tests don't depend on any specific font shipping a Type 2 lookup for a + /// character we control. It's tagged "liga" and appended to the font's own existing "liga" + /// feature so it activates through ShapingOptions.Default without touching any public + /// GsubFeature enum - this test is only about the lookup TYPE working, not about exposing a + /// new feature tag publicly. + /// + [TestClass] + public class MultipleSubstitutionShapingTests : FontTestBase + { + public override TestContext? TestContext { get; set; } + + /// + /// Loads a private (non-cached) Roboto instance and appends a synthetic Multiple + /// Substitution lookup - triggered by 'X' - to the font's existing "liga" feature. 'X' + /// decomposes into the glyphs for 'A' and 'B' (two real, distinct, existing glyphs - + /// not made up ids), so the effect is trivially visible: shaping "X" should produce the + /// same two glyphs as shaping "AB", not the single glyph for 'X'. + /// + private OpenTypeFont LoadFontWithSyntheticMultipleSubst(out ushort glyphIdX, out ushort glyphIdA, out ushort glyphIdB) + { + var font = TestFolderEngine.LoadFont("Roboto", FontSubFamily.Regular, true); + + Assert.IsTrue(font.CmapTable.TryGetGlyphId('X', out glyphIdX), "Test font is expected to contain 'X'."); + 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'."); + + + var multiSubst = new MultipleSubstSubTable + { + SubtableFormat = 1, + Coverage = CoverageTableFormat2.CreateCoverageFormat2(new List { glyphIdX }), + Sequences = new List { new[] { glyphIdA, glyphIdB } } + }; + + var newLookup = new LookupTable + { + LookupType = 2, + LookupFlag = 0, + SubTables = new List { multiSubst } + }; + + int newLookupIndex = font.GsubTable.LookupList.Lookups.Count; + font.GsubTable.LookupList.Lookups.Add(newLookup); + + AppendLookupToActiveLigaFeature(font, newLookupIndex); + + return font; + } + + /// + /// Returns the index of a "liga" FeatureRecord that is actually REACHABLE from the + /// "latn" script, and appends to it. + /// + /// This matters: Roboto defines "liga" three times (once per script grouping), and only + /// ONE of those FeatureRecords is listed in the latn LangSys. Picking the first "liga" + /// by tag alone lands on a record the script-aware feature resolver correctly filters + /// out, so the injected lookup would never run - the test would fail for a reason that + /// has nothing to do with the code under test. + /// + private static void AppendLookupToActiveLigaFeature(OpenTypeFont font, int lookupIndex) + { + var activeIndices = ScriptFeatureResolver.GetActiveFeatureIndices( + font.GsubTable.ScriptList, "latn", null); + Assert.IsNotNull(activeIndices, "Test font is expected to have a ScriptList."); + + var records = font.GsubTable.FeatureList.FeatureRecords; + int ligaIndex = -1; + for (int i = 0; i < records.Count; i++) + { + if (records[i].FeatureTag.Value == "liga" && activeIndices.Contains(i)) + { + ligaIndex = i; + break; + } + } + + Assert.IsTrue(ligaIndex >= 0, "Test font is expected to define a 'liga' feature reachable from the latn script."); + + var featureTable = records[ligaIndex].FeatureTable; + var oldIndices = featureTable.LookupListIndices ?? new ushort[0]; + var newIndices = new ushort[oldIndices.Length + 1]; + oldIndices.CopyTo(newIndices, 0); + newIndices[oldIndices.Length] = (ushort)lookupIndex; + featureTable.LookupListIndices = newIndices; + featureTable.LookupCount = (ushort)newIndices.Length; + } + + [TestMethod] + public void Shape_GlyphWithMultipleSubstitution_ExpandsIntoItsSequence() + { + var font = LoadFontWithSyntheticMultipleSubst(out ushort glyphIdX, out ushort glyphIdA, out ushort glyphIdB); + var shaper = new TextShaper(TestFolderEngine, font); + + var shaped = shaper.Shape("X"); + + Assert.AreEqual(2, shaped.Glyphs.Length, "'X' should expand into its two-glyph substitution sequence."); + Assert.AreEqual(glyphIdA, shaped.Glyphs[0].GlyphId); + Assert.AreEqual(glyphIdB, shaped.Glyphs[1].GlyphId); + } + + [TestMethod] + public void Shape_GlyphWithMultipleSubstitution_PreservesTotalCharCountAndClusterIndex() + { + var font = LoadFontWithSyntheticMultipleSubst(out ushort glyphIdX, out ushort glyphIdA, out ushort glyphIdB); + var shaper = new TextShaper(TestFolderEngine, font); + + var shaped = shaper.Shape("X"); + + int totalCharCount = shaped.Glyphs[0].CharCount + shaped.Glyphs[1].CharCount; + Assert.AreEqual(1, totalCharCount, "The single source character must still be fully accounted for across the expanded glyphs."); + Assert.AreEqual(0, shaped.Glyphs[0].ClusterIndex); + Assert.AreEqual(0, shaped.Glyphs[1].ClusterIndex, "Both expanded glyphs map back to the same source character position."); + } + + [TestMethod] + public void Shape_GlyphWithMultipleSubstitution_EachGlyphGetsItsOwnAdvanceWidth() + { + var font = LoadFontWithSyntheticMultipleSubst(out ushort glyphIdX, out ushort glyphIdA, out ushort glyphIdB); + var shaper = new TextShaper(TestFolderEngine, font); + + var shaped = shaper.Shape("X"); + var plainAB = shaper.Shape("AB"); + + Assert.AreEqual(plainAB.Glyphs[0].BaseAdvance, shaped.Glyphs[0].BaseAdvance, + "Each expanded glyph should carry its own real advance width, not a shared/zero one."); + Assert.AreEqual(plainAB.Glyphs[1].BaseAdvance, shaped.Glyphs[1].BaseAdvance); + } + + [TestMethod] + public void Shape_GlyphWithoutTrigger_IsUnaffected() + { + var font = LoadFontWithSyntheticMultipleSubst(out ushort glyphIdX, out ushort glyphIdA, out ushort glyphIdB); + var shaper = new TextShaper(TestFolderEngine, font); + + var shaped = shaper.Shape("A"); + + Assert.AreEqual(1, shaped.Glyphs.Length, "A character with no Multiple Substitution entry must be completely unaffected."); + Assert.AreEqual(glyphIdA, shaped.Glyphs[0].GlyphId); + Assert.AreEqual(1, shaped.Glyphs[0].CharCount); + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ScriptAwareFeatureLookupTests.cs b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ScriptAwareFeatureLookupTests.cs index aeb38d7820..b3449cb134 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; @@ -63,7 +64,7 @@ public void SingleAdjustment_ForLatinScript_DoesNotPickUpArabicOnlyLookup() var provider = new SingleAdjustmentProvider(font); - // "latn" is what ShapingOptions.Default/.Fast/.Full all pass. Previously, + // "latn" is what ShapingOptions.Default/.Fast both pass. Previously, // TryGetAdjustment had no script parameter at all and always searched every // FeatureRecord tagged "kern"; now it must restrict to the script given here. bool found = provider.TryGetAdjustment( @@ -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/TextShaperTests.cs b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/TextShaperTests.cs index ab868d5dc2..7d6fe7958d 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/TextShaperTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/TextShaperTests.cs @@ -17,6 +17,7 @@ Date Author Change using OfficeOpenXml.Interfaces.Fonts; using System; using System.Diagnostics; +using System.Linq; namespace EPPlus.Fonts.OpenType.Tests.TextShaping { @@ -32,7 +33,7 @@ public void Shape_EmptyString_ReturnsEmptyResult() { // Arrange var font = TestFolderEngine.LoadFont("Roboto"); - var shaper = new TextShaper(TestFolderEngine,font); + var shaper = new TextShaper(TestFolderEngine, font); // Act var shaped = shaper.Shape(""); @@ -83,7 +84,7 @@ public void Shape_SimpleWord_ReturnsCorrectGlyphCount() { // Arrange var font = TestFolderEngine.LoadFont("Roboto"); - var shaper = new TextShaper(TestFolderEngine,font); + var shaper = new TextShaper(TestFolderEngine, font); // Act var shaped = shaper.Shape("Hello"); @@ -100,7 +101,7 @@ public void Shape_WithSpace_IncludesSpaceGlyph() { // Arrange var font = TestFolderEngine.LoadFont("Roboto"); - var shaper = new TextShaper(TestFolderEngine,font); + var shaper = new TextShaper(TestFolderEngine, font); // Act var shaped = shaper.Shape("A B"); @@ -518,7 +519,7 @@ public void ShapedText_GetWidthInPixels_MatchesMeasureText() { // Arrange var font = TestFolderEngine.LoadFont("Roboto"); - var shaper = new TextShaper(TestFolderEngine,font); + var shaper = new TextShaper(TestFolderEngine, font); // Act var shaped = shaper.Shape("Hello"); @@ -639,7 +640,16 @@ public void Shape_DecomposedUnicode_PositionsAccent() // Act // U+0065 = 'e', U+0301 = combining acute accent - var decomposed = shaper.Shape("e\u0301"); // e + ´ + // + // Shaped WITHOUT "ccmp" on purpose. This test is about mark POSITIONING, which needs + // the pair to still be two glyphs when GPOS runs. Roboto's ccmp composes + // e + U+0301 into the precomposed eacute glyph, which is correct rendering but + // leaves nothing for mark positioning to do - the assertions below would then be + // measuring a single precomposed glyph instead. + var options = ShapingOptions.Default; + options.GsubFeatures = options.GsubFeatures.Where(t => t != "ccmp").ToList(); + + var decomposed = shaper.Shape("e\u0301", options); // e + ´ // Assert Assert.AreEqual(2, decomposed.Glyphs.Length, "Should have 2 glyphs (base + mark)"); @@ -713,7 +723,7 @@ public void Shape_SourceSans3_SingleMark_PositionsCorrectly() [TestMethod] public void Shape_Cafe_HandlesDecomposed() { - var tEngine = + var tEngine = new Lazy(() => new OpenTypeFontEngine(cfg => { foreach (var folder in FontFolders) 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/Subsetting/GsubSubsetProcessor.cs b/src/EPPlus.Fonts.OpenType/Subsetting/GsubSubsetProcessor.cs index b2438f4d83..ea457b7037 100644 --- a/src/EPPlus.Fonts.OpenType/Subsetting/GsubSubsetProcessor.cs +++ b/src/EPPlus.Fonts.OpenType/Subsetting/GsubSubsetProcessor.cs @@ -27,6 +27,7 @@ public GsubSubsetProcessor() var handlers = new IGsubLookupHandler[] { new SingleSubstHandler(), + new MultipleSubstHandler(), new LigatureSubstHandler(), new ExtensionSubstHandler(), new ChainingContextualHandler() 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/Tables/Gsub/Data/Lookups/MultipleSubstSubTable.cs b/src/EPPlus.Fonts.OpenType/Tables/Gsub/Data/Lookups/MultipleSubstSubTable.cs new file mode 100644 index 0000000000..e5225ffc8b --- /dev/null +++ b/src/EPPlus.Fonts.OpenType/Tables/Gsub/Data/Lookups/MultipleSubstSubTable.cs @@ -0,0 +1,105 @@ +/************************************************************************************************* + 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 GSUB Multiple Substitution (Type 2) support + *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Tables.Common.Layout.Coverage; +using System.Collections.Generic; + +namespace EPPlus.Fonts.OpenType.Tables.Gsub.Data.Lookups +{ + /// + /// Represents a Multiple Substitution Subtable (Lookup Type 2, format 1 - the only format + /// defined for this lookup type). + /// This lookup replaces a single glyph with a SEQUENCE of one or more glyphs - the opposite + /// direction of Ligature Substitution (Lookup Type 4), which replaces several glyphs with + /// one. Fonts commonly use it for the "ccmp" feature to decompose a precomposed glyph into a + /// base glyph followed by combining mark glyphs. + /// + public class MultipleSubstSubTable : FontTableElement + { + /// + /// Gets or sets the format identifier. Always 1 - Multiple Substitution has only one + /// defined subtable format. + /// + public ushort SubtableFormat { get; set; } = 1; + + /// + /// Gets or sets the Coverage table which defines the input glyphs to be substituted. + /// + public CoverageTable Coverage { get; set; } + + /// + /// Gets or sets the substitute glyph sequences, one per glyph in , + /// in the same order (i.e. Sequences[Coverage.GetGlyphIndex(gid)] is the + /// replacement for gid). + /// + public List Sequences { get; set; } = new List(); + + /// + /// Returns the substitute glyph sequence for a given base glyph ID, or null if the glyph + /// is not covered by this subtable. + /// + /// The original glyph ID. + /// The replacement sequence, or null if not covered. + public ushort[] GetSubstitution(ushort baseGlyphId) + { + int index = Coverage?.GetGlyphIndex(baseGlyphId) ?? -1; + + if (index < 0 || Sequences == null || index >= Sequences.Count) + return null; + + return Sequences[index]; + } + + internal override void Serialize(FontsBinaryWriter writer) + { + long subTableStart = writer.BaseStream.Position; + + // 1. Write SubstFormat (always 1) + writer.WriteUInt16BigEndian(1); + + // 2. Placeholder for CoverageOffset (2 bytes) + long covOffsetPos = writer.BaseStream.Position; + writer.WriteUInt16BigEndian(0); + + // 3. SequenceCount and placeholders for SequenceOffsets + ushort sequenceCount = Sequences != null ? (ushort)Sequences.Count : (ushort)0; + writer.WriteUInt16BigEndian(sequenceCount); + + long seqOffsetArrayStart = writer.BaseStream.Position; + for (int i = 0; i < sequenceCount; i++) + { + writer.WriteUInt16BigEndian(0); + } + + // 4. Write each Sequence table, backfilling its offset in the array above + for (int i = 0; i < sequenceCount; i++) + { + long seqOffsetSlot = seqOffsetArrayStart + (i * 2); + this.WriteRelativeOffset(writer, subTableStart, seqOffsetSlot); + + ushort[] sequence = Sequences[i] ?? new ushort[0]; + writer.WriteUInt16BigEndian((ushort)sequence.Length); + foreach (ushort gid in sequence) + { + writer.WriteUInt16BigEndian(gid); + } + } + + // 5. Serialize CoverageTable and backfill its offset + if (this.Coverage != null) + { + this.WriteRelativeOffset(writer, subTableStart, covOffsetPos); + this.Coverage.Serialize(writer); + } + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/Tables/Gsub/GsubTableLoader.cs b/src/EPPlus.Fonts.OpenType/Tables/Gsub/GsubTableLoader.cs index ec1137cf2f..a8f76a9920 100644 --- a/src/EPPlus.Fonts.OpenType/Tables/Gsub/GsubTableLoader.cs +++ b/src/EPPlus.Fonts.OpenType/Tables/Gsub/GsubTableLoader.cs @@ -135,6 +135,9 @@ private LookupTable LoadLookupTable() case 1: // Single Substitution subTable = new SingleSubstSubTableDeserializer(_reader).Deserialize(subTableAbsoluteStart); break; + case 2: // Multiple Substitution + subTable = new MultipleSubstSubTableDeserializer(_reader).Deserialize(subTableAbsoluteStart); + break; case 4: // Ligature Substitution subTable = new LigatureSubstSubTableDeserializer(_reader).Deserialize(subTableAbsoluteStart); break; diff --git a/src/EPPlus.Fonts.OpenType/Tables/Gsub/Handlers/MultipleSubstHandler.cs b/src/EPPlus.Fonts.OpenType/Tables/Gsub/Handlers/MultipleSubstHandler.cs new file mode 100644 index 0000000000..b8b34a0afc --- /dev/null +++ b/src/EPPlus.Fonts.OpenType/Tables/Gsub/Handlers/MultipleSubstHandler.cs @@ -0,0 +1,138 @@ +/************************************************************************************************* + 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 GSUB Multiple Substitution (Type 2) support + *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Subsetting; +using EPPlus.Fonts.OpenType.Tables.Common.Layout.Coverage; +using EPPlus.Fonts.OpenType.Tables.Common.Layout.Lookups; +using EPPlus.Fonts.OpenType.Tables.Gsub.Data.Lookups; +using System.Collections.Generic; +using System.Linq; + +namespace EPPlus.Fonts.OpenType.Tables.Gsub.Handlers +{ + /// + /// Subsetting handler for GSUB Lookup Type 2 (Multiple Substitution). Unlike Lookup Type 1 + /// (single glyph output) this substitutes ONE input glyph for a SEQUENCE of output glyphs - + /// every glyph in that sequence is a new reference beyond what the ordinary cmap lookup for + /// the source text would find, so it must be discovered here the same way a variation + /// sequence's variant glyph or a ligature's result glyph is. + /// + internal class MultipleSubstHandler : IGsubLookupHandler + { + public ushort LookupType => 2; + + public void Discover(FontSubsettingContext context, LookupTable lookup, GsubSubsetProcessor processor) + { + // Loop until no new glyphs are added, in case a substituted glyph is itself the + // trigger for another lookup's substitution (transitive chains), same as + // SingleSubstHandler does. + bool addedAny; + do + { + addedAny = false; + var currentGlyphs = context.IncludedGlyphs.ToArray(); + + foreach (var subtable in lookup.SubTables.OfType()) + { + var coveredGids = subtable.Coverage?.GetCoveredGlyphs(); + if (coveredGids == null) continue; + + foreach (ushort gid in coveredGids) + { + if (!context.IncludedGlyphs.Contains(gid)) + continue; + + ushort[] sequence = subtable.GetSubstitution(gid); + if (sequence == null) continue; + + foreach (ushort outGid in sequence) + { + if (!context.IncludedGlyphs.Contains(outGid)) + { + context.IncludedGlyphs.Add(outGid); + addedAny = true; + } + } + } + } + } while (addedAny); + } + + public LookupTable Rewrite(FontSubsettingContext context, LookupTable oldLookup) + { + var newLookup = new LookupTable + { + LookupType = 2, + LookupFlag = oldLookup.LookupFlag, + SubTables = new List() + }; + + foreach (var oldSubtable in oldLookup.SubTables.OfType()) + { + var coveredGids = oldSubtable.Coverage?.GetCoveredGlyphs(); + if (coveredGids == null) continue; + + var newInputs = new List(); + var newSequences = new List(); + + foreach (ushort oldInputGid in coveredGids) + { + // 1. Is the triggering glyph itself part of the subset? + if (!context.OldToNewGlyphId.TryGetValue(oldInputGid, out ushort newInputGid)) + continue; + + ushort[] oldSequence = oldSubtable.GetSubstitution(oldInputGid); + if (oldSequence == null) continue; + + // 2. Every glyph in the output sequence must also be in the subset - if even + // one is missing, the whole substitution is invalid and must be dropped, + // rather than emitting a sequence with a dangling .notdef in the middle. + var newSequence = new ushort[oldSequence.Length]; + bool allMapped = true; + + for (int i = 0; i < oldSequence.Length; i++) + { + if (!context.OldToNewGlyphId.TryGetValue(oldSequence[i], out ushort newOutGid)) + { + allMapped = false; + break; + } + newSequence[i] = newOutGid; + } + + if (!allMapped) continue; + + newInputs.Add(newInputGid); + newSequences.Add(newSequence); + } + + if (newInputs.Count == 0) continue; + + // Coverage requires strictly sorted glyph IDs - keep Sequences in the same + // relative order as the (now sorted) inputs. + var order = Enumerable.Range(0, newInputs.Count).OrderBy(i => newInputs[i]).ToArray(); + var sortedInputs = order.Select(i => newInputs[i]).ToList(); + var sortedSequences = order.Select(i => newSequences[i]).ToList(); + + var newSubtable = new MultipleSubstSubTable + { + SubtableFormat = 1, + Coverage = CoverageTableFormat2.CreateCoverageFormat2(sortedInputs), + Sequences = sortedSequences + }; + newLookup.SubTables.Add(newSubtable); + } + + return newLookup.SubTables.Count > 0 ? newLookup : null; + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/Tables/Gsub/IO/MultipleSubstSubTableDeserializer.cs b/src/EPPlus.Fonts.OpenType/Tables/Gsub/IO/MultipleSubstSubTableDeserializer.cs new file mode 100644 index 0000000000..8d3f3ee7e3 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType/Tables/Gsub/IO/MultipleSubstSubTableDeserializer.cs @@ -0,0 +1,105 @@ +/************************************************************************************************* + 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 GSUB Multiple Substitution (Type 2) support + *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Tables.Common.Layout.Coverage.IO; +using EPPlus.Fonts.OpenType.Tables.Gsub.Data.Lookups; +using System; +using System.Collections.Generic; +using System.IO; + +namespace EPPlus.Fonts.OpenType.Tables.Gsub.IO +{ + internal class MultipleSubstSubTableDeserializer + { + private readonly FontsBinaryReader _reader; + + public MultipleSubstSubTableDeserializer(FontsBinaryReader reader) + { + _reader = reader; + } + + public MultipleSubstSubTable Deserialize(long subTableStartOffset) + { + _reader.BaseStream.Seek(subTableStartOffset, SeekOrigin.Begin); + long currentPos = subTableStartOffset; + + // USHORT SubstFormat + ushort format = _reader.ReadUInt16BigEndian(); + if (format != 1) + { + throw new NotSupportedException($"Unsupported MultipleSubstSubTable format: {format}"); + } + + // USHORT CoverageOffset + ushort coverageOffset = _reader.ReadUInt16BigEndian(); + + // USHORT SequenceCount + ushort sequenceCount = _reader.ReadUInt16BigEndian(); + + // USHORT[] SequenceOffsets + ushort[] sequenceOffsets = new ushort[sequenceCount]; + for (int i = 0; i < sequenceCount; i++) + { + sequenceOffsets[i] = _reader.ReadUInt16BigEndian(); + } + + var subTable = new MultipleSubstSubTable + { + SubtableFormat = format, + Sequences = new List(sequenceCount) + }; + + // Each Sequence table is read at its OWN absolute offset - never by continuing to + // read wherever the previous one left off, so there is nothing to restore between + // iterations here. + for (int i = 0; i < sequenceCount; i++) + { + long sequenceAbsoluteStart = subTableStartOffset + sequenceOffsets[i]; + _reader.BaseStream.Seek(sequenceAbsoluteStart, SeekOrigin.Begin); + + // USHORT GlyphCount + ushort glyphCount = _reader.ReadUInt16BigEndian(); + + // USHORT[] SubstituteGlyphIDs + ushort[] sequence = new ushort[glyphCount]; + for (int g = 0; g < glyphCount; g++) + { + sequence[g] = _reader.ReadUInt16BigEndian(); + } + + subTable.Sequences.Add(sequence); + } + + // Deserialize CoverageTable + if (coverageOffset > 0) + { + long coverageAbsoluteStart = subTableStartOffset + coverageOffset; + _reader.BaseStream.Seek(coverageAbsoluteStart, SeekOrigin.Begin); + + ushort coverageFormat = _reader.ReadUInt16BigEndian(); + _reader.BaseStream.Seek(coverageAbsoluteStart, SeekOrigin.Begin); + + if (coverageFormat == 1) + { + subTable.Coverage = new CoverageTableFormat1Deserializer(_reader).Deserialize(coverageAbsoluteStart); + } + else if (coverageFormat == 2) + { + subTable.Coverage = new CoverageTableFormat2Deserializer(_reader).Deserialize(coverageAbsoluteStart); + } + } + + _reader.BaseStream.Seek(currentPos, SeekOrigin.Begin); + return subTable; + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/TextShaping/Substitutions/MultipleSubstitutionProcessor.cs b/src/EPPlus.Fonts.OpenType/TextShaping/Substitutions/MultipleSubstitutionProcessor.cs new file mode 100644 index 0000000000..10f44c5f39 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType/TextShaping/Substitutions/MultipleSubstitutionProcessor.cs @@ -0,0 +1,255 @@ +/************************************************************************************************* + 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 GSUB Multiple Substitution (Type 2) support + *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Tables.Common.Layout.Scripts; +using EPPlus.Fonts.OpenType.Tables.Gsub; +using EPPlus.Fonts.OpenType.Tables.Gsub.Data.Lookups; +using OfficeOpenXml.Interfaces.Fonts; +using System.Collections.Generic; + +namespace EPPlus.Fonts.OpenType.TextShaping.Substitutions +{ + /// + /// Processes GSUB Lookup Type 2 (Multiple Substitution). + /// This handles 1:N glyph expansions - the opposite direction of ligature substitution + /// (Type 4). Fonts commonly use it for the "ccmp" feature to decompose a precomposed glyph + /// into a base glyph plus combining marks. + /// + internal class MultipleSubstitutionProcessor + { + private readonly struct IndexedSubtable + { + public readonly int FeatureIndex; + public readonly MultipleSubstSubTable Subtable; + + public IndexedSubtable(int featureIndex, MultipleSubstSubTable subtable) + { + FeatureIndex = featureIndex; + Subtable = subtable; + } + } + + private readonly OpenTypeFont _font; + private readonly GsubTable _gsubTable; + + // Feature tag -> every (FeatureList index, subtable) pair recorded under that tag, across + // ALL scripts - same shape as SingleSubstitutionProcessor's map, for the same reason: the + // FeatureList index lets ApplySubstitutions filter down to what the requested script can + // actually reach. + private readonly Dictionary> _featureSubtables; + private readonly Dictionary> _activeIndexCache = new Dictionary>(); + + public MultipleSubstitutionProcessor(OpenTypeFont font) + { + _font = font; + _gsubTable = font?.GsubTable; + _featureSubtables = new Dictionary>(); + + if (_gsubTable != null) + { + BuildFeatureSubtableMap(); + } + } + + /// + /// Applies multiple substitution to the glyph list, restricted to the features reachable + /// from the given script and language. A matched glyph is REPLACED by its substitute + /// sequence in place, so the returned list can be longer than the input. + /// + /// List of shaped glyphs to process + /// List of feature tags to apply (e.g., "ccmp") + /// + /// OpenType script tag (e.g. "latn"). Pass null to fall back to unfiltered lookup, which + /// reproduces the previous behavior for callers that have no script to give. + /// + /// OpenType language-system tag, or null for the script's default. + /// Modified glyph list with substitutions applied + public List ApplySubstitutions(List glyphs, List activeFeatures, string script, string language) + { + if (glyphs == null || glyphs.Count == 0) + return glyphs; + + if (activeFeatures == null || activeFeatures.Count == 0) + return glyphs; + + HashSet activeIndices = GetActiveIndices(script, language); + + var subtablesToApply = new List(); + foreach (var feature in activeFeatures) + { + if (!_featureSubtables.TryGetValue(feature, out var entries)) + { + continue; + } + + foreach (var entry in entries) + { + if (activeIndices != null && !activeIndices.Contains(entry.FeatureIndex)) + { + continue; + } + + subtablesToApply.Add(entry.Subtable); + } + } + + if (subtablesToApply.Count == 0) + return glyphs; + + for (int i = 0; i < glyphs.Count; i++) + { + ushort[] sequence = TryGetSequence(glyphs[i].GlyphId, subtablesToApply); + if (sequence == null || sequence.Length == 0) + continue; + + ShapedGlyph original = glyphs[i]; + glyphs.RemoveAt(i); + + var newGlyphs = new List(sequence.Length); + for (int s = 0; s < sequence.Length; s++) + { + ushort newGlyphId = sequence[s]; + var advance = (short)_font.HmtxTable.GetAdvanceWidth(newGlyphId); + + newGlyphs.Add(new ShapedGlyph + { + GlyphId = newGlyphId, + BaseAdvance = advance, + XAdvance = advance, + YAdvance = 0, + XOffset = 0, + YOffset = 0, + ClusterIndex = original.ClusterIndex, + // The whole original CharCount belongs to the first output glyph, so the + // total across the expanded glyphs still adds up to the source text + // length - the same convention LigatureProcessor uses in reverse (there, + // several input CharCounts are summed onto the one output glyph). + CharCount = (byte)(s == 0 ? original.CharCount : 0), + FontId = original.FontId + }); + } + + glyphs.InsertRange(i, newGlyphs); + i += newGlyphs.Count - 1; // Skip past the glyphs just inserted. + } + + return glyphs; + } + + private HashSet GetActiveIndices(string script, string language) + { + string cacheKey = (script ?? string.Empty) + "|" + (language ?? string.Empty); + + if (!_activeIndexCache.TryGetValue(cacheKey, out var indices)) + { + indices = ScriptFeatureResolver.GetActiveFeatureIndices(_gsubTable?.ScriptList, script, language); + _activeIndexCache[cacheKey] = indices; + } + + return indices; + } + + /// + /// Tries to find a substitute sequence for a given glyph ID in the specified subtables. + /// + private static ushort[] TryGetSequence(ushort glyphId, List subtables) + { + foreach (var subtable in subtables) + { + int coverageIndex = subtable.Coverage?.GetGlyphIndex(glyphId) ?? -1; + if (coverageIndex < 0) + continue; + + ushort[] sequence = subtable.GetSubstitution(glyphId); + if (sequence != null) + { + return sequence; + } + } + + return null; + } + + /// + /// Returns the MultipleSubstSubTable a GSUB subtable represents, unwrapping Extension + /// Substitution (Type 7) if needed. Returns null for anything that is not, directly or + /// via unwrapping, a MultipleSubstSubTable. + /// + private static MultipleSubstSubTable UnwrapMultipleSubstSubtable(Tables.FontTableElement subtableObj) + { + if (subtableObj is MultipleSubstSubTable direct) + { + return direct; + } + + if (subtableObj is ExtensionSubstSubTable extension + && extension.ExtensionLookupType == 2 + && extension.ExtendedSubTable is MultipleSubstSubTable wrapped) + { + return wrapped; + } + + return null; + } + + /// + /// Builds a map of feature tags to their Multiple Substitution subtables, keeping each + /// subtable's original FeatureList index so ApplySubstitutions can filter by script + /// later. Entries are APPENDED rather than overwritten per tag: two FeatureRecords can + /// legitimately share a tag (one per script), and both must survive so the script filter + /// has something to choose between at lookup time. + /// + private void BuildFeatureSubtableMap() + { + if (_gsubTable?.FeatureList?.FeatureRecords == null) + return; + + var featureRecords = _gsubTable.FeatureList.FeatureRecords; + + for (int featureIndex = 0; featureIndex < featureRecords.Count; featureIndex++) + { + var featureRecord = featureRecords[featureIndex]; + string featureTag = featureRecord.FeatureTag.Value; + + var feature = featureRecord.FeatureTable; + + foreach (var lookupIndex in feature.LookupListIndices) + { + if (lookupIndex < _gsubTable.LookupList.Lookups.Count) + { + var lookup = _gsubTable.LookupList.Lookups[lookupIndex]; + + // Type 2 (Multiple Substitution) directly, or Type 7 (Extension) wrapping + // one - same reasoning as SingleSubstitutionProcessor: GSUB keeps the + // wrapper rather than flattening it at load time. + if (lookup.LookupType == 2 || lookup.LookupType == 7) + { + foreach (var subtableObj in lookup.SubTables) + { + var multiSubst = UnwrapMultipleSubstSubtable(subtableObj); + if (multiSubst == null) continue; + + if (!_featureSubtables.TryGetValue(featureTag, out var list)) + { + list = new List(); + _featureSubtables[featureTag] = list; + } + + list.Add(new IndexedSubtable(featureIndex, multiSubst)); + } + } + } + } + } + } + } +} \ 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..834c9c47c7 100644 --- a/src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs +++ b/src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs @@ -34,6 +34,7 @@ public partial class TextShaper : ITextShaper private readonly MarkToBaseProvider _markToBaseProvider; private readonly SingleAdjustmentProvider _singleAdjustmentProvider; private readonly SingleSubstitutionProcessor _singleSubstitutionProcessor; + private readonly MultipleSubstitutionProcessor _multipleSubstitutionProcessor; private readonly ChainingContextualProcessor _chainingContextualProcessor; private readonly IFontProvider _fontProvider; @@ -94,6 +95,7 @@ public TextShaper(IFontProvider fontProvider) _markToBaseProvider = new MarkToBaseProvider(_primaryFont); _singleAdjustmentProvider = new SingleAdjustmentProvider(_primaryFont); _singleSubstitutionProcessor = new SingleSubstitutionProcessor(_primaryFont); + _multipleSubstitutionProcessor = new MultipleSubstitutionProcessor(_primaryFont); _chainingContextualProcessor = new ChainingContextualProcessor(_primaryFont, _singleSubstitutionProcessor, _ligatureProcessor); } @@ -317,44 +319,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 +380,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 @@ -454,13 +504,21 @@ private List ApplyGsubSubstitutions(List glyphs, Shapi /// private List ApplyGsubSubstitutionsInternal(List glyphs, ShapingOptions options) { - // Phase 1: Single Substitution (Type 1) + // Phase 1: Multiple Substitution (Type 2) - one glyph expanding into several. Applied + // first, before anything that narrows the glyph list (Type 1/4/6), matching how fonts + // typically use it for "ccmp" decomposition ahead of other substitution/positioning. + if (options.GsubFeatures != null && options.GsubFeatures.Count > 0) + { + glyphs = _multipleSubstitutionProcessor.ApplySubstitutions(glyphs, options.GsubFeatures, options.Script, options.Language); + } + + // Phase 2: Single Substitution (Type 1) if (options.GsubFeatures != null && options.GsubFeatures.Count > 0) { glyphs = _singleSubstitutionProcessor.ApplySubstitutions(glyphs, options.GsubFeatures, options.Script, options.Language); } - // Phase 2: Chaining Contextual Substitution (Type 6) - once per active feature tag, + // Phase 3: Chaining Contextual Substitution (Type 6) - once per active feature tag, // not just "liga". A font can define calt/clig/rlig contextual rules too. if (options.GsubFeatures != null) { @@ -470,7 +528,7 @@ private List ApplyGsubSubstitutionsInternal(List glyph } } - // Phase 3: Ligatures (Type 4, including Type 7 extension-wrapped) - across every + // Phase 4: Ligatures (Type 4, including Type 7 extension-wrapped) - across every // active feature tag (liga, dlig, clig, ...), not just "liga". if (options.GsubFeatures != null && options.GsubFeatures.Count > 0) { diff --git a/src/EPPlus.Interfaces/Fonts/GsubFeature.cs b/src/EPPlus.Interfaces/Fonts/GsubFeature.cs index 5e58a1f10a..268f485b17 100644 --- a/src/EPPlus.Interfaces/Fonts/GsubFeature.cs +++ b/src/EPPlus.Interfaces/Fonts/GsubFeature.cs @@ -54,6 +54,27 @@ public enum GsubFeature /// unless explicitly requested, since they are a stylistic choice rather than a /// correctness requirement. /// - Dlig = 1 << 2 + Dlig = 1 << 2, + + /// + /// Contextual alternates ("calt"). Glyph substitutions a font applies based on + /// surrounding context rather than unconditionally. Connected or cursive script fonts + /// commonly rely on this to join adjacent letterforms - without it, such fonts can + /// render as visibly disconnected glyphs rather than a flowing script. Unlike + /// , this is generally a correctness expectation rather than a + /// stylistic choice, which is why it is included by default. + /// + Calt = 1 << 3, + + /// + /// Glyph composition and decomposition ("ccmp"). Rearranges glyphs so that other + /// features and mark positioning can work on them - most often by DECOMPOSING a + /// precomposed glyph into a base glyph plus separate combining marks, but composition + /// in the other direction is equally valid. The OpenType specification treats this as + /// an always-on feature that shaping engines apply before other substitutions, rather + /// than an optional stylistic choice, which is why it is included by default and runs + /// first. + /// + Ccmp = 1 << 4 } } \ No newline at end of file diff --git a/src/EPPlus.Interfaces/Fonts/GsubFeatureTags.cs b/src/EPPlus.Interfaces/Fonts/GsubFeatureTags.cs index c21ddc83d6..b31e21c493 100644 --- a/src/EPPlus.Interfaces/Fonts/GsubFeatureTags.cs +++ b/src/EPPlus.Interfaces/Fonts/GsubFeatureTags.cs @@ -36,9 +36,15 @@ public static List ToTagList(GsubFeature features) { var tags = new List(); + // "ccmp" is emitted FIRST deliberately. The shaping pipeline applies chaining + // contextual and ligature lookups once per tag in the order this list gives, and + // ccmp is specified to run before other substitutions - it rearranges glyphs so the + // later features have something well-formed to match against. + if ((features & GsubFeature.Ccmp) != 0) tags.Add("ccmp"); if ((features & GsubFeature.Liga) != 0) tags.Add("liga"); if ((features & GsubFeature.Clig) != 0) tags.Add("clig"); if ((features & GsubFeature.Dlig) != 0) tags.Add("dlig"); + if ((features & GsubFeature.Calt) != 0) tags.Add("calt"); return tags; } diff --git a/src/EPPlus.Interfaces/Fonts/ShapingOptions.cs b/src/EPPlus.Interfaces/Fonts/ShapingOptions.cs index f02a24dedf..7a67c8956a 100644 --- a/src/EPPlus.Interfaces/Fonts/ShapingOptions.cs +++ b/src/EPPlus.Interfaces/Fonts/ShapingOptions.cs @@ -54,7 +54,12 @@ public class ShapingOptions public string Language { get; set; } /// - /// Default shaping options: ligatures and kerning enabled. + /// Default shaping options: glyph composition/decomposition, ligatures, contextual + /// alternates, and kerning enabled. + /// This is the shaping engine's own default - every feature that is safe to apply + /// unconditionally without a specific reason to hold it back. Use + /// to explicitly trade some of this off for speed, or for a plain + /// character-to-glyph mapping with no shaping at all. /// public static ShapingOptions Default { @@ -63,7 +68,8 @@ public static ShapingOptions Default return new ShapingOptions { ApplySubstitutions = true, - GsubFeatures = new List { "liga", "clig" }, + // "ccmp" first - it is specified to run before other substitutions. + GsubFeatures = new List { "ccmp", "liga", "calt", "clig" }, ApplyPositioning = true, GposFeatures = new List { "kern", "mark" }, Script = "latn", @@ -92,26 +98,6 @@ public static ShapingOptions Fast } } - /// - /// Full shaping: all features enabled. - /// Use for high-quality rendering. - /// - public static ShapingOptions Full - { - get - { - return new ShapingOptions - { - ApplySubstitutions = true, - GsubFeatures = new List { "liga", "calt", "clig" }, - ApplyPositioning = true, - GposFeatures = new List { "kern", "mark" }, - Script = "latn", - Language = null - }; - } - } - /// /// No shaping: just map characters to glyphs. /// Fastest option, but no ligatures or kerning.