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 ab727dd81a..fe5001f3a0 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/EBGaramond-Regular.ttf b/src/EPPlus.Export.Pdf.Tests/Fonts/EBGaramond-Regular.ttf new file mode 100644 index 0000000000..fd54ab43fd Binary files /dev/null and b/src/EPPlus.Export.Pdf.Tests/Fonts/EBGaramond-Regular.ttf differ diff --git a/src/EPPlus.Export.Pdf.Tests/KerningReproTests.cs b/src/EPPlus.Export.Pdf.Tests/KerningReproTests.cs index 8362375eda..a3d9447e88 100644 --- a/src/EPPlus.Export.Pdf.Tests/KerningReproTests.cs +++ b/src/EPPlus.Export.Pdf.Tests/KerningReproTests.cs @@ -18,7 +18,7 @@ Date Author Change using System.IO; using System.Text; -namespace EPPlusTest.PDF +namespace EPPlus.Export.Pdf.Tests { /// /// Reproduces the reference sheet the kerning and mark-to-base work is measured against. diff --git a/src/EPPlus.Export.Pdf.Tests/LigatureDligReproTests.cs b/src/EPPlus.Export.Pdf.Tests/LigatureDligReproTests.cs new file mode 100644 index 0000000000..a5ae7b1e8c --- /dev/null +++ b/src/EPPlus.Export.Pdf.Tests/LigatureDligReproTests.cs @@ -0,0 +1,126 @@ +/************************************************************************************************* + 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/07/2026 EPPlus Software AB WP3/WP4 dlig ligature repro sheet + *************************************************************************************************/ +using EPPlus.Export.Pdf.Settings; +using EPPlus.Export.Pdf.Tests; +using EPPlus.Fonts.OpenType; +using OfficeOpenXml; +using OfficeOpenXml.Interfaces.Fonts; +using OfficeOpenXml.Style; +using System; +using System.IO; + +namespace EPPlusTest.PDF +{ + /// + /// Visible before/after repro for the ligature feature-tag fix: EBGaramond's "dlig" feature + /// merges "Th" into a single connected historical ligature glyph - a real, visually obvious + /// effect, unlike "liga" which only covers the common ff/fi/fl set. + /// + /// Does not depend on a system font. EBGaramond-Regular.ttf is a repo test font (see + /// EPPlus.Fonts.OpenType.Tests/Fonts) and is loaded via workbook.ConfigureFonts pointing at + /// this test project's own Fonts folder, so both tests below are deterministic and CI-safe. + /// + /// The two tests are NOT symmetric in how they export, and that is unavoidable given the + /// current public API: + /// + /// - "Off" uses PdfTestBase.SaveAsPdf(sheet, name), since the default GsubFeatures + /// (Liga | Clig) is already what it needs. + /// - "On" needs GsubFeature.Dlig, which cannot be reached through the plain SaveAsPdf + /// overloads at all - ExcelWorksheet.SaveAsPdf always builds its own PdfPageSettings + /// internally (GetPdfSettings.GetPdfSettingsFromPrinterSettings), with no path from + /// ExcelPrinterSettings into GsubFeatures/GposFeatures. "On" therefore uses + /// PdfTestBase's PdfPageSettings-accepting SaveAsPdf overload instead. + /// + [TestClass] + public class LigatureDligReproTests : PdfTestBase + { + private const string ReproFontFamily = "EB Garamond"; + private const float ReproFontSize = 48f; + private const string ReproText = "Th"; + + private static string FontsFolder => Path.Combine(AppContext.BaseDirectory, "Fonts"); + + [TestMethod] + public void Th_EBGaramond48_DligOff_DoesNotLigate() + { + using (var package = OpenPackage("WP3DligRepro_Off.xlsx", true)) + { + var workbook = package.Workbook; + + workbook.ConfigureFonts(cfg => + { + cfg.FontDirectories.Add(FontsFolder); + cfg.SearchSystemDirectories = false; + }); + + 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. + SaveAsPdf(sheet, "WP3DligRepro_Off"); + + SaveWorkbook("WP3DligRepro_Off.xlsx", package); + } + } + + [TestMethod] + public void Th_EBGaramond48_DligOn_MergesIntoLigature() + { + using (var engine = new OpenTypeFontEngine(cfg => + { + cfg.FontDirectories.Add(FontsFolder); + cfg.SearchSystemDirectories = false; + })) + using (var package = OpenPackage("WP3DligRepro_On.xlsx", true)) + { + var sheet = BuildReproSheet(package.Workbook); + + // GsubFeature.Dlig cannot be requested through the plain SaveAsPdf overloads - + // see PdfTestBase's PdfPageSettings-accepting overload for why. + var settings = new PdfPageSettings(engine) + { + GsubFeatures = GsubFeature.Liga | GsubFeature.Clig | GsubFeature.Dlig + }; + + SaveAsPdf(sheet, "WP3DligRepro_On", settings); + + SaveWorkbook("WP3DligRepro_On.xlsx", package); + } + } + + /// + /// Open WP3DligRepro_Off.pdf and WP3DligRepro_On.pdf side by side (both under + /// _pdfPath, i.e. c:\epplusTest\Testoutput\PDF\). In "Off", "Th" renders as a plain T + /// followed by a plain h. In "On" it renders as a single connected T-h glyph with a + /// joining stroke - visibly narrower than the two separate letters. Before the fix, both + /// files rendered identically, since "dlig" never reached the shaper regardless of what + /// GsubFeatures was set to. + /// + private static ExcelWorksheet BuildReproSheet(ExcelWorkbook workbook) + { + var sheet = workbook.Worksheets.Add("Dlig"); + + sheet.Cells["A1"].Value = ReproText; + sheet.Cells["A1"].Style.Font.Name = ReproFontFamily; + sheet.Cells["A1"].Style.Font.Size = ReproFontSize; + sheet.Cells["A1"].Style.HorizontalAlignment = ExcelHorizontalAlignment.Left; + sheet.Cells["A1"].Style.Indent = 0; + + sheet.Column(1).Width = 25; + sheet.Row(1).CustomHeight = true; + sheet.Row(1).Height = 70; + + return sheet; + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Export.Pdf.Tests/MeasurementRenderWidthParityTests.cs b/src/EPPlus.Export.Pdf.Tests/MeasurementRenderWidthParityTests.cs new file mode 100644 index 0000000000..38c1215cc3 --- /dev/null +++ b/src/EPPlus.Export.Pdf.Tests/MeasurementRenderWidthParityTests.cs @@ -0,0 +1,253 @@ +/************************************************************************************************* + 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/07/2026 EPPlus Software AB Measurement/render width parity (WP1) + *************************************************************************************************/ +using EPPlus.Export.Pdf.Settings; +using EPPlus.Export.Pdf.Tests; +using EPPlus.Fonts.OpenType; +using OfficeOpenXml; +using OfficeOpenXml.Export.PdfExport; +using OfficeOpenXml.Packaging.Ionic.Zlib; +using OfficeOpenXml.Style; +using System; +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; + +namespace EPPlusTest.PDF +{ + /// + /// WP1's last, previously unverified acceptance criterion: the measurement path + /// (PdfCatalog.GetCellCollectionFromRange, which shapes against the FULL font and skips + /// BuildSubsets) and the render path (PdfCatalog.Save, which shapes against the SUBSETTED + /// font) must produce the same width for the same string and font. + /// + /// This matters because autofit and row-height calculations are driven by the measurement + /// path's PdfCell.TotalTextLength, while what actually appears on the page is driven by the + /// render path. If the two ever diverged, a cell could measure as fitting a string, but the + /// rendered glyphs would run wider (or narrower) than that measurement said - text clipped or + /// falsely wrapped, or autofit-shrunk column widths leaving unnecessary blank margin. + /// + /// The test: + /// 1. Gets the MEASURED width via GetCellCollectionFromRange -> PdfCell.TotalTextLength. + /// 2. Renders the same sheet to actual PDF bytes. + /// 3. Independently recomputes the RENDERED width by parsing the content stream's TJ array + /// and looking up each glyph's advance in the embedded (subsetted) font's own hmtx + /// table, loaded via this same library's OpenTypeFontFactory - not via any external + /// tool, and not by trusting the PDF's own /W CID-widths array, since that would only be + /// checking the writer's arithmetic against itself. + /// 4. Asserts the two match within a small tolerance. + /// + /// The tolerance is not slack for a real discrepancy: PdfContentStream writes kerning + /// adjustments through ToPdfStringF0 (rounded to the nearest integer TJ unit), while + /// TotalTextLength is computed from the exact, unrounded kerning value. That integer rounding + /// is the only expected source of difference, and it is at most a few hundredths of a point + /// per kerned pair. + /// + /// Uses "AVATAR Wa To" in Roboto - a repo test font with real kerning and no accents - so the + /// content stream only contains glyph tokens and kerning numbers, with no Ts (mark offset) or + /// mid-string font switches to account for. + /// + [TestClass] + public class MeasurementRenderWidthParityTests : PdfTestBase + { + // Encoding.Latin1 (a 1:1 byte<->char mapping, needed so string indices from Regex + // line up with byte offsets in the original array) is only available from .NET 5+. + // GetEncoding("ISO-8859-1") gives the same mapping on both net481 and net8.0. + private static readonly Encoding Latin1 = Encoding.GetEncoding("ISO-8859-1"); + + private const string ReproFontFamily = "Roboto"; + private const float ReproFontSize = 48f; + private const string ReproText = "AVATAR Wa To"; + + // PdfContentStream.ToPdfStringF0 rounds each kerning TJ number to the nearest integer + // (1/1000 em unit). At 48pt that is at most 0.048pt of rounding error per kerned pair; + // "AVATAR Wa To" has six kerned pairs (A+V, V+A, A+T, T+A, W+a, T+o), so 0.5pt covers the + // worst case with headroom. + private const double ToleranceInPoints = 0.5; + + [TestMethod] + public void MeasuredWidth_MatchesRenderedWidth_ForKernedText() + { + using (var package = OpenPackage("WidthParity.xlsx", true)) + { + var sheet = package.Workbook.Worksheets.Add("Parity"); + sheet.Cells["A1"].Value = ReproText; + sheet.Cells["A1"].Style.Font.Name = ReproFontFamily; + sheet.Cells["A1"].Style.Font.Size = ReproFontSize; + sheet.Cells["A1"].Style.HorizontalAlignment = ExcelHorizontalAlignment.Left; + sheet.Cells["A1"].Style.Indent = 0; + sheet.Column(1).Width = 60; + sheet.Row(1).CustomHeight = true; + sheet.Row(1).Height = 70; + + var engine = package.Workbook.RenderContext.FontEngine; + var settings = new PdfPageSettings(engine); + var range = sheet.Cells["A1"]; + + double measuredWidth = GetMeasuredWidth(settings, range); + + var pdfBytes = RenderToBytes(settings, sheet); + + // Written out unconditionally (not just on failure) so a parsing mismatch can be + // diagnosed from the actual bytes rather than guessed at from source alone. + File.WriteAllBytes(Path.Combine(_pdfPath, "WidthParity.pdf"), pdfBytes); + + double renderedWidth = GetRenderedWidth(pdfBytes, ReproFontSize); + + Assert.AreNotEqual(0, measuredWidth, "measured width must not be zero - shaping did not run"); + Assert.AreNotEqual(0, renderedWidth, "rendered width must not be zero - TJ parsing found nothing"); + + Assert.AreEqual( + measuredWidth, + renderedWidth, + ToleranceInPoints, + $"measurement path width ({measuredWidth:F3}pt) and render path width " + + $"({renderedWidth:F3}pt) must match within rounding. A real divergence here " + + "means the measurement path (full font) and render path (subsetted font) " + + "are shaping this string differently."); + } + } + + private static double GetMeasuredWidth(PdfPageSettings settings, ExcelRangeBase range) + { + var catalog = new PdfCatalog(); + var cells = catalog.GetCellCollectionFromRange(settings, range); + return cells[range.Start.Row, range.Start.Column].TotalTextLength; + } + + private static byte[] RenderToBytes(PdfPageSettings settings, ExcelWorksheet sheet) + { + using (var stream = new MemoryStream()) + { + new PdfCatalog(settings, sheet).Save(stream); + return stream.ToArray(); + } + } + + private static double GetRenderedWidth(byte[] pdfBytes, float fontSize) + { + string pdfText = Latin1.GetString(pdfBytes); + + byte[] fontBytes = ExtractFirstFontFile2(pdfBytes, pdfText); + Assert.IsNotNull(fontBytes, "no /FontFile2 found in the rendered PDF"); + + var font = OpenTypeFontFactory.CreateFromBytes(fontBytes); + ushort unitsPerEm = font.HeadTable.UnitsPerEm; + + string contentStream = ExtractContentStreamWithTJ(pdfBytes, pdfText); + Assert.IsNotNull(contentStream, "no content stream containing a TJ array was found"); + + var tjMatch = Regex.Match(contentStream, @"\[(?.*?)\]\s*TJ", RegexOptions.Singleline); + Assert.IsTrue(tjMatch.Success, "no [...] TJ array found in the content stream"); + + double widthInFontUnits = 0; + + foreach (Match token in Regex.Matches( + tjMatch.Groups["body"].Value, + @"<(?[0-9A-Fa-f]{4})>|(?-?\d+(\.\d+)?)")) + { + if (token.Groups["glyph"].Success) + { + ushort glyphId = ushort.Parse(token.Groups["glyph"].Value, NumberStyles.HexNumber); + widthInFontUnits += font.HmtxTable.GetAdvanceWidth(glyphId); + } + else if (token.Groups["num"].Success) + { + // TJ numbers are in 1/1000 text space units already, independent of the + // font's own unitsPerEm - convert to the same font-unit space as the glyph + // advances above so both can be summed together before the final scale to + // points. + double tjNumber = double.Parse(token.Groups["num"].Value, CultureInfo.InvariantCulture); + widthInFontUnits -= tjNumber / 1000.0 * unitsPerEm; + } + } + + return widthInFontUnits / unitsPerEm * fontSize; + } + + /// + /// Finds the first "N 0 obj ... stream ... endstream" block that contains "TJ" once + /// inflated, skipping font-file streams. EPPlus's PDF writer never emits object or + /// cross-reference streams, so every stream is either an uncompressed page content + /// stream candidate or a FlateDecode-compressed one - this handles both. + /// + private static string ExtractContentStreamWithTJ(byte[] pdfBytes, string pdfText) + { + foreach (Match m in Regex.Matches(pdfText, @"<<(?.*?)>>\s*stream\r?\n", RegexOptions.Singleline)) + { + if (m.Groups["dict"].Value.Contains("FontFile")) + continue; + + int start = m.Index + m.Length; + int end = pdfText.IndexOf("endstream", start, StringComparison.Ordinal); + if (end < 0) continue; + + byte[] raw = Latin1Slice(pdfBytes, start, end); + byte[] data = m.Groups["dict"].Value.Contains("FlateDecode") ? Inflate(raw) : raw; + + string text = Latin1.GetString(data); + if (text.Contains("TJ")) + return text; + } + + return null; + } + + /// + /// Finds the object referenced by the first /FontFile2 entry and returns its + /// (decompressed) stream bytes - the embedded, SUBSETTED font, which is exactly what the + /// render path actually shaped against and what a PDF viewer would use. + /// + private static byte[] ExtractFirstFontFile2(byte[] pdfBytes, string pdfText) + { + var refMatch = Regex.Match(pdfText, @"/FontFile2\s+(?\d+)\s+0\s+R"); + if (!refMatch.Success) return null; + + string objNum = refMatch.Groups["num"].Value; + + var objMatch = Regex.Match(pdfText, $@"(?<<.*?>>)\s*stream\r?\n", RegexOptions.Singleline); + if (!objMatch.Success) return null; + + int start = objMatch.Index + objMatch.Length; + int end = pdfText.IndexOf("endstream", start, StringComparison.Ordinal); + if (end < 0) return null; + + byte[] raw = Latin1Slice(pdfBytes, start, end); + return objMatch.Groups["dict"].Value.Contains("FlateDecode") ? Inflate(raw) : raw; + } + + /// + /// Slices the ORIGINAL byte array using offsets found via a Latin-1 string view of it. + /// Latin-1 is a 1:1 byte<->char mapping, so string indices returned by Regex against the + /// Latin-1-decoded text correspond exactly to byte offsets in the original array - unlike + /// UTF-8 or any other multi-byte encoding, which would desynchronize the two. + /// + private static byte[] Latin1Slice(byte[] bytes, int start, int end) + { + var result = new byte[end - start]; + Array.Copy(bytes, start, result, 0, result.Length); + return result; + } + + private static byte[] Inflate(byte[] flateCompressed) + { + using (var input = new MemoryStream(flateCompressed)) + using (var zlib = new ZlibStream(input, CompressionMode.Decompress)) + using (var output = new MemoryStream()) + { + zlib.CopyTo(output); + return output.ToArray(); + } + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Export.Pdf.Tests/PdfTestBase.cs b/src/EPPlus.Export.Pdf.Tests/PdfTestBase.cs index 5682e42e1f..ee3cd93380 100644 --- a/src/EPPlus.Export.Pdf.Tests/PdfTestBase.cs +++ b/src/EPPlus.Export.Pdf.Tests/PdfTestBase.cs @@ -1,10 +1,7 @@ -using EPPlusTest; +using EPPlus.Export.Pdf.Settings; +using EPPlusTest; using OfficeOpenXml; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using OfficeOpenXml.Export.PdfExport; namespace EPPlus.Export.Pdf.Tests { @@ -14,7 +11,7 @@ public abstract class PdfTestBase : TestBase protected void SaveAsPdf(ExcelWorksheet sheet, string pdfFileName) { - if(!pdfFileName.ToLower().EndsWith(".pdf")) + if (!pdfFileName.ToLower().EndsWith(".pdf")) { pdfFileName += ".pdf"; } @@ -44,5 +41,54 @@ protected void SaveAsPdf(ExcelWorkbook wb, string pdfFileName, params ExcelRange else ranges[0].SaveAsPdf(path); } + + /// + /// Exports with a caller-supplied PdfPageSettings instead of one built from the + /// worksheet's printer settings. Needed for anything ExcelWorksheet.SaveAsPdf has no way + /// to express - e.g. GsubFeatures/GposFeatures, or a custom font engine/directory - + /// since that extension method always builds its own PdfPageSettings internally + /// (GetPdfSettings.GetPdfSettingsFromPrinterSettings) with no way to override it. + /// Drives PdfCatalog directly: the same internal entry point SaveAsPdf itself calls into. + /// + protected void SaveAsPdf(ExcelWorksheet sheet, string pdfFileName, PdfPageSettings settings) + { + if (!pdfFileName.ToLower().EndsWith(".pdf")) + { + pdfFileName += ".pdf"; + } + var path = Path.Combine(_pdfPath, pdfFileName); + new PdfCatalog(settings, sheet).Save(path); + } + + /// + /// Workbook-level counterpart to . + /// See that overload's remarks for why this bypasses ExcelWorkbook.SaveAsPdf. + /// + protected void SaveAsPdf(ExcelWorkbook wb, string pdfFileName, PdfPageSettings settings) + { + if (!pdfFileName.ToLower().EndsWith(".pdf")) + { + pdfFileName += ".pdf"; + } + var path = Path.Combine(_pdfPath, pdfFileName); + new PdfCatalog(settings, wb).Save(path); + } + + /// + /// Range-collection counterpart to . + /// See that overload's remarks for why this bypasses ExcelRangeBase/ExcelWorkbook.SaveAsPdf. + /// + protected void SaveAsPdf(ExcelWorkbook wb, string pdfFileName, PdfPageSettings settings, params ExcelRangeBase[] ranges) + { + if (!pdfFileName.ToLower().EndsWith(".pdf")) + { + pdfFileName += ".pdf"; + } + var path = Path.Combine(_pdfPath, pdfFileName); + if (ranges.Count() > 1) + new PdfCatalog(settings, ranges).Save(path); + else + new PdfCatalog(settings, ranges[0]).Save(path); + } } -} +} \ No newline at end of file diff --git a/src/EPPlus.Export.Pdf.Tests/PdfTextShaperShapingOptionsTests.cs b/src/EPPlus.Export.Pdf.Tests/PdfTextShaperShapingOptionsTests.cs new file mode 100644 index 0000000000..dc7603fb24 --- /dev/null +++ b/src/EPPlus.Export.Pdf.Tests/PdfTextShaperShapingOptionsTests.cs @@ -0,0 +1,100 @@ +/************************************************************************************************* + 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/07/2026 EPPlus Software AB GsubFeature/GposFeature plumbing + *************************************************************************************************/ +using EPPlus.Export.Pdf.Settings; +using EPPlus.Fonts.OpenType; +using OfficeOpenXml.Export.PdfExport.TextShaping; +using OfficeOpenXml.Interfaces.Fonts; +using System.Linq; + +namespace EPPlus.Export.Pdf.Tests +{ + /// + /// Covers PdfTextShaper.BuildShapingOptions, in particular GsubFeature.None/GposFeature.None. + /// + /// TextShaper.ApplyPositioning treats an empty or null GposFeatures list as "apply every GPOS + /// feature" for kerning and mark positioning - so naively mapping GposFeature.None to an + /// empty tag list and assigning it to ShapingOptions.GposFeatures would turn kerning and mark + /// positioning ON, the opposite of what None means. BuildShapingOptions instead turns + /// ApplyPositioning/ApplySubstitutions off outright when None is requested, which is + /// unambiguous regardless of how an empty tag list is interpreted further down. + /// + [TestClass] + public class PdfTextShaperShapingOptionsTests + { + public TestContext? TestContext { get; set; } + + private static PdfPageSettings CreateSettings() + { + // BuildShapingOptions never touches pageSettings.FontEngine, but the engine is kept + // alive (not disposed) regardless, so this helper stays valid if that ever changes. + var engine = new OpenTypeFontEngine(); + return new PdfPageSettings(engine); + } + + [TestMethod] + public void BuildShapingOptions_Default_AppliesBothAndUsesDefaultTags() + { + var settings = CreateSettings(); + // Defaults per PdfPageSettings: GsubFeatures = Liga|Clig, 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[] { "kern", "mark" }, options.GposFeatures.ToList()); + } + + [TestMethod] + public void BuildShapingOptions_GsubNone_TurnsOffSubstitutionsEntirely() + { + var settings = CreateSettings(); + settings.GsubFeatures = GsubFeature.None; + + var options = PdfTextShaper.BuildShapingOptions(settings); + + Assert.IsFalse( + options.ApplySubstitutions, + "GsubFeature.None must disable substitutions outright, not rely on an empty tag " + + "list being interpreted as \"apply nothing\" somewhere downstream"); + } + + [TestMethod] + public void BuildShapingOptions_GposNone_TurnsOffPositioningEntirely() + { + var settings = CreateSettings(); + settings.GposFeatures = GposFeature.None; + + var options = PdfTextShaper.BuildShapingOptions(settings); + + Assert.IsFalse( + options.ApplyPositioning, + "GposFeature.None must disable positioning outright. TextShaper.ApplyPositioning " + + "treats an empty/null GposFeatures list as \"apply every feature\" for kerning " + + "and mark, so leaving ApplyPositioning on with an empty list here would turn " + + "kerning and mark ON instead of off."); + } + + [TestMethod] + public void BuildShapingOptions_GsubDligOnly_MapsToSingleTag() + { + var settings = CreateSettings(); + settings.GsubFeatures = GsubFeature.Dlig; + + var options = PdfTextShaper.BuildShapingOptions(settings); + + Assert.IsTrue(options.ApplySubstitutions); + CollectionAssert.AreEquivalent(new[] { "dlig" }, options.GsubFeatures.ToList()); + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs b/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs index 76287e5a12..158b29dc61 100644 --- a/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs +++ b/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs @@ -13,6 +13,7 @@ Date Author Change using EPPlus.Export.Pdf.Settings.PdfPageSizes; using EPPlus.Fonts.OpenType; using OfficeOpenXml; +using OfficeOpenXml.Interfaces.Fonts; using System.Collections.Generic; using System.Linq; @@ -196,6 +197,32 @@ 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. + /// + /// + /// This setting applies to the whole export - there is currently no per-cell or + /// per-range override. Whether a requested feature has any visible effect still depends + /// 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; + + /// + /// 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. + /// + /// + /// This setting applies to the whole export - there is currently no per-cell or + /// per-range override. Whether a requested feature has any visible effect still depends + /// on the font actually defining it; see for what each flag + /// means. + /// + public GposFeature GposFeatures { get; set; } = GposFeature.Kern | GposFeature.Mark; + internal PdfContentBounds ContentBounds = new PdfContentBounds(PdfMargins.Normal, PdfPageSize.A4); internal string defaultFontName = ""; diff --git a/src/EPPlus.Fonts.OpenType.Tests/FeatureTagMappingCoverageTests.cs b/src/EPPlus.Fonts.OpenType.Tests/FeatureTagMappingCoverageTests.cs new file mode 100644 index 0000000000..f0b77aaa8d --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/FeatureTagMappingCoverageTests.cs @@ -0,0 +1,97 @@ +/************************************************************************************************* + 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/07/2026 EPPlus Software AB Feature-tag mapping coverage + *************************************************************************************************/ +using OfficeOpenXml.Interfaces.Fonts; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace EPPlus.Fonts.OpenType.Tests +{ + /// + /// GsubFeatureTags.ToTagList and GposFeatureTags.ToTagList use an explicit if-chain rather + /// than reflecting over the enum, specifically so a future named combination value (e.g. an + /// "All = Kern | Mark" convenience member) cannot silently slip an extra tag into the list - + /// see the discussion in Åtgärdsplan about GposFeature.All matching via bitwise AND. + /// + /// The trade-off is that the if-chain has to be updated by hand whenever a new single-bit + /// flag is added to either enum. These tests are the safety net for that: they walk every + /// enum member via reflection and require each single-bit flag to produce exactly one, and a + /// DIFFERENT, tag. Forgetting to add a branch in ToTagList turns into a failing test here, + /// rather than a silently dropped feature. + /// + [TestClass] + public class FeatureTagMappingCoverageTests + { + public TestContext? TestContext { get; set; } + + [TestMethod] + public void GsubFeatureTags_CoversEverySingleBitFlag() + { + AssertEveryFlagMapsToExactlyOneDistinctTag( + GetSingleBitFlags(), + flag => GsubFeatureTags.ToTagList((GsubFeature)flag)); + } + + [TestMethod] + public void GposFeatureTags_CoversEverySingleBitFlag() + { + AssertEveryFlagMapsToExactlyOneDistinctTag( + GetSingleBitFlags(), + flag => GposFeatureTags.ToTagList((GposFeature)flag)); + } + + /// + /// Returns every defined enum member that is a single-bit flag (excludes None = 0 and any + /// named combination such as a hypothetical "All" whose value has more than one bit set). + /// A named combination is intentionally NOT tested here: its whole point is to be a + /// convenience alias, not a distinct feature with its own tag, so it must not appear in + /// ToTagList's if-chain at all. + /// + private static List GetSingleBitFlags() where TEnum : struct, Enum + { + return Enum.GetValues(typeof(TEnum)) + .Cast() + .Select(v => Convert.ToInt32(v)) + .Where(v => v != 0 && (v & (v - 1)) == 0) // v != 0 and exactly one bit set + .Distinct() + .ToList(); + } + + private static void AssertEveryFlagMapsToExactlyOneDistinctTag( + List singleBitFlags, Func> toTagList) + { + Assert.AreNotEqual( + 0, + singleBitFlags.Count, + "the enum under test must actually have single-bit flags for this test to mean anything"); + + var seenTags = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var flag in singleBitFlags) + { + var tags = toTagList(flag); + + Assert.AreEqual( + 1, + tags.Count, + $"flag value {flag} (0x{flag:X}) must map to exactly one tag. Either it is " + + "missing a branch in ToTagList, or it is unexpectedly mapping to more than one."); + + Assert.IsTrue( + seenTags.Add(tags[0]), + $"tag \"{tags[0]}\" is produced by more than one flag - each single-bit flag " + + "must map to a distinct OpenType feature tag."); + } + } + } +} \ 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 new file mode 100644 index 0000000000..da108f1f7f --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/Ligatures/LigatureFeatureTagTests.cs @@ -0,0 +1,207 @@ +/************************************************************************************************* + 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/07/2026 EPPlus Software AB Ligature feature-tag plumbing (WP3/WP4) + *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Tables.Common.Layout.Coverage; +using EPPlus.Fonts.OpenType.Tables.Common.Layout.Features; +using EPPlus.Fonts.OpenType.Tables.Common.Layout.Lookups; +using EPPlus.Fonts.OpenType.Tables.Gsub; +using EPPlus.Fonts.OpenType.Tables.Gsub.Data.Lookups; +using EPPlus.Fonts.OpenType.TextShaping.Ligatures; +using OfficeOpenXml.Interfaces.Fonts; +using System.Collections.Generic; + +namespace EPPlus.Fonts.OpenType.Tests.TextShaping.Ligatures +{ + /// + /// Covers two previous defects in LigatureProcessor: + /// + /// 1. The feature tag "liga" used to be hardcoded in the constructor, so a ligature tagged + /// "dlig", "clig" or "rlig" was never even loaded, regardless of what + /// ShapingOptions.GsubFeatures asked for. + /// 2. "if (lookup.LookupType != 4) continue" used to skip lookup type 7 (Extension + /// Substitution) entirely. Unlike GPOS, the GSUB loader does NOT unwrap extension + /// lookups - the wrapper survives with LookupType still 7 - so an extension-wrapped + /// ligature was silently dropped. + /// + /// Both tests use a synthetic GsubTable so they do not depend on any specific font file. + /// + [TestClass] + public class LigatureFeatureTagTests : FontTestBase + { + public override TestContext? TestContext { get; set; } + + private const ushort GlyphA = 5; + private const ushort GlyphB = 6; + private const ushort LigatureGlyph = 7; + + /// + /// Previously the constructor only ever looked for the "liga" tag, regardless of what was + /// requested at call time, so a ligature tagged "dlig" was invisible to it no matter what. + /// Now that LigatureProcessor's constructor scans every tag and ApplyLigaturesInPlace + /// takes a featureTags parameter (matching the pattern already used by + /// SingleSubstitutionProcessor.ApplySubstitutions and + /// ChainingContextualProcessor.ApplyContextualSubstitutions), this is a regression test. + /// + [TestMethod] + public void Ligature_TaggedDlig_IsAppliedWhenRequested() + { + var font = TestFolderEngine.LoadFont("BIZUDGothic", FontSubFamily.Regular, ignoreCache: true); + Assert.IsNotNull(font, "BIZUDGothic must be present in the test font folder"); + + font.AddOrReplaceTable(BuildDligOnlyGsubTable()); + + var processor = new LigatureProcessor(font); + + var glyphs = new List + { + new ShapedGlyph { GlyphId = GlyphA }, + new ShapedGlyph { GlyphId = GlyphB } + }; + + processor.ApplyLigaturesInPlace(glyphs, new List { "dlig" }, "latn", null); + + Assert.AreEqual( + 1, + glyphs.Count, + "A+B must be merged into the ligature when \"dlig\" is an active feature tag, " + + "even though the ligature is not tagged \"liga\""); + Assert.AreEqual(LigatureGlyph, glyphs[0].GlyphId); + } + + /// + /// Now a regression test. The "liga" lookup here is Extension Substitution (Type 7) wrapping a + /// LigatureSubstSubTable, which is exactly how EBGaramond and other real fonts in the test + /// suite store some of their ligature data. "if (lookup.LookupType != 4) continue" skips + /// it outright, so the A+B ligature never fires despite "liga" being requested and present. + /// + [TestMethod] + public void Ligature_ExtensionWrapped_IsApplied() + { + var font = TestFolderEngine.LoadFont("BIZUDGothic", FontSubFamily.Regular, ignoreCache: true); + Assert.IsNotNull(font, "BIZUDGothic must be present in the test font folder"); + + font.AddOrReplaceTable(BuildExtensionWrappedLigaGsubTable()); + + var processor = new LigatureProcessor(font); + + var glyphs = new List + { + new ShapedGlyph { GlyphId = GlyphA }, + new ShapedGlyph { GlyphId = GlyphB } + }; + + // Uses the same signature as the "dlig" test above: this defect is independent of + // the feature-tag plumbing fix and is reachable through the "liga" tag alone. + processor.ApplyLigaturesInPlace(glyphs, new List { "liga" }, "latn", null); + + Assert.AreEqual( + 1, + glyphs.Count, + "A+B must be merged even though the \"liga\" lookup is Extension Substitution " + + "(Type 7) wrapping a LigatureSubstSubTable, not a direct Type 4 lookup"); + Assert.AreEqual(LigatureGlyph, glyphs[0].GlyphId); + } + + /// + /// One "dlig" FeatureRecord -> a direct Type 4 LigatureSubstSubTable merging A+B. + /// No "liga" tag anywhere in this table. + /// + private static GsubTable BuildDligOnlyGsubTable() + { + var ligatureSubtable = BuildLigatureSubtable(); + + var lookup = new LookupTable + { + LookupType = 4, + SubTables = new List { ligatureSubtable } + }; + + var featureList = new FeatureListTable + { + FeatureRecords = new List + { + new FeatureRecord + { + FeatureTag = new Tag("dlig"), + FeatureTable = new FeatureTable { LookupListIndices = new ushort[] { 0 } } + } + } + }; + + // No ScriptList: GetActiveIndices falls back to "no filter", so this test exercises + // only the feature-tag defect, not script filtering. + return new GsubTable + { + FeatureList = featureList, + LookupList = new LookupListTable { Lookups = new List { lookup } } + }; + } + + /// + /// One "liga" FeatureRecord -> a Type 7 (Extension) lookup wrapping the same Type 4 + /// LigatureSubstSubTable, mirroring how real fonts store extension-wrapped GSUB data + /// (GSUB keeps the wrapper; unlike GPOS, the loader does not flatten it). + /// + private static GsubTable BuildExtensionWrappedLigaGsubTable() + { + var ligatureSubtable = BuildLigatureSubtable(); + + var extensionWrapper = new ExtensionSubstSubTable + { + ExtensionLookupType = 4, + ExtendedSubTable = ligatureSubtable + }; + + var lookup = new LookupTable + { + LookupType = 7, + SubTables = new List { extensionWrapper } + }; + + var featureList = new FeatureListTable + { + FeatureRecords = new List + { + new FeatureRecord + { + FeatureTag = new Tag("liga"), + FeatureTable = new FeatureTable { LookupListIndices = new ushort[] { 0 } } + } + } + }; + + return new GsubTable + { + FeatureList = featureList, + LookupList = new LookupListTable { Lookups = new List { lookup } } + }; + } + + private static LigatureSubstSubTable BuildLigatureSubtable() + { + return new LigatureSubstSubTable + { + Coverage = new CoverageTableFormat1 { GlyphArray = new ushort[] { GlyphA } }, + LigatureSets = new Dictionary + { + [GlyphA] = new LigatureSetTable + { + Ligatures = new List + { + new LigatureTable { LigatureGlyph = LigatureGlyph, Components = new ushort[] { GlyphB } } + } + } + } + }; + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ScriptAwareGsubTests.cs b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ScriptAwareGsubTests.cs index 8da0fee630..2112baa355 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ScriptAwareGsubTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/ScriptAwareGsubTests.cs @@ -64,7 +64,7 @@ public void Ligatures_ForLatinScript_DoNotPickUpArabicOnlyLigature() new ShapedGlyph { GlyphId = GlyphB } }; - processor.ApplyLigaturesInPlace(glyphs, "latn", null); + processor.ApplyLigaturesInPlace(glyphs, new List { "liga" }, "latn", null); Assert.AreEqual( 2, @@ -213,6 +213,73 @@ private static GsubTable BuildChainingContextualGsubTable() }; } + [TestMethod] + public void SingleSubstitution_ExtensionWrapped_IsApplied() + { + var font = LoadHostFont(); + font.AddOrReplaceTable(BuildExtensionWrappedSingleSubstGsubTable()); + + var processor = new SingleSubstitutionProcessor(font); + + var glyphs = new List + { + new ShapedGlyph { GlyphId = GlyphA, BaseAdvance = 500, XAdvance = 500 } + }; + + var result = processor.ApplySubstitutions( + glyphs, new List { "smcp" }, "latn", null); + + Assert.AreEqual( + SubstituteGlyph, + result[0].GlyphId, + "GlyphA must be substituted even though the \"smcp\" lookup is Extension " + + "Substitution (Type 7) wrapping a SingleSubstSubTable, not a direct Type 1 lookup"); + } + + /// + /// One "smcp" FeatureRecord -> a Type 7 (Extension) lookup wrapping a Type 1 + /// SingleSubstSubTable substituting GlyphA -> SubstituteGlyph. No ScriptList: this test + /// is about extension unwrapping, not script filtering. + /// + private static GsubTable BuildExtensionWrappedSingleSubstGsubTable() + { + var singleSubst = new SingleSubstSubTableFormat2 + { + Coverage = new CoverageTableFormat1 { GlyphArray = new ushort[] { GlyphA } }, + SubstituteGlyphIDs = new ushort[] { SubstituteGlyph } + }; + + var extensionWrapper = new ExtensionSubstSubTable + { + ExtensionLookupType = 1, + ExtendedSubTable = singleSubst + }; + + var lookup = new LookupTable + { + LookupType = 7, + SubTables = new List { extensionWrapper } + }; + + var featureList = new FeatureListTable + { + FeatureRecords = new List + { + new FeatureRecord + { + FeatureTag = new Tag("smcp"), + FeatureTable = new FeatureTable { LookupListIndices = new ushort[] { 0 } } + } + } + }; + + return new GsubTable + { + FeatureList = featureList, + LookupList = new LookupListTable { Lookups = new List { lookup } } + }; + } + /// /// Two "smcp" FeatureRecords: index 0 -> 'latn', a lookup with no subtables. Index 1 -> /// 'arab', a SingleSubstSubTable substituting GlyphA -> SubstituteGlyph. Index 1 is last, diff --git a/src/EPPlus.Fonts.OpenType/TextShaping/Ligatures/LigatureProcessor.cs b/src/EPPlus.Fonts.OpenType/TextShaping/Ligatures/LigatureProcessor.cs index cfdccb6c63..43fce78cf6 100644 --- a/src/EPPlus.Fonts.OpenType/TextShaping/Ligatures/LigatureProcessor.cs +++ b/src/EPPlus.Fonts.OpenType/TextShaping/Ligatures/LigatureProcessor.cs @@ -10,6 +10,10 @@ Date Author Change ************************************************************************************************* 01/15/2025 EPPlus Software AB Initial implementation 09/07/2026 EPPlus Software AB Filter by ScriptList/LangSys, not just tag + 09/07/2026 EPPlus Software AB Read feature tags from GsubFeatures instead of + hardcoded "liga"; unwrap extension-wrapped (Type + 7) ligature lookups, which GSUB does not flatten + the way GPOS does *************************************************************************************************/ using EPPlus.Fonts.OpenType.Tables.Common.Layout.Lookups; using EPPlus.Fonts.OpenType.Tables.Common.Layout.Scripts; @@ -36,80 +40,132 @@ public IndexedLookup(int featureIndex, LookupTable lookup) } private readonly OpenTypeFont _font; - private readonly List _ligaLookups; + + // Feature tag ("liga", "dlig", "clig", "rlig", ...) -> every ligature-relevant lookup + // recorded under that tag, across ALL scripts, together with each lookup's original + // FeatureList index so ApplyLigaturesInPlace can filter by script at call time. Built once + // for every tag the font defines, not just "liga" - which tags are actually applied is + // decided per call via the featureTags parameter, driven by ShapingOptions.GsubFeatures. + private readonly Dictionary> _lookupsByFeatureTag; private readonly Dictionary> _activeIndexCache = new Dictionary>(); public LigatureProcessor(OpenTypeFont font) { _font = font; - if (font.GsubTable != null) - { - _ligaLookups = FindLookupsForFeature(font.GsubTable, "liga"); - } - else - { - _ligaLookups = new List(); - } + _lookupsByFeatureTag = font.GsubTable != null + ? BuildLookupsByFeatureTag(font.GsubTable) + : new Dictionary>(); } /// - /// Applies standard ligature substitutions (fi, ff, ffi, ffl, etc.). + /// Applies ligature substitutions for the given feature tags (e.g. "liga", "dlig"). /// Processes glyphs left-to-right, replacing sequences with ligature glyphs. /// - internal List ApplyLigatures(List glyphs, string script, string language) + internal List ApplyLigatures( + List glyphs, List featureTags, string script, string language) { - var gsub = _font.GsubTable; - if (gsub == null) + if (_font.GsubTable == null) return glyphs; - if (_ligaLookups.Count == 0) - return glyphs; - - ApplyLigaturesInPlace(glyphs, script, language); + ApplyLigaturesInPlace(glyphs, featureTags, script, language); return glyphs; } - internal void ApplyLigaturesInPlace(List glyphs, string script, string language) + /// + /// Applies ligature substitutions for the given feature tags directly onto . + /// + /// + /// Feature tags to apply, e.g. ["liga", "clig"]. Only tags the font actually defines a + /// ligature lookup for have any effect - a tag with no matching lookup is silently + /// skipped, the same way an unmatched glyph sequence is. + /// + /// + /// 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. + internal void ApplyLigaturesInPlace( + List glyphs, List featureTags, string script, string language) { - if (_ligaLookups.Count == 0) return; + if (featureTags == null || featureTags.Count == 0) return; + if (_lookupsByFeatureTag.Count == 0) return; HashSet activeIndices = GetActiveIndices(script, language); - foreach (var entry in _ligaLookups) + foreach (var tag in featureTags) { - // null activeIndices means "no ScriptList to filter by" - keep every entry, - // matching the previous behavior rather than discarding ligatures we cannot resolve. - if (activeIndices != null && !activeIndices.Contains(entry.FeatureIndex)) + if (!_lookupsByFeatureTag.TryGetValue(tag, out var entries)) { continue; } - var lookup = entry.Lookup; - if (lookup.LookupType != 4) continue; + foreach (var entry in entries) + { + // null activeIndices means "no ScriptList to filter by" - keep every entry, + // matching the previous behavior rather than discarding ligatures we cannot + // resolve. + if (activeIndices != null && !activeIndices.Contains(entry.FeatureIndex)) + { + continue; + } + + ApplyLookup(glyphs, entry.Lookup); + } + } + } - int i = 0; - while (i < glyphs.Count) + private void ApplyLookup(List glyphs, LookupTable lookup) + { + int i = 0; + while (i < glyphs.Count) + { + bool substituted = false; + + foreach (var subtableObj in lookup.SubTables) { - bool substituted = false; + var subtable = UnwrapLigatureSubtable(subtableObj); + if (subtable == null) continue; - foreach (var subtableObj in lookup.SubTables) + if (TryApplyLigatureInPlace(glyphs, i, subtable, out int consumed)) { - if (subtableObj is not LigatureSubstSubTable subtable) continue; - - if (TryApplyLigatureInPlace(glyphs, i, subtable, out int consumed)) - { - substituted = true; - i += consumed; // Usually 1 after a substitution - break; // First match wins - break out - } + substituted = true; + i += consumed; // Usually 1 after a substitution + break; // First match wins - break out } - - if (!substituted) i++; } + + if (!substituted) i++; } } + /// + /// Returns the LigatureSubstSubTable a GSUB subtable represents, unwrapping Extension + /// Substitution (Type 7) if needed. Unlike GPOS, whose loader flattens extension-wrapped + /// lookups so LookupType 9 never actually appears on a loaded lookup, GSUB keeps the + /// wrapper: an extension-wrapped ligature lookup has LookupType 7 with + /// ExtensionSubstSubTable entries in SubTables, each pointing at the real subtable via + /// ExtendedSubTable. Returns null for anything that is not, directly or via unwrapping, a + /// LigatureSubstSubTable. + /// + private static LigatureSubstSubTable UnwrapLigatureSubtable(Tables.FontTableElement subtableObj) + { + if (subtableObj is LigatureSubstSubTable direct) + { + return direct; + } + + if (subtableObj is ExtensionSubstSubTable extension + && extension.ExtensionLookupType == 4 + && extension.ExtendedSubTable is LigatureSubstSubTable wrapped) + { + return wrapped; + } + + return null; + } + private HashSet GetActiveIndices(string script, string language) { string cacheKey = (script ?? string.Empty) + "|" + (language ?? string.Empty); @@ -176,45 +232,60 @@ private bool TryApplyLigatureInPlace( return false; } - /// - /// Finds all lookups associated with a feature tag, together with each one's original - /// FeatureList index, across all scripts. Two FeatureRecords can legitimately share a - /// tag (one per script); both are kept so ApplyLigaturesInPlace can filter by script at - /// call time instead of the constructor baking in whichever script happened to be active - /// when this processor was built. + /// Builds a map from feature tag to every ligature-relevant lookup recorded under that + /// tag (LookupType 4, or 7 wrapping 4), together with each lookup's original FeatureList + /// index. Scans every FeatureRecord in the font, not just "liga" - which tags matter is + /// decided later, per call, by ApplyLigaturesInPlace's featureTags parameter. Two + /// FeatureRecords can legitimately share a tag (one per script); both are kept so the + /// script filter in ApplyLigaturesInPlace has something to choose between. /// - private List FindLookupsForFeature(GsubTable gsub, string featureTag) + private static Dictionary> BuildLookupsByFeatureTag(GsubTable gsub) { - var lookups = new List(); + var map = new Dictionary>(); - if (gsub?.FeatureList?.FeatureRecords == null) - return lookups; + if (gsub?.FeatureList?.FeatureRecords == null || gsub.LookupList == null) + return map; var featureRecords = gsub.FeatureList.FeatureRecords; for (int featureIndex = 0; featureIndex < featureRecords.Count; featureIndex++) { var featureRecord = featureRecords[featureIndex]; + string tag = featureRecord.FeatureTag.Value; + var feature = featureRecord.FeatureTable; - if (featureRecord.FeatureTag.Value != featureTag) + if (feature?.LookupListIndices == null) continue; - var feature = featureRecord.FeatureTable; - foreach (var lookupIndex in feature.LookupListIndices) { - if (lookupIndex < gsub.LookupList.Lookups.Count) + if (lookupIndex >= gsub.LookupList.Lookups.Count) + continue; + + var lookup = gsub.LookupList.Lookups[lookupIndex]; + + // Only lookups that are, or wrap, a ligature substitution are relevant here. + // A tag can legitimately also reference non-ligature lookup types (e.g. a + // "liga" record could in principle sit next to unrelated data); those are + // simply not collected, exactly like today's UnwrapLigatureSubtable check on + // the individual subtables would have discarded them anyway. + if (lookup.LookupType != 4 && lookup.LookupType != 7) + continue; + + if (!map.TryGetValue(tag, out var list)) { - lookups.Add(new IndexedLookup(featureIndex, gsub.LookupList.Lookups[lookupIndex])); + list = new List(); + map[tag] = list; } + + list.Add(new IndexedLookup(featureIndex, lookup)); } } - return lookups; + return map; } - /// /// Creates a new shaped glyph for a ligature, combining metrics from components. /// diff --git a/src/EPPlus.Fonts.OpenType/TextShaping/Substitutions/SingleSubstitutionProcessor.cs b/src/EPPlus.Fonts.OpenType/TextShaping/Substitutions/SingleSubstitutionProcessor.cs index 4d34ead307..9bd3bcb7cf 100644 --- a/src/EPPlus.Fonts.OpenType/TextShaping/Substitutions/SingleSubstitutionProcessor.cs +++ b/src/EPPlus.Fonts.OpenType/TextShaping/Substitutions/SingleSubstitutionProcessor.cs @@ -10,6 +10,7 @@ Date Author Change ************************************************************************************************* 01/19/2026 EPPlus Software AB GSUB Single Substitution support 09/07/2026 EPPlus Software AB Filter by ScriptList/LangSys, not just tag + 09/07/2026 EPPlus Software AB Unwrap extension-wrapped (Type 7) lookups *************************************************************************************************/ using EPPlus.Fonts.OpenType.Tables.Common.Layout.Scripts; using EPPlus.Fonts.OpenType.Tables.Gsub; @@ -175,6 +176,28 @@ private bool TryGetSubstitution(ushort glyphId, List subtab return false; // No substitution found in any subtable } + /// + /// Returns the SingleSubstSubTable a GSUB subtable represents, unwrapping Extension + /// Substitution (Type 7) if needed. Returns null for anything that is not, directly or + /// via unwrapping, a SingleSubstSubTable. + /// + private static SingleSubstSubTable UnwrapSingleSubstSubtable(Tables.FontTableElement subtableObj) + { + if (subtableObj is SingleSubstSubTable direct) + { + return direct; + } + + if (subtableObj is ExtensionSubstSubTable extension + && extension.ExtensionLookupType == 1 + && extension.ExtendedSubTable is SingleSubstSubTable wrapped) + { + return wrapped; + } + + return null; + } + /// /// Builds a map of feature tags to their Single Substitution subtables, keeping each /// subtable's original FeatureList index so ApplySubstitutions can filter by script later. @@ -202,21 +225,26 @@ private void BuildFeatureSubtableMap() { var lookup = _gsubTable.LookupList.Lookups[lookupIndex]; - // Only process Type 1 (Single Substitution) lookups - if (lookup.LookupType == 1) + // Type 1 (Single Substitution) directly, or Type 7 (Extension) wrapping + // one. Unlike GPOS, whose loader flattens extension-wrapped lookups so + // Type 9 never actually appears on a loaded lookup, GSUB keeps the + // wrapper - an extension-wrapped single-substitution lookup has + // LookupType 7 with ExtensionSubstSubTable entries in SubTables, each + // pointing at the real subtable via ExtendedSubTable. + if (lookup.LookupType == 1 || lookup.LookupType == 7) { - foreach (var subtable in lookup.SubTables) + foreach (var subtableObj in lookup.SubTables) { - if (subtable is SingleSubstSubTable singleSubst) - { - if (!_featureSubtables.TryGetValue(featureTag, out var list)) - { - list = new List(); - _featureSubtables[featureTag] = list; - } + var singleSubst = UnwrapSingleSubstSubtable(subtableObj); + if (singleSubst == null) continue; - list.Add(new IndexedSubtable(featureIndex, singleSubst)); + if (!_featureSubtables.TryGetValue(featureTag, out var list)) + { + list = new List(); + _featureSubtables[featureTag] = list; } + + list.Add(new IndexedSubtable(featureIndex, singleSubst)); } } } diff --git a/src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs b/src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs index fb712758f5..338b9e9659 100644 --- a/src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs +++ b/src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs @@ -460,16 +460,21 @@ private List ApplyGsubSubstitutionsInternal(List glyph glyphs = _singleSubstitutionProcessor.ApplySubstitutions(glyphs, options.GsubFeatures, options.Script, options.Language); } - // Phase 2: Chaining Contextual Substitution (Type 6) - if (options.GsubFeatures != null && options.GsubFeatures.Contains("liga")) + // Phase 2: 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) { - glyphs = _chainingContextualProcessor.ApplyContextualSubstitutions(glyphs, "liga", options.Script, options.Language); + foreach (var tag in options.GsubFeatures) + { + glyphs = _chainingContextualProcessor.ApplyContextualSubstitutions(glyphs, tag, options.Script, options.Language); + } } - // Phase 3: Simple Ligatures (Type 4) - if (options.GsubFeatures != null && options.GsubFeatures.Contains("liga")) + // Phase 3: 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) { - _ligatureProcessor.ApplyLigaturesInPlace(glyphs, options.Script, options.Language); + _ligatureProcessor.ApplyLigaturesInPlace(glyphs, options.GsubFeatures, options.Script, options.Language); } return glyphs; @@ -503,7 +508,10 @@ private void ApplyPositioning(List glyphs, ShapingOptions options) } // Phase 3: Mark-to-Base positioning (GPOS Type 4) - primary font only - _markToBaseProvider.ApplyMarkPositioning(glyphs, options.Script, options.Language); + if (applyAllFeatures || (options.GposFeatures != null && options.GposFeatures.Contains("mark"))) + { + _markToBaseProvider.ApplyMarkPositioning(glyphs, options.Script, options.Language); + } } /// diff --git a/src/EPPlus.Interfaces/Fonts/GposFeature.cs b/src/EPPlus.Interfaces/Fonts/GposFeature.cs new file mode 100644 index 0000000000..b2175a67eb --- /dev/null +++ b/src/EPPlus.Interfaces/Fonts/GposFeature.cs @@ -0,0 +1,52 @@ +/************************************************************************************************* + 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/07/2026 EPPlus Software AB Public feature selection for GPOS shaping + *************************************************************************************************/ +using System; + +namespace OfficeOpenXml.Interfaces.Fonts +{ + /// + /// GPOS (glyph positioning) features that can be requested when shaping text. + /// Combine flags to request more than one feature, e.g. + /// GposFeature.Kern | GposFeature.Mark. + /// + /// + /// Each flag corresponds to a 4-character OpenType feature tag that a font's GPOS table may + /// define. A flag being set only means the feature is requested - whether it has any effect + /// still depends on the specific font actually defining that feature, and for which glyphs + /// and scripts. + /// + [Flags] + public enum GposFeature + { + /// + /// No GPOS positioning is requested. Glyphs are placed using only their default + /// advance widths, with no pair kerning or mark attachment applied. + /// + None = 0, + + /// + /// Pair kerning ("kern"). Adjusts the spacing between specific pairs of glyphs (e.g. "AV", + /// "To") so they sit visually closer or further apart than their default advance widths + /// alone would produce. + /// + Kern = 1 << 0, + + /// + /// Mark-to-base attachment ("mark"). Positions combining marks (accents, diacritics) + /// relative to the base glyph they attach to, rather than at the base glyph's default + /// advance position. Needed for decomposed sequences such as a base letter followed by a + /// combining accent (e.g. "A" + U+0301) to render correctly. + /// + Mark = 1 << 1 + } +} \ No newline at end of file diff --git a/src/EPPlus.Interfaces/Fonts/GposFeatureTags.cs b/src/EPPlus.Interfaces/Fonts/GposFeatureTags.cs new file mode 100644 index 0000000000..0e9dfe9dc2 --- /dev/null +++ b/src/EPPlus.Interfaces/Fonts/GposFeatureTags.cs @@ -0,0 +1,46 @@ +/************************************************************************************************* + 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/07/2026 EPPlus Software AB Public feature selection for GPOS shaping + *************************************************************************************************/ +using System.Collections.Generic; + +namespace OfficeOpenXml.Interfaces.Fonts +{ + /// + /// Converts flag combinations to the OpenType feature tags + /// expects. + /// + public static class GposFeatureTags + { + /// + /// Converts the given flags to a list of OpenType feature tags. + /// + /// + /// currently produces an EMPTY list, not a list that + /// actively blocks every feature. treats a null + /// or empty list as "apply every feature the font defines" (see + /// TextShaper.ApplyPositioning's applyAllFeatures check), so passing the + /// result of ToTagList(GposFeature.None) straight into + /// ShapingOptions.GposFeatures does not suppress kerning/mark positioning - it does + /// the opposite. Callers that need to guarantee no GPOS positioning runs should set + /// ShapingOptions.ApplyPositioning = false instead. + /// + public static List ToTagList(GposFeature features) + { + var tags = new List(); + + if ((features & GposFeature.Kern) != 0) tags.Add("kern"); + if ((features & GposFeature.Mark) != 0) tags.Add("mark"); + + return tags; + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Interfaces/Fonts/GsubFeature.cs b/src/EPPlus.Interfaces/Fonts/GsubFeature.cs new file mode 100644 index 0000000000..5e58a1f10a --- /dev/null +++ b/src/EPPlus.Interfaces/Fonts/GsubFeature.cs @@ -0,0 +1,59 @@ +/************************************************************************************************* + 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/07/2026 EPPlus Software AB Public feature selection for GSUB shaping + *************************************************************************************************/ +using System; + +namespace OfficeOpenXml.Interfaces.Fonts +{ + /// + /// GSUB (glyph substitution) features that can be requested when shaping text. + /// Combine flags to request more than one feature, e.g. + /// GsubFeature.Liga | GsubFeature.Clig. + /// + /// + /// Each flag corresponds to a 4-character OpenType feature tag that a font's GSUB table may + /// define. A flag being set only means the feature is requested - whether it has any effect + /// still depends on the specific font actually defining that feature, and for which glyphs. + /// Fonts commonly use "liga" for a small set of default ligatures (fi, fl, ff, ...) and + /// reserve "dlig"/"clig" for optional or context-dependent ones (e.g. historical ligatures + /// like "Th" or "ct"), so requesting only will not render those. + /// + [Flags] + public enum GsubFeature + { + /// + /// No GSUB substitution is requested. Text is shaped as a plain, unsubstituted sequence + /// of glyphs mapped directly from characters. + /// + None = 0, + + /// + /// Standard ligatures ("liga"). Common, typographically expected ligatures that a font + /// applies by default - typically a small fixed set such as fi, fl, ff, ffi and ffl. + /// + Liga = 1 << 0, + + /// + /// Contextual ligatures ("clig"). Ligatures a font applies only in specific contexts, + /// as opposed to unconditionally whenever the glyphs appear together. + /// + Clig = 1 << 1, + + /// + /// Discretionary ligatures ("dlig"). Optional, often decorative ligatures a font offers + /// but does not apply by default - for example historical forms like "Th" or "ct". Off + /// unless explicitly requested, since they are a stylistic choice rather than a + /// correctness requirement. + /// + Dlig = 1 << 2 + } +} \ No newline at end of file diff --git a/src/EPPlus.Interfaces/Fonts/GsubFeatureTags.cs b/src/EPPlus.Interfaces/Fonts/GsubFeatureTags.cs new file mode 100644 index 0000000000..c21ddc83d6 --- /dev/null +++ b/src/EPPlus.Interfaces/Fonts/GsubFeatureTags.cs @@ -0,0 +1,46 @@ +/************************************************************************************************* + 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/07/2026 EPPlus Software AB Public feature selection for GSUB shaping + *************************************************************************************************/ +using System.Collections.Generic; + +namespace OfficeOpenXml.Interfaces.Fonts +{ + /// + /// Converts flag combinations to the OpenType feature tags + /// expects. + /// + public static class GsubFeatureTags + { + /// + /// Converts the given flags to a list of OpenType feature tags. + /// + /// + /// currently produces an EMPTY list, not a list that + /// actively blocks every feature. treats a null + /// or empty list as "apply every feature the font defines" (see its own XML doc), so + /// passing the result of ToTagList(GsubFeature.None) straight into + /// ShapingOptions.GsubFeatures does not suppress ligatures - it does the opposite. + /// Callers that need to guarantee no GSUB substitutions run should set + /// ShapingOptions.ApplySubstitutions = false instead. + /// + public static List ToTagList(GsubFeature features) + { + var tags = new List(); + + if ((features & GsubFeature.Liga) != 0) tags.Add("liga"); + if ((features & GsubFeature.Clig) != 0) tags.Add("clig"); + if ((features & GsubFeature.Dlig) != 0) tags.Add("dlig"); + + return tags; + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Interfaces/Fonts/ShapingOptions.cs b/src/EPPlus.Interfaces/Fonts/ShapingOptions.cs index a62ca99d72..f02a24dedf 100644 --- a/src/EPPlus.Interfaces/Fonts/ShapingOptions.cs +++ b/src/EPPlus.Interfaces/Fonts/ShapingOptions.cs @@ -65,7 +65,7 @@ public static ShapingOptions Default ApplySubstitutions = true, GsubFeatures = new List { "liga", "clig" }, ApplyPositioning = true, - GposFeatures = new List { "kern" }, + GposFeatures = new List { "kern", "mark" }, Script = "latn", Language = null }; diff --git a/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs b/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs index d71072a671..7423f1381b 100644 --- a/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs +++ b/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs @@ -64,9 +64,7 @@ public static void ShapeText(PdfPageSettings pageSettings, PdfDictionaries dicti layoutEngine = new TextLayoutEngine(shaper); layoutEngineCache[st.FontProvider] = layoutEngine; } - var options = ShapingOptions.Default; - options.ApplyPositioning = true; - options.ApplySubstitutions = true; + var options = BuildShapingOptions(pageSettings); var shaped = shaper.Shape(tf.Text, options); var usedFonts = shaper.GetUsedFonts().ToList(); var fontIdMap = new Dictionary(); @@ -138,9 +136,7 @@ public static void ShapeText(PdfPageSettings pageSettings, PdfDictionaries dicti layoutEngine = new TextLayoutEngine(shaper); layoutEngineCache[st.FontProvider] = layoutEngine; } - var options = ShapingOptions.Default; - options.ApplyPositioning = true; - options.ApplySubstitutions = true; + var options = BuildShapingOptions(pageSettings); var shaped = shaper.Shape(tf.Text, options); var usedFonts = shaper.GetUsedFonts().ToList(); var fontIdMap = new Dictionary(); @@ -181,5 +177,34 @@ public static void ShapeText(PdfPageSettings pageSettings, PdfDictionaries dicti } cell.TotalTextLength = totalTextLength; } + + /// + /// Builds the ShapingOptions used for one text fragment, from the caller's requested + /// GsubFeature/GposFeature flags. + /// + /// + /// GsubFeature.None / GposFeature.None need special handling here rather than a plain + /// pass-through of ToTagList's empty list. TextShaper.ApplyPositioning treats an empty or + /// null GposFeatures list as "apply every GPOS feature" for kerning and mark positioning + /// (though NOT for single adjustment, which treats it as "apply nothing" - the two + /// disagree on empty/null already, independently of this method). That documented + /// contract has other, unrelated callers (measurement, rich text default, benchmarks) and + /// is not changed here. Instead, None is handled at the source: when the caller asks for + /// no GPOS/GSUB features at all, ApplyPositioning/ApplySubstitutions are turned off + /// outright, which is unambiguous regardless of what an empty tag list would otherwise be + /// interpreted as further down. + /// + internal static ShapingOptions BuildShapingOptions(PdfPageSettings pageSettings) + { + var options = ShapingOptions.Default; + + options.GsubFeatures = GsubFeatureTags.ToTagList(pageSettings.GsubFeatures); + options.GposFeatures = GposFeatureTags.ToTagList(pageSettings.GposFeatures); + + options.ApplySubstitutions = pageSettings.GsubFeatures != GsubFeature.None; + options.ApplyPositioning = pageSettings.GposFeatures != GposFeature.None; + + return options; + } } } \ No newline at end of file