From 27f8f5231e5791f521c8c12ec00af6f5466daf1b Mon Sep 17 00:00:00 2001 From: swmal <897655+swmal@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:15:43 +0200 Subject: [PATCH 1/4] #2477 - Fonts: Configurable font embedding policy (fsType / OnFontEmbedding) --- .../Resources/PdfFontResource.cs | 2 +- .../FontSubsetManagerTests.cs | 4 +- .../Reading/TtfReadingTests.cs | 23 +-- .../Subsetting/FontEmbeddingPolicyTests.cs | 167 ++++++++++++++++++ .../EpplusFontConfiguration.cs | 18 ++ .../FontSubsetManager.cs | 59 +++++-- .../OpenTypeFontEngine.cs | 34 ++++ .../Tables/Os2/FsSelectionFlags.cs | 32 ++++ .../Tables/Os2/FsTypeFlags.cs | 33 ++++ .../Tables/Os2/Os2Table.cs | 54 +++--- .../Tables/Os2/Os2TableLoader.cs | 4 +- .../Tables/Os2/Os2Validator.cs | 14 +- .../Fonts/FontEmbeddingDecision.cs | 37 ++++ .../Fonts/FontEmbeddingInfo.cs | 34 ++++ .../Fonts/FontEmbeddingRestriction.cs | 28 +++ .../Fonts/IEpplusFontConfiguration.cs | 15 ++ 16 files changed, 497 insertions(+), 61 deletions(-) create mode 100644 src/EPPlus.Fonts.OpenType.Tests/Subsetting/FontEmbeddingPolicyTests.cs create mode 100644 src/EPPlus.Fonts.OpenType/Tables/Os2/FsSelectionFlags.cs create mode 100644 src/EPPlus.Fonts.OpenType/Tables/Os2/FsTypeFlags.cs create mode 100644 src/EPPlus.Interfaces/Fonts/FontEmbeddingDecision.cs create mode 100644 src/EPPlus.Interfaces/Fonts/FontEmbeddingInfo.cs create mode 100644 src/EPPlus.Interfaces/Fonts/FontEmbeddingRestriction.cs diff --git a/src/EPPlus.Export.Pdf/Resources/PdfFontResource.cs b/src/EPPlus.Export.Pdf/Resources/PdfFontResource.cs index a9382a780a..4ff14c1016 100644 --- a/src/EPPlus.Export.Pdf/Resources/PdfFontResource.cs +++ b/src/EPPlus.Export.Pdf/Resources/PdfFontResource.cs @@ -92,7 +92,7 @@ internal PdfFontDescriptor GetFontDescriptorObject(int objectNumber, int version flag |= 1 << 5; // Nonsymbolic if (fontData.GetEnglishFontFamilyName().ToLower().Contains("script") || fontData.GetEnglishFontFamilyName().ToLower().Contains("cursive")) flag |= 1 << 3; - if (fontData.PostTable.italicAngle.RawValue != 0 || (fontData.Os2Table.fsSelection & Os2Table.FsSelectionFlags.Italic) != 0) + if (fontData.PostTable.italicAngle.RawValue != 0 || (fontData.Os2Table.fsSelection & FsSelectionFlags.Italic) != 0) flag |= 1 << 6; if (((ushort)fontData.Os2Table.fsSelection & 0x100) != 0) flag |= 1 << 16; diff --git a/src/EPPlus.Fonts.OpenType.Tests/FontSubsetManagerTests.cs b/src/EPPlus.Fonts.OpenType.Tests/FontSubsetManagerTests.cs index 2e7289e10f..5c4a8c9e7a 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/FontSubsetManagerTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/FontSubsetManagerTests.cs @@ -50,7 +50,7 @@ public void CreateSubsettedProvider_WithEmoji_SubsetsFallbackFont() // Arrange var font = LoadTestFont(); var provider = new DefaultFontProvider(TestFolderEngine, font); - var manager = new FontSubsetManager(provider); + var manager = new FontSubsetManager(TestFolderEngine, provider); // Act - Add text with emoji (U+1F600 = 😀, handled by Noto Emoji fallback) manager.AddText("Hello 😀"); @@ -102,7 +102,7 @@ public void CreateSubsettedProvider_UnusedFallbackFontsAreExcluded() // Arrange - DefaultFontProvider has Noto Emoji + Noto Math as fallbacks var font = LoadTestFont(); var provider = new DefaultFontProvider(TestFolderEngine, font); - var manager = new FontSubsetManager(provider); + var manager = new FontSubsetManager(TestFolderEngine, provider); // Act - Only ASCII text, no emoji or math symbols manager.AddText("Plain text only"); diff --git a/src/EPPlus.Fonts.OpenType.Tests/Reading/TtfReadingTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Reading/TtfReadingTests.cs index 511dff8e7d..22e0d83e2e 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/Reading/TtfReadingTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/Reading/TtfReadingTests.cs @@ -12,6 +12,7 @@ Date Author Change *************************************************************************************************/ using EPPlus.Fonts.OpenType.FontResolver; using EPPlus.Fonts.OpenType.Scanner; +using EPPlus.Fonts.OpenType.Tables.Os2; using EPPlus.Fonts.OpenType.Tests.Helpers; using OfficeOpenXml.Interfaces.Drawing.Text; using OfficeOpenXml.Interfaces.Fonts; @@ -60,7 +61,7 @@ public void ReadSourceSans3Otf() struct LicenseDataHolder() { public string? FontName; - public ushort LicenseType; + public FsTypeFlags LicenseType; public string? LTypeString; } @@ -71,20 +72,20 @@ struct LicenseDataHolder() /// 4: Preview & Print embedding: the font may be embedded, and may be temporarily loaded on other systems for purposes of viewing or printing the document. Documents containing Preview & Print fonts must be opened "read-only"; no edits can be applied to the document. /// 8: Editable embedding: the font may be embedded, and may be temporarily loaded on other systems. As with Preview & Print embedding, documents containing Editable fonts may be opened for reading. In addition, editing is permitted, including ability to format new text using the embedded font, and changes may be saved. /// - string GetFsString(ushort fsId) + string GetFsString(FsTypeFlags fsType) { - switch (fsId) + switch (fsType & (FsTypeFlags)Os2Table.FsTypeUsageMask) { - case 0: + case FsTypeFlags.Installable: return "Installable Embedding"; - case 2: + case FsTypeFlags.RestrictedLicense: return "Restricted Licence Embedding"; - case 4: + case FsTypeFlags.PreviewPrint: return "Preview & Print Embedding"; - case 8: + case FsTypeFlags.Editable: return "Editable Embedding"; default: - return $"UNKNOWN VALUE: '{fsId}' POTENTIALLY CORRUPT FONT"; + return $"UNKNOWN VALUE: '{(ushort)fsType}' POTENTIALLY CORRUPT FONT"; } } @@ -160,7 +161,8 @@ public void ReadAllOTFFonts() Assert.AreEqual(Scanner.FontFormat.Otf, allFontsList[i].Format); } - var fontsThatCannotBeEmbedded = dataHolder.Where(x => x.LicenseType == 2); + var fontsThatCannotBeEmbedded = dataHolder.Where( + x => (x.LicenseType & (FsTypeFlags)Os2Table.FsTypeUsageMask) == FsTypeFlags.RestrictedLicense); Assert.AreEqual(0, fontsThatCannotBeEmbedded.Count()); } @@ -211,7 +213,8 @@ public void ReadAllTTFFonts() Assert.AreEqual(Scanner.FontFormat.Ttf, allFontsList[i].Format); } - var fontsThatCannotBeEmbedded = dataHolder.Where(x => x.LicenseType == 2); + var fontsThatCannotBeEmbedded = dataHolder.Where( + x => (x.LicenseType & (FsTypeFlags)Os2Table.FsTypeUsageMask) == FsTypeFlags.RestrictedLicense); Assert.AreEqual(0, fontsThatCannotBeEmbedded.Count()); } diff --git a/src/EPPlus.Fonts.OpenType.Tests/Subsetting/FontEmbeddingPolicyTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Subsetting/FontEmbeddingPolicyTests.cs new file mode 100644 index 0000000000..0fa58c285a --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/Subsetting/FontEmbeddingPolicyTests.cs @@ -0,0 +1,167 @@ +using EPPlus.Fonts.OpenType.Tables.Os2; +using OfficeOpenXml.Interfaces.Fonts; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace EPPlus.Fonts.OpenType.Tests.Subsetting +{ + [TestClass] + public class FontEmbeddingPolicyTests : FontTestBase + { + public override TestContext? TestContext { get; set; } + + [TestMethod] + public void GetEmbeddingRestriction_Installable_ReturnsNone() + { + var os2 = new Os2Table { fsType = FsTypeFlags.Installable }; + Assert.AreEqual(FontEmbeddingRestriction.None, os2.GetEmbeddingRestriction()); + } + + [TestMethod] + public void GetEmbeddingRestriction_RestrictedLicense_ReturnsNoEmbedding() + { + var os2 = new Os2Table { fsType = FsTypeFlags.RestrictedLicense }; + Assert.AreEqual(FontEmbeddingRestriction.NoEmbedding, os2.GetEmbeddingRestriction()); + } + + [TestMethod] + public void GetEmbeddingRestriction_NoSubsetting_ReturnsNoSubsetting() + { + var os2 = new Os2Table { fsType = FsTypeFlags.NoSubsetting }; + Assert.AreEqual(FontEmbeddingRestriction.NoSubsetting, os2.GetEmbeddingRestriction()); + } + + [TestMethod] + public void GetEmbeddingRestriction_RestrictedPlusNoSubsetting_NoEmbeddingWins() + { + var os2 = new Os2Table { fsType = FsTypeFlags.RestrictedLicense | FsTypeFlags.NoSubsetting }; + Assert.AreEqual(FontEmbeddingRestriction.NoEmbedding, os2.GetEmbeddingRestriction()); + } + + [TestMethod] + public void GetEmbeddingRestriction_PreviewPrint_ReturnsNone() + { + var os2 = new Os2Table { fsType = FsTypeFlags.PreviewPrint }; + Assert.AreEqual(FontEmbeddingRestriction.None, os2.GetEmbeddingRestriction()); + } + + // ---- Level 2: ResolveEmbeddingDecision (policy + callback) ---- + // Uses Roboto and mutates fsType. Roboto itself is Installable, so the + // baseline decision without mutation is Subset. + + [TestMethod] + public void ResolveEmbeddingDecision_MutationPersists() + { + // Guards the whole level-2 suite: if mutating fsType on a loaded font + // did not stick, every test below would be a false pass. + var font = TestFolderEngine.LoadFont("Roboto", ignoreCache: true); + font.Os2Table.fsType = FsTypeFlags.RestrictedLicense; + Assert.AreEqual(FsTypeFlags.RestrictedLicense, font.Os2Table.fsType); + } + + [TestMethod] + public void ResolveEmbeddingDecision_Installable_NoCallback_ReturnsSubset() + { + var font = TestFolderEngine.LoadFont("Roboto", ignoreCache: true); + font.Os2Table.fsType = FsTypeFlags.Installable; + Assert.AreEqual(FontEmbeddingDecision.Subset, + TestFolderEngine.ResolveEmbeddingDecision(font)); + } + + [TestMethod] + public void ResolveEmbeddingDecision_NoSubsetting_NoCallback_ReturnsEmbedWhole() + { + var font = TestFolderEngine.LoadFont("Roboto", ignoreCache: true); + font.Os2Table.fsType = FsTypeFlags.NoSubsetting; + Assert.AreEqual(FontEmbeddingDecision.EmbedWhole, + TestFolderEngine.ResolveEmbeddingDecision(font)); + } + + [TestMethod] + public void ResolveEmbeddingDecision_RestrictedLicense_NoCallback_Throws() + { + var font = TestFolderEngine.LoadFont("Roboto", ignoreCache: true); + font.Os2Table.fsType = FsTypeFlags.RestrictedLicense; + Assert.ThrowsExactly( + () => TestFolderEngine.ResolveEmbeddingDecision(font)); + } + + // ---- Level 3: callback override ---- + // TestFolderEngine's config is locked at construction, so callback tests + // build their own engine with the same font folders plus OnFontEmbedding. + + private static OpenTypeFontEngine CreateEngineWithCallback( + Func callback) + { + return new OpenTypeFontEngine(cfg => + { + foreach (var folder in FontFolders) + cfg.FontDirectories.Add(folder); + cfg.SearchSystemDirectories = false; + cfg.OnFontEmbedding(callback); + }); + } + + [TestMethod] + public void ResolveEmbeddingDecision_RestrictedLicense_CallbackSubset_OverridesAndDoesNotThrow() + { + var engine = CreateEngineWithCallback(info => FontEmbeddingDecision.Subset); + var font = engine.LoadFont("Roboto", ignoreCache: true); + font.Os2Table.fsType = FsTypeFlags.RestrictedLicense; + + Assert.AreEqual(FontEmbeddingDecision.Subset, + engine.ResolveEmbeddingDecision(font)); + } + + [TestMethod] + public void ResolveEmbeddingDecision_RestrictedLicense_CallbackDefault_FallsThroughToPolicyAndThrows() + { + var engine = CreateEngineWithCallback(info => FontEmbeddingDecision.Default); + var font = engine.LoadFont("Roboto", ignoreCache: true); + font.Os2Table.fsType = FsTypeFlags.RestrictedLicense; + + Assert.ThrowsExactly( + () => engine.ResolveEmbeddingDecision(font)); + } + + [TestMethod] + public void ResolveEmbeddingDecision_CallbackReceivesCorrectInfo() + { + FontEmbeddingInfo captured = null; + var engine = CreateEngineWithCallback(info => + { + captured = info; + return FontEmbeddingDecision.Default; + }); + + var font = engine.LoadFont("Roboto", ignoreCache: true); + font.Os2Table.fsType = FsTypeFlags.NoSubsetting; + + // NoSubsetting + Default falls through to EmbedWhole (no throw), so this is safe to call. + engine.ResolveEmbeddingDecision(font); + + Assert.IsNotNull(captured); + Assert.AreEqual(FontEmbeddingRestriction.NoSubsetting, captured.Restriction); + StringAssert.Contains(captured.FontName, "Roboto"); + } + + [TestMethod] + public void CreateSubsettedProvider_NoSubsettingFont_EmbedsWholeFontNotSubset() + { + var font = TestFolderEngine.LoadFont("Roboto", ignoreCache: true); + font.Os2Table.fsType = FsTypeFlags.NoSubsetting; + + var manager = new FontSubsetManager(TestFolderEngine, font); + // Collect some code points so the font would otherwise be subsetted. + manager.AddText("Hello"); // <-- vet ej exakt API-namn, se nedan + + var provider = manager.CreateSubsettedProvider(); + + Assert.IsFalse(provider.PrimaryFont.IsSubset, + "NoSubsetting font must be embedded whole, not subsetted."); + } + } +} diff --git a/src/EPPlus.Fonts.OpenType/EpplusFontConfiguration.cs b/src/EPPlus.Fonts.OpenType/EpplusFontConfiguration.cs index e39aca5b5e..4aa7e14946 100644 --- a/src/EPPlus.Fonts.OpenType/EpplusFontConfiguration.cs +++ b/src/EPPlus.Fonts.OpenType/EpplusFontConfiguration.cs @@ -33,6 +33,9 @@ internal class EpplusFontConfiguration : IEpplusFontConfiguration private readonly Dictionary _scriptFallbacks = new Dictionary(); + private Func _onFontEmbedding; + + public EpplusFontConfiguration() { SearchSystemDirectories = true; @@ -51,6 +54,21 @@ public IList FontDirectories /// public IFontResolver FontResolver { get; set; } + /// + public void OnFontEmbedding(Func callback) + { + _onFontEmbedding = callback; + } + + /// + /// Returns the registered embedding-decision callback, or null if none is configured. + /// Consumed by the font engine when resolving how a font should be embedded. + /// + internal Func GetEmbeddingCallback() + { + return _onFontEmbedding; + } + /// public IDictionary FontFallbacks { diff --git a/src/EPPlus.Fonts.OpenType/FontSubsetManager.cs b/src/EPPlus.Fonts.OpenType/FontSubsetManager.cs index 641933d282..216c7f1fc3 100644 --- a/src/EPPlus.Fonts.OpenType/FontSubsetManager.cs +++ b/src/EPPlus.Fonts.OpenType/FontSubsetManager.cs @@ -11,6 +11,7 @@ Date Author Change 02/25/2026 EPPlus Software AB Font subset manager for PDF export *************************************************************************************************/ using EPPlus.Fonts.OpenType.Utils; +using OfficeOpenXml.Interfaces.Fonts; using System; using System.Collections.Generic; using System.Linq; @@ -31,21 +32,25 @@ namespace EPPlus.Fonts.OpenType public class FontSubsetManager { private readonly IFontProvider _sourceProvider; + private readonly OpenTypeFontEngine _fontEngine; // Code points collected per font (key = original font instance) private readonly Dictionary> _codePointsByFont = new Dictionary>(); - public FontSubsetManager(IFontProvider sourceProvider) + public FontSubsetManager(OpenTypeFontEngine engine, IFontProvider sourceProvider) { + if (engine == null) + throw new ArgumentNullException("engine"); if (sourceProvider == null) throw new ArgumentNullException("sourceProvider"); _sourceProvider = sourceProvider; + _fontEngine = engine; } public FontSubsetManager(OpenTypeFontEngine engine, OpenTypeFont font) - : this(new DefaultFontProvider(engine, font)) + : this(engine, new DefaultFontProvider(engine, font)) { } @@ -94,7 +99,6 @@ public IFontProvider CreateSubsettedProvider() var primaryFont = _sourceProvider.PrimaryFont; var allFonts = _sourceProvider.GetAllFonts().ToList(); - // Subset each font that has collected code points var subsetMap = new Dictionary(); foreach (var kvp in _codePointsByFont) @@ -105,18 +109,43 @@ public IFontProvider CreateSubsettedProvider() if (codePoints.Count == 0) continue; - try - { - var chars = CodePointUtil.CodePointsToString(codePoints); - var subset = originalFont.CreateSubset(chars); - subsetMap[originalFont] = subset; - } - catch (Exception ex) + // Resolve the embedding decision OUTSIDE the try/catch: a NoEmbedding font + // throws intentionally, and that error must reach the caller — not be + // swallowed and silently embedded by the fallback below. + var decision = _fontEngine.ResolveEmbeddingDecision(originalFont); + + switch (decision) { - // If subsetting fails, use the original font - System.Diagnostics.Debug.WriteLine( - $"Warning: Could not subset '{originalFont.NameTable?.GetFullFontName()}': {ex.Message}"); - subsetMap[originalFont] = originalFont; + case FontEmbeddingDecision.EmbedWhole: + // No-subsetting font (or caller opted to embed whole): embed unmodified. + subsetMap[originalFont] = originalFont; + break; + + case FontEmbeddingDecision.Skip: + throw new NotSupportedException( + string.Format( + "Font '{0}' resolved to a Skip embedding decision, but the PDF exporter " + + "has no font-substitution path yet. Return Subset or EmbedWhole from " + + "IEpplusFontConfiguration.OnFontEmbedding, or make the font embeddable.", + originalFont.NameTable != null ? originalFont.NameTable.GetFullFontName() : "(unknown)")); + + case FontEmbeddingDecision.Subset: + try + { + var chars = CodePointUtil.CodePointsToString(codePoints); + subsetMap[originalFont] = originalFont.CreateSubset(chars); + } + catch (Exception ex) + { + // If subsetting itself fails, fall back to the original font. + System.Diagnostics.Debug.WriteLine( + $"Warning: Could not subset '{originalFont.NameTable?.GetFullFontName()}': {ex.Message}"); + subsetMap[originalFont] = originalFont; + } + break; + + default: + throw new ArgumentOutOfRangeException(); } } @@ -127,7 +156,6 @@ public IFontProvider CreateSubsettedProvider() var provider = new CustomFontProvider(subsetPrimary); - // Add fallback fonts in their original order (skip primary) for (int i = 1; i < allFonts.Count; i++) { var originalFallback = allFonts[i]; @@ -136,7 +164,6 @@ public IFontProvider CreateSubsettedProvider() { provider.AddFallback(subsetMap[originalFallback]); } - // If no code points were collected for this fallback, skip it entirely } return provider; diff --git a/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs b/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs index 4de8871caf..e5df082f74 100644 --- a/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs +++ b/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs @@ -374,6 +374,39 @@ public FontAvailability GetFontAvailability( : FontAvailability.NotFound; } + internal FontEmbeddingDecision ResolveEmbeddingDecision(OpenTypeFont font) + { + var restriction = font.Os2Table != null + ? font.Os2Table.GetEmbeddingRestriction() + : FontEmbeddingRestriction.None; + + var fontName = font.NameTable != null ? font.NameTable.GetFullFontName() : null; + var callback = _configuration.GetEmbeddingCallback(); + if (callback != null) + { + var decision = callback(new FontEmbeddingInfo(fontName, restriction)); + if (decision != FontEmbeddingDecision.Default) + return decision; // user override wins + } + + // No callback, or callback returned Default → derive from the restriction. + switch (restriction) + { + case FontEmbeddingRestriction.NoEmbedding: + // Default policy: fail loud. User must opt in via the callback. + throw new InvalidOperationException( + string.Format( + "Font '{0}' declares Restricted License embedding (fsType) and may not be embedded. " + + "If you hold a licence permitting embedding, return FontEmbeddingDecision.Subset or " + + "EmbedWhole from IEpplusFontConfiguration.OnFontEmbedding.", + string.IsNullOrWhiteSpace(fontName) ? "(unknown)" : fontName)); + case FontEmbeddingRestriction.NoSubsetting: + return FontEmbeddingDecision.EmbedWhole; + default: + return FontEmbeddingDecision.Subset; + } + } + // ----------------------------------------------------------------------------------------- // Internal helpers // ----------------------------------------------------------------------------------------- @@ -425,6 +458,7 @@ internal static List GetLocationsCollection( return DefaultFontLocations.GetLocationsCollection(fontDirectories, searchSystemDirectories); } + // I OpenTypeFontEngine private void ThrowIfDisposed() { if (_disposed) diff --git a/src/EPPlus.Fonts.OpenType/Tables/Os2/FsSelectionFlags.cs b/src/EPPlus.Fonts.OpenType/Tables/Os2/FsSelectionFlags.cs new file mode 100644 index 0000000000..e0a63fbf9d --- /dev/null +++ b/src/EPPlus.Fonts.OpenType/Tables/Os2/FsSelectionFlags.cs @@ -0,0 +1,32 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 10/07/2026 EPPlus Software AB EPPlus.Fonts.OpenType 1.0 + *************************************************************************************************/ +using System; + +namespace EPPlus.Fonts.OpenType.Tables.Os2 +{ + [Flags] + public enum FsSelectionFlags : ushort + { + Italic = 1 << 0, // Bit 0 + Underscore = 1 << 1, // Bit 1 + Negative = 1 << 2, // Bit 2 + Outlined = 1 << 3, // Bit 3 + Strikeout = 1 << 4, // Bit 4 + Bold = 1 << 5, // Bit 5 + Regular = 1 << 6, // Bit 6 + UseTypoMetrics = 1 << 7, // Bit 7 + WWS = 1 << 8, // Bit 8 + Oblique = 1 << 9 // Bit 9 + // Bits 10-15 are reserved + } +} diff --git a/src/EPPlus.Fonts.OpenType/Tables/Os2/FsTypeFlags.cs b/src/EPPlus.Fonts.OpenType/Tables/Os2/FsTypeFlags.cs new file mode 100644 index 0000000000..79a18e6fdd --- /dev/null +++ b/src/EPPlus.Fonts.OpenType/Tables/Os2/FsTypeFlags.cs @@ -0,0 +1,33 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 10/07/2026 EPPlus Software AB EPPlus.Fonts.OpenType 1.0 + *************************************************************************************************/ +using System; + +namespace EPPlus.Fonts.OpenType.Tables.Os2 +{ + [Flags] + public enum FsTypeFlags : ushort + { + /// Installable embedding (no restrictions). Bits 0-3 all clear. + Installable = 0x0000, + /// Restricted License embedding. Bit 1. + RestrictedLicense = 0x0002, + /// Preview & Print embedding. Bit 2. + PreviewPrint = 0x0004, + /// Editable embedding. Bit 3. + Editable = 0x0008, + /// No subsetting: font must be embedded whole, not subsetted. Bit 8. + NoSubsetting = 0x0100, + /// Bitmap embedding only: only bitmap data may be embedded. Bit 9. + BitmapEmbeddingOnly = 0x0200, + } +} diff --git a/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2Table.cs b/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2Table.cs index f35b727110..b7d3053e29 100644 --- a/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2Table.cs +++ b/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2Table.cs @@ -11,9 +11,10 @@ Date Author Change 10/07/2025 EPPlus Software AB EPPlus.Fonts.OpenType 1.0 *************************************************************************************************/ -using System; using EPPlus.Fonts.OpenType; using EPPlus.Fonts.OpenType.Utils; +using OfficeOpenXml.Interfaces.Fonts; +using System; namespace EPPlus.Fonts.OpenType.Tables.Os2 { @@ -44,13 +45,34 @@ public class Os2Table : FontTableBase public ushort usWidthClass { get; set; } /// - /// Indicates font embedding licensing rights for the font. The interpretation of flags is as follows: - /// 0: Installable embedding: the font may be embedded, and may be permanently installed for use on a remote systems, or for use by other users. - /// 2: Restricted License embedding: the font must not be modified, embedded or exchanged in any manner without first obtaining explicit permission of the legal owner. - /// 4: Preview & Print embedding: the font may be embedded, and may be temporarily loaded on other systems for purposes of viewing or printing the document. Documents containing Preview & Print fonts must be opened “read-only”; no edits can be applied to the document. - /// 8: Editable embedding: the font may be embedded, and may be temporarily loaded on other systems. As with Preview & Print embedding, documents containing Editable fonts may be opened for reading. In addition, editing is permitted, including ability to format new text using the embedded font, and changes may be saved. + /// Indicates font embedding licensing rights for the font. See + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2#fst + /// Bits 0-3 form a mutually-exclusive usage-permission level; bits 8 and 9 + /// are independent flags. Interpret with masks, not equality — e.g. + /// (fsType & FsTypeUsageMask) == FsTypeFlags.RestrictedLicense, or + /// (fsType & FsTypeFlags.NoSubsetting) != 0. + /// + public FsTypeFlags fsType { get; set; } + + /// + /// Mask covering the mutually-exclusive usage-permission bits (0-3) of . + /// Use this to isolate the usage level before comparing against a specific + /// value, since those bits are not independent flags. + /// + internal const ushort FsTypeUsageMask = 0x000F; + + /// + /// Interprets into the embedding restriction the font declares. + /// Pure interpretation — carries no policy about what EPPlus does with it. /// - public ushort fsType { get; set; } + public FontEmbeddingRestriction GetEmbeddingRestriction() + { + if ((fsType & (FsTypeFlags)FsTypeUsageMask) == FsTypeFlags.RestrictedLicense) + return FontEmbeddingRestriction.NoEmbedding; + if ((fsType & FsTypeFlags.NoSubsetting) != 0) + return FontEmbeddingRestriction.NoSubsetting; + return FontEmbeddingRestriction.None; + } /// /// The recommended horizontal size in font design units for subscripts for this font. @@ -138,21 +160,7 @@ public class Os2Table : FontTableBase /// See https://docs.microsoft.com/en-us/typography/opentype/spec/os2#fss /// public FsSelectionFlags fsSelection { get; set; } - [Flags] - public enum FsSelectionFlags : ushort - { - Italic = 1 << 0, // Bit 0 - Underscore = 1 << 1, // Bit 1 - Negative = 1 << 2, // Bit 2 - Outlined = 1 << 3, // Bit 3 - Strikeout = 1 << 4, // Bit 4 - Bold = 1 << 5, // Bit 5 - Regular = 1 << 6, // Bit 6 - UseTypoMetrics = 1 << 7, // Bit 7 - WWS = 1 << 8, // Bit 8 - Oblique = 1 << 9 // Bit 9 - // Bits 10-15 are reserved - } + //public FsSelectionFlags SelectionFlags => (FsSelectionFlags)fsSelection; @@ -212,7 +220,7 @@ internal override void SerializeInternal(FontsBinaryWriter writer, FontSerializa writer.WriteInt16BigEndian(xAvgCharWidth); writer.WriteUInt16BigEndian(usWeightClass); writer.WriteUInt16BigEndian(usWidthClass); - writer.WriteUInt16BigEndian(fsType); + writer.WriteUInt16BigEndian((ushort)fsType); writer.WriteInt16BigEndian(ySubscriptXSize); writer.WriteInt16BigEndian(ySubscriptYSize); writer.WriteInt16BigEndian(ySubscriptXOffset); diff --git a/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2TableLoader.cs b/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2TableLoader.cs index ce51d90ebd..68c753b62e 100644 --- a/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2TableLoader.cs +++ b/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2TableLoader.cs @@ -82,7 +82,7 @@ protected override Os2Table LoadInternal() xAvgCharWidth = xAvgCharWidth, usWeightClass = usWeightClass, usWidthClass = usWidthClass, - fsType = fsType, + fsType = (FsTypeFlags)fsType, ySubscriptXSize = ySubscriptXSize, ySubscriptYSize = ySubscriptYSize, ySubscriptXOffset = ySubscriptXOffset, @@ -100,7 +100,7 @@ protected override Os2Table LoadInternal() UnicodeRange3 = ucr3, UnicodeRange4 = ucr4, achVendId = achVendId, - fsSelection = (Os2Table.FsSelectionFlags)fsSelection, + fsSelection = (FsSelectionFlags)fsSelection, usFirstCharIndex = usFirstCharIndex, usLastCharIndex = usLastCharIndex, sTypoAscender = sTypoAscender, diff --git a/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2Validator.cs b/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2Validator.cs index e75381d5d7..62d2e9bc54 100644 --- a/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2Validator.cs +++ b/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2Validator.cs @@ -56,7 +56,7 @@ public override TableValidationResult Validate(Os2Table table, FontValidationCon } // fsType basic info - if ((table.fsType & 0x0002) != 0) + if ((table.fsType & FsTypeFlags.RestrictedLicense) != 0) { result.AddMessage(FontValidationSeverity.Information, "Font has restricted embedding (fsType bit 1 set)."); @@ -106,20 +106,20 @@ public override TableValidationResult Validate(Os2Table table, FontValidationCon // ------------------------- // Embedding permissions - if ((table.fsType & 0x0002) != 0) + if ((table.fsType & (FsTypeFlags)Os2Table.FsTypeUsageMask) == FsTypeFlags.RestrictedLicense) { result.AddMessage(FontValidationSeverity.Error, - "Embedding is restricted (fsType bit 1 set). Subsetting cannot proceed."); + "Embedding is restricted (fsType Restricted License). Subsetting cannot proceed."); } - if ((table.fsType & 0x0008) != 0) + if ((table.fsType & FsTypeFlags.NoSubsetting) != 0) { result.AddMessage(FontValidationSeverity.Error, - "No subsetting allowed (fsType bit 3 set)."); + "No subsetting allowed (fsType NoSubsetting bit set). Font must be embedded whole."); } - if ((table.fsType & 0x0004) != 0) + if ((table.fsType & (FsTypeFlags)Os2Table.FsTypeUsageMask) == FsTypeFlags.PreviewPrint) { result.AddMessage(FontValidationSeverity.Warning, - "Preview & Print embedding only (fsType bit 2 set). Check usage context."); + "Preview & Print embedding only. Check usage context."); } // Metrics must be valid diff --git a/src/EPPlus.Interfaces/Fonts/FontEmbeddingDecision.cs b/src/EPPlus.Interfaces/Fonts/FontEmbeddingDecision.cs new file mode 100644 index 0000000000..a9df0272c0 --- /dev/null +++ b/src/EPPlus.Interfaces/Fonts/FontEmbeddingDecision.cs @@ -0,0 +1,37 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 10/07/2026 EPPlus Software AB EPPlus.Fonts.OpenType 1.0 + *************************************************************************************************/ +namespace OfficeOpenXml.Interfaces.Fonts +{ + /// + /// The action EPPlus takes for a font when preparing it for embedding. + /// Returned from the callback registered via + /// . + /// + public enum FontEmbeddingDecision + { + /// + /// Follow the font's declared fsType: throw for a Restricted License font, + /// embed whole for a no-subsetting font, subset otherwise. + /// + Default, + /// + /// Subset the font regardless of fsType. By choosing this, the caller asserts + /// they hold the rights to embed and subset the font. + /// + Subset, + /// Embed the whole font without subsetting. + EmbedWhole, + /// Do not embed the font; a fallback/substitute is used instead. + Skip, + } +} diff --git a/src/EPPlus.Interfaces/Fonts/FontEmbeddingInfo.cs b/src/EPPlus.Interfaces/Fonts/FontEmbeddingInfo.cs new file mode 100644 index 0000000000..545880d088 --- /dev/null +++ b/src/EPPlus.Interfaces/Fonts/FontEmbeddingInfo.cs @@ -0,0 +1,34 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 10/07/2026 EPPlus Software AB EPPlus.Fonts.OpenType 1.0 + *************************************************************************************************/ + +namespace OfficeOpenXml.Interfaces.Fonts +{ + /// + /// Information passed to the + /// callback so the caller can decide how a font should be embedded. + /// + public class FontEmbeddingInfo + { + public FontEmbeddingInfo(string fontName, FontEmbeddingRestriction restriction) + { + FontName = fontName; + Restriction = restriction; + } + + /// The full name of the font being prepared for embedding. + public string FontName { get; private set; } + + /// The restriction the font declares via its OS/2 fsType field. + public FontEmbeddingRestriction Restriction { get; private set; } + } +} diff --git a/src/EPPlus.Interfaces/Fonts/FontEmbeddingRestriction.cs b/src/EPPlus.Interfaces/Fonts/FontEmbeddingRestriction.cs new file mode 100644 index 0000000000..740d496379 --- /dev/null +++ b/src/EPPlus.Interfaces/Fonts/FontEmbeddingRestriction.cs @@ -0,0 +1,28 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 10/07/2026 EPPlus Software AB EPPlus.Fonts.OpenType 1.0 + *************************************************************************************************/ +namespace OfficeOpenXml.Interfaces.Fonts +{ + /// + /// The embedding/subsetting restriction a font declares via its OS/2 fsType field. + /// This is a pure interpretation of fsType — it carries no policy about what EPPlus does. + /// + public enum FontEmbeddingRestriction + { + /// Font may be embedded and subsetted freely. + None, + /// Font may be embedded, but must be embedded whole — not subsetted. + NoSubsetting, + /// Font must not be embedded at all (Restricted License). + NoEmbedding, + } +} diff --git a/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs b/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs index 0aa862747f..ef0980b2bc 100644 --- a/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs +++ b/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs @@ -12,6 +12,7 @@ Date Author Change 05/06/2026 EPPlus Software AB Property-based transactional configuration 05/20/2026 EPPlus Software AB Added per-script glyph fallback configuration *************************************************************************************************/ +using System; using System.Collections.Generic; namespace OfficeOpenXml.Interfaces.Fonts @@ -81,6 +82,20 @@ public interface IEpplusFontConfiguration /// /// void Reset(); + + /// + /// Registers a callback invoked for each font that is about to be embedded, letting the + /// caller override how EPPlus handles the font's declared embedding restriction (fsType). + /// Return to keep EPPlus's standard behaviour. + /// + /// + /// A font may declare that it must not be embedded (Restricted License) or must not be + /// subsetted. By returning or + /// , the caller asserts they hold the rights + /// to do so; EPPlus cannot verify any licence the caller may have obtained from the font's + /// owner. Only one callback is active; a later call replaces the earlier one. + /// + void OnFontEmbedding(Func callback); } } \ No newline at end of file From cc48b5d3f7752eeb2e0fb5f01416d87ac6186d4f Mon Sep 17 00:00:00 2001 From: swmal <897655+swmal@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:25:25 +0200 Subject: [PATCH 2/4] Skip embedding decision now falls back to font chain (#2473) --- .../Subsetting/FontEmbeddingPolicyTests.cs | 145 ++++++++++++++++++ .../FontSubsetManager.cs | 117 ++++++++------ 2 files changed, 213 insertions(+), 49 deletions(-) diff --git a/src/EPPlus.Fonts.OpenType.Tests/Subsetting/FontEmbeddingPolicyTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Subsetting/FontEmbeddingPolicyTests.cs index 0fa58c285a..537623af5a 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/Subsetting/FontEmbeddingPolicyTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/Subsetting/FontEmbeddingPolicyTests.cs @@ -163,5 +163,150 @@ public void CreateSubsettedProvider_NoSubsettingFont_EmbedsWholeFontNotSubset() Assert.IsFalse(provider.PrimaryFont.IsSubset, "NoSubsetting font must be embedded whole, not subsetted."); } + + // ----------------------------------------------------------------------------------------- + // Level 4: Skip as a real fallback path (colleague feedback). + // + // A Skip decision can only originate from the OnFontEmbedding callback — the fsType policy + // never produces it. When a font is skipped it must be removed from the effective chain and + // its code points redistributed over the remaining fonts, rather than throwing. These tests + // build an engine whose callback skips a specific font by name. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + public void CreateSubsettedProvider_SkippedPrimary_NextFontBecomesPrimary() + { + // Roboto is the primary; the callback skips it. The provider's default fallback chain + // (Noto Emoji, Noto Math) plus the resolver's last resort should take over, so the + // resulting primary must be something other than Roboto and must not be null. + var engine = CreateEngineWithCallback(info => + info.FontName != null && info.FontName.Contains("Roboto") + ? FontEmbeddingDecision.Skip + : FontEmbeddingDecision.Default); + + var roboto = engine.LoadFont("Roboto", ignoreCache: true); + + var manager = new FontSubsetManager(engine, roboto); + manager.AddText("Hello"); + + var provider = manager.CreateSubsettedProvider(); + + Assert.IsNotNull(provider.PrimaryFont); + StringAssert.DoesNotMatch( + provider.PrimaryFont.GetEnglishFontFamilyName(), + new System.Text.RegularExpressions.Regex("Roboto"), + "A skipped primary must not remain the provider's primary font."); + } + + [TestMethod] + public void CreateSubsettedProvider_SkippedPrimary_AllTextSkipped_UsesLastResort() + { + // With ONLY Latin text and Roboto skipped, none of the default fallbacks (Emoji, Math) + // cover the letters. The chain would collapse to empty, so the last-resort font + // (Archivo Narrow) must step in and carry the glyphs. + var engine = CreateEngineWithCallback(info => + info.FontName != null && info.FontName.Contains("Roboto") + ? FontEmbeddingDecision.Skip + : FontEmbeddingDecision.Default); + + var roboto = engine.LoadFont("Roboto", ignoreCache: true); + + var manager = new FontSubsetManager(engine, roboto); + manager.AddText("Hello"); + + var provider = manager.CreateSubsettedProvider(); + + // Archivo Narrow is the guaranteed last resort. Its family name identifies it. + StringAssert.Contains( + provider.PrimaryFont.GetEnglishFontFamilyName(), + "Archivo", + "When the whole chain is skipped, the last-resort font must become primary."); + + // The redistributed Latin code points must actually be present in that font. + ushort glyphId; + Assert.IsTrue( + provider.PrimaryFont.CmapTable.TryGetGlyphId('H', out glyphId) && glyphId != 0, + "Latin glyphs must be carried by the last-resort font after redistribution."); + + } + + [TestMethod] + public void CreateSubsettedProvider_SkippedPrimary_CjkText_GlyphsLandInReplacement() + { + // The heart of the redistribution logic: Roboto (Latin) is primary and covers none of + // the CJK text. A CJK-capable fallback (BIZ UDGothic) sits in a CustomFontProvider chain. + // When Roboto is skipped, the CJK code points that were distributed to it must be + // redistributed to BIZ UDGothic and appear in the subsetted result. + var engine = CreateEngineWithCallback(info => + info.FontName != null && info.FontName.Contains("Roboto") + ? FontEmbeddingDecision.Skip + : FontEmbeddingDecision.Default); + + var roboto = engine.LoadFont("Roboto", ignoreCache: true); + var biz = engine.LoadFont("BIZ UDGothic", ignoreCache: true); + + var source = new CustomFontProvider(roboto); + source.AddFallback(biz); + + var manager = new FontSubsetManager(engine, source); + + // U+6F22 漢 — a Han ideograph covered by BIZ UDGothic, not by Roboto. + const int han = 0x6F22; + manager.AddText(char.ConvertFromUtf32(han)); + + var provider = manager.CreateSubsettedProvider(); + + // Roboto skipped → the CJK-capable font becomes primary. + ushort glyphId; + Assert.IsTrue( + provider.PrimaryFont.CmapTable.TryGetGlyphId((uint)han, out glyphId) && glyphId != 0, + "The Han code point must be carried (and subsetted) by the replacement font."); + + StringAssert.DoesNotMatch( + provider.PrimaryFont.GetEnglishFontFamilyName(), + new System.Text.RegularExpressions.Regex("Roboto"), + "The skipped primary must not remain the provider's primary font."); + + StringAssert.Contains( + provider.PrimaryFont.GetEnglishFontFamilyName(), + "BIZ", + "The CJK-capable fallback must have become the primary font."); + } + + [TestMethod] + public void CreateSubsettedProvider_SkippedPrimary_PrefersChainFontOverLastResort() + { + // A skipped primary must hand off to a real font from the chain, NOT jump straight + // to the Archivo Narrow last resort. Roboto (Latin) is primary; BIZ UDGothic is a + // fallback that covers the CJK text. When Roboto is skipped, BIZ — not Archivo — + // must become primary. + var engine = CreateEngineWithCallback(info => + info.FontName != null && info.FontName.Contains("Roboto") + ? FontEmbeddingDecision.Skip + : FontEmbeddingDecision.Default); + + var roboto = engine.LoadFont("Roboto", ignoreCache: true); + var biz = engine.LoadFont("BIZ UDGothic", ignoreCache: true); + + var source = new CustomFontProvider(roboto); + source.AddFallback(biz); + + var manager = new FontSubsetManager(engine, source); + manager.AddText(char.ConvertFromUtf32(0x6F22)); // 漢 + + var provider = manager.CreateSubsettedProvider(); + + var family = provider.PrimaryFont.GetEnglishFontFamilyName(); + + // The positive assertion: the chain font took over. + StringAssert.Contains(family, "BIZ", + "A chain fallback must take over a skipped primary."); + + // The negative assertion — the crux: the last resort was NOT used. + StringAssert.DoesNotMatch( + family, + new System.Text.RegularExpressions.Regex("Archivo"), + "The last-resort font must not pre-empt an available chain fallback."); + } } } diff --git a/src/EPPlus.Fonts.OpenType/FontSubsetManager.cs b/src/EPPlus.Fonts.OpenType/FontSubsetManager.cs index 216c7f1fc3..9f098c7928 100644 --- a/src/EPPlus.Fonts.OpenType/FontSubsetManager.cs +++ b/src/EPPlus.Fonts.OpenType/FontSubsetManager.cs @@ -96,77 +96,96 @@ public void AddText(string text) /// public IFontProvider CreateSubsettedProvider() { - var primaryFont = _sourceProvider.PrimaryFont; - var allFonts = _sourceProvider.GetAllFonts().ToList(); + var originalChain = _sourceProvider.GetAllFonts().ToList(); - var subsetMap = new Dictionary(); - - foreach (var kvp in _codePointsByFont) + // --- Step 1: chain-level decision. Call ResolveEmbeddingDecision ONCE per font, + // outside try/catch (a NoEmbedding font must throw straight to the caller). --- + var decisions = new Dictionary(); + var effectiveChain = new List(); // ordered, skipped fonts removed + foreach (var font in originalChain) { - var originalFont = kvp.Key; - var codePoints = kvp.Value; + var decision = _fontEngine.ResolveEmbeddingDecision(font); + decisions[font] = decision; + if (decision != FontEmbeddingDecision.Skip) + effectiveChain.Add(font); + } + + // If everything was skipped, pull in the last-resort font so the chain is never empty. + if (effectiveChain.Count == 0) + effectiveChain.Add(EmbeddedFonts.LoadArchivoNarrow(FontSubFamily.Regular)); - if (codePoints.Count == 0) + // --- Step 2: redistribute the skipped fonts' code points over the reduced chain. --- + foreach (var font in originalChain) + { + if (decisions[font] != FontEmbeddingDecision.Skip) continue; - // Resolve the embedding decision OUTSIDE the try/catch: a NoEmbedding font - // throws intentionally, and that error must reach the caller — not be - // swallowed and silently embedded by the fallback below. - var decision = _fontEngine.ResolveEmbeddingDecision(originalFont); + HashSet cps; + if (_codePointsByFont.TryGetValue(font, out cps)) + { + foreach (var cp in cps) + { + var target = ResolveOverChain(effectiveChain, cp); // cmap walk, ultimately chain[0] + HashSet targetCps; + if (!_codePointsByFont.TryGetValue(target, out targetCps)) + _codePointsByFont[target] = targetCps = new HashSet(); + targetCps.Add(cp); + } + } + _codePointsByFont.Remove(font); // a skipped font is never subsetted + } + + // --- Step 3: subset loop, now only over fonts in effectiveChain. + // Same switch as before BUT the Skip branch is gone — it can no longer occur here. --- + var subsetMap = new Dictionary(); + foreach (var font in effectiveChain) + { + HashSet cps; + if (!_codePointsByFont.TryGetValue(font, out cps) || cps.Count == 0) + continue; - switch (decision) + switch (decisions.ContainsKey(font) ? decisions[font] : FontEmbeddingDecision.Subset) { case FontEmbeddingDecision.EmbedWhole: - // No-subsetting font (or caller opted to embed whole): embed unmodified. - subsetMap[originalFont] = originalFont; + subsetMap[font] = font; break; - - case FontEmbeddingDecision.Skip: - throw new NotSupportedException( - string.Format( - "Font '{0}' resolved to a Skip embedding decision, but the PDF exporter " + - "has no font-substitution path yet. Return Subset or EmbedWhole from " + - "IEpplusFontConfiguration.OnFontEmbedding, or make the font embeddable.", - originalFont.NameTable != null ? originalFont.NameTable.GetFullFontName() : "(unknown)")); - case FontEmbeddingDecision.Subset: - try - { - var chars = CodePointUtil.CodePointsToString(codePoints); - subsetMap[originalFont] = originalFont.CreateSubset(chars); - } + try { subsetMap[font] = font.CreateSubset(CodePointUtil.CodePointsToString(cps)); } catch (Exception ex) { - // If subsetting itself fails, fall back to the original font. System.Diagnostics.Debug.WriteLine( - $"Warning: Could not subset '{originalFont.NameTable?.GetFullFontName()}': {ex.Message}"); - subsetMap[originalFont] = originalFont; + $"Warning: could not subset '{font.NameTable?.GetFullFontName()}': {ex.Message}"); + subsetMap[font] = font; } break; - - default: - throw new ArgumentOutOfRangeException(); } } - // Build new provider with subsetted fonts, preserving fallback order - var subsetPrimary = subsetMap.ContainsKey(primaryFont) - ? subsetMap[primaryFont] - : primaryFont; + // --- Step 4: build the provider. effectiveChain[0] becomes the primary — a skipped + // primary is already filtered out, so "primary is replaced" is expressed naturally. --- + var provider = new CustomFontProvider(Resolved(effectiveChain[0], subsetMap)); + for (int i = 1; i < effectiveChain.Count; i++) + provider.AddFallback(Resolved(effectiveChain[i], subsetMap)); + return provider; + } - var provider = new CustomFontProvider(subsetPrimary); + private static OpenTypeFont Resolved(OpenTypeFont font, Dictionary map) + { + // A font with no collected code points is kept unchanged. + OpenTypeFont subset; + return map.TryGetValue(font, out subset) ? subset : font; + } - for (int i = 1; i < allFonts.Count; i++) + // Chain-local cmap lookup. Last resort: chain[0] (which, in the all-skipped case, IS Archivo Narrow). + private static OpenTypeFont ResolveOverChain(List chain, int codePoint) + { + foreach (var font in chain) { - var originalFallback = allFonts[i]; - - if (subsetMap.ContainsKey(originalFallback)) - { - provider.AddFallback(subsetMap[originalFallback]); - } + ushort glyphId; + if (font.CmapTable.TryGetGlyphId((uint)codePoint, out glyphId)) + return font; } - - return provider; + return chain[0]; } } } \ No newline at end of file From 9a67e5bc6316775394d4b2300f70a71df77359d8 Mon Sep 17 00:00:00 2001 From: swmal <897655+swmal@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:54:19 +0200 Subject: [PATCH 3/4] WIP --- .../Resources/PdfDictionaries.cs | 71 ++++-- .../Resources/PdfFontResource.cs | 8 +- .../Settings/PdfPageSettings.cs | 4 +- .../FontSubsetManagerTests.cs | 133 ---------- .../DocumentFontSubsetBuilderTests.cs | 198 +++++++++++++++ .../Subsetting/FontEmbeddingPolicyTests.cs | 166 ++----------- ...SubsetManager.cs => FontSubsetManager2.cs} | 6 +- .../Subsetting/DocumentFontSubsetBuilder.cs | 231 ++++++++++++++++++ .../Subsetting/SingleFontSubsetter.cs | 56 +++++ .../Subsetting/SubsettedFont.cs | 45 ++++ src/EPPlus/Export/PdfExport/PdfCatalog.cs | 45 ++-- .../PdfExport/TextShaping/PdfTextShaper.cs | 5 +- 12 files changed, 640 insertions(+), 328 deletions(-) delete mode 100644 src/EPPlus.Fonts.OpenType.Tests/FontSubsetManagerTests.cs create mode 100644 src/EPPlus.Fonts.OpenType.Tests/Subsetting/DocumentFontSubsetBuilderTests.cs rename src/EPPlus.Fonts.OpenType/{FontSubsetManager.cs => FontSubsetManager2.cs} (97%) create mode 100644 src/EPPlus.Fonts.OpenType/Subsetting/DocumentFontSubsetBuilder.cs create mode 100644 src/EPPlus.Fonts.OpenType/Subsetting/SingleFontSubsetter.cs create mode 100644 src/EPPlus.Fonts.OpenType/Subsetting/SubsettedFont.cs diff --git a/src/EPPlus.Export.Pdf/Resources/PdfDictionaries.cs b/src/EPPlus.Export.Pdf/Resources/PdfDictionaries.cs index 6bfc199d03..d8c40e83c4 100644 --- a/src/EPPlus.Export.Pdf/Resources/PdfDictionaries.cs +++ b/src/EPPlus.Export.Pdf/Resources/PdfDictionaries.cs @@ -10,13 +10,15 @@ Date Author Change ************************************************************************************************* 27/11/2025 EPPlus Software AB EPPlus 9 08/17/2026 EPPlus Software AB Canonical FontKey + resolve cache + 08/20/2026 EPPlus Software AB Document-wide subsetting via DocumentFontSubsetBuilder *************************************************************************************************/ +using EPPlus.Export.Pdf.Settings; using EPPlus.Fonts.OpenType; using EPPlus.Fonts.OpenType.Integration; +using EPPlus.Fonts.OpenType.Subsetting; using OfficeOpenXml.Interfaces.Fonts; using System.Collections.Generic; using System.Linq; -using EPPlus.Export.Pdf.Settings; namespace EPPlus.Export.Pdf.Resources { @@ -27,6 +29,10 @@ internal class PdfDictionaries internal readonly Dictionary Shadings = new Dictionary(); internal Dictionary ShapedProviders = new Dictionary(); + // One document-wide subset builder, replacing the per-font FontSubsetManager. Owns all + // fallback resolution, embedding-restriction decisions, and shared subset construction. + private DocumentFontSubsetBuilder _subsetBuilder; + // Cache mapping a requested (family, subfamily) to the canonical FontKey of // the loaded font. Case-insensitive on the requested family so casing in the // source workbook resolves to the same key. Ensures the font is only loaded @@ -61,30 +67,63 @@ internal FontKey ResolveFontKey(PdfPageSettings pageSettings, string family, Fon return key; } - public void AddFont(PdfPageSettings pageSettings, string FontName, FontSubFamily SubFamily, string Text) + // CHANGE 1: AddFont now only feeds the builder. It no longer creates a PdfFontResource — + // resources are created later, per ACTUAL font, during shaping. We still resolve the + // requested key so it is registered in _requestedToKey for later provider wiring. + public void AddFont(PdfPageSettings pageSettings, string fontName, FontSubFamily subFamily, string text) { - var key = ResolveFontKey(pageSettings, FontName, SubFamily); - if (!Fonts.ContainsKey(key)) + EnsureBuilder(pageSettings); + ResolveFontKey(pageSettings, fontName, subFamily); // register the requested key + _subsetBuilder.AddText(fontName, subFamily, text); + } + + private void EnsureBuilder(PdfPageSettings pageSettings) + { + if (_subsetBuilder == null) + _subsetBuilder = new DocumentFontSubsetBuilder(pageSettings.FontEngine); + } + + // CHANGE 2: new. Runs the single document-wide build, then wires one shaping provider per + // requested font. Call once, after all text is collected, before shaping. Replaces the + // old per-font CreateSubsettedProvider loop in PdfCatalog. + internal void BuildSubsets(PdfPageSettings pageSettings) + { + if (_subsetBuilder == null) return; // no text was collected + _subsetBuilder.Build(); + + foreach (var requestedKey in _requestedToKey.Values.Distinct()) { - int label = 1; - if (Fonts.Count > 0) - { - label = Fonts.Last().Value.labelNumber + 1; - } - Fonts.Add(key, new PdfFontResource(FontName, SubFamily, label, pageSettings)); + var provider = _subsetBuilder.GetShapingProvider(requestedKey.Family, requestedKey.SubFamily); + if (provider != null) + ShapedProviders[requestedKey] = provider; } - var manger = Fonts[key].fontSubsetManager; - manger.AddText(Text); } + // CHANGE 3: GetFont is used by the renderer for METRICS only (glyph font selection is done + // per-glyph via FontIdMap). After skipping, the requested font may not be embedded, so we + // translate the requested font to the ACTUAL primary that renders it (the shaping + // provider's primary) and return that resource. internal PdfFontResource GetFont(PdfPageSettings pageSettings, string fontName, FontSubFamily subFamily) { - var key = ResolveFontKey(pageSettings, fontName, subFamily); - if (!Fonts.ContainsKey(key)) + var requestedKey = ResolveFontKey(pageSettings, fontName, subFamily); + + // Preferred path: translate requested -> actual via the shaping provider's primary. + IFontProvider provider; + if (ShapedProviders.TryGetValue(requestedKey, out provider) && provider.PrimaryFont != null) { - throw new KeyNotFoundException("Font: " + key + " is missing from dictionary."); + var actual = provider.PrimaryFont; + var actualKey = new FontKey(actual.GetEnglishFontFamilyName(), actual.NameTable.GetSubfamilyEnum()); + PdfFontResource viaProvider; + if (Fonts.TryGetValue(actualKey, out viaProvider)) + return viaProvider; } - return Fonts[key]; + + // Fallback: the requested font was embedded under its own identity (not skipped). + PdfFontResource direct; + if (Fonts.TryGetValue(requestedKey, out direct)) + return direct; + + throw new KeyNotFoundException("Font: " + requestedKey + " is missing from dictionary."); } } } \ No newline at end of file diff --git a/src/EPPlus.Export.Pdf/Resources/PdfFontResource.cs b/src/EPPlus.Export.Pdf/Resources/PdfFontResource.cs index 4ff14c1016..e502ec50b5 100644 --- a/src/EPPlus.Export.Pdf/Resources/PdfFontResource.cs +++ b/src/EPPlus.Export.Pdf/Resources/PdfFontResource.cs @@ -45,15 +45,15 @@ internal class PdfFontResource : PdfResource internal HashSet Subset = new HashSet(); internal HashSet Gids = new HashSet(); internal Dictionary charactermappings = new Dictionary(); - internal FontSubsetManager fontSubsetManager; public PdfFontResource(string fontName, FontSubFamily subFamily, int labelNumber, PdfPageSettings pageSettings) - : base("F", labelNumber) + : base("F", labelNumber) { this.fontName = fontName; _fontEngine = pageSettings.FontEngine; - fontData = _fontEngine.LoadFont(fontName, subFamily); - fontSubsetManager = new FontSubsetManager(pageSettings.FontEngine, fontData); + // fontData is assigned by the caller (ShapeText / GidsAndCharMap) to the actual, already- + // subsetted font. The resource must not load a whole font here — for fallback fonts (Noto + // Emoji, Archivo) a name-based load would be wrong or wasteful. } //Get the Font Descriptor object to write in PDF. diff --git a/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs b/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs index 5416f18870..d3d6efbc4c 100644 --- a/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs +++ b/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs @@ -200,8 +200,8 @@ public PdfScaling Scaling internal string defaultFontName = ""; //DEBUG - internal bool Debug = false; - internal bool PrintAsText = false; + internal bool Debug = true; + internal bool PrintAsText = true; public PdfPageSettings(OpenTypeFontEngine fontEngine) { diff --git a/src/EPPlus.Fonts.OpenType.Tests/FontSubsetManagerTests.cs b/src/EPPlus.Fonts.OpenType.Tests/FontSubsetManagerTests.cs deleted file mode 100644 index 5c4a8c9e7a..0000000000 --- a/src/EPPlus.Fonts.OpenType.Tests/FontSubsetManagerTests.cs +++ /dev/null @@ -1,133 +0,0 @@ -using EPPlus.Fonts.OpenType; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System.Collections.Generic; -using System.Linq; - -namespace EPPlus.Fonts.OpenType.Tests -{ - [TestClass] - public class FontSubsetManagerTests : FontTestBase - { - public override TestContext? TestContext { get; set; } - - // Helper: Load a real font for testing - private OpenTypeFont LoadTestFont() - { - // Adjust path to a font available in your test environment - return TestFolderEngine.LoadFont("Roboto"); - } - - [TestMethod] - public void CreateSubsettedProvider_WithAsciiText_ReturnsSubsettedPrimaryFont() - { - // Arrange - var font = LoadTestFont(); - var manager = new FontSubsetManager(TestFolderEngine, font); - - // Act - manager.AddText("Hello World"); - var provider = manager.CreateSubsettedProvider(); - - // Assert - The subset should be a different (smaller) font instance - var subsetFont = provider.PrimaryFont; - Assert.IsNotNull(subsetFont); - Assert.IsTrue(subsetFont.IsSubset, "Primary font should be subsetted"); - - // Verify the subset contains the glyphs we need - foreach (char c in "Hello World") - { - ushort glyphId; - Assert.IsTrue( - subsetFont.CmapTable.TryGetGlyphId(c, out glyphId), - $"Subset should contain glyph for '{c}'"); - Assert.AreNotEqual((ushort)0, glyphId, $"Glyph for '{c}' should not be .notdef"); - } - } - - [TestMethod] - public void CreateSubsettedProvider_WithEmoji_SubsetsFallbackFont() - { - // Arrange - var font = LoadTestFont(); - var provider = new DefaultFontProvider(TestFolderEngine, font); - var manager = new FontSubsetManager(TestFolderEngine, provider); - - // Act - Add text with emoji (U+1F600 = 😀, handled by Noto Emoji fallback) - manager.AddText("Hello 😀"); - var subsettedProvider = manager.CreateSubsettedProvider(); - - // Assert - Should have primary + at least one fallback - var allFonts = subsettedProvider.GetAllFonts().ToList(); - Assert.IsTrue(allFonts.Count >= 2, - "Should have primary font + emoji fallback font"); - - // The fallback font should also be subsetted - var fallbackFont = allFonts[1]; - Assert.IsTrue(fallbackFont.IsSubset, - "Fallback (emoji) font should be subsetted"); - - // The subsetted emoji font should be much smaller than the original - var serialized = fallbackFont.Serialize(); - Assert.IsTrue(serialized.Length < 100 * 1024, - $"Subsetted emoji font should be small, was {serialized.Length / 1024} KB"); - } - - [TestMethod] - public void CreateSubsettedProvider_WithMultipleAddTextCalls_CollectsAllCodePoints() - { - // Arrange - var font = LoadTestFont(); - var manager = new FontSubsetManager(TestFolderEngine, font); - - // Act - Add text in multiple calls (simulates scanning multiple cells) - manager.AddText("ABC"); - manager.AddText("DEF"); - manager.AddText("ADF"); // Overlapping characters - var provider = manager.CreateSubsettedProvider(); - - // Assert - All characters from all calls should be present - var subsetFont = provider.PrimaryFont; - foreach (char c in "ABCDEF") - { - ushort glyphId; - Assert.IsTrue( - subsetFont.CmapTable.TryGetGlyphId(c, out glyphId), - $"Subset should contain glyph for '{c}'"); - } - } - - [TestMethod] - public void CreateSubsettedProvider_UnusedFallbackFontsAreExcluded() - { - // Arrange - DefaultFontProvider has Noto Emoji + Noto Math as fallbacks - var font = LoadTestFont(); - var provider = new DefaultFontProvider(TestFolderEngine, font); - var manager = new FontSubsetManager(TestFolderEngine, provider); - - // Act - Only ASCII text, no emoji or math symbols - manager.AddText("Plain text only"); - var subsettedProvider = manager.CreateSubsettedProvider(); - - // Assert - Should only have the primary font (no fallbacks needed) - var allFonts = subsettedProvider.GetAllFonts().ToList(); - Assert.AreEqual(1, allFonts.Count, - "Only primary font should be included when no fallback glyphs are used"); - } - - [TestMethod] - public void AddText_WithNullOrEmpty_DoesNotThrow() - { - // Arrange - var font = LoadTestFont(); - var manager = new FontSubsetManager(TestFolderEngine, font); - - // Act & Assert - Should handle gracefully - manager.AddText(null); - manager.AddText(""); - manager.AddText("A"); // Then add real text - - var provider = manager.CreateSubsettedProvider(); - Assert.IsNotNull(provider.PrimaryFont); - } - } -} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType.Tests/Subsetting/DocumentFontSubsetBuilderTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Subsetting/DocumentFontSubsetBuilderTests.cs new file mode 100644 index 0000000000..0aca385d97 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/Subsetting/DocumentFontSubsetBuilderTests.cs @@ -0,0 +1,198 @@ +using EPPlus.Fonts.OpenType.Subsetting; +using OfficeOpenXml.Interfaces.Fonts; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace EPPlus.Fonts.OpenType.Tests.Subsetting +{ + [TestClass] + public class DocumentFontSubsetBuilderTests : FontTestBase + { + public override TestContext? TestContext { get; set; } + + private static DocumentFontSubsetBuilder CreateBuilderWithCallback( + Func callback) + { + var engine = new OpenTypeFontEngine(cfg => + { + foreach (var folder in FontFolders) + cfg.FontDirectories.Add(folder); + cfg.SearchSystemDirectories = false; + cfg.OnFontEmbedding(callback); + }); + return new DocumentFontSubsetBuilder(engine); + } + + private static Func SkipByName(string namePart) + { + return info => info.FontName != null && info.FontName.Contains(namePart) + ? FontEmbeddingDecision.Skip + : FontEmbeddingDecision.Default; + } + + [TestMethod] + public void Build_SkippedPrimary_NextFontBecomesPrimary() + { + var builder = CreateBuilderWithCallback(SkipByName("Roboto")); + builder.AddText("Roboto", FontSubFamily.Regular, "Hello"); + builder.Build(); + + var provider = builder.GetShapingProvider("Roboto", FontSubFamily.Regular); + + Assert.IsNotNull(provider.PrimaryFont); + StringAssert.DoesNotMatch( + provider.PrimaryFont.GetEnglishFontFamilyName(), + new System.Text.RegularExpressions.Regex("Roboto"), + "A skipped primary must not remain the provider's primary font."); + } + + [TestMethod] + public void Build_SkippedPrimary_AllTextSkipped_UsesLastResort() + { + var builder = CreateBuilderWithCallback(SkipByName("Roboto")); + builder.AddText("Roboto", FontSubFamily.Regular, "Hello"); + builder.Build(); + + var provider = builder.GetShapingProvider("Roboto", FontSubFamily.Regular); + + StringAssert.Contains( + provider.PrimaryFont.GetEnglishFontFamilyName(), + "Archivo", + "When the whole chain is skipped, the last-resort font must become primary."); + + ushort glyphId; + Assert.IsTrue( + provider.PrimaryFont.CmapTable.TryGetGlyphId('H', out glyphId) && glyphId != 0, + "Latin glyphs must be carried by the last-resort font after redistribution."); + } + + [TestMethod] + public void Build_SkippedPrimary_PrefersChainFontOverLastResort() + { + // Roboto skipped, but its text is an emoji that a chain fallback (Noto Emoji) covers. + // The emoji must be carried by that chain font — NOT by the Archivo last resort. + // (Han/CJK cannot be used here until script fallback is wired into the provider chain.) + var builder = CreateBuilderWithCallback(SkipByName("Roboto")); + builder.AddText("Roboto", FontSubFamily.Regular, char.ConvertFromUtf32(0x1F600)); // 😀 + builder.Build(); + + var provider = builder.GetShapingProvider("Roboto", FontSubFamily.Regular); + + ushort glyphId; + Assert.IsTrue( + provider.PrimaryFont.CmapTable.TryGetGlyphId(0x1F600, out glyphId) && glyphId != 0, + "The emoji must be carried by the chain fallback, not the last resort."); + } + + [TestMethod] + public void Build_SharedFallback_AllPrimariesSkipped_ProduceSingleConsistentSubset() + { + // The A1/B1/C1 regression, as a unit test: three primaries, all skipped, all collapsing + // to the same last-resort font. That font must be ONE shared subset containing every + // routed glyph — not three colliding subsets. + var builder = CreateBuilderWithCallback(info => FontEmbeddingDecision.Skip); // skip everything + builder.AddText("Roboto", FontSubFamily.Regular, "A"); + builder.AddText("Open Sans", FontSubFamily.Regular, "B"); + builder.AddText("Mulish", FontSubFamily.Regular, "C"); + builder.Build(); + + var embedded = builder.GetFontsToEmbed().ToList(); + + // Exactly one font embedded (the shared last resort), carrying A, B and C. + Assert.AreEqual(1, embedded.Count, "All skipped primaries must collapse to one shared font."); + var shared = embedded[0].Font; + + foreach (var ch in new[] { 'A', 'B', 'C' }) + { + ushort glyphId; + Assert.IsTrue( + shared.CmapTable.TryGetGlyphId(ch, out glyphId) && glyphId != 0, + "Shared subset must carry '" + ch + "' from all three primaries."); + } + } + + private const string TestFamily = "Roboto"; + + [TestMethod] + public void AddText_WithNullOrEmpty_DoesNotThrow() + { + var builder = new DocumentFontSubsetBuilder(TestFolderEngine); + builder.AddText(TestFamily, FontSubFamily.Regular, null); + builder.AddText(TestFamily, FontSubFamily.Regular, ""); + // No text was ever added, so Build has nothing to do — it must not throw either. + builder.Build(); + } + + [TestMethod] + public void Build_WithAsciiText_ReturnsSubsettedPrimaryFont() + { + var builder = new DocumentFontSubsetBuilder(TestFolderEngine); + builder.AddText(TestFamily, FontSubFamily.Regular, "Hello"); + builder.Build(); + + var provider = builder.GetShapingProvider(TestFamily, FontSubFamily.Regular); + + Assert.IsNotNull(provider); + Assert.IsTrue(provider.PrimaryFont.IsSubset, + "Ascii text through the primary font must yield a subsetted primary."); + } + + [TestMethod] + public void Build_WithMultipleAddTextCalls_CollectsAllCodePoints() + { + var builder = new DocumentFontSubsetBuilder(TestFolderEngine); + builder.AddText(TestFamily, FontSubFamily.Regular, "abc"); + builder.AddText(TestFamily, FontSubFamily.Regular, "def"); + builder.Build(); + + var provider = builder.GetShapingProvider(TestFamily, FontSubFamily.Regular); + + // Every code point from every AddText call must survive into the subset. + foreach (var ch in "abcdef") + { + ushort glyphId; + Assert.IsTrue( + provider.PrimaryFont.CmapTable.TryGetGlyphId(ch, out glyphId) && glyphId != 0, + "Subset must contain '" + ch + "' collected across multiple AddText calls."); + } + } + + [TestMethod] + public void Build_WithEmoji_SubsetsFallbackFont() + { + var builder = new DocumentFontSubsetBuilder(TestFolderEngine); + builder.AddText(TestFamily, FontSubFamily.Regular, char.ConvertFromUtf32(0x1F600)); // 😀 + builder.Build(); + + // The emoji routes to the Noto Emoji fallback, which must appear among the embedded + // fonts and carry the glyph. + var embedded = builder.GetFontsToEmbed().ToList(); + + bool emojiCarried = embedded.Any(sf => + { + ushort glyphId; + return sf.Font.CmapTable.TryGetGlyphId(0x1F600, out glyphId) && glyphId != 0; + }); + + Assert.IsTrue(emojiCarried, "The emoji fallback font must be subsetted and embedded."); + } + + [TestMethod] + public void Build_UnusedFallbackFontsAreExcluded() + { + // Pure ascii: only the primary is needed. No emoji/math fallback should be embedded. + var builder = new DocumentFontSubsetBuilder(TestFolderEngine); + builder.AddText(TestFamily, FontSubFamily.Regular, "Hello"); + builder.Build(); + + var embedded = builder.GetFontsToEmbed().ToList(); + + Assert.AreEqual(1, embedded.Count, + "Only the primary font should be embedded when no fallback was needed."); + StringAssert.Contains(embedded[0].Family, "Roboto"); + } + } +} diff --git a/src/EPPlus.Fonts.OpenType.Tests/Subsetting/FontEmbeddingPolicyTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Subsetting/FontEmbeddingPolicyTests.cs index 537623af5a..f0190de767 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/Subsetting/FontEmbeddingPolicyTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/Subsetting/FontEmbeddingPolicyTests.cs @@ -1,4 +1,6 @@ -using EPPlus.Fonts.OpenType.Tables.Os2; +using EPPlus.Fonts.OpenType.Subsetting; +using EPPlus.Fonts.OpenType.Tables.Os2; +using OfficeOpenXml; using OfficeOpenXml.Interfaces.Fonts; using System; using System.Collections.Generic; @@ -149,164 +151,24 @@ public void ResolveEmbeddingDecision_CallbackReceivesCorrectInfo() } [TestMethod] - public void CreateSubsettedProvider_NoSubsettingFont_EmbedsWholeFontNotSubset() + public void Build_EmbedWholeDecision_EmbedsWholeFontNotSubset() { - var font = TestFolderEngine.LoadFont("Roboto", ignoreCache: true); - font.Os2Table.fsType = FsTypeFlags.NoSubsetting; - - var manager = new FontSubsetManager(TestFolderEngine, font); - // Collect some code points so the font would otherwise be subsetted. - manager.AddText("Hello"); // <-- vet ej exakt API-namn, se nedan - - var provider = manager.CreateSubsettedProvider(); - - Assert.IsFalse(provider.PrimaryFont.IsSubset, - "NoSubsetting font must be embedded whole, not subsetted."); - } - - // ----------------------------------------------------------------------------------------- - // Level 4: Skip as a real fallback path (colleague feedback). - // - // A Skip decision can only originate from the OnFontEmbedding callback — the fsType policy - // never produces it. When a font is skipped it must be removed from the effective chain and - // its code points redistributed over the remaining fonts, rather than throwing. These tests - // build an engine whose callback skips a specific font by name. - // ----------------------------------------------------------------------------------------- - - [TestMethod] - public void CreateSubsettedProvider_SkippedPrimary_NextFontBecomesPrimary() - { - // Roboto is the primary; the callback skips it. The provider's default fallback chain - // (Noto Emoji, Noto Math) plus the resolver's last resort should take over, so the - // resulting primary must be something other than Roboto and must not be null. - var engine = CreateEngineWithCallback(info => - info.FontName != null && info.FontName.Contains("Roboto") - ? FontEmbeddingDecision.Skip - : FontEmbeddingDecision.Default); - - var roboto = engine.LoadFont("Roboto", ignoreCache: true); - - var manager = new FontSubsetManager(engine, roboto); - manager.AddText("Hello"); - - var provider = manager.CreateSubsettedProvider(); - - Assert.IsNotNull(provider.PrimaryFont); - StringAssert.DoesNotMatch( - provider.PrimaryFont.GetEnglishFontFamilyName(), - new System.Text.RegularExpressions.Regex("Roboto"), - "A skipped primary must not remain the provider's primary font."); - } - - [TestMethod] - public void CreateSubsettedProvider_SkippedPrimary_AllTextSkipped_UsesLastResort() - { - // With ONLY Latin text and Roboto skipped, none of the default fallbacks (Emoji, Math) - // cover the letters. The chain would collapse to empty, so the last-resort font - // (Archivo Narrow) must step in and carry the glyphs. + // A font whose embedding decision is EmbedWhole (here forced via the callback, exactly as a + // NoSubsetting fsType would resolve) must be embedded whole — not subsetted — even though + // text was collected that would otherwise trigger subsetting. var engine = CreateEngineWithCallback(info => info.FontName != null && info.FontName.Contains("Roboto") - ? FontEmbeddingDecision.Skip + ? FontEmbeddingDecision.EmbedWhole : FontEmbeddingDecision.Default); - var roboto = engine.LoadFont("Roboto", ignoreCache: true); - - var manager = new FontSubsetManager(engine, roboto); - manager.AddText("Hello"); - - var provider = manager.CreateSubsettedProvider(); - - // Archivo Narrow is the guaranteed last resort. Its family name identifies it. - StringAssert.Contains( - provider.PrimaryFont.GetEnglishFontFamilyName(), - "Archivo", - "When the whole chain is skipped, the last-resort font must become primary."); + var builder = new DocumentFontSubsetBuilder(engine); + builder.AddText("Roboto", FontSubFamily.Regular, "Hello"); + builder.Build(); - // The redistributed Latin code points must actually be present in that font. - ushort glyphId; - Assert.IsTrue( - provider.PrimaryFont.CmapTable.TryGetGlyphId('H', out glyphId) && glyphId != 0, - "Latin glyphs must be carried by the last-resort font after redistribution."); + var provider = builder.GetShapingProvider("Roboto", FontSubFamily.Regular); - } - - [TestMethod] - public void CreateSubsettedProvider_SkippedPrimary_CjkText_GlyphsLandInReplacement() - { - // The heart of the redistribution logic: Roboto (Latin) is primary and covers none of - // the CJK text. A CJK-capable fallback (BIZ UDGothic) sits in a CustomFontProvider chain. - // When Roboto is skipped, the CJK code points that were distributed to it must be - // redistributed to BIZ UDGothic and appear in the subsetted result. - var engine = CreateEngineWithCallback(info => - info.FontName != null && info.FontName.Contains("Roboto") - ? FontEmbeddingDecision.Skip - : FontEmbeddingDecision.Default); - - var roboto = engine.LoadFont("Roboto", ignoreCache: true); - var biz = engine.LoadFont("BIZ UDGothic", ignoreCache: true); - - var source = new CustomFontProvider(roboto); - source.AddFallback(biz); - - var manager = new FontSubsetManager(engine, source); - - // U+6F22 漢 — a Han ideograph covered by BIZ UDGothic, not by Roboto. - const int han = 0x6F22; - manager.AddText(char.ConvertFromUtf32(han)); - - var provider = manager.CreateSubsettedProvider(); - - // Roboto skipped → the CJK-capable font becomes primary. - ushort glyphId; - Assert.IsTrue( - provider.PrimaryFont.CmapTable.TryGetGlyphId((uint)han, out glyphId) && glyphId != 0, - "The Han code point must be carried (and subsetted) by the replacement font."); - - StringAssert.DoesNotMatch( - provider.PrimaryFont.GetEnglishFontFamilyName(), - new System.Text.RegularExpressions.Regex("Roboto"), - "The skipped primary must not remain the provider's primary font."); - - StringAssert.Contains( - provider.PrimaryFont.GetEnglishFontFamilyName(), - "BIZ", - "The CJK-capable fallback must have become the primary font."); - } - - [TestMethod] - public void CreateSubsettedProvider_SkippedPrimary_PrefersChainFontOverLastResort() - { - // A skipped primary must hand off to a real font from the chain, NOT jump straight - // to the Archivo Narrow last resort. Roboto (Latin) is primary; BIZ UDGothic is a - // fallback that covers the CJK text. When Roboto is skipped, BIZ — not Archivo — - // must become primary. - var engine = CreateEngineWithCallback(info => - info.FontName != null && info.FontName.Contains("Roboto") - ? FontEmbeddingDecision.Skip - : FontEmbeddingDecision.Default); - - var roboto = engine.LoadFont("Roboto", ignoreCache: true); - var biz = engine.LoadFont("BIZ UDGothic", ignoreCache: true); - - var source = new CustomFontProvider(roboto); - source.AddFallback(biz); - - var manager = new FontSubsetManager(engine, source); - manager.AddText(char.ConvertFromUtf32(0x6F22)); // 漢 - - var provider = manager.CreateSubsettedProvider(); - - var family = provider.PrimaryFont.GetEnglishFontFamilyName(); - - // The positive assertion: the chain font took over. - StringAssert.Contains(family, "BIZ", - "A chain fallback must take over a skipped primary."); - - // The negative assertion — the crux: the last resort was NOT used. - StringAssert.DoesNotMatch( - family, - new System.Text.RegularExpressions.Regex("Archivo"), - "The last-resort font must not pre-empt an available chain fallback."); + Assert.IsFalse(provider.PrimaryFont.IsSubset, + "An EmbedWhole font must be embedded whole, not subsetted."); } } } diff --git a/src/EPPlus.Fonts.OpenType/FontSubsetManager.cs b/src/EPPlus.Fonts.OpenType/FontSubsetManager2.cs similarity index 97% rename from src/EPPlus.Fonts.OpenType/FontSubsetManager.cs rename to src/EPPlus.Fonts.OpenType/FontSubsetManager2.cs index 9f098c7928..434bdd9b92 100644 --- a/src/EPPlus.Fonts.OpenType/FontSubsetManager.cs +++ b/src/EPPlus.Fonts.OpenType/FontSubsetManager2.cs @@ -29,7 +29,7 @@ namespace EPPlus.Fonts.OpenType /// 3. Call CreateSubsettedProvider() to get a new IFontProvider with subsetted fonts /// 4. Use the returned provider for shaping and PDF rendering /// - public class FontSubsetManager + public class FontSubsetManager2 { private readonly IFontProvider _sourceProvider; private readonly OpenTypeFontEngine _fontEngine; @@ -38,7 +38,7 @@ public class FontSubsetManager private readonly Dictionary> _codePointsByFont = new Dictionary>(); - public FontSubsetManager(OpenTypeFontEngine engine, IFontProvider sourceProvider) + public FontSubsetManager2(OpenTypeFontEngine engine, IFontProvider sourceProvider) { if (engine == null) throw new ArgumentNullException("engine"); @@ -49,7 +49,7 @@ public FontSubsetManager(OpenTypeFontEngine engine, IFontProvider sourceProvider _fontEngine = engine; } - public FontSubsetManager(OpenTypeFontEngine engine, OpenTypeFont font) + public FontSubsetManager2(OpenTypeFontEngine engine, OpenTypeFont font) : this(engine, new DefaultFontProvider(engine, font)) { diff --git a/src/EPPlus.Fonts.OpenType/Subsetting/DocumentFontSubsetBuilder.cs b/src/EPPlus.Fonts.OpenType/Subsetting/DocumentFontSubsetBuilder.cs new file mode 100644 index 0000000000..a046e7d2ab --- /dev/null +++ b/src/EPPlus.Fonts.OpenType/Subsetting/DocumentFontSubsetBuilder.cs @@ -0,0 +1,231 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 10/07/2025 EPPlus Software AB EPPlus.Fonts.OpenType 1.0 + *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Integration; +using EPPlus.Fonts.OpenType.Utils; +using OfficeOpenXml.Interfaces.Fonts; +using System; +using System.Collections.Generic; + +namespace EPPlus.Fonts.OpenType.Subsetting +{ + public sealed class DocumentFontSubsetBuilder + { + private readonly OpenTypeFontEngine _engine; + private readonly SingleFontSubsetter _subsetter = new SingleFontSubsetter(); + + // Requested primaries, keyed by request identity. Value carries the primary font instance + // plus the raw text collected for it (we re-resolve routing in Build, not incrementally). + private readonly Dictionary _requested = + new Dictionary(); + + // ---- Build outputs ---- + private readonly Dictionary _sharedSubsetByIdentity = + new Dictionary(); + private readonly Dictionary _providerByRequest = + new Dictionary(); + private bool _built; + + public DocumentFontSubsetBuilder(OpenTypeFontEngine engine) + { + if (engine == null) throw new ArgumentNullException("engine"); + _engine = engine; + } + + // ---- Step 1: collect ---- + public void AddText(string family, FontSubFamily subFamily, string text) + { + if (_built) throw new InvalidOperationException("Cannot AddText after Build()."); + if (string.IsNullOrEmpty(text)) return; + + var key = new FontKey(family, subFamily); + RequestedFont req; + if (!_requested.TryGetValue(key, out req)) + { + var primary = _engine.LoadFont(family, subFamily); + req = new RequestedFont(key, primary); + _requested[key] = req; + } + foreach (var cp in CodePointUtil.ExtractCodePoints(text)) + req.CodePoints.Add(cp); + } + + // ---- Step 2: build ---- + // Add this field alongside the other private fields: + private readonly Dictionary _decisionByIdentity = + new Dictionary(); + + public void Build() + { + if (_built) return; + + var codePointsByIdentity = new Dictionary>(); + var fontByIdentity = new Dictionary(); + var chainByRequest = new Dictionary>(); + + // ===== PHASE 1: route each code point through the provider, then apply skip ===== + foreach (var kvp in _requested) + { + var req = kvp.Value; + var provider = new DefaultFontProvider(_engine, req.Primary); + + // Distinct destination identities for this request, in first-seen order. + // First entry becomes the request's primary in phase 3. + var chainIdentities = new List(); + + foreach (var cp in req.CodePoints) + { + // The provider resolves the best font for this code point (primary, or a script-/ + // emoji-routed fallback), lazy-loading fallbacks as needed. + OpenTypeFont dest; + ushort glyphId; + provider.TryGetGlyphFont((uint)cp, out dest, out glyphId); + + // If that font may not be embedded, the only replacement in this model is the + // last-resort font: the provider yields ONE answer per code point, not a ranked + // list, so there is no "next best" to fall to. + if (DecisionForFont(dest) == FontEmbeddingDecision.Skip) + dest = LastResort(); + + var id = IdentityOf(dest); + + if (!fontByIdentity.ContainsKey(id)) + fontByIdentity[id] = dest; + + HashSet set; + if (!codePointsByIdentity.TryGetValue(id, out set)) + codePointsByIdentity[id] = set = new HashSet(); + set.Add(cp); + + if (!chainIdentities.Contains(id)) + chainIdentities.Add(id); + } + + // A request with no code points (possible if AddText was called with only skippable + // content) still needs a primary to shape against. + if (chainIdentities.Count == 0) + { + var lr = LastResort(); + var lrId = IdentityOf(lr); + if (!fontByIdentity.ContainsKey(lrId)) + fontByIdentity[lrId] = lr; + chainIdentities.Add(lrId); + } + + chainByRequest[kvp.Key] = chainIdentities; + } + + // ===== PHASE 2: subset (or embed whole) each identity ONCE ===== + foreach (var kvp in fontByIdentity) + { + var id = kvp.Key; + var font = kvp.Value; + + HashSet cps; + codePointsByIdentity.TryGetValue(id, out cps); + + if (DecisionForIdentity(id) == FontEmbeddingDecision.EmbedWhole) + _sharedSubsetByIdentity[id] = font; + else + _sharedSubsetByIdentity[id] = _subsetter.Subset(font, cps); + } + + // ===== PHASE 3: build one provider per request from the SHARED subsets ===== + foreach (var kvp in chainByRequest) + { + var chain = kvp.Value; + var provider = new CustomFontProvider(_sharedSubsetByIdentity[chain[0]]); + for (int i = 1; i < chain.Count; i++) + provider.AddFallback(_sharedSubsetByIdentity[chain[i]]); + _providerByRequest[kvp.Key] = provider; + } + + _built = true; + } + + // Loads the last-resort font and ensures a decision is registered for it (it bypasses + // name resolution, so ResolveEmbeddingDecision is never called for it). It must always be + // subsettable and must never itself be skipped. + private OpenTypeFont LastResort() + { + var font = EmbeddedFonts.LoadArchivoNarrow(FontSubFamily.Regular); + _decisionByIdentity[IdentityOf(font)] = FontEmbeddingDecision.Subset; + return font; + } + + // Resolves and caches the embedding decision for a font, keyed by identity so the user's + // OnFontEmbedding hook fires at most once per FontKey. A NoEmbedding font throws here (via + // ResolveEmbeddingDecision), exactly as in the old per-font path. + private FontEmbeddingDecision DecisionForFont(OpenTypeFont font) + { + var id = IdentityOf(font); + FontEmbeddingDecision decision; + if (!_decisionByIdentity.TryGetValue(id, out decision)) + { + decision = _engine.ResolveEmbeddingDecision(font); + _decisionByIdentity[id] = decision; + } + return decision; + } + + // Looks up an already-resolved decision by identity. Every identity in fontByIdentity passed + // through DecisionForFont during phase 1, so it is always present here. + private FontEmbeddingDecision DecisionForIdentity(FontKey id) + { + return _decisionByIdentity[id]; + } + + // Canonical identity from the pre-subset font instance: family + subfamily. + private static FontKey IdentityOf(OpenTypeFont font) + { + return new FontKey(font.GetEnglishFontFamilyName(), font.NameTable.GetSubfamilyEnum()); + } + + /// + /// The subsetted fonts to embed — one per distinct font identity used in the document. + /// Skipped fonts are absent; each shared fallback appears once. Call after Build(). + /// + public IEnumerable GetFontsToEmbed() + { + RequireBuilt(); + foreach (var kvp in _sharedSubsetByIdentity) + yield return new SubsettedFont(kvp.Key.Family, kvp.Key.SubFamily, kvp.Value); + } + + /// + /// The provider a given requested font shapes against, wired to the shared subsets. + /// Returns null if that font was never added. Call after Build(). + /// + public IFontProvider GetShapingProvider(string family, FontSubFamily subFamily) + { + RequireBuilt(); + IFontProvider provider; + return _providerByRequest.TryGetValue(new FontKey(family, subFamily), out provider) + ? provider : null; + } + + private void RequireBuilt() + { + if (!_built) + throw new InvalidOperationException("Call Build() before reading results."); + } + + private sealed class RequestedFont + { + public FontKey Key { get; private set; } + public OpenTypeFont Primary { get; private set; } + public HashSet CodePoints { get; private set; } + public RequestedFont(FontKey key, OpenTypeFont primary) + { Key = key; Primary = primary; CodePoints = new HashSet(); } + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/Subsetting/SingleFontSubsetter.cs b/src/EPPlus.Fonts.OpenType/Subsetting/SingleFontSubsetter.cs new file mode 100644 index 0000000000..7f5a6454a5 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType/Subsetting/SingleFontSubsetter.cs @@ -0,0 +1,56 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 08/20/2026 EPPlus Software AB Single-font subsetter extracted from FontSubsetManager + *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Utils; +using System; +using System.Collections.Generic; + +namespace EPPlus.Fonts.OpenType +{ + /// + /// Subsets one font down to a given set of code points. This is a low-level building block: + /// it does not resolve fallback chains and makes no embedding-policy decisions — the caller + /// owns all of that. Kept separate so it can be unit-tested in isolation and reused by any + /// component that needs to reduce a single font. + /// + internal sealed class SingleFontSubsetter + { + /// + /// Produces a subset of containing only the glyphs required for + /// . Returns the font unchanged when it is already a subset + /// or when no code points are supplied. If subsetting fails, the original font is returned + /// so the caller always receives an embeddable instance. + /// + public OpenTypeFont Subset(OpenTypeFont font, HashSet codePoints) + { + if (font == null) + throw new ArgumentNullException("font"); + + if (font.IsSubset || codePoints == null || codePoints.Count == 0) + return font; + + try + { + var chars = CodePointUtil.CodePointsToString(codePoints); + return font.CreateSubset(chars); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine( + "Warning: could not subset '" + + (font.NameTable != null ? font.NameTable.GetFullFontName() : "(unknown)") + + "': " + ex.Message); + return font; + } + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/Subsetting/SubsettedFont.cs b/src/EPPlus.Fonts.OpenType/Subsetting/SubsettedFont.cs new file mode 100644 index 0000000000..1b0fe4f655 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType/Subsetting/SubsettedFont.cs @@ -0,0 +1,45 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 10/07/2025 EPPlus Software AB EPPlus.Fonts.OpenType 1.0 + *************************************************************************************************/ +using OfficeOpenXml.Interfaces.Fonts; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace EPPlus.Fonts.OpenType.Subsetting +{ + public sealed class SubsettedFont + { + /// + /// Constructor + /// + /// canonical, pre-subset family name + /// + /// the subsetted instance to embed + internal SubsettedFont(string family, FontSubFamily subFamily, OpenTypeFont font) + { + Family = family; SubFamily = subFamily; Font = font; + } + + /// + /// canonical, pre-subset family name + /// + public string Family { get; } + public FontSubFamily SubFamily { get; } + /// + /// the subsetted instance to embed + /// + public OpenTypeFont Font { get; } + } +} diff --git a/src/EPPlus/Export/PdfExport/PdfCatalog.cs b/src/EPPlus/Export/PdfExport/PdfCatalog.cs index f82db724f9..9b87793ef5 100644 --- a/src/EPPlus/Export/PdfExport/PdfCatalog.cs +++ b/src/EPPlus/Export/PdfExport/PdfCatalog.cs @@ -86,7 +86,12 @@ private void HandleWorksheetCollection(PdfPageSettings pageSettings, ExcelWorksh // Collect text for every worksheet. pdfSheets = GetPdfWorksheets(pageSettings, worksheets); - // Shape text and auto-fit rows per sheet. + // Pass 1: collect all sheets. Pass 2: one document-wide build. Pass 3: shape all sheets. + foreach (var pdfSheet in pdfSheets) + CollectTextInPdfWorksheet(pageSettings, pdfSheet); + + BuildSubsets(pageSettings); + foreach (var pdfSheet in pdfSheets) { ShapeTextInPdfWorksheet(pageSettings, pdfSheet); @@ -95,8 +100,6 @@ private void HandleWorksheetCollection(PdfPageSettings pageSettings, ExcelWorksh // One layout spanning all sheets and their ranges. var layout = GetLayout(pageSettings, pdfSheets); - - // Write the PDF document. writePdf(layout); } finally @@ -148,6 +151,8 @@ private void BuildPdf(PdfPageSettings pageSettings, ExcelWorksheet worksheet, Ac sw.Start(); //Shape Text + CollectTextInPdfWorksheet(pageSettings, pdfSheet); + BuildSubsets(pageSettings); ShapeTextInPdfWorksheet(pageSettings, pdfSheet); sw.Stop(); var ShapeTextTime = sw.ElapsedMilliseconds; @@ -208,10 +213,11 @@ private void BuildPdfFromRange(PdfPageSettings pageSettings, ExcelRangeBase rang try { pdfSheet = GetPdfWorksheet(pageSettings, range); + CollectTextInPdfWorksheet(pageSettings, pdfSheet); + BuildSubsets(pageSettings); ShapeTextInPdfWorksheet(pageSettings, pdfSheet); PdfCalculateRowHeight.ResizeRowHeights(pdfSheet); - var layout = GetLayout(pageSettings, pdfSheet); // single-sheet GetLayout overload - + var layout = GetLayout(pageSettings, pdfSheet); writePdf(layout); } finally @@ -251,9 +257,13 @@ private void HandleRangeCollection(PdfPageSettings pageSettings, ExcelRangeBase[ PdfWorksheet[] pdfSheets = null; try { - // One PdfWorksheet per worksheet, each carrying all of its ranges. pdfSheets = GetPdfWorksheets(pageSettings, ranges); + foreach (var pdfSheet in pdfSheets) + CollectTextInPdfWorksheet(pageSettings, pdfSheet); + + BuildSubsets(pageSettings); + foreach (var pdfSheet in pdfSheets) { ShapeTextInPdfWorksheet(pageSettings, pdfSheet); @@ -261,7 +271,6 @@ private void HandleRangeCollection(PdfPageSettings pageSettings, ExcelRangeBase[ } var layout = GetLayout(pageSettings, pdfSheets); - writePdf(layout); } finally @@ -283,6 +292,8 @@ private void HandleRangeCollection(PdfPageSettings pageSettings, ExcelRangeBase[ internal PdfCellCollection GetCellCollectionFromRange(PdfPageSettings pageSettings, ExcelRangeBase range) { PdfWorksheet pdfSheet = GetPdfWorksheet(pageSettings, range); + CollectTextInPdfWorksheet(pageSettings, pdfSheet); + BuildSubsets(pageSettings); ShapeTextInPdfWorksheet(pageSettings, pdfSheet); return pdfSheet.Ranges[0].Map; } @@ -317,18 +328,22 @@ private Transform GetLayout(PdfPageSettings pageSettings, PdfWorksheet pdfSheet) //Shape Text Methods - internal void ShapeTextInPdfWorksheet(PdfPageSettings pageSettings, PdfWorksheet pdfSheet) + // Pass 1: collect text for one sheet. Safe to call for every sheet before any Build. + internal void CollectTextInPdfWorksheet(PdfPageSettings pageSettings, PdfWorksheet pdfSheet) { - // Pass 1: collect text per font IterateCells(pdfSheet, cell => PdfTextShaper.CollectText(pageSettings, _dictionaries, cell)); + } - // Pass 2: build one provider per font - foreach (var kvp in _dictionaries.Fonts) - { - _dictionaries.ShapedProviders[kvp.Key] = kvp.Value.fontSubsetManager.CreateSubsettedProvider(); - } + // Build subsets ONCE for the whole document, after all sheets have been collected. + // Replaces the old per-sheet pass-2 loop over _dictionaries.Fonts. + internal void BuildSubsets(PdfPageSettings pageSettings) + { + _dictionaries.BuildSubsets(pageSettings); + } - // Pass 3: shape text using the pre-built providers + // Pass 3: shape one sheet using the already-built providers. Call after BuildSubsets. + internal void ShapeTextInPdfWorksheet(PdfPageSettings pageSettings, PdfWorksheet pdfSheet) + { IterateCells(pdfSheet, cell => PdfTextShaper.ShapeText(pageSettings, _dictionaries, cell)); } diff --git a/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs b/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs index 94a77fb467..2b0c52b172 100644 --- a/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs +++ b/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs @@ -30,15 +30,14 @@ internal static class PdfTextShaper private static Dictionary layoutEngineCache = new Dictionary(); // Pass 1: collect text per font so FontSubsetManager can build subsets once + // Pass 1: collect text per requested font into the document-wide subset builder. public static void CollectText(PdfPageSettings pageSettings, PdfDictionaries dictionaries, PdfCell cell) { if (cell == null || cell.TextFragments == null) return; for (int i = 0; i < cell.TextFragments.Count; i++) { var tf = cell.TextFragments[i]; - var key = dictionaries.ResolveFontKey(pageSettings, tf.Font.Family, tf.Font.SubFamily); - if (!dictionaries.Fonts.ContainsKey(key)) continue; - dictionaries.Fonts[key].fontSubsetManager.AddText(tf.Text); + dictionaries.AddFont(pageSettings, tf.Font.Family, tf.Font.SubFamily, tf.Text); } } From 5804d685ff6d99ffc408e83e45ff08a6925ef248 Mon Sep 17 00:00:00 2001 From: swmal <897655+swmal@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:53:51 +0200 Subject: [PATCH 4/4] Move font subsetting to document-wide DocumentFontSubsetBuilder --- src/EPPlus.Export.Pdf.Tests/FontTests.cs | 33 ++- src/EPPlus.Export.Pdf.Tests/PdfTestBase.cs | 23 +++ src/EPPlus.Export.Pdf.Tests/PdfTests.cs | 111 ++++++++++ .../FontSubsetManager2.cs | 191 ------------------ .../Subsetting/DocumentFontSubsetBuilder.cs | 8 +- src/EPPlus/Export/PdfExport/PdfCatalog.cs | 57 +----- .../PdfExport/TextShaping/PdfTextShaper.cs | 22 +- 7 files changed, 185 insertions(+), 260 deletions(-) delete mode 100644 src/EPPlus.Fonts.OpenType/FontSubsetManager2.cs diff --git a/src/EPPlus.Export.Pdf.Tests/FontTests.cs b/src/EPPlus.Export.Pdf.Tests/FontTests.cs index a7f2fe8169..b2a1f06c00 100644 --- a/src/EPPlus.Export.Pdf.Tests/FontTests.cs +++ b/src/EPPlus.Export.Pdf.Tests/FontTests.cs @@ -12,6 +12,7 @@ This software is licensed under PolyForm Noncommercial License 1.0.0 using EPPlus.Export.Pdf.Resources; using EPPlus.Export.Pdf.Settings; using EPPlus.Fonts.OpenType; +using EPPlus.Fonts.OpenType.Integration; using Microsoft.VisualStudio.TestTools.UnitTesting; using OfficeOpenXml.Interfaces.Fonts; using System; @@ -57,11 +58,33 @@ private static PdfPageSettings CreateSettings(OpenTypeFontEngine engine, bool em return settings; } - private static PdfDictionaries CreateDictionariesWithSingleFont(PdfPageSettings settings) + private static PdfDictionaries CreateDictionariesWithSingleFont(PdfPageSettings settings, OpenTypeFontEngine engine) { var dictionaries = new PdfDictionaries(); - // Register one font with some text so a subset is produced. - dictionaries.AddFont(settings, TestFontName, FontSubFamily.Regular, "Hello world!"); + + // In the new model Fonts is populated during shaping (ShapeText creates the resource, + // GidsAndCharMap fills gids + charmap), NOT by AddFont. Reproduce that end state directly + // so AddFontData has a realistic embedded resource to emit, without running a full export. + var font = engine.LoadFont(TestFontName, FontSubFamily.Regular); + var key = new FontKey(font.GetEnglishFontFamilyName(), font.NameTable.GetSubfamilyEnum()); + + var resource = new PdfFontResource(font.GetEnglishFontFamilyName(), font.NameTable.GetSubfamilyEnum(), 1, settings); + resource.fontData = font; + + // Populate a few glyphs as shaping would, so the embedded path (CIDSet, font stream subset) + // has real glyph ids to work with. + ushort gid; + foreach (var ch in "Hi") + { + if (font.CmapTable.TryGetGlyphId(ch, out gid) && gid != 0) + { + resource.Gids.Add(gid); + if (!resource.charactermappings.ContainsKey(gid)) + resource.charactermappings[gid] = ch.ToString(); + } + } + + dictionaries.Fonts[key] = resource; return dictionaries; } @@ -77,7 +100,7 @@ public void AddFontData_Embedded_FontResourcePointsAtType0Dict() using (var engine = CreateEngine()) { var settings = CreateSettings(engine, true); - var dictionaries = CreateDictionariesWithSingleFont(settings); + var dictionaries = CreateDictionariesWithSingleFont(settings, engine); var excelPdf = new ExcelPdf(); excelPdf.SetPageSettingsForTest(settings); @@ -126,7 +149,7 @@ public void AddFontData_Embedded_DoesNotEmitSimpleFontObject() using (var engine = CreateEngine()) { var settings = CreateSettings(engine, true); - var dictionaries = CreateDictionariesWithSingleFont(settings); + var dictionaries = CreateDictionariesWithSingleFont(settings, engine); var excelPdf = new ExcelPdf(); excelPdf.SetPageSettingsForTest(settings); diff --git a/src/EPPlus.Export.Pdf.Tests/PdfTestBase.cs b/src/EPPlus.Export.Pdf.Tests/PdfTestBase.cs index a2b449a329..5682e42e1f 100644 --- a/src/EPPlus.Export.Pdf.Tests/PdfTestBase.cs +++ b/src/EPPlus.Export.Pdf.Tests/PdfTestBase.cs @@ -21,5 +21,28 @@ protected void SaveAsPdf(ExcelWorksheet sheet, string pdfFileName) var path = Path.Combine(_pdfPath, pdfFileName); sheet.SaveAsPdf(path); } + + protected void SaveAsPdf(ExcelWorkbook wb, string pdfFileName) + { + if (!pdfFileName.ToLower().EndsWith(".pdf")) + { + pdfFileName += ".pdf"; + } + var path = Path.Combine(_pdfPath, pdfFileName); + wb.SaveAsPdf(path); + } + + protected void SaveAsPdf(ExcelWorkbook wb, string pdfFileName, params ExcelRangeBase[] ranges) + { + if (!pdfFileName.ToLower().EndsWith(".pdf")) + { + pdfFileName += ".pdf"; + } + var path = Path.Combine(_pdfPath, pdfFileName); + if (ranges.Count() > 1) + wb.SaveAsPdf(path, ranges); + else + ranges[0].SaveAsPdf(path); + } } } diff --git a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs index 8ab741f0c7..c106befc49 100644 --- a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs +++ b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs @@ -14,6 +14,7 @@ Date Author Change using EPPlus.Export.Pdf.Tests; using OfficeOpenXml; using OfficeOpenXml.Export.PdfExport; +using OfficeOpenXml.Interfaces.Fonts; using OfficeOpenXml.Style; using System.Text; @@ -501,6 +502,116 @@ public void SaveRangeToNonWritableStreamThrowsTest() Assert.ThrowsExactly(() => range.SaveAsPdf(readOnly)); } + [TestMethod] + public void ThreeFonts_NoSkip_RendersAllThreeCorrectly() + { + // Baseline: three different fonts, no skipping. Verifies the normal path still works + // after the subsetting rewrite. Open the PDF and confirm A1/B1/C1 read correctly. + using var p = OpenPackage("ThreeFonts_NoSkip.xlsx", true); + var ws = p.Workbook.Worksheets.Add("Sheet1"); + + ws.Cells["A1"].Style.Font.Name = "Aptos Narrow"; + ws.Cells["A1"].Value = "A1"; + ws.Cells["B1"].Style.Font.Name = "Times New Roman"; + ws.Cells["B1"].Value = "B1"; + ws.Cells["C1"].Style.Font.Name = "Arial"; + ws.Cells["C1"].Value = "C1"; + + SaveAsPdf(ws, "ThreeFonts_NoSkip.pdf"); + } + + [TestMethod] + public void MultiSheetWorkbook() + { + // Baseline: three different fonts, no skipping. Verifies the normal path still works + // after the subsetting rewrite. Open the PDF and confirm A1/B1/C1 read correctly. + using var p = OpenPackage("MultiSheetWorkbook.xlsx", true); + p.Workbook.ConfigureFonts(x => x.SearchSystemDirectories = true); + var ws = p.Workbook.Worksheets.Add("Sheet1"); + + ws.Cells["A1"].Value = "Sheet1:A1"; + + var ws2 = p.Workbook.Worksheets.Add("Sheet2"); + + ws2.Cells["A1"].Style.Font.Name = "Times New Roman"; + ws2.Cells["A1"].Value = "Sheet2:A1"; + + SaveAsPdf(p.Workbook, "MultiSheetWorkbook.pdf"); + } + + [TestMethod] + public void MultiRanges() + { + // Baseline: three different fonts, no skipping. Verifies the normal path still works + // after the subsetting rewrite. Open the PDF and confirm A1/B1/C1 read correctly. + using var p = OpenPackage("MultiRanges.xlsx", true); + p.Workbook.ConfigureFonts(x => x.SearchSystemDirectories = true); + var ws = p.Workbook.Worksheets.Add("Sheet1"); + + ws.Cells["A1"].Value = "Sheet1:A1"; + ws.Cells["F100"].Value = "Sheet1:F100"; + + SaveAsPdf(p.Workbook, "MultiRanges.pdf", ws.Cells["A1"], ws.Cells["F100"]); + } + + [TestMethod] + public void SingleRange() + { + // Baseline: three different fonts, no skipping. Verifies the normal path still works + // after the subsetting rewrite. Open the PDF and confirm A1/B1/C1 read correctly. + using var p = OpenPackage("SingleRange.xlsx", true); + p.Workbook.ConfigureFonts(x => x.SearchSystemDirectories = true); + var ws = p.Workbook.Worksheets.Add("Sheet1"); + + ws.Cells["A1"].Value = "Sheet1:A1"; + + SaveAsPdf(p.Workbook, "SingleRange.pdf", ws.Cells["A1"]); + } + + [TestMethod] + public void ArialBlack_RendersCorrectly() + { + // Baseline: three different fonts, no skipping. Verifies the normal path still works + // after the subsetting rewrite. Open the PDF and confirm A1/B1/C1 read correctly. + using var p = OpenPackage("ArialBlack.xlsx", true); + var ws = p.Workbook.Worksheets.Add("Sheet1"); + + ws.Cells["A1"].Style.Font.Name = "Arial Black"; + ws.Cells["A1"].Value = "A1"; + + SaveAsPdf(ws, "ArialBlack.pdf"); + } + + [TestMethod] + public void ThreeFonts_SkipAll_CollapseToSharedLastResort() + { + // The regression case: three fonts, all skipped via OnFontEmbedding. Expected AFTER the fix: + // - small PDF (one shared Archivo subset, not three whole fonts) + // - A1 / B1 / C1 render DISTINCTLY and correctly (not all "A1") + // - the PDF opens without corruption + using var p = OpenPackage("ThreeFonts_SkipAll.xlsx", true); + var ws = p.Workbook.Worksheets.Add("Sheet1"); + + ws.Cells["A1"].Style.Font.Name = "Aptos Narrow"; + ws.Cells["A1"].Value = "A1"; + ws.Cells["B1"].Style.Font.Name = "Times New Roman"; + ws.Cells["B1"].Value = "B1"; + ws.Cells["C1"].Style.Font.Name = "Arial"; + ws.Cells["C1"].Value = "C1"; + + p.Workbook.ConfigureFonts(cfg => + { + cfg.OnFontEmbedding(info => + { + System.Diagnostics.Debug.WriteLine("OnFontEmbedding fired for: " + info.FontName); + return FontEmbeddingDecision.Skip; + }); + }); + + + SaveAsPdf(ws, "ThreeFonts_SkipAll.pdf"); + } + [TestMethod] // works as expected. //[DataRow("PDFTest.xlsx", "C:\\epplustest\\pdf\\FullPageTest56.pdf", "Sheet1")] diff --git a/src/EPPlus.Fonts.OpenType/FontSubsetManager2.cs b/src/EPPlus.Fonts.OpenType/FontSubsetManager2.cs deleted file mode 100644 index 434bdd9b92..0000000000 --- a/src/EPPlus.Fonts.OpenType/FontSubsetManager2.cs +++ /dev/null @@ -1,191 +0,0 @@ -/************************************************************************************************* - 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 - ************************************************************************************************* - 02/25/2026 EPPlus Software AB Font subset manager for PDF export - *************************************************************************************************/ -using EPPlus.Fonts.OpenType.Utils; -using OfficeOpenXml.Interfaces.Fonts; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace EPPlus.Fonts.OpenType -{ - /// - /// Prepares subsetted fonts for PDF export by pre-scanning text, - /// distributing code points to the correct font via the fallback chain, - /// and creating minimal subsets of all fonts (including fallbacks). - /// - /// Usage: - /// 1. Create with an IFontProvider (e.g., DefaultFontProvider) - /// 2. Call AddText() for all text that will be rendered (e.g., all cell values) - /// 3. Call CreateSubsettedProvider() to get a new IFontProvider with subsetted fonts - /// 4. Use the returned provider for shaping and PDF rendering - /// - public class FontSubsetManager2 - { - private readonly IFontProvider _sourceProvider; - private readonly OpenTypeFontEngine _fontEngine; - - // Code points collected per font (key = original font instance) - private readonly Dictionary> _codePointsByFont = - new Dictionary>(); - - public FontSubsetManager2(OpenTypeFontEngine engine, IFontProvider sourceProvider) - { - if (engine == null) - throw new ArgumentNullException("engine"); - if (sourceProvider == null) - throw new ArgumentNullException("sourceProvider"); - - _sourceProvider = sourceProvider; - _fontEngine = engine; - } - - public FontSubsetManager2(OpenTypeFontEngine engine, OpenTypeFont font) - : this(engine, new DefaultFontProvider(engine, font)) - { - - } - - /// - /// Scans text and distributes each code point to the font that will render it. - /// Call this for every piece of text that will appear in the document. - /// - public void AddText(string text) - { - if (string.IsNullOrEmpty(text)) - return; - - var codePoints = CodePointUtil.ExtractCodePoints(text); - - foreach (var cp in codePoints) - { - OpenTypeFont font; - ushort glyphId; - _sourceProvider.TryGetGlyphFont((uint)cp, out font, out glyphId); - - //var fontName = font?.NameTable?.GetFullFontName() ?? "null"; - //if ((char)cp == 'E' || (char)cp == 'P') - //{ - // Console.WriteLine($"[FontSubsetManager.AddText] cp='{(char)cp}' (U+{cp:X4}) -> font='{fontName}', glyphId={glyphId}"); - //} - - HashSet fontCodePoints; - if (!_codePointsByFont.TryGetValue(font, out fontCodePoints)) - { - fontCodePoints = new HashSet(); - _codePointsByFont[font] = fontCodePoints; - } - - fontCodePoints.Add(cp); - } - } - - /// - /// Creates a new IFontProvider where all fonts (primary + fallbacks) are subsetted - /// to contain only the glyphs needed for the collected text. - /// Fonts that had no text collected are excluded from the result. - /// - public IFontProvider CreateSubsettedProvider() - { - var originalChain = _sourceProvider.GetAllFonts().ToList(); - - // --- Step 1: chain-level decision. Call ResolveEmbeddingDecision ONCE per font, - // outside try/catch (a NoEmbedding font must throw straight to the caller). --- - var decisions = new Dictionary(); - var effectiveChain = new List(); // ordered, skipped fonts removed - foreach (var font in originalChain) - { - var decision = _fontEngine.ResolveEmbeddingDecision(font); - decisions[font] = decision; - if (decision != FontEmbeddingDecision.Skip) - effectiveChain.Add(font); - } - - // If everything was skipped, pull in the last-resort font so the chain is never empty. - if (effectiveChain.Count == 0) - effectiveChain.Add(EmbeddedFonts.LoadArchivoNarrow(FontSubFamily.Regular)); - - // --- Step 2: redistribute the skipped fonts' code points over the reduced chain. --- - foreach (var font in originalChain) - { - if (decisions[font] != FontEmbeddingDecision.Skip) - continue; - - HashSet cps; - if (_codePointsByFont.TryGetValue(font, out cps)) - { - foreach (var cp in cps) - { - var target = ResolveOverChain(effectiveChain, cp); // cmap walk, ultimately chain[0] - HashSet targetCps; - if (!_codePointsByFont.TryGetValue(target, out targetCps)) - _codePointsByFont[target] = targetCps = new HashSet(); - targetCps.Add(cp); - } - } - _codePointsByFont.Remove(font); // a skipped font is never subsetted - } - - // --- Step 3: subset loop, now only over fonts in effectiveChain. - // Same switch as before BUT the Skip branch is gone — it can no longer occur here. --- - var subsetMap = new Dictionary(); - foreach (var font in effectiveChain) - { - HashSet cps; - if (!_codePointsByFont.TryGetValue(font, out cps) || cps.Count == 0) - continue; - - switch (decisions.ContainsKey(font) ? decisions[font] : FontEmbeddingDecision.Subset) - { - case FontEmbeddingDecision.EmbedWhole: - subsetMap[font] = font; - break; - case FontEmbeddingDecision.Subset: - try { subsetMap[font] = font.CreateSubset(CodePointUtil.CodePointsToString(cps)); } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine( - $"Warning: could not subset '{font.NameTable?.GetFullFontName()}': {ex.Message}"); - subsetMap[font] = font; - } - break; - } - } - - // --- Step 4: build the provider. effectiveChain[0] becomes the primary — a skipped - // primary is already filtered out, so "primary is replaced" is expressed naturally. --- - var provider = new CustomFontProvider(Resolved(effectiveChain[0], subsetMap)); - for (int i = 1; i < effectiveChain.Count; i++) - provider.AddFallback(Resolved(effectiveChain[i], subsetMap)); - return provider; - } - - private static OpenTypeFont Resolved(OpenTypeFont font, Dictionary map) - { - // A font with no collected code points is kept unchanged. - OpenTypeFont subset; - return map.TryGetValue(font, out subset) ? subset : font; - } - - // Chain-local cmap lookup. Last resort: chain[0] (which, in the all-skipped case, IS Archivo Narrow). - private static OpenTypeFont ResolveOverChain(List chain, int codePoint) - { - foreach (var font in chain) - { - ushort glyphId; - if (font.CmapTable.TryGetGlyphId((uint)codePoint, out glyphId)) - return font; - } - return chain[0]; - } - } -} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/Subsetting/DocumentFontSubsetBuilder.cs b/src/EPPlus.Fonts.OpenType/Subsetting/DocumentFontSubsetBuilder.cs index a046e7d2ab..bac0dea09e 100644 --- a/src/EPPlus.Fonts.OpenType/Subsetting/DocumentFontSubsetBuilder.cs +++ b/src/EPPlus.Fonts.OpenType/Subsetting/DocumentFontSubsetBuilder.cs @@ -47,11 +47,15 @@ public void AddText(string family, FontSubFamily subFamily, string text) if (_built) throw new InvalidOperationException("Cannot AddText after Build()."); if (string.IsNullOrEmpty(text)) return; - var key = new FontKey(family, subFamily); + var primary = _engine.LoadFont(family, subFamily); + // Key on the RESOLVED font's identity, not the requested name. A requested font that + // resolves via fallback (e.g. "Arial Black" -> Liberation Sans) must share identity with + // how PdfDictionaries and shaping key it, or the provider lookup in BuildSubsets misses. + var key = new FontKey(primary.GetEnglishFontFamilyName(), primary.NameTable.GetSubfamilyEnum()); + RequestedFont req; if (!_requested.TryGetValue(key, out req)) { - var primary = _engine.LoadFont(family, subFamily); req = new RequestedFont(key, primary); _requested[key] = req; } diff --git a/src/EPPlus/Export/PdfExport/PdfCatalog.cs b/src/EPPlus/Export/PdfExport/PdfCatalog.cs index 9b87793ef5..5c30cbf635 100644 --- a/src/EPPlus/Export/PdfExport/PdfCatalog.cs +++ b/src/EPPlus/Export/PdfExport/PdfCatalog.cs @@ -86,10 +86,6 @@ private void HandleWorksheetCollection(PdfPageSettings pageSettings, ExcelWorksh // Collect text for every worksheet. pdfSheets = GetPdfWorksheets(pageSettings, worksheets); - // Pass 1: collect all sheets. Pass 2: one document-wide build. Pass 3: shape all sheets. - foreach (var pdfSheet in pdfSheets) - CollectTextInPdfWorksheet(pageSettings, pdfSheet); - BuildSubsets(pageSettings); foreach (var pdfSheet in pdfSheets) @@ -136,53 +132,30 @@ public PdfCatalog(Stream stream, PdfPageSettings pageSettings, ExcelWorksheet wo private void BuildPdf(PdfPageSettings pageSettings, ExcelWorksheet worksheet, Action writePdf) { - //pageSettings.defaultFontName = worksheet.Workbook.ThemeManager.CurrentTheme.FontScheme.MinorFont[0].Typeface; pageSettings.defaultFontName = worksheet.Workbook.ThemeManager.GetOrCreateTheme().FontScheme.MinorFont[0].Typeface; PdfWorksheet pdfSheet = null; try { - Stopwatch sw = Stopwatch.StartNew(); - - //Collect Text + // Collect Text (GetPdfWorksheet collects into the builder via SetTextMap -> AddFont) pdfSheet = GetPdfWorksheet(pageSettings, worksheet); - sw.Stop(); - var CollectTextTime = sw.ElapsedMilliseconds; - sw.Reset(); - sw.Start(); - //Shape Text - CollectTextInPdfWorksheet(pageSettings, pdfSheet); + // Build subsets once, then shape BuildSubsets(pageSettings); ShapeTextInPdfWorksheet(pageSettings, pdfSheet); - sw.Stop(); - var ShapeTextTime = sw.ElapsedMilliseconds; - sw.Reset(); - sw.Start(); - //Auto-Fit Rows + // Auto-Fit Rows PdfCalculateRowHeight.ResizeRowHeights(pdfSheet); - sw.Stop(); - var AutoFitRowTime = sw.ElapsedMilliseconds; - sw.Reset(); - sw.Start(); - //Create Layout + // Create Layout var layout = GetLayout(pageSettings, pdfSheet); - sw.Stop(); - var CreateLayoutTime = sw.ElapsedMilliseconds; - sw.Reset(); - sw.Start(); - //Write Pdf Document + // Write Pdf Document writePdf(layout); - sw.Stop(); - var CreatePdfTime = sw.ElapsedMilliseconds; - sw.Reset(); } finally { - //Clean up the temporary worksheet used to build the comments/notes pages, - //so the source workbook isn't permanently mutated by the PDF export. + // Clean up the temporary worksheet used to build the comments/notes pages, + // so the source workbook isn't permanently mutated by the PDF export. if (pdfSheet != null && pdfSheet.CommentsAndNotesSheet != null) { worksheet.Workbook.Worksheets.Delete(pdfSheet.CommentsAndNotesSheet); @@ -213,7 +186,6 @@ private void BuildPdfFromRange(PdfPageSettings pageSettings, ExcelRangeBase rang try { pdfSheet = GetPdfWorksheet(pageSettings, range); - CollectTextInPdfWorksheet(pageSettings, pdfSheet); BuildSubsets(pageSettings); ShapeTextInPdfWorksheet(pageSettings, pdfSheet); PdfCalculateRowHeight.ResizeRowHeights(pdfSheet); @@ -259,9 +231,6 @@ private void HandleRangeCollection(PdfPageSettings pageSettings, ExcelRangeBase[ { pdfSheets = GetPdfWorksheets(pageSettings, ranges); - foreach (var pdfSheet in pdfSheets) - CollectTextInPdfWorksheet(pageSettings, pdfSheet); - BuildSubsets(pageSettings); foreach (var pdfSheet in pdfSheets) @@ -292,8 +261,8 @@ private void HandleRangeCollection(PdfPageSettings pageSettings, ExcelRangeBase[ internal PdfCellCollection GetCellCollectionFromRange(PdfPageSettings pageSettings, ExcelRangeBase range) { PdfWorksheet pdfSheet = GetPdfWorksheet(pageSettings, range); - CollectTextInPdfWorksheet(pageSettings, pdfSheet); - BuildSubsets(pageSettings); + //CollectTextInPdfWorksheet(pageSettings, pdfSheet); + //BuildSubsets(pageSettings); ShapeTextInPdfWorksheet(pageSettings, pdfSheet); return pdfSheet.Ranges[0].Map; } @@ -326,14 +295,6 @@ private Transform GetLayout(PdfPageSettings pageSettings, PdfWorksheet pdfSheet) return Layout; } - //Shape Text Methods - - // Pass 1: collect text for one sheet. Safe to call for every sheet before any Build. - internal void CollectTextInPdfWorksheet(PdfPageSettings pageSettings, PdfWorksheet pdfSheet) - { - IterateCells(pdfSheet, cell => PdfTextShaper.CollectText(pageSettings, _dictionaries, cell)); - } - // Build subsets ONCE for the whole document, after all sheets have been collected. // Replaces the old per-sheet pass-2 loop over _dictionaries.Fonts. internal void BuildSubsets(PdfPageSettings pageSettings) diff --git a/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs b/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs index 2b0c52b172..bcd046ff8e 100644 --- a/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs +++ b/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs @@ -29,18 +29,6 @@ internal static class PdfTextShaper private static Dictionary shaperCache = new Dictionary(); private static Dictionary layoutEngineCache = new Dictionary(); - // Pass 1: collect text per font so FontSubsetManager can build subsets once - // Pass 1: collect text per requested font into the document-wide subset builder. - public static void CollectText(PdfPageSettings pageSettings, PdfDictionaries dictionaries, PdfCell cell) - { - if (cell == null || cell.TextFragments == null) return; - for (int i = 0; i < cell.TextFragments.Count; i++) - { - var tf = cell.TextFragments[i]; - dictionaries.AddFont(pageSettings, tf.Font.Family, tf.Font.SubFamily, tf.Text); - } - } - // Pass 2: shape text using already-built providers from PdfDictionaries.ShapedProviders public static void ShapeText(PdfPageSettings pageSettings, PdfDictionaries dictionaries, PdfCell cell) { @@ -54,9 +42,15 @@ public static void ShapeText(PdfPageSettings pageSettings, PdfDictionaries dicti cell.ShapedTexts.Add(new PdfShapedText()); var st = cell.ShapedTexts[i]; var key = dictionaries.ResolveFontKey(pageSettings, tf.Font.Family, tf.Font.SubFamily); - if (!dictionaries.ShapedProviders.TryGetValue(key, out var provider)) + IFontProvider provider; + if (!dictionaries.ShapedProviders.TryGetValue(key, out provider)) { - continue; + // No subset provider was built for this font — this is the measurement path + // (GetCellCollectionFromRange), which does not run BuildSubsets. Shape against the + // whole font instead: advance widths are identical to the subset, so measured width + // is exact, and no subsetting or embedding decision is triggered. + var font = pageSettings.FontEngine.LoadFont(tf.Font.Family, tf.Font.SubFamily); + provider = new DefaultFontProvider(pageSettings.FontEngine, font); } st.FontProvider = provider; if (!shaperCache.TryGetValue(st.FontProvider, out var shaper))