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 01/26] #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 02/26] 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 03/26] 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 04/26] 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)) From 0a86142c6871ae3d44525c397ab78e3dc915e77d Mon Sep 17 00:00:00 2001 From: swmal <897655+swmal@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:57:36 +0200 Subject: [PATCH 05/26] Fixed some merge conflicts --- src/EPPlus.Export.Pdf.Tests/FontTests.cs | 4 ++-- src/EPPlus.Export.Pdf/ExcelPdf.cs | 19 ++++++++++++------- .../Resources/PdfFontResource.cs | 2 -- src/EPPlus/Export/PdfExport/PdfCatalog.cs | 12 ++++++++---- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/EPPlus.Export.Pdf.Tests/FontTests.cs b/src/EPPlus.Export.Pdf.Tests/FontTests.cs index b2a1f06c00..c30f7b9b79 100644 --- a/src/EPPlus.Export.Pdf.Tests/FontTests.cs +++ b/src/EPPlus.Export.Pdf.Tests/FontTests.cs @@ -104,8 +104,8 @@ public void AddFontData_Embedded_FontResourcePointsAtType0Dict() var excelPdf = new ExcelPdf(); excelPdf.SetPageSettingsForTest(settings); + excelPdf.SetDocumentSettingsForTest(PdfDocumentSettings.From(settings)); excelPdf.SetDictionariesForTest(dictionaries); - excelPdf.AddFontData(); var fontResource = dictionaries.GetFont(settings, TestFontName, FontSubFamily.Regular); @@ -153,8 +153,8 @@ public void AddFontData_Embedded_DoesNotEmitSimpleFontObject() var excelPdf = new ExcelPdf(); excelPdf.SetPageSettingsForTest(settings); + excelPdf.SetDocumentSettingsForTest(PdfDocumentSettings.From(settings)); excelPdf.SetDictionariesForTest(dictionaries); - excelPdf.AddFontData(); foreach (var obj in excelPdf._document) diff --git a/src/EPPlus.Export.Pdf/ExcelPdf.cs b/src/EPPlus.Export.Pdf/ExcelPdf.cs index 64e96421ed..34ec0a4f8f 100644 --- a/src/EPPlus.Export.Pdf/ExcelPdf.cs +++ b/src/EPPlus.Export.Pdf/ExcelPdf.cs @@ -44,14 +44,19 @@ internal static string Header } internal void SetPageSettingsForTest(PdfPageSettings pageSettings) -{ - _pageSettings = pageSettings; -} + { + _pageSettings = pageSettings; + } -internal void SetDictionariesForTest(PdfDictionaries dictionaries) -{ - _dictionaries = dictionaries; -} + internal void SetDictionariesForTest(PdfDictionaries dictionaries) + { + _dictionaries = dictionaries; + } + + internal void SetDocumentSettingsForTest(PdfDocumentSettings documentSettings) + { + _documentSettings = documentSettings; + } //Get the label to use for pattern. private string GetPatternLabel(PdfCellLayout layout) diff --git a/src/EPPlus.Export.Pdf/Resources/PdfFontResource.cs b/src/EPPlus.Export.Pdf/Resources/PdfFontResource.cs index e502ec50b5..3893ca0538 100644 --- a/src/EPPlus.Export.Pdf/Resources/PdfFontResource.cs +++ b/src/EPPlus.Export.Pdf/Resources/PdfFontResource.cs @@ -35,7 +35,6 @@ internal class PdfFontResource : PdfResource internal int fontWidthObjectNumber = -1; internal int cidSetObjectNumber = -1; internal OpenTypeFont fontData; - private OpenTypeFontEngine _fontEngine; private int firstChar = 32; private int lastChar = 255; private CIDSystemInfo cidSystemInfo = null; @@ -50,7 +49,6 @@ public PdfFontResource(string fontName, FontSubFamily subFamily, int labelNumber : base("F", labelNumber) { this.fontName = fontName; - _fontEngine = pageSettings.FontEngine; // 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. diff --git a/src/EPPlus/Export/PdfExport/PdfCatalog.cs b/src/EPPlus/Export/PdfExport/PdfCatalog.cs index 5c30cbf635..66085bc9ea 100644 --- a/src/EPPlus/Export/PdfExport/PdfCatalog.cs +++ b/src/EPPlus/Export/PdfExport/PdfCatalog.cs @@ -272,26 +272,30 @@ internal PdfCellCollection GetCellCollectionFromRange(PdfPageSettings pageSettin private Action WriteToFile(PdfPageSettings pageSettings, string fileName) { - return layout => new ExcelPdf().CreatePdf(pageSettings, _dictionaries, layout, fileName); + return layout => new ExcelPdf().CreatePdf(PdfDocumentSettings.From(pageSettings), _dictionaries, layout, fileName); } private Action WriteToStream(PdfPageSettings pageSettings, Stream stream) { - return layout => new ExcelPdf().CreatePdf(pageSettings, _dictionaries, layout, stream); + return layout => new ExcelPdf().CreatePdf(PdfDocumentSettings.From(pageSettings), _dictionaries, layout, stream); } //Create Layout Methods private Transform GetLayout(PdfPageSettings pageSettings, PdfWorksheet[] pdfSheets) { - var Layout = PdfLayout.GetLayout(pageSettings, _dictionaries, pdfSheets); + var sheetSettings = new PdfPageSettings[pdfSheets.Length]; + for (int i = 0; i < pdfSheets.Length; i++) + sheetSettings[i] = pageSettings; + var Layout = PdfLayout.GetLayout(sheetSettings, _dictionaries, pdfSheets); return Layout; } private Transform GetLayout(PdfPageSettings pageSettings, PdfWorksheet pdfSheet) { PdfWorksheet[] pdfSheets = new PdfWorksheet[1] { pdfSheet }; - var Layout = PdfLayout.GetLayout(pageSettings, _dictionaries, pdfSheets); + var sheetSettings = new PdfPageSettings[1] { pageSettings }; + var Layout = PdfLayout.GetLayout(sheetSettings, _dictionaries, pdfSheets); return Layout; } From 6a9ef161396e869b28d2b4d3ae865d619ef99071 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Tue, 25 Aug 2026 15:10:17 +0200 Subject: [PATCH 06/26] WIP --- src/EPPlus.Export.Pdf.Tests/PdfTests.cs | 12 ++++++ .../Settings/PdfPageSettings.cs | 4 +- src/EPPlus/Export/PdfExport/Data/PageData.cs | 2 + .../PdfExport/Layout/PdfGridlinesLayout.cs | 16 +++++--- .../Export/PdfExport/Layout/PdfLayout.cs | 37 +++++++++++++++++++ 5 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs index 8ab741f0c7..4f05b1de49 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.Export.PdfExport.Layout; using OfficeOpenXml.Style; using System.Text; @@ -637,5 +638,16 @@ public void EPPlusToPdf() p.Workbook.SaveAsPdf(_pdfPath + "Snake.Pdf"); p.SaveAs(_pdfPath + "Snake.xlsx"); } + [TestMethod] + public void CenterOnPageTest() + { + using (var p = OpenTemplatePackage("CenterOnPagePdf.xlsx")) + { + var wb = p.Workbook; + var ws = wb.Worksheets[0]; + string path = _pdfPath + "CenterOnPagePdf.pdf"; + ws.SaveAsPdf(path); + } + } } } 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/Export/PdfExport/Data/PageData.cs b/src/EPPlus/Export/PdfExport/Data/PageData.cs index 6a7d8c510c..ab3056461d 100644 --- a/src/EPPlus/Export/PdfExport/Data/PageData.cs +++ b/src/EPPlus/Export/PdfExport/Data/PageData.cs @@ -39,6 +39,8 @@ internal struct Page public double[] RowHeights; public double HeadingWidth; public double HeadingHeight; + public double UsedWidth; + public double UsedHeight; } internal struct Pages diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfGridlinesLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfGridlinesLayout.cs index d2f9f50e7d..8b432e073d 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfGridlinesLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfGridlinesLayout.cs @@ -50,7 +50,8 @@ public static void AddGridLines(PdfPageSettings pageSettings, Page page, PdfPage // colX[ci] = X of left edge of column ci (0-based within page). // colX[colCount] = X of right edge of last column. var colX = new double[colCount + 1]; - colX[0] = pageSettings.ContentBounds.Left + page.HeadingWidth + page.PrintTitleWidth; + //colX[0] = pageSettings.ContentBounds.Left + page.HeadingWidth + page.PrintTitleWidth; + colX[0] = PdfLayout.GetOriginX(pageSettings, page) + page.HeadingWidth + page.PrintTitleWidth; for (int ci = 0; ci < colCount; ci++) { var cell = page.Map[page.FromRow, page.FromColumn + ci]; @@ -61,9 +62,10 @@ public static void AddGridLines(PdfPageSettings pageSettings, Page page, PdfPage // rowY[rowCount] = Y of bottom edge of last row. // Y decreases downward (PDF coordinate system used throughout GetCatalog). var rowY = new double[rowCount + 1]; - rowY[0] = pageSettings.ContentBounds.Top - page.HeadingHeight - page.PrintTitleHeight; + //rowY[0] = pageSettings.ContentBounds.Top - page.HeadingHeight - page.PrintTitleHeight; + rowY[0] = PdfLayout.GetOriginY(pageSettings, page) - page.HeadingHeight - page.PrintTitleHeight; - for (int ri = 0; ri < rowCount; ri++) + for (int ri = 0; ri < rowCount; ri++) { rowY[ri + 1] = rowY[ri] - page.RowHeights[ri]; } @@ -72,9 +74,13 @@ public static void AddGridLines(PdfPageSettings pageSettings, Page page, PdfPage // Always computed so BorderLines is available for margin clipping regardless of // whether ShowGridLines is on. When borderOnly is true we stop here. - double frameLeft = pageSettings.ContentBounds.Left; //colX[0]; + //double frameLeft = pageSettings.ContentBounds.Left; //colX[0]; + //double frameRight = colX[colCount]; + //double frameTop = pageSettings.ContentBounds.Top; //rowY[0]; + //double frameBottom = rowY[rowCount]; + double frameLeft = PdfLayout.GetOriginX(pageSettings, page); double frameRight = colX[colCount]; - double frameTop = pageSettings.ContentBounds.Top; //rowY[0]; + double frameTop = PdfLayout.GetOriginY(pageSettings, page); double frameBottom = rowY[rowCount]; pageLayout.BorderLines.Add(new GridLine(frameLeft, frameTop, frameRight, frameTop)); diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index f4c4f1a9aa..27f1f7ae5a 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -1410,6 +1410,20 @@ internal static Pages MapPage(PdfRange range, Pages pdfPages) page.Map[row, col] = range.Map[row, col]; } } + double usedWidth = page.HeadingWidth + page.PrintTitleWidth; + for (int col = page.FromColumn; col <= page.ToColumn; col++) + { + usedWidth += page.Map[page.FromRow, col]?.ColumnWidth ?? 0d; + } + page.UsedWidth = usedWidth; + + double usedHeight = page.HeadingHeight + page.PrintTitleHeight; + for (int ri = 0; ri < page.RowHeights.Length; ri++) + { + usedHeight += page.RowHeights[ri]; + } + page.UsedHeight = usedHeight; + pdfPages.Page[i] = page; } pdfPages = pages; @@ -1613,5 +1627,28 @@ private static void EmitBandFrameV(List target, PdfRange range, double } if (rs != null) target.Add(new GridLine(x, rs.Value, x, re)); } + + /// + /// The X coordinate where the page's printed block begins. + /// Currently the left content bound; will include the centering offset. + /// + internal static double GetOriginX(PdfPageSettings pageSettings, Page page) + { + if (!pageSettings.CenterOnPageHorizontally) return pageSettings.ContentBounds.Left; + + var offset = (pageSettings.ContentBounds.Width - page.UsedWidth) / 2d; + return pageSettings.ContentBounds.Left + Math.Max(0d, offset); + } + + /// + /// The Y coordinate where the page's printed block begins (top edge). + /// + internal static double GetOriginY(PdfPageSettings pageSettings, Page page) + { + if (!pageSettings.CenterOnPageVertically) return pageSettings.ContentBounds.Top; + + var offset = (pageSettings.ContentBounds.Height - page.UsedHeight) / 2d; + return pageSettings.ContentBounds.Top - Math.Max(0d, offset); + } } } From d1a4386721bf651b54ed6a43ca4e3eba387bbc07 Mon Sep 17 00:00:00 2001 From: swmal <{ID}+username}@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:10:55 +0200 Subject: [PATCH 07/26] Fix font nameID pairing so Arial Black resolves correctly (#2476) --- .../FontScanning/ArialBlackTests.cs | 66 ++++ .../FontScanning/NameTableSubfamilyTests.cs | 360 ++++++++++++++++++ .../Scanner/FontScannerV2Core.cs | 24 +- .../Tables/Name/NameTable.cs | 72 ++-- 4 files changed, 494 insertions(+), 28 deletions(-) create mode 100644 src/EPPlus.Fonts.OpenType.Tests/FontScanning/ArialBlackTests.cs create mode 100644 src/EPPlus.Fonts.OpenType.Tests/FontScanning/NameTableSubfamilyTests.cs diff --git a/src/EPPlus.Fonts.OpenType.Tests/FontScanning/ArialBlackTests.cs b/src/EPPlus.Fonts.OpenType.Tests/FontScanning/ArialBlackTests.cs new file mode 100644 index 0000000000..2304d16e79 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/FontScanning/ArialBlackTests.cs @@ -0,0 +1,66 @@ +using EPPlus.Fonts.OpenType.Scanner; +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.FontScanning +{ + [TestClass] + public class ArialBlackTests : FontTestBase + { + public override TestContext? TestContext { get; set; } + + [TestMethod] + public void ScanArialBlack_ShouldReturnArialBlack() + { + var face = FontScannerV2.FindBestMatch(string.Empty, "Arial Black", FontSubFamily.Regular, true); + if(face == null) + { + Assert.Inconclusive(); + } + Assert.AreEqual("Arial Black", face.FamilyName, $"face.FamilyName was not 'Arial Black' as expected but '{face.FamilyName}'"); + Assert.IsTrue(face.IsExactMatch, "face.IsExactMatch was false"); + } + + [TestMethod] + public void LoadArialBlackFullFont_ShouldReturnArialBlack() + { + var factory = new OpenTypeFontEngine(); + var availability = factory.GetFontAvailability("Arial Black"); + if(availability == FontAvailability.NotFound) + { + Assert.Inconclusive(); + } + var font = factory.LoadFont("Arial Black"); + Assert.IsNotNull(font); + Assert.AreEqual("Arial Black", font.FullName); + } + + [TestMethod] + public void Dump_AllFacesNamedLikeArialBlack() + { + var directories = System.Array.Empty(); + var allFaces = FontScannerV2.EnumerateAllFaces( + EPPlus.Fonts.OpenType.FontResolver.DefaultFontLocations.GetLocationsCollection( + directories, searchSystemDirectories: true)); + + bool foundAny = false; + foreach (var face in allFaces) + { + if (face.FamilyName != null && + face.FamilyName.IndexOf("black", System.StringComparison.OrdinalIgnoreCase) >= 0) + { + foundAny = true; + Console.WriteLine( + "FamilyName='{0}' SubfamilyName='{1}' Subfamily={2} FsSelection=0x{3:X4} FilePath={4}", + face.FamilyName, face.SubfamilyName, face.Subfamily, face.FsSelection, face.FilePath); + } + } + + Assert.IsTrue(foundAny, "No installed face with 'black' in the family name was found at all."); + } + } +} diff --git a/src/EPPlus.Fonts.OpenType.Tests/FontScanning/NameTableSubfamilyTests.cs b/src/EPPlus.Fonts.OpenType.Tests/FontScanning/NameTableSubfamilyTests.cs new file mode 100644 index 0000000000..6ca194d05f --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/FontScanning/NameTableSubfamilyTests.cs @@ -0,0 +1,360 @@ +/************************************************************************************************* + 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/26/2026 EPPlus Software AB Initial tests for NameTable family/subfamily naming + *************************************************************************************************/ +using EPPlus.Fonts.OpenType.FontLocalization; +using EPPlus.Fonts.OpenType.Tables.Name; +using OfficeOpenXml.Interfaces.Fonts; + +namespace EPPlus.Fonts.OpenType.Tests.FontScanning +{ + /// + /// Unit tests for how NameTable resolves family and subfamily names — bare table instances, + /// no font file loading, so they stay fast and deterministic regardless of installed fonts. + /// + /// THE PAIR PRINCIPLE (what most of these tests defend) + /// ----------------------------------------------------- + /// OpenType fonts can carry two parallel, complete naming systems: + /// + /// legacy / RIBBI nameID 1 (family) + nameID 2 (subfamily) + /// typographic nameID 16 (family) + nameID 17 (subfamily) + /// + /// Both describe the same file correctly, but they are PAIRS and must never be mixed. + /// Arial Black (ariblk.ttf) reads: + /// + /// nameID 1 = "Arial Black" nameID 2 = "Regular" + /// nameID 16 = "Arial" nameID 17 = "Black" + /// + /// Taking the family from one system and the subfamily from the other yields the pair + /// "Arial Black" + "Black", which exists in neither system. That was the original bug: + /// a request for "Arial Black" + Regular matched the family but not the style, so + /// IsExactMatch was false and DefaultFontResolver fell through to the built-in fallback + /// chain ("Arial Black" -> "Liberation Sans" -> "Arial") and returned plain Arial, even + /// though Arial Black was installed. + /// + /// EPPlus uses the legacy/RIBBI system, because FontSubFamily's four values + /// (Regular/Bold/Italic/BoldItalic) ARE the RIBBI model, and nameID 2 is guaranteed by + /// the spec to be one of those four. It is also the view Windows, GDI and Excel present. + /// + [TestClass] + public class NameTableSubfamilyTests : FontTestBase + { + public override TestContext? TestContext { get; set; } + + #region The pair principle — real ariblk.ttf field layout + + /// + /// The exact four-field layout found in C:\Windows\Fonts\ariblk.ttf. This is the + /// regression test for the original bug and the single most important test here. + /// + [TestMethod] + public void ArialBlackLayout_ResolvesToLegacyPair_NotAMixOfBothSystems() + { + var nameTable = CreateNameTable( + MakeEnglishRecord(NameRecordTypes.FontFamilyName, "Arial Black"), + MakeEnglishRecord(NameRecordTypes.FontSubfamilyName, "Regular"), + MakeEnglishRecord(NameRecordTypes.TypographicFamilyName, "Arial"), + MakeEnglishRecord(NameRecordTypes.TypographicSubfamilyName, "Black")); + + var family = nameTable.GetFamilyName(); + var subfamily = nameTable.GetSubfamilyName(); + + // Both halves must come from the SAME system. Asserting them together (rather than + // in two separate tests) is deliberate: the failure mode is a mismatched pair, and + // either value on its own looks perfectly reasonable. + Assert.AreEqual("Arial Black", family, + "Family must come from nameID 1 (legacy), not nameID 16 ('Arial')."); + Assert.AreEqual("Regular", subfamily, + "Subfamily must come from nameID 2 (legacy), not nameID 17 ('Black'). " + + "Reading nameID 17 here produces the impossible pair 'Arial Black' + 'Black'."); + Assert.AreEqual(FontSubFamily.Regular, nameTable.GetSubfamilyEnum()); + } + + /// + /// Guard against someone "fixing" GetSubfamilyName to prefer the newer nameID 17. + /// Deliberately makes the two systems disagree so preferring 17 is unmistakable. + /// + [TestMethod] + public void GetSubfamilyName_DoesNotPreferTypographicSubfamily17() + { + var nameTable = CreateNameTable( + MakeEnglishRecord(NameRecordTypes.FontSubfamilyName, "Regular"), + MakeEnglishRecord(NameRecordTypes.TypographicSubfamilyName, "Black")); + + Assert.AreEqual("Regular", nameTable.GetSubfamilyName(), + "nameID 17 must not win over nameID 2. nameID 17 belongs to the typographic " + + "system (paired with nameID 16) and carries weights outside the RIBBI model."); + } + + /// + /// Mirror of the above for the family side — guards the ID1-over-ID16 priority that + /// GetFamilyName's (previously contradictory) doc comment used to describe backwards. + /// + [TestMethod] + public void GetFamilyName_DoesNotPreferTypographicFamily16() + { + var nameTable = CreateNameTable( + MakeEnglishRecord(NameRecordTypes.FontFamilyName, "Arial Black"), + MakeEnglishRecord(NameRecordTypes.TypographicFamilyName, "Arial")); + + Assert.AreEqual("Arial Black", nameTable.GetFamilyName(), + "nameID 16 must not win over nameID 1, or 'Arial Black' collapses into the " + + "'Arial' family and can no longer be resolved as a distinct font."); + } + + #endregion + + #region Typographic system as last resort — only when the legacy field is absent + + [TestMethod] + public void GetSubfamilyName_NoNameId2_FallsBackToTypographicSubfamily17() + { + // A font that omits nameID 2 entirely. Then nameID 17 is all we have, and using + // it is correct — the pair principle only forbids mixing when BOTH are present. + var nameTable = CreateNameTable( + MakeEnglishRecord(NameRecordTypes.TypographicSubfamilyName, "Bold")); + + Assert.AreEqual("Bold", nameTable.GetSubfamilyName()); + Assert.AreEqual(FontSubFamily.Bold, nameTable.GetSubfamilyEnum()); + } + + [TestMethod] + public void GetFamilyName_NoNameId1_FallsBackToTypographicFamily16() + { + var nameTable = CreateNameTable( + MakeEnglishRecord(NameRecordTypes.TypographicFamilyName, "Arial")); + + Assert.AreEqual("Arial", nameTable.GetFamilyName()); + } + + #endregion + + #region English must win over localized records + + /// + /// ariblk.ttf carries 75 name records, including a dozen localized nameID 2 values + /// ("Normal", "obycejne", "Standard", "Kanonika", "Obychnyy", "Arrunta", ...). + /// Picking whichever comes first in file order happens to work for ariblk.ttf, but + /// that is luck, not a guarantee — file order is entirely up to the font vendor. + /// + [TestMethod] + public void GetSubfamilyName_LocalizedRecordFirst_StillPrefersEnglish() + { + var nameTable = CreateNameTable( + MakeLocalizedRecord(NameRecordTypes.FontSubfamilyName, "Fet"), // sv-SE + MakeEnglishRecord(NameRecordTypes.FontSubfamilyName, "Bold")); + + Assert.AreEqual("Bold", nameTable.GetSubfamilyName(), + "A localized nameID 2 appearing earlier in the table must not beat the " + + "English one, or the subfamily string becomes unparseable by the enum mapping."); + Assert.AreEqual(FontSubFamily.Bold, nameTable.GetSubfamilyEnum()); + } + + [TestMethod] + public void GetFamilyName_LocalizedRecordFirst_StillPrefersEnglish() + { + var nameTable = CreateNameTable( + MakeLocalizedRecord(NameRecordTypes.FontFamilyName, "Arial Svart"), + MakeEnglishRecord(NameRecordTypes.FontFamilyName, "Arial Black")); + + Assert.AreEqual("Arial Black", nameTable.GetFamilyName()); + } + + [TestMethod] + public void GetSubfamilyName_OnlyLocalizedAvailable_UsesItRatherThanNothing() + { + // No English record at all — better to return the localized string than to fall + // through to the typographic system or the "Regular" default. + var nameTable = CreateNameTable( + MakeLocalizedRecord(NameRecordTypes.FontSubfamilyName, "Normal")); + + Assert.AreEqual("Normal", nameTable.GetSubfamilyName()); + Assert.AreEqual(FontSubFamily.Regular, nameTable.GetSubfamilyEnum(), + "'Normal' is one of the recognized Regular spellings."); + } + + #endregion + + #region GetSubfamilyEnum — RIBBI mapping (regression guards) + + [TestMethod] + public void GetSubfamilyEnum_Regular_ReturnsRegular() + { + Assert.AreEqual(FontSubFamily.Regular, EnumFor("Regular")); + } + + [TestMethod] + public void GetSubfamilyEnum_Bold_ReturnsBold() + { + Assert.AreEqual(FontSubFamily.Bold, EnumFor("Bold")); + } + + [TestMethod] + public void GetSubfamilyEnum_Italic_ReturnsItalic() + { + Assert.AreEqual(FontSubFamily.Italic, EnumFor("Italic")); + } + + [TestMethod] + public void GetSubfamilyEnum_BoldItalic_ReturnsBoldItalic() + { + Assert.AreEqual(FontSubFamily.BoldItalic, EnumFor("Bold Italic")); + } + + [TestMethod] + public void GetSubfamilyEnum_Oblique_ReturnsItalic() + { + Assert.AreEqual(FontSubFamily.Italic, EnumFor("Oblique")); + } + + [TestMethod] + public void GetSubfamilyEnum_AlternateRegularSpellings_ReturnRegular() + { + Assert.AreEqual(FontSubFamily.Regular, EnumFor("Normal")); + Assert.AreEqual(FontSubFamily.Regular, EnumFor("Roman")); + Assert.AreEqual(FontSubFamily.Regular, EnumFor("Book")); + } + + #endregion + + #region GetSubfamilyEnum — weight names beyond Bold (defense in depth) + + // With the nameID 2 priority fixed, a well-formed font never reaches these branches: + // nameID 2 is always a RIBBI name. They still matter for fonts that omit nameID 2 and + // fall back to nameID 17, which is where weights like "Black" or "Light" show up. + + [TestMethod] + public void GetSubfamilyEnum_WeightNamesBeyondBold_ReturnRegularNotBold() + { + // These are separate typographic weights, already distinguished by the family + // name (e.g. "Arial Black"). Within the 4-value enum their base instance is + // Regular. Mapping them to Bold would disqualify an exact Regular match, and + // would let a Bold request be satisfied by a far heavier face than intended. + Assert.AreEqual(FontSubFamily.Regular, EnumFor("Black"), "Black"); + Assert.AreEqual(FontSubFamily.Regular, EnumFor("Heavy"), "Heavy"); + Assert.AreEqual(FontSubFamily.Regular, EnumFor("Demi"), "Demi"); + Assert.AreEqual(FontSubFamily.Regular, EnumFor("Light"), "Light"); + Assert.AreEqual(FontSubFamily.Regular, EnumFor("Medium"), "Medium"); + } + + [TestMethod] + public void GetSubfamilyEnum_BlackItalic_ReturnsItalic() + { + // Bodoni MT Black and Segoe UI Black both ship a "Black Italic" face. The weight + // is dropped (no enum value for it) but the italic axis is real and must survive. + Assert.AreEqual(FontSubFamily.Italic, EnumFor("Black Italic")); + } + + [TestMethod] + public void GetSubfamilyEnum_SemiBold_ReturnsBold() + { + // "Semibold" legitimately contains the substring "bold", unlike Black/Heavy/Demi, + // and Bold is the closest of the four values. This behaviour is intentional. + Assert.AreEqual(FontSubFamily.Bold, EnumFor("SemiBold")); + } + + [TestMethod] + public void GetSubfamilyEnum_WeightNameBeyondBold_DoesNotConsultFsSelection() + { + // Regression guard for a subtle trap: if the weight-name branch falls through to + // the OS/2 fsSelection fallback instead of returning Regular explicitly, the bug + // reappears through a different path. Vendors commonly set fsSelection's BOLD bit + // on Black/Heavy faces as a legacy hint for apps that can't read the name table. + const ushort fsSelectionBold = 0x0020; + var nameTable = CreateNameTable( + MakeEnglishRecord(NameRecordTypes.TypographicSubfamilyName, "Black")); + nameTable.Os2FsSelection = fsSelectionBold; + + Assert.AreEqual(FontSubFamily.Regular, nameTable.GetSubfamilyEnum(), + "The name table gave a usable answer, so fsSelection must not be consulted."); + } + + #endregion + + #region fsSelection fallback — only when the name table has nothing usable + + [TestMethod] + public void GetSubfamilyEnum_NoSubfamilyRecords_FallsBackToFsSelection() + { + const ushort bold = 0x0020; + const ushort italic = 0x0001; + + Assert.AreEqual(FontSubFamily.Regular, EnumForFsSelection(0)); + Assert.AreEqual(FontSubFamily.Bold, EnumForFsSelection(bold)); + Assert.AreEqual(FontSubFamily.Italic, EnumForFsSelection(italic)); + Assert.AreEqual(FontSubFamily.BoldItalic, EnumForFsSelection((ushort)(bold | italic))); + } + + #endregion + + #region Helpers + + private static FontSubFamily EnumFor(string subfamilyName) + { + return CreateNameTable( + MakeEnglishRecord(NameRecordTypes.FontSubfamilyName, subfamilyName)) + .GetSubfamilyEnum(); + } + + private static FontSubFamily EnumForFsSelection(ushort fsSelection) + { + var nameTable = CreateNameTable(); + nameTable.Os2FsSelection = fsSelection; + return nameTable.GetSubfamilyEnum(); + } + + private static NameTable CreateNameTable(params NameRecord[] records) + { + return new NameTable { NameRecords = records }; + } + + /// + /// Builds a Windows/en-US name record. GetEnglishName() matches on LanguageMapping, + /// not on the raw languageID, so LanguageMapping must be populated for the + /// English-preference tests to mean anything. + /// + private static NameRecord MakeEnglishRecord(NameRecordTypes type, string name) + { + const int enUs = 0x0409; + return new NameRecord + { + RecordType = type, + nameId = (ushort)type, + platformId = 3, // Windows + encodingId = 1, // Unicode BMP + languageID = enUs, + Name = name, + LanguageMapping = new LanguageMapping { code = enUs, Language = Languages.English } + }; + } + + /// + /// Builds a non-English name record. The specific language is irrelevant to the logic + /// under test — all that matters is that it is not Languages.English. + /// + private static NameRecord MakeLocalizedRecord(NameRecordTypes type, string name) + { + const int svSe = 0x041D; + return new NameRecord + { + RecordType = type, + nameId = (ushort)type, + platformId = 3, + encodingId = 1, + languageID = svSe, + Name = name, + LanguageMapping = new LanguageMapping { code = svSe, Language = Languages.Swedish } + }; + } + + #endregion + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/Scanner/FontScannerV2Core.cs b/src/EPPlus.Fonts.OpenType/Scanner/FontScannerV2Core.cs index f8237e49ee..fe45739553 100644 --- a/src/EPPlus.Fonts.OpenType/Scanner/FontScannerV2Core.cs +++ b/src/EPPlus.Fonts.OpenType/Scanner/FontScannerV2Core.cs @@ -99,14 +99,28 @@ internal static FontFaceInfo ScanSingleFace(string filePath, long offset) if (info.TableRecords.TryGetValue("OS/2", out TableRecord os2Rec)) { - try + // fsSelection sits at byte offset 62 in the OS/2 table, after sFamilyClass (30), + // panose[10] (32-41), ulUnicodeRange1-4 (42-57) and achVendID (58-61). + // Reading at offset 32 returns the first two PANOSE bytes instead. + const int fsSelectionOffset = 62; + + // Every OS/2 version (0 and up) is at least 78 bytes, so a table too short to hold + // fsSelection is malformed. Check up front rather than relying on the read throwing. + if (os2Rec.Length >= fsSelectionOffset + 2) { - fs.Position = info.OffsetInFile + os2Rec.Offset + 32; - info.FsSelection = reader.ReadUInt16BigEndian(); + try + { + fs.Position = info.OffsetInFile + os2Rec.Offset + fsSelectionOffset; + info.FsSelection = reader.ReadUInt16BigEndian(); + } + catch + { + // Om tabellen är korrupt eller för kort → ignorera, behåll 0 + info.FsSelection = 0; + } } - catch + else { - // Om tabellen är korrupt eller för kort → ignorera, behåll 0 info.FsSelection = 0; } } diff --git a/src/EPPlus.Fonts.OpenType/Tables/Name/NameTable.cs b/src/EPPlus.Fonts.OpenType/Tables/Name/NameTable.cs index f29a296ebf..b389889a5e 100644 --- a/src/EPPlus.Fonts.OpenType/Tables/Name/NameTable.cs +++ b/src/EPPlus.Fonts.OpenType/Tables/Name/NameTable.cs @@ -327,42 +327,56 @@ public string GetFullFontName() } /// - /// Returns the preferred font family name using OpenType specification priority. - /// Prefers Typographic Family (16) over regular Family (1). + /// Returns the font family name in the legacy/RIBBI naming system (nameID 1), which pairs + /// with GetSubfamilyName()'s nameID 2. Together they form the family+style view that Windows, + /// GDI and Excel present, and the one FontSubFamily's four values are defined against. + /// + /// nameID 16 (Typographic Family) is deliberately NOT preferred, even though it is the newer + /// field: it belongs to the *other* naming system, paired with nameID 17. Arial Black reads + /// "Arial Black" + "Regular" as (1+2) but "Arial" + "Black" as (16+17). Preferring 16 here + /// while GetSubfamilyName() reads 2 (or vice versa) mixes the two systems and produces a pair + /// that exists in neither, breaking font matching. nameID 16 is used only as a last resort, + /// for fonts that omit nameID 1 entirely. /// public string GetFamilyName() { - //// Typographic Family (16) first - //string name = GetFirstNonEmpty(NameRecordTypes.TypographicFamilyName); - //if (!string.IsNullOrEmpty(name)) - // return name; - - // Then regular Family Name (1) - string name = GetFirstNonEmpty(NameRecordTypes.FontFamilyName); + // Legacy Family Name (1), English first: NameRecords can hold one localized nameID 1 + // per language, and GetFirstNonEmpty returns whichever comes first in file order. + string name = GetEnglishName(NameRecordTypes.FontFamilyName); if (!string.IsNullOrEmpty(name)) return name; - // Typographic Family (16) first - name = GetFirstNonEmpty(NameRecordTypes.TypographicFamilyName); + name = GetFirstNonEmpty(NameRecordTypes.FontFamilyName); if (!string.IsNullOrEmpty(name)) return name; - - // Fallback to English + // Last resort only: the typographic family, for fonts that omit nameID 1. name = GetEnglishName(NameRecordTypes.TypographicFamilyName); if (!string.IsNullOrEmpty(name)) return name; - return GetEnglishName(NameRecordTypes.FontFamilyName) ?? "Unknown Family"; + return GetFirstNonEmpty(NameRecordTypes.TypographicFamilyName) ?? "Unknown Family"; } /// - /// Returns the preferred subfamily name. - /// Prefers Typographic Subfamily (17) over regular Subfamily (2). + /// Returns the subfamily name in the legacy/RIBBI naming system (nameID 2), which pairs + /// with GetFamilyName()'s nameID 1. nameID 2 is guaranteed by the OpenType spec to be one + /// of "Regular"/"Bold"/"Italic"/"Bold Italic", which is exactly the FontSubFamily model. + /// + /// nameID 17 (Typographic Subfamily) is deliberately NOT preferred: it belongs to the + /// *other* naming system, paired with nameID 16. Arial Black reads "Arial Black" + "Regular" + /// as (1+2) but "Arial" + "Black" as (16+17). Mixing them yields the pair "Arial Black" + + /// "Black", which exists in neither system and matches no style request. + /// + /// Returns null when the font carries no subfamily name at all. Callers that need a display + /// string apply their own default; GetSubfamilyEnum relies on null to know it should fall + /// back to OS/2 fsSelection instead. /// public string GetSubfamilyName() { - string name = GetFirstNonEmpty(NameRecordTypes.TypographicSubfamilyName); + // Legacy Subfamily (2), English first: a font can carry one localized nameID 2 per + // language, and GetFirstNonEmpty returns whichever happens to come first in file order. + string name = GetEnglishName(NameRecordTypes.FontSubfamilyName); if (!string.IsNullOrEmpty(name)) return name; @@ -370,19 +384,21 @@ public string GetSubfamilyName() if (!string.IsNullOrEmpty(name)) return name; + // Last resort only: the typographic subfamily, for fonts that omit nameID 2. name = GetEnglishName(NameRecordTypes.TypographicSubfamilyName); if (!string.IsNullOrEmpty(name)) return name; - return GetEnglishName(NameRecordTypes.FontSubfamilyName) ?? "Regular"; + return GetFirstNonEmpty(NameRecordTypes.TypographicSubfamilyName); } public FontSubFamily GetSubfamilyEnum() { string subfamily = GetSubfamilyName(); + // No subfamily name in the name table at all → fall back to OS/2 fsSelection. if (string.IsNullOrEmpty(subfamily)) - goto UseFsSelection; + return GetSubfamilyFromFsSelection(); string lower = subfamily.ToLowerInvariant(); @@ -398,14 +414,24 @@ public FontSubFamily GetSubfamilyEnum() if (lower.Contains("bold") && lower.Contains("italic")) return FontSubFamily.BoldItalic; - if (lower.Contains("bold") || lower.Contains("heavy") || lower.Contains("black") || lower.Contains("demi")) + if (lower.Contains("bold")) return FontSubFamily.Bold; if (lower.Contains("italic") || lower.Contains("oblique")) return FontSubFamily.Italic; - // Om name-tabellen är konstig → fallback till OS/2 - UseFsSelection: - return GetSubfamilyFromFsSelection(); + // Weight names beyond "Bold" (Black, Heavy, Demi, Light, Medium, etc.) don't fit the + // 4-value RIBBI model and are NOT treated as Bold here. Fonts using these names + // (e.g. "Arial Black", "Segoe UI Black") already distinguish themselves via FamilyName, + // so their base instance is Regular within FontSubFamily. Mapping them to Bold would + // falsely disqualify an exact match against a Regular request, sending the resolver + // into the fallback chain even though the font is installed. + // + // Note that we return Regular here rather than consulting fsSelection: the name table + // did give us an answer, it just isn't expressible in four values. fsSelection is not a + // tie-breaker for that case — vendors commonly set its BOLD bit on Black/Heavy faces as + // a legacy hint for apps that can't read the name table, so consulting it here would + // silently re-introduce this exact bug through a different path. + return FontSubFamily.Regular; } private FontSubFamily GetSubfamilyFromFsSelection() From 5031a1f97b2439ff46ff04b07fec401039b61556 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Wed, 26 Aug 2026 16:30:01 +0200 Subject: [PATCH 08/26] WIP --- src/EPPlus.Export.Pdf.Tests/PdfTests.cs | 39 ++++++++++++++++ .../Export/PdfExport/Layout/PdfLayout.cs | 45 ++++++++++--------- 2 files changed, 63 insertions(+), 21 deletions(-) diff --git a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs index 4f05b1de49..18fa9a4f2e 100644 --- a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs +++ b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs @@ -14,7 +14,9 @@ Date Author Change using EPPlus.Export.Pdf.Tests; using OfficeOpenXml; using OfficeOpenXml.Export.PdfExport; +using OfficeOpenXml.Export.PdfExport.Data; using OfficeOpenXml.Export.PdfExport.Layout; +using OfficeOpenXml.FormulaParsing.Excel.Functions.Information; using OfficeOpenXml.Style; using System.Text; @@ -638,6 +640,7 @@ public void EPPlusToPdf() p.Workbook.SaveAsPdf(_pdfPath + "Snake.Pdf"); p.SaveAs(_pdfPath + "Snake.xlsx"); } + [TestMethod] public void CenterOnPageTest() { @@ -645,9 +648,45 @@ public void CenterOnPageTest() { var wb = p.Workbook; var ws = wb.Worksheets[0]; + ws.HeaderFooter.OddFooter.LeftAlignedText = "Confidential Report"; + string path = _pdfPath + "CenterOnPagePdf.pdf"; ws.SaveAsPdf(path); } } + + [TestMethod] + public void GetOriginX_CenteringOff_ReturnsContentBoundsLeft() + { + var s = new PdfPageSettings(null); + var p = new Page() + { + FromRow = 1, ToRow = 10, FromColumn = 1, ToColumn = 5, + UsedWidth = 100, + UsedHeight = 100, + RowHeights = new double[10] + }; + + Assert.AreEqual(s.ContentBounds.Left, PdfLayout.GetOriginX(s, p), 0.0001); + } + + [TestMethod] + public void GetOrigin_FlagsAreIndependent() + { + var s = new PdfPageSettings(null); + s.CenterOnPageHorizontally = true; + var p = new Page() + { + FromRow = 1, + ToRow = 10, + FromColumn = 1, + ToColumn = 5, + UsedWidth = s.ContentBounds.Width - 100d, + UsedHeight = s.ContentBounds.Height - 200d, + RowHeights = new double[10] + }; + Assert.AreEqual(s.ContentBounds.Left + 50d, PdfLayout.GetOriginX(s, p), 0.0001); + Assert.AreEqual(s.ContentBounds.Top, PdfLayout.GetOriginY(s, p), 0.0001); + } } } diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index 27f1f7ae5a..61048bbacc 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -88,8 +88,9 @@ internal static Transform GetCatalog(PdfPageSettings pageSettings, PdfDictionari pageLayout.PrintTitleWidth = page.PrintTitleWidth; pageLayout.PrintTitleHeight = page.PrintTitleHeight; var drawnMergedCells = new HashSet(); - double contentStartX = pageSettings.ContentBounds.Left + page.HeadingWidth + page.PrintTitleWidth; - double contentStartY = pageSettings.ContentBounds.Top - page.HeadingHeight - page.PrintTitleHeight; + //double contentStartX = pageSettings.ContentBounds.Left + page.HeadingWidth + page.PrintTitleWidth; + double contentStartX = GetOriginX(pageSettings, page) + page.HeadingWidth + page.PrintTitleWidth; + double contentStartY = GetOriginY(pageSettings, page) - page.HeadingHeight - page.PrintTitleHeight; if (pageSettings.ShowHeadings && !pdfPages[i].IsCommentsPage) { AddHeadingCells(pageSettings, dictionaries, page, pageLayout, contentStartX, contentStartY, page.HeadingWidth, page.HeadingHeight, pdfPages[i].HeadingFontName, pdfPages[i].HeadingFontSize, pdfPages[i].HeadingFill); @@ -458,7 +459,7 @@ private static void AddHeadingCells(PdfPageSettings pageSettings, PdfDictionarie { var headingStyle = new PdfCellStyle(); headingStyle.xfFill = fill; - var cornerFill = new PdfCellLayout(pageSettings.ContentBounds.Left, pageSettings.ContentBounds.Top, headingWidth, headingHeight); + var cornerFill = new PdfCellLayout(GetOriginX(pageSettings, page), GetOriginY(pageSettings, page), headingWidth, headingHeight); SetFill(dictionaries, headingStyle, "", cornerFill); cornerFill.Name = "Heading_Corner"; cornerFill.UpdateShadingPositionMatrix(pageSettings); @@ -470,7 +471,7 @@ private static void AddHeadingCells(PdfPageSettings pageSettings, PdfDictionarie if (colWidth == 0d) { x += colWidth; continue; } string colLetter = ExcelCellBase.GetColumnLetter(col); AddHeadingCell(pageSettings, dictionaries, pageLayout, headingStyle, colLetter, - x, pageSettings.ContentBounds.Top, colWidth, headingHeight, fontName, fontSize, "Heading_Col_" + colLetter); + x, GetOriginY(pageSettings, page), colWidth, headingHeight, fontName, fontSize, "Heading_Col_" + colLetter); x += colWidth; } double y = contentStartY; @@ -480,7 +481,7 @@ private static void AddHeadingCells(PdfPageSettings pageSettings, PdfDictionarie if (rowHeight == 0d) { y -= rowHeight; continue; } string rowNum = row.ToString(); AddHeadingCell(pageSettings, dictionaries, pageLayout, headingStyle, rowNum, - pageSettings.ContentBounds.Left, y, headingWidth, rowHeight, fontName, fontSize, "Heading_Row_" + rowNum); + GetOriginX(pageSettings, page), y, headingWidth, rowHeight, fontName, fontSize, "Heading_Row_" + rowNum); y -= rowHeight; } } @@ -716,7 +717,8 @@ private static Page PrecomputePageMergedCells(PdfPageSettings pageSettings, PdfR } // --- Y --- // Replace the * 15d line with a sum of real row heights - double drawY = pageSettings.ContentBounds.Top - page.HeadingHeight - page.PrintTitleHeight; + //double drawY = pageSettings.ContentBounds.Top - page.HeadingHeight - page.PrintTitleHeight; + double drawY = GetOriginY(pageSettings, page) - page.HeadingHeight - page.PrintTitleHeight; for (int r = page.FromRow; r < row; r++) { drawY -= range.RowHeights[r - range.Range._fromRow].Height; @@ -765,7 +767,8 @@ private static double[] BuildColumnXPositions(PdfPageSettings pageSettings, Page { int colCount = page.ToColumn - page.FromColumn + 1; var colX = new double[colCount]; - double x = pageSettings.ContentBounds.Left + page.HeadingWidth + page.PrintTitleWidth; + //double x = pageSettings.ContentBounds.Left + page.HeadingWidth + page.PrintTitleWidth; + double x = GetOriginY(pageSettings, page) + page.HeadingWidth + page.PrintTitleWidth; for (int col = page.FromColumn; col <= page.ToColumn; col++) { colX[col - page.FromColumn] = x; @@ -817,7 +820,7 @@ private static Page PrecomputePagePrintTitleCells(PdfPageSettings pageSettings, // Content-column X: same origin/widths the content loop uses (step-2 origin). var contentColX = new Dictionary(); - double cx = pageSettings.ContentBounds.Left + page.HeadingWidth + page.PrintTitleWidth; + double cx = PdfLayout.GetOriginX(pageSettings, page) + page.HeadingWidth + page.PrintTitleWidth; for (int c = page.FromColumn; c <= page.ToColumn; c++) { contentColX[c] = cx; @@ -825,7 +828,7 @@ private static Page PrecomputePagePrintTitleCells(PdfPageSettings pageSettings, } // Content-row Y. var contentRowY = new Dictionary(); - double cy = pageSettings.ContentBounds.Top - page.HeadingHeight - page.PrintTitleHeight; + double cy = GetOriginY(pageSettings, page) - page.HeadingHeight - page.PrintTitleHeight; for (int r = page.FromRow; r <= page.ToRow; r++) { contentRowY[r] = cy; @@ -835,7 +838,7 @@ private static Page PrecomputePagePrintTitleCells(PdfPageSettings pageSettings, var titleColX = new Dictionary(); if (leftBand) { - double tx = pageSettings.ContentBounds.Left + page.HeadingWidth; + double tx = GetOriginX(pageSettings, page) + page.HeadingWidth; for (int c = pdfSheet.PrintTitleColFrom; c <= pdfSheet.PrintTitleColTo; c++) { titleColX[c] = tx; @@ -846,7 +849,7 @@ private static Page PrecomputePagePrintTitleCells(PdfPageSettings pageSettings, var titleRowY = new Dictionary(); if (topBand) { - double ty = pageSettings.ContentBounds.Top - page.HeadingHeight; + double ty = GetOriginY(pageSettings, page) - page.HeadingHeight; for (int r = pdfSheet.PrintTitleRowFrom; r <= pdfSheet.PrintTitleRowTo; r++) { titleRowY[r] = ty; @@ -889,7 +892,7 @@ private static Page PrecomputePagePrintTitleCells(PdfPageSettings pageSettings, { IsRow = true, Index = r, - X = pageSettings.ContentBounds.Left, + X = GetOriginX(pageSettings, page), Y = titleRowY[r], Width = page.HeadingWidth, Height = h @@ -906,7 +909,7 @@ private static Page PrecomputePagePrintTitleCells(PdfPageSettings pageSettings, IsRow = false, Index = c, X = titleColX[c], - Y = pageSettings.ContentBounds.Top, + Y = GetOriginY(pageSettings, page), Width = w, Height = page.HeadingHeight }); @@ -916,24 +919,24 @@ private static Page PrecomputePagePrintTitleCells(PdfPageSettings pageSettings, // repeated title-row text continues onto the next horizontal page's band if (topBand) AddIncomingSpill(page, range, pdfSheet.PrintTitleRowFrom, pdfSheet.PrintTitleRowTo, page.FromColumn, page.ToColumn, - pageSettings.ContentBounds.Left + page.HeadingWidth + page.PrintTitleWidth, - pageSettings.ContentBounds.Top - page.HeadingHeight, + GetOriginX(pageSettings, page) + page.HeadingWidth + page.PrintTitleWidth, + GetOriginY(pageSettings, page) - page.HeadingHeight, isPrintTitle: true); // left band: a neighbour whose text spills INTO a title column travels with the repeated column if (leftBand) AddIncomingSpill(page, range, page.FromRow, page.ToRow, pdfSheet.PrintTitleColFrom, pdfSheet.PrintTitleColTo, - pageSettings.ContentBounds.Left + page.HeadingWidth, // band origin X (left edge of the title columns) - pageSettings.ContentBounds.Top - page.HeadingHeight - page.PrintTitleHeight, // content-rows origin Y + GetOriginX(pageSettings, page) + page.HeadingWidth, // band origin X (left edge of the title columns) + GetOriginY(pageSettings, page) - page.HeadingHeight - page.PrintTitleHeight, // content-rows origin Y isPrintTitle: true); // corner: same, for the title-rows × title-columns intersection if (topBand && leftBand) AddIncomingSpill(page, range, pdfSheet.PrintTitleRowFrom, pdfSheet.PrintTitleRowTo, pdfSheet.PrintTitleColFrom, pdfSheet.PrintTitleColTo, - pageSettings.ContentBounds.Left + page.HeadingWidth, // band origin X - pageSettings.ContentBounds.Top - page.HeadingHeight, // title-rows origin Y + GetOriginX(pageSettings, page) + page.HeadingWidth, // band origin X + GetOriginY(pageSettings, page) - page.HeadingHeight, // title-rows origin Y isPrintTitle: true); return page; @@ -1570,8 +1573,8 @@ internal static Pages PrecomputeSpillCells(PdfPageSettings pageSettings, PdfRang { var page = pdfPages.Page[i]; page.SpillCells = new List(); - double originX = pageSettings.ContentBounds.Left + page.HeadingWidth + page.PrintTitleWidth; - double originY = pageSettings.ContentBounds.Top - page.HeadingHeight - page.PrintTitleHeight; + double originX = GetOriginX(pageSettings, page) + page.HeadingWidth + page.PrintTitleWidth; + double originY = GetOriginY(pageSettings, page) - page.HeadingHeight - page.PrintTitleHeight; AddIncomingSpill(page, range, page.FromRow, page.ToRow, page.FromColumn, page.ToColumn, originX, originY, isPrintTitle: false); pdfPages.Page[i] = page; } From 69ad6488399476afee79fdf1934edf86e31526a6 Mon Sep 17 00:00:00 2001 From: swmal <{ID}+username}@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:19:13 +0200 Subject: [PATCH 09/26] Keep bundled fallback fonts embeddable when OnFontEmbedding skips --- src/EPPlus.Export.Pdf.Tests/PdfTests.cs | 1 + src/EPPlus.Export.Pdf/ExcelPdf.cs | 5 +++- .../FallbackFonts/EmbeddedFontsTests.cs | 27 +++++++++++++++++++ .../FontScanning/ArialBlackTests.cs | 26 ++++++++++++++++++ src/EPPlus.Fonts.OpenType/EmbeddedFonts.cs | 25 +++++++++++++++++ .../OpenTypeFontEngine.cs | 6 +++++ .../Subsetting/DocumentFontSubsetBuilder.cs | 8 +++--- .../PdfExport/TextShaping/PdfTextShaper.cs | 15 ++++++++--- 8 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 src/EPPlus.Fonts.OpenType.Tests/FallbackFonts/EmbeddedFontsTests.cs diff --git a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs index 0f67718f93..8a0dc819e2 100644 --- a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs +++ b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs @@ -54,6 +54,7 @@ private static long ParseStartXref(byte[] bytes, int pdfStart) public void SaveWorksheetAsPdfTest1() { using var p = OpenTemplatePackage("PDFTest.xlsx"); + p.Workbook.ConfigureFonts(x => x.OnFontEmbedding(f => FontEmbeddingDecision.Skip)); var ws = p.Workbook.Worksheets[0]; string path = _pdfPath + "WorksheetTest1.pdf"; ws.SaveAsPdf(path); diff --git a/src/EPPlus.Export.Pdf/ExcelPdf.cs b/src/EPPlus.Export.Pdf/ExcelPdf.cs index 34ec0a4f8f..60693fc592 100644 --- a/src/EPPlus.Export.Pdf/ExcelPdf.cs +++ b/src/EPPlus.Export.Pdf/ExcelPdf.cs @@ -18,6 +18,7 @@ Date Author Change using EPPlus.Graphics; using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Text; @@ -75,7 +76,9 @@ private string GetPatternLabel(PdfCellLayout layout) //Add Fonts //Need to update this method a bit. We should check for all default fonts and not only courier new? Also need to check if we are allowed to embedd the font. internal void AddFontData() - { + { + foreach (var f in _dictionaries.Fonts) + Debug.WriteLine($"Fonts: {f.Key} → label={f.Value.Label} nr={f.Value.labelNumber}"); if (_documentSettings.EmbeddFonts) { foreach (var font in _dictionaries.Fonts) diff --git a/src/EPPlus.Fonts.OpenType.Tests/FallbackFonts/EmbeddedFontsTests.cs b/src/EPPlus.Fonts.OpenType.Tests/FallbackFonts/EmbeddedFontsTests.cs new file mode 100644 index 0000000000..52de10081f --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/FallbackFonts/EmbeddedFontsTests.cs @@ -0,0 +1,27 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml.Interfaces.Fonts; +using System; +using System.Linq; + +namespace EPPlus.Fonts.OpenType.Tests.FallbackFonts +{ + [TestClass] + public class EmbeddedFontsTests : FontTestBase + { + public override TestContext? TestContext { get; set; } + + [TestMethod] + public void BundledFamilies_MatchesEmbeddedResources() + { + Assert.IsTrue(EmbeddedFonts.IsBundledFamily( + EmbeddedFonts.LoadNotoEmoji().GetEnglishFontFamilyName())); + Assert.IsTrue(EmbeddedFonts.IsBundledFamily( + EmbeddedFonts.LoadNotoMath().GetEnglishFontFamilyName())); + foreach (FontSubFamily sf in Enum.GetValues(typeof(FontSubFamily))) + { + Assert.IsTrue(EmbeddedFonts.IsBundledFamily( + EmbeddedFonts.LoadArchivoNarrow(sf).GetEnglishFontFamilyName()), sf.ToString()); + } + } + } +} diff --git a/src/EPPlus.Fonts.OpenType.Tests/FontScanning/ArialBlackTests.cs b/src/EPPlus.Fonts.OpenType.Tests/FontScanning/ArialBlackTests.cs index 2304d16e79..84de0df93f 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/FontScanning/ArialBlackTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/FontScanning/ArialBlackTests.cs @@ -25,6 +25,18 @@ public void ScanArialBlack_ShouldReturnArialBlack() Assert.IsTrue(face.IsExactMatch, "face.IsExactMatch was false"); } + [TestMethod] + public void ScanAptosNarrow_ShouldReturnArialBlack() + { + var face = FontScannerV2.FindBestMatch(string.Empty, "Aptos Narrow", FontSubFamily.Regular, true); + if (face == null) + { + Assert.Inconclusive(); + } + Assert.AreEqual("Aptos Narrow", face.FamilyName, $"face.FamilyName was not 'Aptos Narrow' as expected but '{face.FamilyName}'"); + Assert.IsTrue(face.IsExactMatch, "face.IsExactMatch was false"); + } + [TestMethod] public void LoadArialBlackFullFont_ShouldReturnArialBlack() { @@ -39,6 +51,20 @@ public void LoadArialBlackFullFont_ShouldReturnArialBlack() Assert.AreEqual("Arial Black", font.FullName); } + [TestMethod] + public void LoadAptosNarrowFullFont_ShouldReturnAptosNarrow() + { + var factory = new OpenTypeFontEngine(); + var availability = factory.GetFontAvailability("Aptos Narrow"); + if (availability == FontAvailability.NotFound) + { + Assert.Inconclusive(); + } + var font = factory.LoadFont("Aptos Narrow"); + Assert.IsNotNull(font); + Assert.AreEqual("Aptos Narrow", font.FullName); + } + [TestMethod] public void Dump_AllFacesNamedLikeArialBlack() { diff --git a/src/EPPlus.Fonts.OpenType/EmbeddedFonts.cs b/src/EPPlus.Fonts.OpenType/EmbeddedFonts.cs index 7c6198b17e..5c9d5cffc0 100644 --- a/src/EPPlus.Fonts.OpenType/EmbeddedFonts.cs +++ b/src/EPPlus.Fonts.OpenType/EmbeddedFonts.cs @@ -75,6 +75,31 @@ private static OpenTypeFont LoadCached(string resourceName) } } + // The families EPPlus ships as embedded resources. Kept as names rather than instances: + // a bundled font also reaches the engine as a fresh OpenTypeFont built from + // IFontResolver.ResolveFont's byte[], which is not reference-equal to the cached instance. + private static readonly string[] _bundledFamilies = new string[] + { + "Archivo Narrow", + "Noto Emoji", + "Noto Sans Math" + }; + + /// + /// True if the family is one EPPlus distributes as an embedded resource. All four + /// Archivo Narrow styles are covered by the family name alone. + /// + internal static bool IsBundledFamily(string familyName) + { + if (string.IsNullOrEmpty(familyName)) return false; + for (int i = 0; i < _bundledFamilies.Length; i++) + { + if (string.Equals(_bundledFamilies[i], familyName, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + /// /// Reads all bytes from a stream into a byte array. /// .NET 3.5 compatible (no CopyTo available). diff --git a/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs b/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs index e5df082f74..7518114408 100644 --- a/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs +++ b/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs @@ -20,6 +20,7 @@ Date Author Change using OfficeOpenXml.Interfaces.RichText; using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; namespace EPPlus.Fonts.OpenType @@ -380,6 +381,9 @@ internal FontEmbeddingDecision ResolveEmbeddingDecision(OpenTypeFont font) ? font.Os2Table.GetEmbeddingRestriction() : FontEmbeddingRestriction.None; + if (font.NameTable != null && EmbeddedFonts.IsBundledFamily(font.GetEnglishFontFamilyName())) + return FontEmbeddingDecision.Subset; + var fontName = font.NameTable != null ? font.NameTable.GetFullFontName() : null; var callback = _configuration.GetEmbeddingCallback(); if (callback != null) @@ -389,6 +393,8 @@ internal FontEmbeddingDecision ResolveEmbeddingDecision(OpenTypeFont font) return decision; // user override wins } + Debug.WriteLine($"ResolveEmbeddingDecision: {fontName} restriction={restriction} callback={(callback != null)}"); + // No callback, or callback returned Default → derive from the restriction. switch (restriction) { diff --git a/src/EPPlus.Fonts.OpenType/Subsetting/DocumentFontSubsetBuilder.cs b/src/EPPlus.Fonts.OpenType/Subsetting/DocumentFontSubsetBuilder.cs index bac0dea09e..b7be7ccf06 100644 --- a/src/EPPlus.Fonts.OpenType/Subsetting/DocumentFontSubsetBuilder.cs +++ b/src/EPPlus.Fonts.OpenType/Subsetting/DocumentFontSubsetBuilder.cs @@ -98,7 +98,7 @@ public void Build() // 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(); + dest = LastResort(kvp.Key.SubFamily); var id = IdentityOf(dest); @@ -118,7 +118,7 @@ public void Build() // content) still needs a primary to shape against. if (chainIdentities.Count == 0) { - var lr = LastResort(); + var lr = LastResort(kvp.Key.SubFamily); var lrId = IdentityOf(lr); if (!fontByIdentity.ContainsKey(lrId)) fontByIdentity[lrId] = lr; @@ -159,9 +159,9 @@ public void Build() // 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() + private OpenTypeFont LastResort(FontSubFamily subFamily) { - var font = EmbeddedFonts.LoadArchivoNarrow(FontSubFamily.Regular); + var font = EmbeddedFonts.LoadArchivoNarrow(subFamily); _decisionByIdentity[IdentityOf(font)] = FontEmbeddingDecision.Subset; return font; } diff --git a/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs b/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs index bcd046ff8e..2f71206f73 100644 --- a/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs +++ b/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs @@ -10,16 +10,17 @@ Date Author Change ************************************************************************************************* 27/11/2025 EPPlus Software AB EPPlus 9 *************************************************************************************************/ +using EPPlus.Export.Pdf.Layout; +using EPPlus.Export.Pdf.Resources; +using EPPlus.Export.Pdf.Settings; using EPPlus.Fonts.OpenType; using EPPlus.Fonts.OpenType.Integration; using EPPlus.Fonts.OpenType.TextShaping; -using EPPlus.Export.Pdf.Resources; -using EPPlus.Export.Pdf.Settings; -using EPPlus.Export.Pdf.Layout; using OfficeOpenXml.Export.PdfExport.Data; using OfficeOpenXml.Interfaces.Fonts; using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; namespace OfficeOpenXml.Export.PdfExport.TextShaping @@ -84,6 +85,10 @@ public static void ShapeText(PdfPageSettings pageSettings, PdfDictionaries dicti } fontIdMap[fontId] = dictionaries.Fonts[loadedKey].Label; } + // I ShapeText, EFTER fontIdMap-loopen (ersätt den nuvarande raden): + Debug.WriteLine($"Shape: {tf.Font.Family}/{tf.Font.SubFamily} " + + $"usedFonts=[{string.Join(", ", usedFonts.Select(f => f.GetEnglishFontFamilyName()))}] " + + $"labels=[{string.Join(",", fontIdMap.Values)}]"); cell.TextLayoutEngine = layoutEngine; st.ShapedText = shaped; totalTextLength += st.ShapedText.GetWidthInPoints((float)tf.Font.Size); @@ -152,6 +157,10 @@ public static void ShapeText(PdfPageSettings pageSettings, PdfDictionaries dicti } fontIdMap[fontId] = dictionaries.Fonts[loadedKey].Label; } + Debug.WriteLine($"Shape: {tf.Font.Family}/{tf.Font.SubFamily} " + + $"usedFonts=[{string.Join(", ", usedFonts.Select(f => f.GetEnglishFontFamilyName()))}] " + + $"labels=[{string.Join(",", fontIdMap.Values)}]"); + cell.TextLayoutEngine = layoutEngine; st.ShapedText = shaped; totalTextLength += st.ShapedText.GetWidthInPoints((float)tf.Font.Size); From 7061a74fb54df06d694529463c538f017c343a61 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Thu, 27 Aug 2026 08:02:21 +0200 Subject: [PATCH 10/26] WIP --- src/EPPlus/Export/PdfExport/Layout/PdfGridlinesLayout.cs | 5 ----- src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs | 3 --- 2 files changed, 8 deletions(-) diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfGridlinesLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfGridlinesLayout.cs index 8b432e073d..ca4e94313f 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfGridlinesLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfGridlinesLayout.cs @@ -50,7 +50,6 @@ public static void AddGridLines(PdfPageSettings pageSettings, Page page, PdfPage // colX[ci] = X of left edge of column ci (0-based within page). // colX[colCount] = X of right edge of last column. var colX = new double[colCount + 1]; - //colX[0] = pageSettings.ContentBounds.Left + page.HeadingWidth + page.PrintTitleWidth; colX[0] = PdfLayout.GetOriginX(pageSettings, page) + page.HeadingWidth + page.PrintTitleWidth; for (int ci = 0; ci < colCount; ci++) { @@ -74,10 +73,6 @@ public static void AddGridLines(PdfPageSettings pageSettings, Page page, PdfPage // Always computed so BorderLines is available for margin clipping regardless of // whether ShowGridLines is on. When borderOnly is true we stop here. - //double frameLeft = pageSettings.ContentBounds.Left; //colX[0]; - //double frameRight = colX[colCount]; - //double frameTop = pageSettings.ContentBounds.Top; //rowY[0]; - //double frameBottom = rowY[rowCount]; double frameLeft = PdfLayout.GetOriginX(pageSettings, page); double frameRight = colX[colCount]; double frameTop = PdfLayout.GetOriginY(pageSettings, page); diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index 61048bbacc..f97904e838 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -88,7 +88,6 @@ internal static Transform GetCatalog(PdfPageSettings pageSettings, PdfDictionari pageLayout.PrintTitleWidth = page.PrintTitleWidth; pageLayout.PrintTitleHeight = page.PrintTitleHeight; var drawnMergedCells = new HashSet(); - //double contentStartX = pageSettings.ContentBounds.Left + page.HeadingWidth + page.PrintTitleWidth; double contentStartX = GetOriginX(pageSettings, page) + page.HeadingWidth + page.PrintTitleWidth; double contentStartY = GetOriginY(pageSettings, page) - page.HeadingHeight - page.PrintTitleHeight; if (pageSettings.ShowHeadings && !pdfPages[i].IsCommentsPage) @@ -717,7 +716,6 @@ private static Page PrecomputePageMergedCells(PdfPageSettings pageSettings, PdfR } // --- Y --- // Replace the * 15d line with a sum of real row heights - //double drawY = pageSettings.ContentBounds.Top - page.HeadingHeight - page.PrintTitleHeight; double drawY = GetOriginY(pageSettings, page) - page.HeadingHeight - page.PrintTitleHeight; for (int r = page.FromRow; r < row; r++) { @@ -767,7 +765,6 @@ private static double[] BuildColumnXPositions(PdfPageSettings pageSettings, Page { int colCount = page.ToColumn - page.FromColumn + 1; var colX = new double[colCount]; - //double x = pageSettings.ContentBounds.Left + page.HeadingWidth + page.PrintTitleWidth; double x = GetOriginY(pageSettings, page) + page.HeadingWidth + page.PrintTitleWidth; for (int col = page.FromColumn; col <= page.ToColumn; col++) { From 7e9dd1269b440cdc6414c95540c551decea46002 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Thu, 27 Aug 2026 08:42:37 +0200 Subject: [PATCH 11/26] Added functionality for centered content horiziontally and vertically in PDF --- src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs b/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs index fe20194ede..76287e5a12 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 = true; - internal bool PrintAsText = true; + internal bool Debug = false; + internal bool PrintAsText = false; public PdfPageSettings(OpenTypeFontEngine fontEngine) { From 9c63117ad5d0ff210a282e35a570b1782b42a95a Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Thu, 27 Aug 2026 16:14:50 +0200 Subject: [PATCH 12/26] Fix for bug #2485 --- src/EPPlus.Export.Pdf.Tests/PdfTests.cs | 10 ++++++++++ src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs | 4 ++-- src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs | 7 +++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs index a566064de5..141f00e955 100644 --- a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs +++ b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs @@ -21,6 +21,7 @@ Date Author Change using System.Globalization; using System.Text; using System.Text.RegularExpressions; +using FakeItEasy.Configuration; namespace EPPlusTest.PDF { @@ -761,5 +762,14 @@ public void EachWorksheetUsesItsOwnPaperSize() } } + [TestMethod] + public void ColLargerThanPrintableArea() + { + using(var p = OpenTemplatePackage("CenterOnPagePdf.xlsx")) + { + var ms = p.Workbook; + ms.SaveAsPdf(_pdfPath + "test.pdf") +; } + } } } diff --git a/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs b/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs index 76287e5a12..fe20194ede 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/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index e6fe821c38..b8edcb35ca 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -1348,6 +1348,13 @@ private static List GetColumnSegments(PdfPageSettings pageSettings, // Content-bounds overflow: col doesn't fit, end segment before it and reprocess. if (width + range.ColWidths[col] + effectiveAdded >= pageSettings.ContentBounds.Width) { + if(col == segStartIdx) + { + segments.Add(new PageSegment(range.Map.FromColumn + col, range.Map.FromColumn + col)); + segStartIdx = col + 1; + width = 0d; + continue; + } segments.Add(new PageSegment(range.Map.FromColumn + segStartIdx, range.Map.FromColumn + col - 1)); segStartIdx = col; width = 0d; From eb88e31d62a619e9c4c7aa7762a81900209a2692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Thu, 27 Aug 2026 16:27:44 +0200 Subject: [PATCH 13/26] Fixed header footer issue & performance for table style. --- src/EPPlus/Export/PdfExport/Data/PageData.cs | 1 + .../Export/PdfExport/Layout/PdfLayout.cs | 52 +++++-- src/EPPlus/Export/PdfExport/PdfCatalog.cs | 6 - .../TextMapping/PdfHeaderFooterCollection.cs | 11 ++ .../PdfExport/TextMapping/PdfTextMap.cs | 138 ++++++++---------- 5 files changed, 115 insertions(+), 93 deletions(-) diff --git a/src/EPPlus/Export/PdfExport/Data/PageData.cs b/src/EPPlus/Export/PdfExport/Data/PageData.cs index 458d1bb62c..be84394883 100644 --- a/src/EPPlus/Export/PdfExport/Data/PageData.cs +++ b/src/EPPlus/Export/PdfExport/Data/PageData.cs @@ -56,6 +56,7 @@ internal struct Pages /// Set in PdfLayout.GetPages, read in PdfLayout.GetCatalog. /// public PdfPageSettings Settings; + public int SheetIndex; public int Count { get { return Width * Height; } diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index e6fe821c38..16309e94fb 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -74,12 +74,21 @@ public static Transform GetLayout(PdfPageSettings[] sheetSettings, PdfDictionari internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictionaries, List pdfPages) { Transform Catalog = new Transform(0d, 0d, 0d, 0d); - int totalPages = GetTotalPages(pdfPages); + int totalPages = 0; + var sheetTotalPages = GetTotalPagesPerSheet(pdfPages); + int displayedPageNumber = 0; + int physicalPageIndex = 1; + int currentSheetIndex = -1; for (int i = 0; i < pdfPages.Count; i++) { var pageSettings = pdfPages[i].Settings; - - + if (pdfPages[i].SheetIndex != currentSheetIndex) + { + currentSheetIndex = pdfPages[i].SheetIndex; + displayedPageNumber = pageSettings.FirstPageNumber; + physicalPageIndex = 1; + sheetTotalPages.TryGetValue(currentSheetIndex, out totalPages); + } var pages = pdfPages[i].Page; int pageNumber = pageSettings.FirstPageNumber; for (int j = 0; j < pages.Length; j++) @@ -204,12 +213,14 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio } if (page.HeaderFooters != null) { - bool isVeryFirstPage = (i == 0 && j == 0); - var hfType = isVeryFirstPage ? HeaderFooterType.First : (pageNumber % 2 == 0 ? HeaderFooterType.Even : HeaderFooterType.Odd); + //bool isVeryFirstPage = (i == 0 && j == 0); + //var hfType = isVeryFirstPage ? HeaderFooterType.First : (pageNumber % 2 == 0 ? HeaderFooterType.Even : HeaderFooterType.Odd); + //var leftH = page.HeaderFooters.Get(hfType, HeaderFooterSection.Header, HeaderFooterAlignment.Left); + var hfType = page.HeaderFooters.GetPageType(physicalPageIndex); var leftH = page.HeaderFooters.Get(hfType, HeaderFooterSection.Header, HeaderFooterAlignment.Left); if (leftH != null) { - SubstitutePageNumbers(pageSettings, dictionaries, leftH, pageNumber, totalPages); + SubstitutePageNumbers(pageSettings, dictionaries, leftH, displayedPageNumber, totalPages); var ascent = leftH.Content.TextLines[0].LargestAscent; var hfx = pageSettings.Margins.LeftPu; var hfy = pageSettings.PageSize.HeightPu - pageSettings.Margins.HeaderPu - ascent; @@ -222,7 +233,7 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio var centerH = page.HeaderFooters.Get(hfType, HeaderFooterSection.Header, HeaderFooterAlignment.Center); if (centerH != null) { - SubstitutePageNumbers(pageSettings, dictionaries, centerH, pageNumber, totalPages); + SubstitutePageNumbers(pageSettings, dictionaries, centerH, displayedPageNumber, totalPages); var ascent = centerH.Content.TextLines[0].LargestAscent; var hfx = pageSettings.Margins.LeftPu; var hfy = pageSettings.PageSize.HeightPu - pageSettings.Margins.HeaderPu - ascent; @@ -236,7 +247,7 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio var rightH = page.HeaderFooters.Get(hfType, HeaderFooterSection.Header, HeaderFooterAlignment.Right); if (rightH != null) { - SubstitutePageNumbers(pageSettings, dictionaries, rightH, pageNumber, totalPages); + SubstitutePageNumbers(pageSettings, dictionaries, rightH, displayedPageNumber, totalPages); var ascent = rightH.Content.TextLines[0].LargestAscent; var hfx = pageSettings.PageSize.WidthPu - pageSettings.Margins.RightPu; var hfy = pageSettings.PageSize.HeightPu - pageSettings.Margins.HeaderPu - ascent; @@ -249,7 +260,7 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio var leftF = page.HeaderFooters.Get(hfType, HeaderFooterSection.Footer, HeaderFooterAlignment.Left); if (leftF != null) { - SubstitutePageNumbers(pageSettings, dictionaries, leftF, pageNumber, totalPages); + SubstitutePageNumbers(pageSettings, dictionaries, leftF, displayedPageNumber, totalPages); int last = leftF.Content.TextLines.Count - 1; var descent = leftF.Content.TextLines[last].LargestDescent; var hfx = pageSettings.Margins.LeftPu; @@ -263,7 +274,7 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio var centerF = page.HeaderFooters.Get(hfType, HeaderFooterSection.Footer, HeaderFooterAlignment.Center); if (centerF != null) { - SubstitutePageNumbers(pageSettings, dictionaries, centerF, pageNumber, totalPages); + SubstitutePageNumbers(pageSettings, dictionaries, centerF, displayedPageNumber, totalPages); int last = centerF.Content.TextLines.Count - 1; var descent = centerF.Content.TextLines[last].LargestDescent; var hfx = pageSettings.PageSize.WidthPu / 2d; @@ -277,7 +288,7 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio var rightF = page.HeaderFooters.Get(hfType, HeaderFooterSection.Footer, HeaderFooterAlignment.Right); if (rightF != null) { - SubstitutePageNumbers(pageSettings, dictionaries, rightF, pageNumber, totalPages); + SubstitutePageNumbers(pageSettings, dictionaries, rightF, displayedPageNumber, totalPages); int last = rightF.Content.TextLines.Count - 1; var descent = rightF.Content.TextLines[last].LargestDescent; var hfx = pageSettings.PageSize.WidthPu - pageSettings.Margins.RightPu; @@ -297,13 +308,26 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase); return cmp; }); - pageNumber++; + displayedPageNumber++; + physicalPageIndex++; Catalog.AddChild(pageLayout); } } return Catalog; } + private static Dictionary GetTotalPagesPerSheet(List pdfPages) + { + var totals = new Dictionary(); + for (int i = 0; i < pdfPages.Count; i++) + { + int si = pdfPages[i].SheetIndex; + if (!totals.ContainsKey(si)) totals[si] = 0; + totals[si] += pdfPages[i].Page.Length; + } + return totals; + } + private static void SetFill(PdfDictionaries dictionaries, PdfCellStyle cellStyle, string text, PdfCellLayout fill) { var xfFill = cellStyle.xfFill; @@ -673,6 +697,7 @@ internal static List GetPages(PdfPageSettings[] sheetSettings, PdfWorkshe pages.HeadingFontSize = pdfSheet.NormalStyle.Style.Font.Size; pages.HeadingFill = pdfSheet.NormalStyle.Style.Fill; pages.Settings = pageSettings; + pages.SheetIndex = si; PagesCollection.Add(pages); } if (pdfSheet.CommentsAndNotes.Range != null) @@ -684,7 +709,8 @@ internal static List GetPages(PdfPageSettings[] sheetSettings, PdfWorkshe pages = MapPage(pdfSheet.CommentsAndNotes, pages); pageSettings.ShowHeadings = savedShowHeadings; pages.IsCommentsPage = true; - pages.Settings = pageSettings; + pages.Settings = pageSettings; + pages.SheetIndex = si; PagesCollection.Add(pages); } } diff --git a/src/EPPlus/Export/PdfExport/PdfCatalog.cs b/src/EPPlus/Export/PdfExport/PdfCatalog.cs index 0cca173b41..1744207159 100644 --- a/src/EPPlus/Export/PdfExport/PdfCatalog.cs +++ b/src/EPPlus/Export/PdfExport/PdfCatalog.cs @@ -11,12 +11,8 @@ Date Author Change 27/11/2025 EPPlus Software AB EPPlus 9 *************************************************************************************************/ using EPPlus.Export.Pdf; -using EPPlus.Export.Pdf; -using EPPlus.Export.Pdf.Resources; using EPPlus.Export.Pdf.Resources; using EPPlus.Export.Pdf.Settings; -using EPPlus.Export.Pdf.Settings; -using EPPlus.Graphics; using EPPlus.Graphics; using OfficeOpenXml.Export.PdfExport.Data; using OfficeOpenXml.Export.PdfExport.Layout; @@ -26,8 +22,6 @@ Date Author Change using OfficeOpenXml.Export.PdfExport.TextShaping; using System; using System.Collections.Generic; -using System.Collections.Generic; -using System.Diagnostics; using System.Diagnostics; using System.IO; using System.Linq; diff --git a/src/EPPlus/Export/PdfExport/TextMapping/PdfHeaderFooterCollection.cs b/src/EPPlus/Export/PdfExport/TextMapping/PdfHeaderFooterCollection.cs index 0f6e9be031..8ac65d2c03 100644 --- a/src/EPPlus/Export/PdfExport/TextMapping/PdfHeaderFooterCollection.cs +++ b/src/EPPlus/Export/PdfExport/TextMapping/PdfHeaderFooterCollection.cs @@ -24,11 +24,15 @@ internal class PdfHeaderFooterCollection public List PdfHeaderFooterEntries = new List(); public bool ScaleWithDocument = false; public bool AlignWithMargins = false; + public bool HasFirstPage = false; + public bool HasOddEvenPages = false; public PdfHeaderFooterCollection(PdfPageSettings pageSettings, PdfDictionaries dictionaries, PdfWorksheet pdfSheet, ExcelHeaderFooter headerFooter) { bool differentFirst = pdfSheet.Worksheet.HeaderFooter.differentFirst; bool differentOddEven = pdfSheet.Worksheet.HeaderFooter.differentOddEven; + HasFirstPage = differentFirst; + HasOddEvenPages = differentOddEven; bool AlignWithMargins = pdfSheet.Worksheet.HeaderFooter.AlignWithMargins; bool ScaleWithDocument = pdfSheet.Worksheet.HeaderFooter.ScaleWithDocument; PdfHeaderFooter entry = null; @@ -161,5 +165,12 @@ public PdfHeaderFooter Get(HeaderFooterType type, HeaderFooterSection section, H e.Section == section && e.Alignment == alignment); } + + public HeaderFooterType GetPageType(int physicalPageIndex) + { + if (physicalPageIndex == 1 && HasFirstPage) return HeaderFooterType.First; + if (physicalPageIndex % 2 == 0 && HasOddEvenPages) return HeaderFooterType.Even; + return HeaderFooterType.Odd; + } } } diff --git a/src/EPPlus/Export/PdfExport/TextMapping/PdfTextMap.cs b/src/EPPlus/Export/PdfExport/TextMapping/PdfTextMap.cs index 5e97469096..7218d6190c 100644 --- a/src/EPPlus/Export/PdfExport/TextMapping/PdfTextMap.cs +++ b/src/EPPlus/Export/PdfExport/TextMapping/PdfTextMap.cs @@ -34,6 +34,7 @@ internal class PdfTextMap { public static PdfCellCollection SetTextMap(PdfPageSettings pageSettings, PdfDictionaries dictionaries, PdfWorksheet pdfSheet, ref PdfRange pdfRange) { + var tableStyleCache = new Dictionary(); var Range = pdfRange; var worksheet = Range.Range.Worksheet; var ZeroCharWidth = pdfSheet.ZeroCharWidth = PdfWorksheet.GetThemeFont0Width(worksheet); @@ -75,14 +76,14 @@ public static PdfCellCollection SetTextMap(PdfPageSettings pageSettings, PdfDict tempMap.Name = cell.Address; if (cell.Merge) { - HandleMergedCell(pageSettings, dictionaries, cell, checkedMergedCells, Map, tempMap, pdfSheet.ZeroCharWidth); + HandleMergedCell(pageSettings, dictionaries, cell, checkedMergedCells, Map, tempMap, pdfSheet.ZeroCharWidth, tableStyleCache); } var cellStyle = new PdfCellStyle(); - GetBorderStyles(cell, cellStyle, tempMap); + GetBorderStyles(cell, cellStyle, tempMap, tableStyleCache); if (tempMap.Main == null) { - GetFillStyles(cell, cellStyle); - GetFontStyle(cell, cellStyle); + GetFillStyles(cell, cellStyle, tableStyleCache); + GetFontStyle(cell, cellStyle, tableStyleCache); tempMap.ContentAligmnet = GetContentAlignment(cell); if (!string.IsNullOrEmpty(cell.Text)) { @@ -113,7 +114,7 @@ public static PdfCellCollection SetTextMap(PdfPageSettings pageSettings, PdfDict return Map; } - private static void HandleMergedCell(PdfPageSettings pageSettings, PdfDictionaries dictionaries, ExcelRange cell, List checkedMergedCells, PdfCellCollection map, PdfCell tempMap, double ZeroCharWidth) + private static void HandleMergedCell(PdfPageSettings pageSettings, PdfDictionaries dictionaries, ExcelRange cell, List checkedMergedCells, PdfCellCollection map, PdfCell tempMap, double ZeroCharWidth, Dictionary tableStyleCache) { var worksheet = cell.Worksheet; string mergeAddress = worksheet.MergedCells[cell.Start.Row, cell.Start.Column]; @@ -144,9 +145,9 @@ private static void HandleMergedCell(PdfPageSettings pageSettings, PdfDictionari var main = worksheet.Cells[address._fromRow, address._fromCol]; PdfCell mainCell = new PdfCell(); var cellStyle = new PdfCellStyle(); - GetBorderStyles(main, cellStyle, mainCell); - GetFillStyles(main, cellStyle); - GetFontStyle(main, cellStyle); + GetBorderStyles(main, cellStyle, mainCell, tableStyleCache); + GetFillStyles(main, cellStyle, tableStyleCache); + GetFontStyle(main, cellStyle, tableStyleCache); mainCell.ContentAligmnet = GetContentAlignment(main); if (!string.IsNullOrEmpty(main.Text)) { @@ -162,7 +163,7 @@ private static void HandleMergedCell(PdfPageSettings pageSettings, PdfDictionari tempMap.Merged = true; } - private static void GetFillStyles(ExcelRangeBase cell, PdfCellStyle cellStyle) + private static void GetFillStyles(ExcelRangeBase cell, PdfCellStyle cellStyle, Dictionary tableStyleCache) { if (cell.Style.Fill.IsEmpty()) { @@ -206,56 +207,45 @@ private static void GetFillStyles(ExcelRangeBase cell, PdfCellStyle cellStyle) var range = table.Range; int tableRow = 0; int tableCol = 0; - ExcelTableNamedStyle tableStyle = null; - if (table.TableStyle == TableStyles.Custom) + ExcelTableNamedStyle tableStyle = GetTableStyle(table, tableStyleCache); + tableRow = cell._fromRow - range._fromRow; + tableCol = cell._fromCol - range._fromCol; + if (table.ShowHeader && tableRow == 0) { - if (!string.IsNullOrEmpty(table.StyleName)) - tableStyle = cell.Worksheet.Workbook.Styles.TableStyles[table.StyleName].As.TableStyle; + cellStyle.dxfFill = tableStyle.HeaderRow.Style.Fill; } - else + if (table.ShowHeader && tableRow == 0) { - var tmpNode = table.WorkSheet.Workbook.StylesXml.CreateElement("c:tableStyle"); - tableStyle = new ExcelTableNamedStyle(cell.Worksheet.Workbook.Styles.NameSpaceManager, tmpNode, cell.Worksheet.Workbook.Styles); - tableStyle.SetFromTemplate((TableStyles)table.TableStyle); + cellStyle.dxfFill = tableStyle.HeaderRow.Style.Fill; } - if (tableStyle != null) + else if (table.ShowTotal && range._toRow == cell._fromRow) { - tableRow = cell._fromRow - range._fromRow; - tableCol = cell._fromCol - range._fromCol; - cellStyle.dxfFill = tableStyle.WholeTable.Style.Fill; - if (table.ShowHeader && tableRow == 0) - { - cellStyle.dxfFill = tableStyle.HeaderRow.Style.Fill; - } - else if (table.ShowTotal && range._toRow == cell._fromRow) - { - cellStyle.dxfFill = tableStyle.TotalRow.Style.Fill; - } - else if (table.ShowFirstColumn && tableCol == 0) - { - cellStyle.dxfFill = tableStyle.FirstColumn.Style.Fill; - } - else if (table.ShowLastColumn && range._toCol == cell._fromCol) - { - cellStyle.dxfFill = tableStyle.LastColumn.Style.Fill; - } - else if (table.ShowRowStripes) - { - var fill = (tableRow & 1) == 0 ? tableStyle.SecondRowStripe.Style.Fill : tableStyle.FirstRowStripe.Style.Fill; - if (fill.HasValue) cellStyle.dxfFill = fill; - } - else if (table.ShowColumnStripes) - { - var fill = (tableCol & 1) != 0 ? tableStyle.SecondColumnStripe.Style.Fill : tableStyle.FirstColumnStripe.Style.Fill; - if (fill.HasValue) cellStyle.dxfFill = fill; - } + cellStyle.dxfFill = tableStyle.TotalRow.Style.Fill; + } + else if (table.ShowFirstColumn && tableCol == 0) + { + cellStyle.dxfFill = tableStyle.FirstColumn.Style.Fill; + } + else if (table.ShowLastColumn && range._toCol == cell._fromCol) + { + cellStyle.dxfFill = tableStyle.LastColumn.Style.Fill; + } + else if (table.ShowRowStripes) + { + var fill = (tableRow & 1) == 0 ? tableStyle.SecondRowStripe.Style.Fill : tableStyle.FirstRowStripe.Style.Fill; + if (fill.HasValue) cellStyle.dxfFill = fill; + } + else if (table.ShowColumnStripes) + { + var fill = (tableCol & 1) != 0 ? tableStyle.SecondColumnStripe.Style.Fill : tableStyle.FirstColumnStripe.Style.Fill; + if (fill.HasValue) cellStyle.dxfFill = fill; } } } cellStyle.xfFill = cell.Style.Fill; } - private static void GetBorderStyles(ExcelRangeBase cell, PdfCellStyle cellStyle, PdfCell pcell) + private static void GetBorderStyles(ExcelRangeBase cell, PdfCellStyle cellStyle, PdfCell pcell, Dictionary tableStyleCache) { if (cell != null) { @@ -275,22 +265,11 @@ private static void GetBorderStyles(ExcelRangeBase cell, PdfCellStyle cellStyle, cellStyle.DiagonalUp = false; cellStyle.DiagonalDown = false; } - var tables = cell.Worksheet.Tables.GetIntersectingRanges(cell); + var tables = cell.Worksheet.Tables.GetIntersectingRanges(cell); if (tables.Count > 0) { var table = tables[0].Value; - ExcelTableNamedStyle tableStyle = null; - if (table.TableStyle == TableStyles.Custom) - { - if(!string.IsNullOrEmpty(table.StyleName)) - tableStyle = cell.Worksheet.Workbook.Styles.TableStyles[table.StyleName].As.TableStyle; - } - else - { - var tmpNode = table.WorkSheet.Workbook.StylesXml.CreateElement("c:tableStyle"); - tableStyle = new ExcelTableNamedStyle(cell.Worksheet.Workbook.Styles.NameSpaceManager, tmpNode, cell.Worksheet.Workbook.Styles); - tableStyle.SetFromTemplate((TableStyles)table.TableStyle); - } + ExcelTableNamedStyle tableStyle = GetTableStyle(table, tableStyleCache); if (tableStyle != null) { cellStyle.dxfTop = GetTopBorderItem(cell, cellStyle.xfTop, table, tableStyle, out int topOrder); @@ -400,7 +379,7 @@ private static System.Xml.XmlNode GetTableColumnNode(System.Xml.XmlNode tableNod return null; } - private static PdfCellStyle GetFontStyle(ExcelRangeBase cell, PdfCellStyle cellStyle) + private static PdfCellStyle GetFontStyle(ExcelRangeBase cell, PdfCellStyle cellStyle, Dictionary tableStyleCache) { var cf = cell.ConditionalFormatting.GetConditionalFormattings(); if (cf != null && cf.Count > 0) @@ -427,18 +406,7 @@ private static PdfCellStyle GetFontStyle(ExcelRangeBase cell, PdfCellStyle cellS { var table = tables[0].Value; var range = table.Range; - ExcelTableNamedStyle tableStyle = null; - if (table.TableStyle == TableStyles.Custom) - { - if (!string.IsNullOrEmpty(table.StyleName)) - tableStyle = cell.Worksheet.Workbook.Styles.TableStyles[table.StyleName].As.TableStyle; - } - else - { - var tmpNode = table.WorkSheet.Workbook.StylesXml.CreateElement("c:tableStyle"); - tableStyle = new ExcelTableNamedStyle(cell.Worksheet.Workbook.Styles.NameSpaceManager, tmpNode, cell.Worksheet.Workbook.Styles); - tableStyle.SetFromTemplate((TableStyles)table.TableStyle); - } + ExcelTableNamedStyle tableStyle = GetTableStyle(table, tableStyleCache); if (tableStyle != null) { int tableRow = cell._fromRow - range._fromRow; @@ -1084,5 +1052,27 @@ internal static class TableEdgeOrder ConditionalFormat = 50, // beats any table element UserSet = 100; // beats CF and table } + + private static ExcelTableNamedStyle GetTableStyle(ExcelTable table, Dictionary cache) + { + if (cache.TryGetValue(table, out var cached)) + return cached; + + ExcelTableNamedStyle tableStyle; + if (table.TableStyle == TableStyles.Custom) + { + tableStyle = table.WorkSheet.Workbook.Styles.TableStyles[table.StyleName].As.TableStyle; + } + else + { + var tmpNode = table.WorkSheet.Workbook.StylesXml.CreateElement("c:tableStyle"); + tableStyle = new ExcelTableNamedStyle( + table.WorkSheet.Workbook.Styles.NameSpaceManager, tmpNode, table.WorkSheet.Workbook.Styles); + tableStyle.SetFromTemplate((TableStyles)table.TableStyle); + } + + cache[table] = tableStyle; + return tableStyle; + } } } From 9e6a5e45f958972bb2f8449e4b433a5fd5ec9c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Thu, 27 Aug 2026 17:11:27 +0200 Subject: [PATCH 14/26] Added simple support for distibuter, justify and centered continious text alignment. --- src/EPPlus.Export.Pdf.Tests/PdfTests.cs | 10 ++++++++++ .../DocumentObjects/PdfContentStream.cs | 2 ++ src/EPPlus.Export.Pdf/Layout/PdfCellContentLayout.cs | 5 +++++ 3 files changed, 17 insertions(+) diff --git a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs index a566064de5..e4b3e03c0f 100644 --- a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs +++ b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs @@ -761,5 +761,15 @@ public void EachWorksheetUsesItsOwnPaperSize() } } + [TestMethod] + public void HeaderFooterTest1() + { + using var p = OpenTemplatePackage("1.06-Salesreport.xlsx"); + var ws = p.Workbook.Worksheets[0]; + string path = _pdfPath + "HeaderFooterTest1.pdf"; + ws.SaveAsPdf(path); + Assert.IsTrue(File.Exists(path), "PDF file was not created."); + AssertLooksLikePdf(File.ReadAllBytes(path)); + } } } diff --git a/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs b/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs index cdddc3a9d5..854fa67d28 100644 --- a/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs +++ b/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs @@ -120,6 +120,8 @@ public void AddText(PdfCellContentLayout cell, Vector2 position, double textRota lineOffsetX = line0Width - line.Width; break; case ExcelHorizontalAlignment.Center: + case ExcelHorizontalAlignment.CenterContinuous: + case ExcelHorizontalAlignment.Distributed: lineOffsetX = (line0Width - line.Width) / 2d; break; } diff --git a/src/EPPlus.Export.Pdf/Layout/PdfCellContentLayout.cs b/src/EPPlus.Export.Pdf/Layout/PdfCellContentLayout.cs index 76d47b756c..bbd4640d8f 100644 --- a/src/EPPlus.Export.Pdf/Layout/PdfCellContentLayout.cs +++ b/src/EPPlus.Export.Pdf/Layout/PdfCellContentLayout.cs @@ -81,6 +81,8 @@ private double CalculateVerticalAlignment(string text, double textHeight, double switch (CellAlignmentData.VerticalAlignment) { case ExcelVerticalAlignment.Top: + case ExcelVerticalAlignment.Distributed: + case ExcelVerticalAlignment.Justify: newY = (y + height) - padding - firstAscent; break; case ExcelVerticalAlignment.Center: @@ -110,9 +112,12 @@ private double CalculateHorizontalAlignment(string text, double textLength, doub } break; case ExcelHorizontalAlignment.Left: + case ExcelHorizontalAlignment.Justify: + case ExcelHorizontalAlignment.Distributed: newX = x + padding; break; case ExcelHorizontalAlignment.Center: + case ExcelHorizontalAlignment.CenterContinuous: newX = x + (width - textLength) / 2d; break; case ExcelHorizontalAlignment.Right: From 51db1195ef1051299e95ae117719b7b6e44774f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Fri, 28 Aug 2026 12:49:25 +0200 Subject: [PATCH 15/26] if row height has not bee explicilty set, we do autofit on that row with font size in mind. --- .../RowResize/PdfCalculateRowHeight.cs | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/EPPlus/Export/PdfExport/RowResize/PdfCalculateRowHeight.cs b/src/EPPlus/Export/PdfExport/RowResize/PdfCalculateRowHeight.cs index 0d1421fd8e..8810b92daa 100644 --- a/src/EPPlus/Export/PdfExport/RowResize/PdfCalculateRowHeight.cs +++ b/src/EPPlus/Export/PdfExport/RowResize/PdfCalculateRowHeight.cs @@ -48,28 +48,36 @@ public static void ResizeRange(ref PdfRange range) } int row = range.Range._fromRow + rowIdx; double maxRequired = rowHeight.Height; - bool hasWrappedCell = false; + bool grew = false; for (int colIdx = 0; colIdx < range.ColWidths.Count; colIdx++) { int col = range.Range._fromCol + colIdx; var cell = range.Map[row, col]; if (cell == null || cell.Hidden) continue; - if (cell.Merged) - continue; - if (cell.ContentAligmnet.ShrinkToFit) - continue; - if (!cell.ContentAligmnet.WrapText) - continue; if (cell.TextLines == null || cell.TextLines.Count == 0) continue; + if (cell.ContentAligmnet == null || cell.ContentAligmnet.ShrinkToFit) + continue; - hasWrappedCell = true; - double required = GetRequiredHeightFromLines(cell); + double required; + if (cell.Merged) + { + if (cell.MergedAddress == null || cell.MergedAddress.Start.Row != cell.MergedAddress.End.Row) + continue; + required = GetMaxLineHeight(cell); + } + else + { + required = GetRequiredHeightFromLines(cell); + } if (required > maxRequired) + { maxRequired = required; + grew = true; + } } - if (hasWrappedCell) + if (grew) { rowHeight.Height = maxRequired; range.RowHeights[rowIdx] = rowHeight; @@ -88,5 +96,16 @@ private static double GetRequiredHeightFromLines(PdfCell cell) } return total; } + + private static double GetMaxLineHeight(PdfCell cell) + { + double max = 0d; + foreach (var line in cell.TextLines) + { + double h = line.LargestAscent + line.LargestDescent; + if (h > max) max = h; + } + return max; + } } } From 7412c9a1bc1462a8e1e32140410cb061c02fdc3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Fri, 28 Aug 2026 13:13:20 +0200 Subject: [PATCH 16/26] fixed comments and notes not having header and footer. --- src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index 16309e94fb..baabb200c0 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -120,6 +120,11 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio { var map = pages[j].Map[row, col]; MergedCellDrawInfo info = new MergedCellDrawInfo(); + if (map.Hidden && !map.Merged) + { + x += map.ColumnWidth; + continue; + } //Merged Cell if (map.Merged) { @@ -707,6 +712,7 @@ internal static List GetPages(PdfPageSettings[] sheetSettings, PdfWorkshe var pages = GetNumberOfPages(pageSettings, pdfSheet, ref pdfSheet.CommentsAndNotes); pages = AssignRangeToPages(pageSettings, pdfSheet.CommentsAndNotes, pages); pages = MapPage(pdfSheet.CommentsAndNotes, pages); + pages = GetHeaderFooter(pdfSheet.CommentsAndNotes, pages, pdfSheet); pageSettings.ShowHeadings = savedShowHeadings; pages.IsCommentsPage = true; pages.Settings = pageSettings; From 6061b9061ff293cb9fbbc96a3ca136367b4d91ce Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Fri, 28 Aug 2026 13:29:54 +0200 Subject: [PATCH 17/26] Added test --- src/EPPlus.Export.Pdf.Tests/PdfTests.cs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs index 141f00e955..3e700671ba 100644 --- a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs +++ b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs @@ -21,7 +21,6 @@ Date Author Change using System.Globalization; using System.Text; using System.Text.RegularExpressions; -using FakeItEasy.Configuration; namespace EPPlusTest.PDF { @@ -761,15 +760,5 @@ public void EachWorksheetUsesItsOwnPaperSize() Assert.AreEqual(PdfPageSize.A3.HeightPu, h2, "Page 2 should be A3, not sheet 1's A4."); } } - - [TestMethod] - public void ColLargerThanPrintableArea() - { - using(var p = OpenTemplatePackage("CenterOnPagePdf.xlsx")) - { - var ms = p.Workbook; - ms.SaveAsPdf(_pdfPath + "test.pdf") -; } - } } } From 4a54ce8bff0825c51336aea7390e9cb1dfd955ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Fri, 28 Aug 2026 15:35:08 +0200 Subject: [PATCH 18/26] Changed radial gradients to box gradients. --- .../PdfPostScriptCalculatorFunction.cs | 112 ++++++++++++++++++ .../Shadings/PdfFunctionBasedShading.cs | 54 +++++++++ src/EPPlus.Export.Pdf/ExcelPdf.cs | 15 ++- .../Resources/PdfShadingResource.cs | 8 +- 4 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 src/EPPlus.Export.Pdf/DocumentObjects/Functions/PdfPostScriptCalculatorFunction.cs create mode 100644 src/EPPlus.Export.Pdf/DocumentObjects/Shadings/PdfFunctionBasedShading.cs diff --git a/src/EPPlus.Export.Pdf/DocumentObjects/Functions/PdfPostScriptCalculatorFunction.cs b/src/EPPlus.Export.Pdf/DocumentObjects/Functions/PdfPostScriptCalculatorFunction.cs new file mode 100644 index 0000000000..2bd487f7f8 --- /dev/null +++ b/src/EPPlus.Export.Pdf/DocumentObjects/Functions/PdfPostScriptCalculatorFunction.cs @@ -0,0 +1,112 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 27/11/2025 EPPlus Software AB EPPlus 9 + *************************************************************************************************/ +using EPPlus.Export.Pdf.Helpers; +using EPPlus.Export.Pdf.Layout; +using System.Drawing; +using System.IO; +using System.Text; + +namespace EPPlus.Export.Pdf.DocumentObjects.Functions +{ + /// + /// FunctionType 4 (PostScript calculator). Maps a 2-D point (u,v) in the unit domain to an + /// RGB colour using the "box" (rectangular) gradient parameter Excel uses for path gradients: + /// t = max(|u-fx|/dx, |v-fy|/dy). Unlike the Type 2/3 functions, a Type 4 function is a + /// stream object, so it must be an indirect object and be referenced by the shading (N 0 R). + /// + internal class PdfPostScriptCalculatorFunction : PdfFunction + { + private readonly string _code; + + public PdfPostScriptCalculatorFunction(int objectNumber, PdfCellGradientFillData gradientFillData, int version = 0) + : base(objectNumber, version) + { + _code = BuildBoxGradientCode(gradientFillData); + } + + private static string BuildBoxGradientCode(PdfCellGradientFillData g) + { + // Focus point (fx,fy) and half-extents (dx,dy) in the shading's unit domain (v is "up"). + GetFocus(g, out double fx, out double fy, out double dx, out double dy); + + // Colours are normalised to 0..1 for DeviceRGB. Color1 = focus (t=0), Color2 = edge (t=1). + double r0 = g.Color1.GetR(), g0 = g.Color1.GetG(), b0 = g.Color1.GetB(); + double r1 = g.Color2.GetR(), g1 = g.Color2.GetG(), b1 = g.Color2.GetB(); + + var sb = new StringBuilder(); + sb.Append("{ "); + // Stack in: u v (v on top). Compute t = max(|u-fx|/dx, |v-fy|/dy), then clamp to [0,1]. + sb.Append($"{fy.ToPdfString()} sub abs {dy.ToPdfString()} div "); // |v-fy|/dy + sb.Append("exch "); + sb.Append($"{fx.ToPdfString()} sub abs {dx.ToPdfString()} div "); // |u-fx|/dx + sb.Append("2 copy lt { exch } if pop "); // -> max + sb.Append("dup 1 gt { pop 1 } if "); // clamp high (abs keeps >= 0) + + if (!g.Color3.Equals(Color.Empty)) + { + double rm = g.Color3.GetR(), gm = g.Color3.GetG(), bm = g.Color3.GetB(); + sb.Append("dup 0.5 le { 2 mul "); // t in [0,0.5] -> s = t*2 + AppendRamp(sb, r0, g0, b0, rm, gm, bm); // Color1 -> Color3 + sb.Append("} { 0.5 sub 2 mul "); // t in (0.5,1] -> s = (t-0.5)*2 + AppendRamp(sb, rm, gm, bm, r1, g1, b1); // Color3 -> Color2 + sb.Append("} ifelse "); + } + else + { + AppendRamp(sb, r0, g0, b0, r1, g1, b1); // Color1 -> Color2 + } + sb.Append("}"); + return sb.ToString(); + } + + // Given parameter s in [0,1] on the stack, leave R G B where each = c0 + s*(c1 - c0). + private static void AppendRamp(StringBuilder sb, + double r0, double g0, double b0, double r1, double g1, double b1) + { + sb.Append($"dup {r1.ToPdfString()} {r0.ToPdfString()} sub mul {r0.ToPdfString()} add exch "); + sb.Append($"dup {g1.ToPdfString()} {g0.ToPdfString()} sub mul {g0.ToPdfString()} add exch "); + sb.Append($"{b1.ToPdfString()} {b0.ToPdfString()} sub mul {b0.ToPdfString()} add "); + } + + // The five Excel presets (four corners + centre). fillToRect insets are stored in + // Left/Right/Top/Bottom; Top==0 means the focus is at the top edge (v == 1 in unit space). + private static void GetFocus(PdfCellGradientFillData g, out double fx, out double fy, out double dx, out double dy) + { + if (g.Left == 0.5 && g.Right == 0.5 && g.Top == 0.5 && g.Bottom == 0.5) + { + fx = 0.5; fy = 0.5; dx = 0.5; dy = 0.5; // from centre + } + else + { + fx = g.Left == 0 ? 0d : 1d; // left inset 0 -> focus at left edge + fy = g.Top == 0 ? 1d : 0d; // top inset 0 -> focus at top edge (v up) + dx = 1d; dy = 1d; // from a corner + } + } + + internal override string RenderDictionary() + { + return "<< /FunctionType 4 /Domain [ 0 1 0 1 ] /Range [ 0 1 0 1 0 1 ] " + + $"/Length {Encoding.ASCII.GetByteCount(_code)} >>\nstream\n{_code}\nendstream"; + } + + internal override void RenderDictionary(BinaryWriter bw) + { + var bytes = Encoding.ASCII.GetBytes(_code); + WriteAscii(bw, "<< /FunctionType 4 /Domain [ 0 1 0 1 ] /Range [ 0 1 0 1 0 1 ] " + + $"/Length {bytes.Length} >>\nstream\n"); + bw.Write(bytes); + WriteAscii(bw, "\nendstream"); + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Export.Pdf/DocumentObjects/Shadings/PdfFunctionBasedShading.cs b/src/EPPlus.Export.Pdf/DocumentObjects/Shadings/PdfFunctionBasedShading.cs new file mode 100644 index 0000000000..b822c2612c --- /dev/null +++ b/src/EPPlus.Export.Pdf/DocumentObjects/Shadings/PdfFunctionBasedShading.cs @@ -0,0 +1,54 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 27/11/2025 EPPlus Software AB EPPlus 9 + *************************************************************************************************/ +using EPPlus.Export.Pdf.Helpers; +using EPPlus.Export.Pdf.Layout; +using System.IO; +using System.Linq; +using System.Text; + +namespace EPPlus.Export.Pdf.DocumentObjects.Shadings +{ + /// + /// ShadingType 1 (function-based). The colour at each point comes from a 2-in / 3-out + /// function of (u,v) evaluated over the unit Domain; the shading pattern's Matrix maps that + /// unit square onto the cell. Used for Excel path (rectangular / "box") gradients, which have + /// no native PDF shading. The Function is a stream object referenced indirectly. + /// + internal class PdfFunctionBasedShading : PdfShading + { + internal double[] Domain = [0d, 1d, 0d, 1d]; + internal int FunctionObjectNumber; + + public PdfFunctionBasedShading(int objectNumber, PdfCellGradientFillData gradientFillData, int version = 0) + : base(objectNumber, version) + { + ColorSpace = DeviceColorSpace.DeviceRGB; + } + + private string Build() + { + var domainStr = string.Join(" ", Domain.Select(w => w.ToPdfString()).ToArray()); + var sb = new StringBuilder(); + sb.AppendFormat($"<< /Type /Shading\n" + + $" /ShadingType 1\n" + + $" /ColorSpace /{ColorSpace.ToString()}\n" + + $" /Domain [ {domainStr} ]\n" + + $" /Function {FunctionObjectNumber} 0 R >>"); + return sb.ToString(); + } + + internal override string RenderDictionary() => Build(); + + internal override void RenderDictionary(BinaryWriter bw) => WriteAscii(bw, Build()); + } +} \ No newline at end of file diff --git a/src/EPPlus.Export.Pdf/ExcelPdf.cs b/src/EPPlus.Export.Pdf/ExcelPdf.cs index 64e96421ed..f86a6897a1 100644 --- a/src/EPPlus.Export.Pdf/ExcelPdf.cs +++ b/src/EPPlus.Export.Pdf/ExcelPdf.cs @@ -11,6 +11,7 @@ Date Author Change 27/11/2025 EPPlus Software AB EPPlus 9 *************************************************************************************************/ using EPPlus.Export.Pdf.DocumentObjects; +using EPPlus.Export.Pdf.DocumentObjects.Functions; using EPPlus.Export.Pdf.Enums; using EPPlus.Export.Pdf.Layout; using EPPlus.Export.Pdf.Resources; @@ -111,7 +112,19 @@ private void AddShadingsData() { foreach (var shading in _dictionaries.Shadings) { - _document.Add(shading.Value.GetShadingObject(_document.Count + 1)); + var gradient = shading.Value.CellFillData.GradientFillData; + if (gradient != null && gradient.GradientType == ExcelFillGradientType.Path) + { + // Box gradient: ShadingType 1 + Type 4 PostScript function. A Type 4 function is + // a stream object, so it must be its own indirect object referenced by the shading. + var boxFunction = new PdfPostScriptCalculatorFunction(_document.Count + 1, gradient); + _document.Add(boxFunction); + _document.Add(shading.Value.GetShadingObject(_document.Count + 1, boxFunction.objectNumber)); + } + else + { + _document.Add(shading.Value.GetShadingObject(_document.Count + 1)); + } _document.Add(shading.Value.GetShadingPatternObject(_document.Count + 1, _document.Count)); int label = _dictionaries.Patterns.Last().Value.labelNumber + 1; var pr = new PdfPatternResource(label, shading.Value.CellFillData); diff --git a/src/EPPlus.Export.Pdf/Resources/PdfShadingResource.cs b/src/EPPlus.Export.Pdf/Resources/PdfShadingResource.cs index ba565f6b16..f82a8f1f50 100644 --- a/src/EPPlus.Export.Pdf/Resources/PdfShadingResource.cs +++ b/src/EPPlus.Export.Pdf/Resources/PdfShadingResource.cs @@ -28,7 +28,7 @@ public PdfShadingResource(int labelNumber, PdfCellFillData cellFillData) CellFillData = cellFillData; } - public PdfShading GetShadingObject(int objectNumber, int version = 0) + public PdfShading GetShadingObject(int objectNumber, int functionObjectNumber = 0, int version = 0) { this.objectNumber = objectNumber; if (CellFillData.GradientFillData != null) @@ -41,9 +41,9 @@ public PdfShading GetShadingObject(int objectNumber, int version = 0) } else if (CellFillData.GradientFillData.GradientType == ExcelFillGradientType.Path) { - var prs = new PdfRadialShading(objectNumber, CellFillData.GradientFillData, version); - prs.Coords = CellFillData.GradientFillData.coords; - return prs; + var fbs = new PdfFunctionBasedShading(objectNumber, CellFillData.GradientFillData, version); + fbs.FunctionObjectNumber = functionObjectNumber; + return fbs; } } return null; From 4428c35eecfe01c52c68ebf5f92b2359d499dbd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Mon, 31 Aug 2026 13:12:14 +0200 Subject: [PATCH 19/26] double border fix progress --- .../DocumentObjects/PdfBorderRenderer.cs | 423 +++++++++++------- src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs | 6 + .../Export/PdfExport/Layout/PdfLayout.cs | 44 ++ 3 files changed, 323 insertions(+), 150 deletions(-) diff --git a/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs b/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs index de630d55ac..aa56809c5c 100644 --- a/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs +++ b/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs @@ -196,43 +196,238 @@ private void DrawBasicBorder(PdfContentStream contentStream, PdfCellBorderData b contentStream.AddCommand(dash); } + //private void DrawDoubleBorder(PdfContentStream contentStream, PdfCellBorderData border, double x1, double y1, double x2, double y2) + //{ + // var ix1 = x1; + // var ix2 = x2; + // var iy1 = y1; + // var iy2 = y2; + // var ox1 = x1; + // var ox2 = x2; + // var oy1 = y1; + // var oy2 = y2; + + // var DiagonalUpFactor = 0d; + // var DiagonalDownFactor = 0d; + + // if (border.LineType == LineType.Top) + // { + // ////Inner Line + // //ix1 = x1; + // //ix2 = x2; + // //iy1 = y1 - (PdfCellBorderData.Hair / 0.65d); + // //iy2 = y2 - (PdfCellBorderData.Hair / 0.65d); + // //if (Left.BorderStyle != ExcelBorderStyle.None) ix1 = x1 + 0.7d; + // //if (Right.BorderStyle != ExcelBorderStyle.None) ix2 = x2 - 0.7d; + // //if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) ix2 = x2 - 4.87d; + // //if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) ix1 = x1 + 4.87d; + + // ix1 = x1; + // ix2 = x2; + // iy1 = y1 - (PdfCellBorderData.Hair / 0.65d); + // iy2 = y2 - (PdfCellBorderData.Hair / 0.65d); + // if (Left.BorderStyle != ExcelBorderStyle.None) ix1 = x1 + 0.7d; + // if (Right.BorderStyle != ExcelBorderStyle.None) ix2 = x2 - 0.7d; + + // // For a multi-column merged cell the diagonal endpoint sits at the far + // // corner of the full merge, not at the right/left edge of this single + // // cell column. Applying the indent here would create a gap at the wrong + // // position along the top border, so suppress it. + // bool multiColMerge = IsMerged && info.Width > Width + 0.5d; + // if (!multiColMerge) + // { + // if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) ix2 = x2 - 4.87d; + // if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) ix1 = x1 + 4.87d; + // } + + // //Outer Line + // ox1 = x1; + // ox2 = x2; + // oy1 = y1 + (PdfCellBorderData.Hair / 0.65d); + // oy2 = y2 + (PdfCellBorderData.Hair / 0.65d); + // if (Left.BorderStyle != ExcelBorderStyle.None) ox1 = x1 - 0.7d; + // if (Right.BorderStyle != ExcelBorderStyle.None) ox2 = x2 + 0.7d; + // } + // if (border.LineType == LineType.Bottom) + // { + // ix1 = x1; + // ix2 = x2; + // iy1 = y1 + (PdfCellBorderData.Hair / 0.65d); + // iy2 = y2 + (PdfCellBorderData.Hair / 0.65d); + // if (Left.BorderStyle != ExcelBorderStyle.None) ix1 = x1 + 0.7d; + // if (Right.BorderStyle != ExcelBorderStyle.None) ix2 = x2 - 0.7d; + // if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) ix1 = x1 + 4.87d; + // if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) ix2 = x2 - 4.87d; + + // ox1 = x1; + // ox2 = x2; + // oy1 = y1 - (PdfCellBorderData.Hair / 0.65d); + // oy2 = y2 - (PdfCellBorderData.Hair / 0.65d); + // if (Left.BorderStyle != ExcelBorderStyle.None) ox1 = x1 - 0.7d; + // if (Right.BorderStyle != ExcelBorderStyle.None) ox2 = x2 + 0.7d; + // } + // else if (border.LineType == LineType.Left) + // { + // //DiagonalUpFactor = 0.5d; + // //DiagonalDownFactor = 0.5d; + // //ix1 = x1 + (PdfCellBorderData.Hair / 0.65d); + // //ix2 = x2 + (PdfCellBorderData.Hair / 0.65d); + // //iy1 = y1; + // //iy2 = y2; + // //if (Top.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - 0.7d; + // //if (Bottom.BorderStyle != ExcelBorderStyle.None) iy1 = y1 + 0.7d; + // //if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) iy1 = y1 + 0.7d + DiagonalUpFactor; + // //if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - 0.7d - DiagonalDownFactor; + + // DiagonalUpFactor = 0.5d; + // DiagonalDownFactor = 0.5d; + // ix1 = x1 + (PdfCellBorderData.Hair / 0.65d); + // ix2 = x2 + (PdfCellBorderData.Hair / 0.65d); + // iy1 = y1; + // iy2 = y2; + // if (Top.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - 0.7d; + // if (Bottom.BorderStyle != ExcelBorderStyle.None) iy1 = y1 + 0.7d; + + // // For a multi-row merged cell the diagonal endpoint sits at the far + // // corner of the full merge height, not at the bottom/top edge of this + // // single row. Suppress the indent to avoid a gap at the wrong position. + // bool multiRowMerge = IsMerged && info.Height > Height + 0.5d; + // if (!multiRowMerge) + // { + // if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) iy1 = y1 + 0.7d + DiagonalUpFactor; + // if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - 0.7d - DiagonalDownFactor; + // } + + // ox1 = x1 - (PdfCellBorderData.Hair / 0.65d); + // ox2 = x2 - (PdfCellBorderData.Hair / 0.65d); + // oy1 = y1; + // oy2 = y2; + // if (Top.BorderStyle != ExcelBorderStyle.None) oy2 = y2 + 0.7d; + // if (Bottom.BorderStyle != ExcelBorderStyle.None) oy1 = y1 - 0.7d; + // } + // else if (border.LineType == LineType.Right) + // { + // DiagonalUpFactor = 0.5d; + // DiagonalDownFactor = 0.5d; + // ix1 = x1 - (PdfCellBorderData.Hair / 0.65d); + // ix2 = x2 - (PdfCellBorderData.Hair / 0.65d); + // iy1 = y1; + // iy2 = y2; + // if (Top.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - 0.7d; + // if (Bottom.BorderStyle != ExcelBorderStyle.None) iy1 = y1 + 0.7d; + // if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - 0.7d - DiagonalUpFactor; + // if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) iy1 = y1 + 0.7d + DiagonalDownFactor; + + // ox1 = x1 + (PdfCellBorderData.Hair / 0.65d); + // ox2 = x2 + (PdfCellBorderData.Hair / 0.65d); + // oy1 = y1; + // oy2 = y2; + // if (Top.BorderStyle != ExcelBorderStyle.None) oy2 = y2 + 0.7d; + // if (Bottom.BorderStyle != ExcelBorderStyle.None) oy1 = y1 - 0.7d; + // } + // else if (border.LineType == LineType.DiagonalUp) + // { + // ix1 = x1 + 0.6d; + // ix2 = x2 - 4.87d; + // iy1 = y1 + 0.98d; + // iy2 = y2 - 0.765d; + // ox1 = x1 + 4.87d; + // ox2 = x2 - 0.6d; + // oy1 = y1 + 0.765d; + // oy2 = y2 - 0.98d; + // } + // else if (border.LineType == LineType.DiagonalDown) + // { + // ix1 = x1 + 0.6d; + // ix2 = x2 - 4.87d; + // iy1 = y1 - 0.98d; + // iy2 = y2 + 0.765d; + // ox1 = x1 + 4.87d; + // ox2 = x2 - 0.6d; + // oy1 = y1 - 0.765d; + // oy2 = y2 + 0.98d; + // } + // contentStream.AddCommand(border.BorderColor.ToStrokeCommand()); + // contentStream.AddCommand($"{PdfCellBorderData.Hair.ToPdfString()} w"); + // contentStream.AddCommand(border.BorderStyle != ExcelBorderStyle.Dotted ? (border.LineType == LineType.DiagonalUp || border.LineType == LineType.DiagonalDown ? "0 J" : "2 J") : "1 J"); + // contentStream.AddCommand(PdfCellBorderData.NoDash); + // if ((border.LineType == LineType.DiagonalUp || border.LineType == LineType.DiagonalDown) && DiagonalUp.BorderStyle != ExcelBorderStyle.None && DiagonalDown.BorderStyle != ExcelBorderStyle.None) + // { + + // //break to method. + // double dx = ix2 - ix1; + // double dy = iy2 - iy1; + // double length = System.Math.Sqrt(dx * dx + dy * dy); + + // double ux = dx / length; + // double uy = dy / length; + + // double midX = (ix1 + ix2) / 2.0; + // double midY = (iy1 + iy2) / 2.0; + + // double leftDist = 0.25; + // double rightDist = 2.15; + + // double xA = midX - leftDist * ux; + // double yA = midY - leftDist * uy; + // double xB = midX + rightDist * ux; + // double yB = midY + rightDist * uy; + + // contentStream.AddCommand($"{ix1.ToPdfStringF4()} {iy1.ToPdfStringF4()} m"); + // contentStream.AddCommand($"{xA.ToPdfStringF4()} {yA.ToPdfStringF4()} l"); + // contentStream.AddCommand($"{xB.ToPdfStringF4()} {yB.ToPdfStringF4()} m"); + // contentStream.AddCommand($"{ix2.ToPdfStringF4()} {iy2.ToPdfStringF4()} l"); + + + // dx = ox2 - ox1; + // dy = oy2 - oy1; + // length = System.Math.Sqrt(dx * dx + dy * dy); + + // ux = dx / length; + // uy = dy / length; + + // midX = (ox1 + ox2) / 2.0; + // midY = (oy1 + oy2) / 2.0; + + // leftDist = 2.15; + // rightDist = 0.25; + + // xA = midX - leftDist * ux; + // yA = midY - leftDist * uy; + // xB = midX + rightDist * ux; + // yB = midY + rightDist * uy; + + // contentStream.AddCommand($"{ox1.ToPdfStringF4()} {oy1.ToPdfStringF4()} m"); + // contentStream.AddCommand($"{xA.ToPdfStringF4()} {yA.ToPdfStringF4()} l"); + // contentStream.AddCommand($"{xB.ToPdfStringF4()} {yB.ToPdfStringF4()} m"); + // contentStream.AddCommand($"{ox2.ToPdfStringF4()} {oy2.ToPdfStringF4()} l"); + // } + // else + // { + // contentStream.AddCommand($"{ix1.ToPdfStringF4()} {iy1.ToPdfStringF4()} m"); + // contentStream.AddCommand($"{ix2.ToPdfStringF4()} {iy2.ToPdfStringF4()} l"); + // contentStream.AddCommand($"{ox1.ToPdfStringF4()} {oy1.ToPdfStringF4()} m"); + // contentStream.AddCommand($"{ox2.ToPdfStringF4()} {oy2.ToPdfStringF4()} l"); + // } + // contentStream.AddCommand("S"); + //} private void DrawDoubleBorder(PdfContentStream contentStream, PdfCellBorderData border, double x1, double y1, double x2, double y2) { - var ix1 = x1; - var ix2 = x2; - var iy1 = y1; - var iy2 = y2; - var ox1 = x1; - var ox2 = x2; - var oy1 = y1; - var oy2 = y2; + var ix1 = x1; var ix2 = x2; var iy1 = y1; var iy2 = y2; + var ox1 = x1; var ox2 = x2; var oy1 = y1; var oy2 = y2; var DiagonalUpFactor = 0d; var DiagonalDownFactor = 0d; + const double G = PdfCellBorderData.DoubleOffset; // half-gap AND corner miter amount + if (border.LineType == LineType.Top) { - ////Inner Line - //ix1 = x1; - //ix2 = x2; - //iy1 = y1 - (PdfCellBorderData.Hair / 0.65d); - //iy2 = y2 - (PdfCellBorderData.Hair / 0.65d); - //if (Left.BorderStyle != ExcelBorderStyle.None) ix1 = x1 + 0.7d; - //if (Right.BorderStyle != ExcelBorderStyle.None) ix2 = x2 - 0.7d; - //if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) ix2 = x2 - 4.87d; - //if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) ix1 = x1 + 4.87d; - - ix1 = x1; - ix2 = x2; - iy1 = y1 - (PdfCellBorderData.Hair / 0.65d); - iy2 = y2 - (PdfCellBorderData.Hair / 0.65d); - if (Left.BorderStyle != ExcelBorderStyle.None) ix1 = x1 + 0.7d; - if (Right.BorderStyle != ExcelBorderStyle.None) ix2 = x2 - 0.7d; - - // For a multi-column merged cell the diagonal endpoint sits at the far - // corner of the full merge, not at the right/left edge of this single - // cell column. Applying the indent here would create a gap at the wrong - // position along the top border, so suppress it. + // start = left end (x1), end = right end (x2) + iy1 = y1 - G; iy2 = y2 - G; + if (border.PerpAtStart) ix1 = x1 + G; + if (border.PerpAtEnd) ix2 = x2 - G; + bool multiColMerge = IsMerged && info.Width > Width + 0.5d; if (!multiColMerge) { @@ -240,163 +435,90 @@ private void DrawDoubleBorder(PdfContentStream contentStream, PdfCellBorderData if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) ix1 = x1 + 4.87d; } - //Outer Line - ox1 = x1; - ox2 = x2; - oy1 = y1 + (PdfCellBorderData.Hair / 0.65d); - oy2 = y2 + (PdfCellBorderData.Hair / 0.65d); - if (Left.BorderStyle != ExcelBorderStyle.None) ox1 = x1 - 0.7d; - if (Right.BorderStyle != ExcelBorderStyle.None) ox2 = x2 + 0.7d; + oy1 = y1 + G; oy2 = y2 + G; + if (border.PerpAtStart) ox1 = x1 - G; + if (border.PerpAtEnd) ox2 = x2 + G; } if (border.LineType == LineType.Bottom) { - ix1 = x1; - ix2 = x2; - iy1 = y1 + (PdfCellBorderData.Hair / 0.65d); - iy2 = y2 + (PdfCellBorderData.Hair / 0.65d); - if (Left.BorderStyle != ExcelBorderStyle.None) ix1 = x1 + 0.7d; - if (Right.BorderStyle != ExcelBorderStyle.None) ix2 = x2 - 0.7d; + iy1 = y1 + G; iy2 = y2 + G; + if (border.PerpAtStart) ix1 = x1 + G; + if (border.PerpAtEnd) ix2 = x2 - G; if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) ix1 = x1 + 4.87d; if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) ix2 = x2 - 4.87d; - ox1 = x1; - ox2 = x2; - oy1 = y1 - (PdfCellBorderData.Hair / 0.65d); - oy2 = y2 - (PdfCellBorderData.Hair / 0.65d); - if (Left.BorderStyle != ExcelBorderStyle.None) ox1 = x1 - 0.7d; - if (Right.BorderStyle != ExcelBorderStyle.None) ox2 = x2 + 0.7d; + oy1 = y1 - G; oy2 = y2 - G; + if (border.PerpAtStart) ox1 = x1 - G; + if (border.PerpAtEnd) ox2 = x2 + G; } else if (border.LineType == LineType.Left) { - //DiagonalUpFactor = 0.5d; - //DiagonalDownFactor = 0.5d; - //ix1 = x1 + (PdfCellBorderData.Hair / 0.65d); - //ix2 = x2 + (PdfCellBorderData.Hair / 0.65d); - //iy1 = y1; - //iy2 = y2; - //if (Top.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - 0.7d; - //if (Bottom.BorderStyle != ExcelBorderStyle.None) iy1 = y1 + 0.7d; - //if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) iy1 = y1 + 0.7d + DiagonalUpFactor; - //if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - 0.7d - DiagonalDownFactor; - - DiagonalUpFactor = 0.5d; - DiagonalDownFactor = 0.5d; - ix1 = x1 + (PdfCellBorderData.Hair / 0.65d); - ix2 = x2 + (PdfCellBorderData.Hair / 0.65d); - iy1 = y1; - iy2 = y2; - if (Top.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - 0.7d; - if (Bottom.BorderStyle != ExcelBorderStyle.None) iy1 = y1 + 0.7d; - - // For a multi-row merged cell the diagonal endpoint sits at the far - // corner of the full merge height, not at the bottom/top edge of this - // single row. Suppress the indent to avoid a gap at the wrong position. + // start = bottom end (y1), end = top end (y2) + DiagonalUpFactor = 0.5d; DiagonalDownFactor = 0.5d; + ix1 = x1 + G; ix2 = x2 + G; + if (border.PerpAtEnd) iy2 = y2 - G; + if (border.PerpAtStart) iy1 = y1 + G; + bool multiRowMerge = IsMerged && info.Height > Height + 0.5d; if (!multiRowMerge) { - if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) iy1 = y1 + 0.7d + DiagonalUpFactor; - if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - 0.7d - DiagonalDownFactor; + if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) iy1 = y1 + G + DiagonalUpFactor; + if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - G - DiagonalDownFactor; } - ox1 = x1 - (PdfCellBorderData.Hair / 0.65d); - ox2 = x2 - (PdfCellBorderData.Hair / 0.65d); - oy1 = y1; - oy2 = y2; - if (Top.BorderStyle != ExcelBorderStyle.None) oy2 = y2 + 0.7d; - if (Bottom.BorderStyle != ExcelBorderStyle.None) oy1 = y1 - 0.7d; + ox1 = x1 - G; ox2 = x2 - G; + if (border.PerpAtEnd) oy2 = y2 + G; + if (border.PerpAtStart) oy1 = y1 - G; } else if (border.LineType == LineType.Right) { - DiagonalUpFactor = 0.5d; - DiagonalDownFactor = 0.5d; - ix1 = x1 - (PdfCellBorderData.Hair / 0.65d); - ix2 = x2 - (PdfCellBorderData.Hair / 0.65d); - iy1 = y1; - iy2 = y2; - if (Top.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - 0.7d; - if (Bottom.BorderStyle != ExcelBorderStyle.None) iy1 = y1 + 0.7d; - if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - 0.7d - DiagonalUpFactor; - if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) iy1 = y1 + 0.7d + DiagonalDownFactor; - - ox1 = x1 + (PdfCellBorderData.Hair / 0.65d); - ox2 = x2 + (PdfCellBorderData.Hair / 0.65d); - oy1 = y1; - oy2 = y2; - if (Top.BorderStyle != ExcelBorderStyle.None) oy2 = y2 + 0.7d; - if (Bottom.BorderStyle != ExcelBorderStyle.None) oy1 = y1 - 0.7d; + DiagonalUpFactor = 0.5d; DiagonalDownFactor = 0.5d; + ix1 = x1 - G; ix2 = x2 - G; + if (border.PerpAtEnd) iy2 = y2 - G; + if (border.PerpAtStart) iy1 = y1 + G; + if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - G - DiagonalUpFactor; + if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) iy1 = y1 + G + DiagonalDownFactor; + + ox1 = x1 + G; ox2 = x2 + G; + if (border.PerpAtEnd) oy2 = y2 + G; + if (border.PerpAtStart) oy1 = y1 - G; } else if (border.LineType == LineType.DiagonalUp) { - ix1 = x1 + 0.6d; - ix2 = x2 - 4.87d; - iy1 = y1 + 0.98d; - iy2 = y2 - 0.765d; - ox1 = x1 + 4.87d; - ox2 = x2 - 0.6d; - oy1 = y1 + 0.765d; - oy2 = y2 - 0.98d; + ix1 = x1 + 0.6d; ix2 = x2 - 4.87d; iy1 = y1 + 0.98d; iy2 = y2 - 0.765d; + ox1 = x1 + 4.87d; ox2 = x2 - 0.6d; oy1 = y1 + 0.765d; oy2 = y2 - 0.98d; } else if (border.LineType == LineType.DiagonalDown) { - ix1 = x1 + 0.6d; - ix2 = x2 - 4.87d; - iy1 = y1 - 0.98d; - iy2 = y2 + 0.765d; - ox1 = x1 + 4.87d; - ox2 = x2 - 0.6d; - oy1 = y1 - 0.765d; - oy2 = y2 + 0.98d; + ix1 = x1 + 0.6d; ix2 = x2 - 4.87d; iy1 = y1 - 0.98d; iy2 = y2 + 0.765d; + ox1 = x1 + 4.87d; ox2 = x2 - 0.6d; oy1 = y1 - 0.765d; oy2 = y2 + 0.98d; } + contentStream.AddCommand(border.BorderColor.ToStrokeCommand()); - contentStream.AddCommand($"{PdfCellBorderData.Hair.ToPdfString()} w"); + contentStream.AddCommand($"{PdfCellBorderData.DoubleWidth.ToPdfString()} w"); contentStream.AddCommand(border.BorderStyle != ExcelBorderStyle.Dotted ? (border.LineType == LineType.DiagonalUp || border.LineType == LineType.DiagonalDown ? "0 J" : "2 J") : "1 J"); contentStream.AddCommand(PdfCellBorderData.NoDash); if ((border.LineType == LineType.DiagonalUp || border.LineType == LineType.DiagonalDown) && DiagonalUp.BorderStyle != ExcelBorderStyle.None && DiagonalDown.BorderStyle != ExcelBorderStyle.None) { - - //break to method. - double dx = ix2 - ix1; - double dy = iy2 - iy1; + double dx = ix2 - ix1, dy = iy2 - iy1; double length = System.Math.Sqrt(dx * dx + dy * dy); - - double ux = dx / length; - double uy = dy / length; - - double midX = (ix1 + ix2) / 2.0; - double midY = (iy1 + iy2) / 2.0; - - double leftDist = 0.25; - double rightDist = 2.15; - - double xA = midX - leftDist * ux; - double yA = midY - leftDist * uy; - double xB = midX + rightDist * ux; - double yB = midY + rightDist * uy; - + double ux = dx / length, uy = dy / length; + double midX = (ix1 + ix2) / 2.0, midY = (iy1 + iy2) / 2.0; + double leftDist = 0.25, rightDist = 2.15; + double xA = midX - leftDist * ux, yA = midY - leftDist * uy; + double xB = midX + rightDist * ux, yB = midY + rightDist * uy; contentStream.AddCommand($"{ix1.ToPdfStringF4()} {iy1.ToPdfStringF4()} m"); contentStream.AddCommand($"{xA.ToPdfStringF4()} {yA.ToPdfStringF4()} l"); contentStream.AddCommand($"{xB.ToPdfStringF4()} {yB.ToPdfStringF4()} m"); contentStream.AddCommand($"{ix2.ToPdfStringF4()} {iy2.ToPdfStringF4()} l"); - - dx = ox2 - ox1; - dy = oy2 - oy1; + dx = ox2 - ox1; dy = oy2 - oy1; length = System.Math.Sqrt(dx * dx + dy * dy); - - ux = dx / length; - uy = dy / length; - - midX = (ox1 + ox2) / 2.0; - midY = (oy1 + oy2) / 2.0; - - leftDist = 2.15; - rightDist = 0.25; - - xA = midX - leftDist * ux; - yA = midY - leftDist * uy; - xB = midX + rightDist * ux; - yB = midY + rightDist * uy; - + ux = dx / length; uy = dy / length; + midX = (ox1 + ox2) / 2.0; midY = (oy1 + oy2) / 2.0; + leftDist = 2.15; rightDist = 0.25; + xA = midX - leftDist * ux; yA = midY - leftDist * uy; + xB = midX + rightDist * ux; yB = midY + rightDist * uy; contentStream.AddCommand($"{ox1.ToPdfStringF4()} {oy1.ToPdfStringF4()} m"); contentStream.AddCommand($"{xA.ToPdfStringF4()} {yA.ToPdfStringF4()} l"); contentStream.AddCommand($"{xB.ToPdfStringF4()} {yB.ToPdfStringF4()} m"); @@ -411,6 +533,7 @@ private void DrawDoubleBorder(PdfContentStream contentStream, PdfCellBorderData } contentStream.AddCommand("S"); } + private void DrawSlantDashDotBorder(PdfContentStream contentStream, PdfCellBorderData border, double x1, double y1, double x2, double y2) { contentStream.AddCommand(border.BorderColor.ToStrokeCommand()); diff --git a/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs b/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs index 5af7cc4015..9d24cd9876 100644 --- a/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs +++ b/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs @@ -65,6 +65,12 @@ internal class PdfCellBorderData public double Y = 0; public bool IsHeading = false; + internal const double DoubleWidth = 0.75d; // weight of each of the two lines + internal const double DoubleOffset = 0.85d; // offset from the gridline; also the corner miter amount + + public bool PerpAtStart = false; // Top/Bottom: left end · Left/Right: bottom end + public bool PerpAtEnd = false; // Top/Bottom: right end · Left/Right: top end + public PdfCellBorderData(LineType LineType) { this.LineType = LineType; diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index baabb200c0..99314b073b 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -209,6 +209,7 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio if (col != addr.Start.Column) border.BorderData.Left.BorderStyle = (EPPlus.Export.Pdf.Enums.ExcelBorderStyle)ExcelBorderStyle.None; if (col != addr.End.Column) border.BorderData.Right.BorderStyle = (EPPlus.Export.Pdf.Enums.ExcelBorderStyle)ExcelBorderStyle.None; } + SetDoubleBorderMiterFlags(pages[j], row, col, border); pageLayout.AddChild(border); } x += map.ColumnWidth; @@ -1606,6 +1607,49 @@ internal static Pages PrecomputeSpillCells(PdfPageSettings pageSettings, PdfRang return pdfPages; } + private static PdfCell CellAt(Page page, int row, int col) + { + if (row < page.FromRow || row > page.ToRow || col < page.FromColumn || col > page.ToColumn) return null; + return page.Map[row, col]; + } + + // A vertical border exists on the gridline to the LEFT of column 'col', in 'row', + // if either cell sharing that gridline segment has the matching side border. + private static bool VBorderAt(Page page, int row, int col) + => CellHasLeftBorder(CellAt(page, row, col)) || CellHasRightBorder(CellAt(page, row, col - 1)); + + // A horizontal border on the gridline BELOW 'row' (between row and row+1), in 'col'. + private static bool HBorderBelow(Page page, int row, int col) + => CellHasBottomBorder(CellAt(page, row, col)) || CellHasTopBorder(CellAt(page, row + 1, col)); + + // A horizontal border on the gridline ABOVE 'row' (between row-1 and row), in 'col'. + private static bool HBorderAbove(Page page, int row, int col) + => CellHasTopBorder(CellAt(page, row, col)) || CellHasBottomBorder(CellAt(page, row - 1, col)); + + // Per-edge, per-end "is there a perpendicular border at this vertex" — checking both + // gridline segments meeting the vertex (this cell + the relevant neighbours). This is + // what lets a double border miter against a partner border owned by an adjacent cell. + private static void SetDoubleBorderMiterFlags(Page page, int row, int col, PdfCellBorderLayout border) + { + var b = border.BorderData; + + // Top edge (gridline above 'row'): ends are left (Start) and right (End). + b.Top.PerpAtStart = VBorderAt(page, row, col) || VBorderAt(page, row - 1, col); + b.Top.PerpAtEnd = VBorderAt(page, row, col + 1) || VBorderAt(page, row - 1, col + 1); + + // Bottom edge (gridline below 'row'). + b.Bottom.PerpAtStart = VBorderAt(page, row, col) || VBorderAt(page, row + 1, col); + b.Bottom.PerpAtEnd = VBorderAt(page, row, col + 1) || VBorderAt(page, row + 1, col + 1); + + // Left edge (gridline left of 'col'): ends are bottom (Start) and top (End). + b.Left.PerpAtStart = HBorderBelow(page, row, col) || HBorderBelow(page, row, col - 1); + b.Left.PerpAtEnd = HBorderAbove(page, row, col) || HBorderAbove(page, row, col - 1); + + // Right edge (gridline right of 'col'). + b.Right.PerpAtStart = HBorderBelow(page, row, col) || HBorderBelow(page, row, col + 1); + b.Right.PerpAtEnd = HBorderAbove(page, row, col) || HBorderAbove(page, row, col + 1); + } + private static bool CellHasRightBorder(PdfCell cell) { var cs = cell?.CellStyle; if (cs == null) return false; From 2a0bf9e1c36c5587a6ec40b4fe10e3b2873c814b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Mon, 31 Aug 2026 13:31:26 +0200 Subject: [PATCH 20/26] double border progress --- .../DocumentObjects/PdfBorderRenderer.cs | 77 +++++++++++-------- src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs | 5 ++ .../Export/PdfExport/Layout/PdfLayout.cs | 21 +++-- 3 files changed, 65 insertions(+), 38 deletions(-) diff --git a/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs b/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs index aa56809c5c..bae876690d 100644 --- a/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs +++ b/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs @@ -419,14 +419,18 @@ private void DrawDoubleBorder(PdfContentStream contentStream, PdfCellBorderData var DiagonalUpFactor = 0d; var DiagonalDownFactor = 0d; - const double G = PdfCellBorderData.DoubleOffset; // half-gap AND corner miter amount + const double G = PdfCellBorderData.DoubleOffset; // parallel offset AND corner miter amount + + // Miter this end only when a perpendicular border meets it (corner) AND the border does + // not continue straight through the vertex (so crossings stay open). + bool mStart = border.PerpAtStart && !border.ContAtStart; + bool mEnd = border.PerpAtEnd && !border.ContAtEnd; if (border.LineType == LineType.Top) { - // start = left end (x1), end = right end (x2) iy1 = y1 - G; iy2 = y2 - G; - if (border.PerpAtStart) ix1 = x1 + G; - if (border.PerpAtEnd) ix2 = x2 - G; + if (mStart) ix1 = x1 + G; + if (mEnd) ix2 = x2 - G; bool multiColMerge = IsMerged && info.Width > Width + 0.5d; if (!multiColMerge) @@ -436,28 +440,27 @@ private void DrawDoubleBorder(PdfContentStream contentStream, PdfCellBorderData } oy1 = y1 + G; oy2 = y2 + G; - if (border.PerpAtStart) ox1 = x1 - G; - if (border.PerpAtEnd) ox2 = x2 + G; + if (mStart) ox1 = x1 - G; + if (mEnd) ox2 = x2 + G; } if (border.LineType == LineType.Bottom) { iy1 = y1 + G; iy2 = y2 + G; - if (border.PerpAtStart) ix1 = x1 + G; - if (border.PerpAtEnd) ix2 = x2 - G; + if (mStart) ix1 = x1 + G; + if (mEnd) ix2 = x2 - G; if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) ix1 = x1 + 4.87d; if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) ix2 = x2 - 4.87d; oy1 = y1 - G; oy2 = y2 - G; - if (border.PerpAtStart) ox1 = x1 - G; - if (border.PerpAtEnd) ox2 = x2 + G; + if (mStart) ox1 = x1 - G; + if (mEnd) ox2 = x2 + G; } else if (border.LineType == LineType.Left) { - // start = bottom end (y1), end = top end (y2) DiagonalUpFactor = 0.5d; DiagonalDownFactor = 0.5d; ix1 = x1 + G; ix2 = x2 + G; - if (border.PerpAtEnd) iy2 = y2 - G; - if (border.PerpAtStart) iy1 = y1 + G; + if (mEnd) iy2 = y2 - G; // top end + if (mStart) iy1 = y1 + G; // bottom end bool multiRowMerge = IsMerged && info.Height > Height + 0.5d; if (!multiRowMerge) @@ -467,21 +470,21 @@ private void DrawDoubleBorder(PdfContentStream contentStream, PdfCellBorderData } ox1 = x1 - G; ox2 = x2 - G; - if (border.PerpAtEnd) oy2 = y2 + G; - if (border.PerpAtStart) oy1 = y1 - G; + if (mEnd) oy2 = y2 + G; + if (mStart) oy1 = y1 - G; } else if (border.LineType == LineType.Right) { DiagonalUpFactor = 0.5d; DiagonalDownFactor = 0.5d; ix1 = x1 - G; ix2 = x2 - G; - if (border.PerpAtEnd) iy2 = y2 - G; - if (border.PerpAtStart) iy1 = y1 + G; + if (mEnd) iy2 = y2 - G; + if (mStart) iy1 = y1 + G; if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - G - DiagonalUpFactor; if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) iy1 = y1 + G + DiagonalDownFactor; ox1 = x1 + G; ox2 = x2 + G; - if (border.PerpAtEnd) oy2 = y2 + G; - if (border.PerpAtStart) oy1 = y1 - G; + if (mEnd) oy2 = y2 + G; + if (mStart) oy1 = y1 - G; } else if (border.LineType == LineType.DiagonalUp) { @@ -500,25 +503,37 @@ private void DrawDoubleBorder(PdfContentStream contentStream, PdfCellBorderData contentStream.AddCommand(PdfCellBorderData.NoDash); if ((border.LineType == LineType.DiagonalUp || border.LineType == LineType.DiagonalDown) && DiagonalUp.BorderStyle != ExcelBorderStyle.None && DiagonalDown.BorderStyle != ExcelBorderStyle.None) { - double dx = ix2 - ix1, dy = iy2 - iy1; + double dx = ix2 - ix1; + double dy = iy2 - iy1; double length = System.Math.Sqrt(dx * dx + dy * dy); - double ux = dx / length, uy = dy / length; - double midX = (ix1 + ix2) / 2.0, midY = (iy1 + iy2) / 2.0; - double leftDist = 0.25, rightDist = 2.15; - double xA = midX - leftDist * ux, yA = midY - leftDist * uy; - double xB = midX + rightDist * ux, yB = midY + rightDist * uy; + double ux = dx / length; + double uy = dy / length; + double midX = (ix1 + ix2) / 2.0; + double midY = (iy1 + iy2) / 2.0; + double leftDist = 0.25; + double rightDist = 2.15; + double xA = midX - leftDist * ux; + double yA = midY - leftDist * uy; + double xB = midX + rightDist * ux; + double yB = midY + rightDist * uy; contentStream.AddCommand($"{ix1.ToPdfStringF4()} {iy1.ToPdfStringF4()} m"); contentStream.AddCommand($"{xA.ToPdfStringF4()} {yA.ToPdfStringF4()} l"); contentStream.AddCommand($"{xB.ToPdfStringF4()} {yB.ToPdfStringF4()} m"); contentStream.AddCommand($"{ix2.ToPdfStringF4()} {iy2.ToPdfStringF4()} l"); - dx = ox2 - ox1; dy = oy2 - oy1; + dx = ox2 - ox1; + dy = oy2 - oy1; length = System.Math.Sqrt(dx * dx + dy * dy); - ux = dx / length; uy = dy / length; - midX = (ox1 + ox2) / 2.0; midY = (oy1 + oy2) / 2.0; - leftDist = 2.15; rightDist = 0.25; - xA = midX - leftDist * ux; yA = midY - leftDist * uy; - xB = midX + rightDist * ux; yB = midY + rightDist * uy; + ux = dx / length; + uy = dy / length; + midX = (ox1 + ox2) / 2.0; + midY = (oy1 + oy2) / 2.0; + leftDist = 2.15; + rightDist = 0.25; + xA = midX - leftDist * ux; + yA = midY - leftDist * uy; + xB = midX + rightDist * ux; + yB = midY + rightDist * uy; contentStream.AddCommand($"{ox1.ToPdfStringF4()} {oy1.ToPdfStringF4()} m"); contentStream.AddCommand($"{xA.ToPdfStringF4()} {yA.ToPdfStringF4()} l"); contentStream.AddCommand($"{xB.ToPdfStringF4()} {yB.ToPdfStringF4()} m"); diff --git a/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs b/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs index 9d24cd9876..6324c8fb58 100644 --- a/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs +++ b/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs @@ -71,6 +71,11 @@ internal class PdfCellBorderData public bool PerpAtStart = false; // Top/Bottom: left end · Left/Right: bottom end public bool PerpAtEnd = false; // Top/Bottom: right end · Left/Right: top end + public bool ContAtStart = false; // NEW: the border continues collinearly past the start vertex + public bool ContAtEnd = false; // NEW: the border continues collinearly past the end vertex + // + // (DoubleWidth = 0.75 and DoubleOffset = 0.85 are already present — keep them.) + public PdfCellBorderData(LineType LineType) { this.LineType = LineType; diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index 99314b073b..38cfe0971e 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -1633,21 +1633,28 @@ private static void SetDoubleBorderMiterFlags(Page page, int row, int col, PdfCe { var b = border.BorderData; - // Top edge (gridline above 'row'): ends are left (Start) and right (End). + // --- Perpendicular border present at each end (unchanged) --- b.Top.PerpAtStart = VBorderAt(page, row, col) || VBorderAt(page, row - 1, col); b.Top.PerpAtEnd = VBorderAt(page, row, col + 1) || VBorderAt(page, row - 1, col + 1); - - // Bottom edge (gridline below 'row'). b.Bottom.PerpAtStart = VBorderAt(page, row, col) || VBorderAt(page, row + 1, col); b.Bottom.PerpAtEnd = VBorderAt(page, row, col + 1) || VBorderAt(page, row + 1, col + 1); - - // Left edge (gridline left of 'col'): ends are bottom (Start) and top (End). b.Left.PerpAtStart = HBorderBelow(page, row, col) || HBorderBelow(page, row, col - 1); b.Left.PerpAtEnd = HBorderAbove(page, row, col) || HBorderAbove(page, row, col - 1); - - // Right edge (gridline right of 'col'). b.Right.PerpAtStart = HBorderBelow(page, row, col) || HBorderBelow(page, row, col + 1); b.Right.PerpAtEnd = HBorderAbove(page, row, col) || HBorderAbove(page, row, col + 1); + + // --- Does the SAME border continue collinearly past this end? (NEW) --- + // Top/Bottom: Start = left end, End = right end -> look at the horizontal gridline in the + // neighbouring column. Left/Right: Start = bottom end, End = top end -> look at the vertical + // gridline in the neighbouring row. + b.Top.ContAtStart = HBorderAbove(page, row, col - 1); + b.Top.ContAtEnd = HBorderAbove(page, row, col + 1); + b.Bottom.ContAtStart = HBorderBelow(page, row, col - 1); + b.Bottom.ContAtEnd = HBorderBelow(page, row, col + 1); + b.Left.ContAtStart = VBorderAt(page, row + 1, col); + b.Left.ContAtEnd = VBorderAt(page, row - 1, col); + b.Right.ContAtStart = VBorderAt(page, row + 1, col + 1); + b.Right.ContAtEnd = VBorderAt(page, row - 1, col + 1); } private static bool CellHasRightBorder(PdfCell cell) From 6c04f61e4fd595581030e1debb9fa643a9e8c73c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Mon, 31 Aug 2026 13:52:47 +0200 Subject: [PATCH 21/26] progress --- .../DocumentObjects/PdfBorderRenderer.cs | 27 +++++----- src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs | 5 +- .../Export/PdfExport/Layout/PdfLayout.cs | 54 +++++++++++++------ 3 files changed, 52 insertions(+), 34 deletions(-) diff --git a/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs b/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs index bae876690d..e926085533 100644 --- a/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs +++ b/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs @@ -421,24 +421,21 @@ private void DrawDoubleBorder(PdfContentStream contentStream, PdfCellBorderData const double G = PdfCellBorderData.DoubleOffset; // parallel offset AND corner miter amount - // Miter this end only when a perpendicular border meets it (corner) AND the border does - // not continue straight through the vertex (so crossings stay open). - bool mStart = border.PerpAtStart && !border.ContAtStart; - bool mEnd = border.PerpAtEnd && !border.ContAtEnd; + // Miter an end where a perpendicular border meets it (a real corner). + bool mStart = border.PerpAtStart; + bool mEnd = border.PerpAtEnd; if (border.LineType == LineType.Top) { iy1 = y1 - G; iy2 = y2 - G; if (mStart) ix1 = x1 + G; if (mEnd) ix2 = x2 - G; - bool multiColMerge = IsMerged && info.Width > Width + 0.5d; if (!multiColMerge) { if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) ix2 = x2 - 4.87d; if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) ix1 = x1 + 4.87d; } - oy1 = y1 + G; oy2 = y2 + G; if (mStart) ox1 = x1 - G; if (mEnd) ox2 = x2 + G; @@ -450,7 +447,6 @@ private void DrawDoubleBorder(PdfContentStream contentStream, PdfCellBorderData if (mEnd) ix2 = x2 - G; if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) ix1 = x1 + 4.87d; if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) ix2 = x2 - 4.87d; - oy1 = y1 - G; oy2 = y2 - G; if (mStart) ox1 = x1 - G; if (mEnd) ox2 = x2 + G; @@ -459,16 +455,14 @@ private void DrawDoubleBorder(PdfContentStream contentStream, PdfCellBorderData { DiagonalUpFactor = 0.5d; DiagonalDownFactor = 0.5d; ix1 = x1 + G; ix2 = x2 + G; - if (mEnd) iy2 = y2 - G; // top end - if (mStart) iy1 = y1 + G; // bottom end - + if (mEnd) iy2 = y2 - G; + if (mStart) iy1 = y1 + G; bool multiRowMerge = IsMerged && info.Height > Height + 0.5d; if (!multiRowMerge) { if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) iy1 = y1 + G + DiagonalUpFactor; if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - G - DiagonalDownFactor; } - ox1 = x1 - G; ox2 = x2 - G; if (mEnd) oy2 = y2 + G; if (mStart) oy1 = y1 - G; @@ -481,7 +475,6 @@ private void DrawDoubleBorder(PdfContentStream contentStream, PdfCellBorderData if (mStart) iy1 = y1 + G; if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - G - DiagonalUpFactor; if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) iy1 = y1 + G + DiagonalDownFactor; - ox1 = x1 + G; ox2 = x2 + G; if (mEnd) oy2 = y2 + G; if (mStart) oy1 = y1 - G; @@ -541,10 +534,16 @@ private void DrawDoubleBorder(PdfContentStream contentStream, PdfCellBorderData } else { + // Inner line is always drawn. contentStream.AddCommand($"{ix1.ToPdfStringF4()} {iy1.ToPdfStringF4()} m"); contentStream.AddCommand($"{ix2.ToPdfStringF4()} {iy2.ToPdfStringF4()} l"); - contentStream.AddCommand($"{ox1.ToPdfStringF4()} {oy1.ToPdfStringF4()} m"); - contentStream.AddCommand($"{ox2.ToPdfStringF4()} {oy2.ToPdfStringF4()} l"); + // Outer line only when the neighbour across this edge is NOT also double + // (otherwise the neighbour supplies the other half of the shared double). + if (!border.NeighborDouble) + { + contentStream.AddCommand($"{ox1.ToPdfStringF4()} {oy1.ToPdfStringF4()} m"); + contentStream.AddCommand($"{ox2.ToPdfStringF4()} {oy2.ToPdfStringF4()} l"); + } } contentStream.AddCommand("S"); } diff --git a/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs b/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs index 6324c8fb58..cb34c9f799 100644 --- a/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs +++ b/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs @@ -71,10 +71,7 @@ internal class PdfCellBorderData public bool PerpAtStart = false; // Top/Bottom: left end · Left/Right: bottom end public bool PerpAtEnd = false; // Top/Bottom: right end · Left/Right: top end - public bool ContAtStart = false; // NEW: the border continues collinearly past the start vertex - public bool ContAtEnd = false; // NEW: the border continues collinearly past the end vertex - // - // (DoubleWidth = 0.75 and DoubleOffset = 0.85 are already present — keep them.) + public bool NeighborDouble = false; public PdfCellBorderData(LineType LineType) { diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index 38cfe0971e..b8953a792d 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -1626,14 +1626,11 @@ private static bool HBorderBelow(Page page, int row, int col) private static bool HBorderAbove(Page page, int row, int col) => CellHasTopBorder(CellAt(page, row, col)) || CellHasBottomBorder(CellAt(page, row - 1, col)); - // Per-edge, per-end "is there a perpendicular border at this vertex" — checking both - // gridline segments meeting the vertex (this cell + the relevant neighbours). This is - // what lets a double border miter against a partner border owned by an adjacent cell. private static void SetDoubleBorderMiterFlags(Page page, int row, int col, PdfCellBorderLayout border) { var b = border.BorderData; - // --- Perpendicular border present at each end (unchanged) --- + // Perpendicular border present at each end -> miter (close) a real corner. (unchanged) b.Top.PerpAtStart = VBorderAt(page, row, col) || VBorderAt(page, row - 1, col); b.Top.PerpAtEnd = VBorderAt(page, row, col + 1) || VBorderAt(page, row - 1, col + 1); b.Bottom.PerpAtStart = VBorderAt(page, row, col) || VBorderAt(page, row + 1, col); @@ -1643,18 +1640,43 @@ private static void SetDoubleBorderMiterFlags(Page page, int row, int col, PdfCe b.Right.PerpAtStart = HBorderBelow(page, row, col) || HBorderBelow(page, row, col + 1); b.Right.PerpAtEnd = HBorderAbove(page, row, col) || HBorderAbove(page, row, col + 1); - // --- Does the SAME border continue collinearly past this end? (NEW) --- - // Top/Bottom: Start = left end, End = right end -> look at the horizontal gridline in the - // neighbouring column. Left/Right: Start = bottom end, End = top end -> look at the vertical - // gridline in the neighbouring row. - b.Top.ContAtStart = HBorderAbove(page, row, col - 1); - b.Top.ContAtEnd = HBorderAbove(page, row, col + 1); - b.Bottom.ContAtStart = HBorderBelow(page, row, col - 1); - b.Bottom.ContAtEnd = HBorderBelow(page, row, col + 1); - b.Left.ContAtStart = VBorderAt(page, row + 1, col); - b.Left.ContAtEnd = VBorderAt(page, row - 1, col); - b.Right.ContAtStart = VBorderAt(page, row + 1, col + 1); - b.Right.ContAtEnd = VBorderAt(page, row - 1, col + 1); + // Does the cell ACROSS this edge also have a double border? If so, this cell draws only + // its inner line and the neighbour draws its inner line -> together one shared double, + // with no outer line spilling into the neighbour. (Excel behaviour.) + b.Top.NeighborDouble = IsDoubleBottom(CellAt(page, row - 1, col)); + b.Bottom.NeighborDouble = IsDoubleTop(CellAt(page, row + 1, col)); + b.Left.NeighborDouble = IsDoubleRight(CellAt(page, row, col - 1)); + b.Right.NeighborDouble = IsDoubleLeft(CellAt(page, row, col + 1)); + } + + // Effective border style of a side == Double (xf wins over dxf, mirrors SetBorderStyle). + private static bool IsDoubleTop(PdfCell cell) + { + var cs = cell?.CellStyle; if (cs == null) return false; + var s = cs.xfTop.Style != ExcelBorderStyle.None ? cs.xfTop.Style + : ((cs.dxfTop != null && cs.dxfTop.HasValue) ? (ExcelBorderStyle)cs.dxfTop.Style : ExcelBorderStyle.None); + return s == ExcelBorderStyle.Double; + } + private static bool IsDoubleBottom(PdfCell cell) + { + var cs = cell?.CellStyle; if (cs == null) return false; + var s = cs.xfBottom.Style != ExcelBorderStyle.None ? cs.xfBottom.Style + : ((cs.dxfBottom != null && cs.dxfBottom.HasValue) ? (ExcelBorderStyle)cs.dxfBottom.Style : ExcelBorderStyle.None); + return s == ExcelBorderStyle.Double; + } + private static bool IsDoubleLeft(PdfCell cell) + { + var cs = cell?.CellStyle; if (cs == null) return false; + var s = cs.xfLeft.Style != ExcelBorderStyle.None ? cs.xfLeft.Style + : ((cs.dxfLeft != null && cs.dxfLeft.HasValue) ? (ExcelBorderStyle)cs.dxfLeft.Style : ExcelBorderStyle.None); + return s == ExcelBorderStyle.Double; + } + private static bool IsDoubleRight(PdfCell cell) + { + var cs = cell?.CellStyle; if (cs == null) return false; + var s = cs.xfRight.Style != ExcelBorderStyle.None ? cs.xfRight.Style + : ((cs.dxfRight != null && cs.dxfRight.HasValue) ? (ExcelBorderStyle)cs.dxfRight.Style : ExcelBorderStyle.None); + return s == ExcelBorderStyle.Double; } private static bool CellHasRightBorder(PdfCell cell) From 1d5af5317e4ed4a2dfcb0e5dc27845242a9725a7 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Mon, 31 Aug 2026 16:29:59 +0200 Subject: [PATCH 22/26] WIP --- src/EPPlus.Export.Pdf.Tests/PdfTests.cs | 11 +++++++++++ .../DocumentObjects/PdfContentStream.cs | 6 +++++- src/EPPlus.Export.Pdf/ExcelPdf.cs | 2 +- .../Layout/PdfCellContentLayout.cs | 2 +- src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs | 13 ++++++++++--- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs index 3e700671ba..4cd23487e5 100644 --- a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs +++ b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs @@ -760,5 +760,16 @@ public void EachWorksheetUsesItsOwnPaperSize() Assert.AreEqual(PdfPageSize.A3.HeightPu, h2, "Page 2 should be A3, not sheet 1's A4."); } } + + [TestMethod] + public void ClippingWhenCellIsWiderThanPage() + { + using(var package = OpenTemplatePackage("CenterOnPagePdf.xlsx")) + { + var ws = package.Workbook.Worksheets[0]; + string path = _pdfPath + "ClippingWideCellTest.pdf"; + ws.SaveAsPdf(path); + } + } } } diff --git a/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs b/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs index cdddc3a9d5..c1fc73abed 100644 --- a/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs +++ b/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs @@ -362,11 +362,13 @@ public void AddOuterGridBorder(Transform pageLayout) commands.Add($"% Gridlines Border End"); } - public void AddMarginClipping(PdfPageLayout pageLayout) + //public void AddMarginClipping(PdfPageLayout pageLayout) + public void AddMarginClipping(PdfPageLayout pageLayout, PdfPageSettings pageSettings) { if (pageLayout is not PdfPageLayout pl) return; if (pageLayout.isCommentsPage) return; commands.Add($"% Margin Clip Start"); + if (pl.BorderLines.Count == 0) return; // Derive the tight bounding box directly from BorderLines. // pageLayout is created with all-zero dimensions so ContentTop/Bottom/Left/Height // cannot be used here — they are always 0. @@ -381,6 +383,8 @@ public void AddMarginClipping(PdfPageLayout pageLayout) left = System.Math.Min(left, System.Math.Min(line.X1, line.X2)); right = System.Math.Max(right, System.Math.Max(line.X1, line.X2)); } + right = System.Math.Min(right, left + pageSettings.ContentBounds.Width); + bottom = System.Math.Max(bottom, top - pageSettings.ContentBounds.Height); var pad = GridLine.Width * 4; var x = left + pl.HeadingWidth + pl.PrintTitleWidth - pad; var y = bottom - pad; diff --git a/src/EPPlus.Export.Pdf/ExcelPdf.cs b/src/EPPlus.Export.Pdf/ExcelPdf.cs index 64e96421ed..864a47ad66 100644 --- a/src/EPPlus.Export.Pdf/ExcelPdf.cs +++ b/src/EPPlus.Export.Pdf/ExcelPdf.cs @@ -162,7 +162,7 @@ private void AddContent(PdfPageLayout pageLayout, PdfPage page) contentStream.AddCommand($"% {pageLayout.Name} start"); //Add clipping rectangle around page content. contentStream.AddCommand("q"); - contentStream.AddMarginClipping((PdfPageLayout)pageLayout); + contentStream.AddMarginClipping((PdfPageLayout)pageLayout, pageSettings); if (pageSettings.ShowGridLines) { contentStream.AddInnerGridLines(pageLayout); diff --git a/src/EPPlus.Export.Pdf/Layout/PdfCellContentLayout.cs b/src/EPPlus.Export.Pdf/Layout/PdfCellContentLayout.cs index 76d47b756c..1d3951ecce 100644 --- a/src/EPPlus.Export.Pdf/Layout/PdfCellContentLayout.cs +++ b/src/EPPlus.Export.Pdf/Layout/PdfCellContentLayout.cs @@ -55,7 +55,7 @@ public PdfCellContentLayout(PdfPageSettings pageSettings, PdfDictionaries dictio } double firstLineAscent = TextLines[0].LargestAscent; double lastLineAscent = TextLines[TextLines.Count - 1].LargestAscent; - LocalPosition = CalculateAlignment(cell.Text, TextLines.LineFragments[0].Width, totalTextHeight, firstLineAscent, lastLineAscent, LocalPosition.X, LocalPosition.Y, cell.Width, height); + LocalPosition = CalculateAlignment(cell.Text, TextLines.LineFragments[0].Width, totalTextHeight, firstLineAscent, lastLineAscent, LocalPosition.X, LocalPosition.Y, width, height); } public PdfCellContentLayout(PdfPageSettings pageSettings, PdfDictionaries dictionaries, PdfHeaderFooter headerFooter, double x, double y, double width, double height, double scaleX = 1, double scaleY = 1, double rotation = 0, Transform parent = null) diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index b8edcb35ca..014299d659 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -163,8 +163,10 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio } else { + var contentRight = pageSettings.ContentBounds.Left + pageSettings.ContentBounds.Width; + var effectiveWidth = GetClampedCellWidth(pageSettings, x, map.ColumnWidth); //Fill - var fill = new PdfCellLayout(x, y, map.ColumnWidth, rowHeight); + var fill = new PdfCellLayout(x, y, effectiveWidth, rowHeight); SetFill(dictionaries, map.CellStyle, map.Text, fill); fill.UpdateShadingPositionMatrix(pageSettings); fill.Name = map.Name; @@ -172,11 +174,11 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio //Text if (map.TextLines != null && map.TextLines.Count > 0) { - var text = new PdfCellContentLayout(pageSettings, dictionaries, map, info, x, y, map.ColumnWidth, rowHeight); + var text = new PdfCellContentLayout(pageSettings, dictionaries, map, info, x, y, effectiveWidth, rowHeight); text.Name = map.Name; text.GidsAndCharMap(dictionaries); if (NeedsClipping(map, pages[j], row, col)) - text.SetupClipping(x, y, map.ColumnWidth, rowHeight); + text.SetupClipping(x, y, effectiveWidth, rowHeight); pageLayout.AddChild(text); } } @@ -1630,5 +1632,10 @@ private static void EmitBandFrameV(List target, PdfRange range, double } if (rs != null) target.Add(new GridLine(x, rs.Value, x, re)); } + private static double GetClampedCellWidth(PdfPageSettings pageSettings, double cellX, double cellWidth) + { + var contentRight = pageSettings.ContentBounds.Left + pageSettings.ContentBounds.Width; + return System.Math.Min(cellWidth, contentRight - cellX); + } } } From f4d3c2154d62fd6e253987835589286d8c7ae6fb Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Tue, 1 Sep 2026 08:26:02 +0200 Subject: [PATCH 23/26] WIP --- src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs | 5 ++--- src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs b/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs index c1fc73abed..9f897bfb9e 100644 --- a/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs +++ b/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs @@ -362,7 +362,6 @@ public void AddOuterGridBorder(Transform pageLayout) commands.Add($"% Gridlines Border End"); } - //public void AddMarginClipping(PdfPageLayout pageLayout) public void AddMarginClipping(PdfPageLayout pageLayout, PdfPageSettings pageSettings) { if (pageLayout is not PdfPageLayout pl) return; @@ -383,8 +382,8 @@ public void AddMarginClipping(PdfPageLayout pageLayout, PdfPageSettings pageSett left = System.Math.Min(left, System.Math.Min(line.X1, line.X2)); right = System.Math.Max(right, System.Math.Max(line.X1, line.X2)); } - right = System.Math.Min(right, left + pageSettings.ContentBounds.Width); - bottom = System.Math.Max(bottom, top - pageSettings.ContentBounds.Height); + right = System.Math.Min(right, pageSettings.PageSize.WidthPu); + bottom = System.Math.Max(bottom, 0d); var pad = GridLine.Width * 4; var x = left + pl.HeadingWidth + pl.PrintTitleWidth - pad; var y = bottom - pad; diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index 014299d659..f7cb0c2a82 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -1634,8 +1634,7 @@ private static void EmitBandFrameV(List target, PdfRange range, double } private static double GetClampedCellWidth(PdfPageSettings pageSettings, double cellX, double cellWidth) { - var contentRight = pageSettings.ContentBounds.Left + pageSettings.ContentBounds.Width; - return System.Math.Min(cellWidth, contentRight - cellX); + return System.Math.Min(cellWidth, pageSettings.PageSize.WidthPu - cellX); } } } From c8cafbc0416522dc0a5755284a4e289479743565 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Tue, 1 Sep 2026 09:11:48 +0200 Subject: [PATCH 24/26] Fix #2845: infinite loop and off-page content for oversized columns --- src/EPPlus.Export.Pdf.Tests/PdfTests.cs | 15 +++++++-------- src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs | 4 ++-- src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs | 3 ++- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs index 4cd23487e5..4fa1cf91a8 100644 --- a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs +++ b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs @@ -11,10 +11,11 @@ Date Author Change 10/07/2025 EPPlus Software AB EPPlus.Fonts.OpenType 1.0 *************************************************************************************************/ using EPPlus.Export.Pdf.Settings; -using EPPlus.Export.Pdf.Tests; using EPPlus.Export.Pdf.Settings.PdfPageSizes; +using EPPlus.Export.Pdf.Tests; using OfficeOpenXml; using OfficeOpenXml.Export.PdfExport; +using OfficeOpenXml.Export.PdfExport.Layout; using OfficeOpenXml.Export.PdfExport.Settings; using OfficeOpenXml.Style; using System.Diagnostics; @@ -762,14 +763,12 @@ public void EachWorksheetUsesItsOwnPaperSize() } [TestMethod] - public void ClippingWhenCellIsWiderThanPage() + public void GetClampedCellWidth_CellFitsWithinPage_ReturnsCellWidthUnchanged() { - using(var package = OpenTemplatePackage("CenterOnPagePdf.xlsx")) - { - var ws = package.Workbook.Worksheets[0]; - string path = _pdfPath + "ClippingWideCellTest.pdf"; - ws.SaveAsPdf(path); - } + var s = new PdfPageSettings(null); + + Assert.AreEqual(51.71d, PdfLayout.GetClampedCellWidth(s, 126.31d, 51.71d), 0.0001); } + } } diff --git a/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs b/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs index fe20194ede..76287e5a12 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 = true; - internal bool PrintAsText = true; + internal bool Debug = false; + internal bool PrintAsText = false; public PdfPageSettings(OpenTypeFontEngine fontEngine) { diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index f7cb0c2a82..2131c5bbb3 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -1632,7 +1632,8 @@ private static void EmitBandFrameV(List target, PdfRange range, double } if (rs != null) target.Add(new GridLine(x, rs.Value, x, re)); } - private static double GetClampedCellWidth(PdfPageSettings pageSettings, double cellX, double cellWidth) + + internal static double GetClampedCellWidth(PdfPageSettings pageSettings, double cellX, double cellWidth) { return System.Math.Min(cellWidth, pageSettings.PageSize.WidthPu - cellX); } From c3a63bcf4538ca572d5f1a596c47ebf7196490ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Tue, 1 Sep 2026 13:56:59 +0200 Subject: [PATCH 25/26] double borders done for now. --- .../DocumentObjects/PdfBorderRenderer.cs | 36 ++++++++++---- src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs | 12 +++++ .../Export/PdfExport/Layout/PdfLayout.cs | 47 +++++++++++++++++++ .../PdfExport/TextMapping/PdfTextMap.cs | 34 ++++++++++---- 4 files changed, 113 insertions(+), 16 deletions(-) diff --git a/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs b/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs index e926085533..0c385f71c9 100644 --- a/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs +++ b/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs @@ -437,8 +437,14 @@ private void DrawDoubleBorder(PdfContentStream contentStream, PdfCellBorderData if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) ix1 = x1 + 4.87d; } oy1 = y1 + G; oy2 = y2 + G; - if (mStart) ox1 = x1 - G; - if (mEnd) ox2 = x2 + G; + // Normal corner: extend the outer past the gridline (x ∓ G) to close a square corner. + // Diagonal junction (CutOuter*): pull the outer IN to x ± G instead, so it ends exactly + // on the diagonally-opposite cell's perpendicular outer line (they meet, not cross). + ox1 = border.CutOuterAtStart ? x1 + G : (mStart ? x1 - G : ox1); + ox2 = border.CutOuterAtEnd ? x2 - G : (mEnd ? x2 + G : ox2); + // Pull the outer line back so the diagonal of the cell above stays open. + if (border.NeighborDiagAtStart) ox1 = x1 + 4.87d; + if (border.NeighborDiagAtEnd) ox2 = x2 - 4.87d; } if (border.LineType == LineType.Bottom) { @@ -448,8 +454,14 @@ private void DrawDoubleBorder(PdfContentStream contentStream, PdfCellBorderData if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) ix1 = x1 + 4.87d; if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) ix2 = x2 - 4.87d; oy1 = y1 - G; oy2 = y2 - G; - if (mStart) ox1 = x1 - G; - if (mEnd) ox2 = x2 + G; + // Normal corner: extend the outer past the gridline (x ∓ G) to close a square corner. + // Diagonal junction (CutOuter*): pull the outer IN to x ± G instead, so it ends exactly + // on the diagonally-opposite cell's perpendicular outer line (they meet, not cross). + ox1 = border.CutOuterAtStart ? x1 + G : (mStart ? x1 - G : ox1); + ox2 = border.CutOuterAtEnd ? x2 - G : (mEnd ? x2 + G : ox2); + // Pull the outer line back so the diagonal of the cell below stays open. + if (border.NeighborDiagAtStart) ox1 = x1 + 4.87d; + if (border.NeighborDiagAtEnd) ox2 = x2 - 4.87d; } else if (border.LineType == LineType.Left) { @@ -464,8 +476,12 @@ private void DrawDoubleBorder(PdfContentStream contentStream, PdfCellBorderData if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - G - DiagonalDownFactor; } ox1 = x1 - G; ox2 = x2 - G; - if (mEnd) oy2 = y2 + G; - if (mStart) oy1 = y1 - G; + // Diagonal junction: pull the outer IN to y ± G so it meets the neighbour's outer line. + oy2 = border.CutOuterAtEnd ? y2 - G : (mEnd ? y2 + G : oy2); + oy1 = border.CutOuterAtStart ? y1 + G : (mStart ? y1 - G : oy1); + // Pull the outer line back so the diagonal of the cell to the left stays open. + if (border.NeighborDiagAtStart) oy1 = y1 + G + 0.5d; + if (border.NeighborDiagAtEnd) oy2 = y2 - G - 0.5d; } else if (border.LineType == LineType.Right) { @@ -476,8 +492,12 @@ private void DrawDoubleBorder(PdfContentStream contentStream, PdfCellBorderData if (DiagonalUp.BorderStyle != ExcelBorderStyle.None) iy2 = y2 - G - DiagonalUpFactor; if (DiagonalDown.BorderStyle != ExcelBorderStyle.None) iy1 = y1 + G + DiagonalDownFactor; ox1 = x1 + G; ox2 = x2 + G; - if (mEnd) oy2 = y2 + G; - if (mStart) oy1 = y1 - G; + // Diagonal junction: pull the outer IN to y ± G so it meets the neighbour's outer line. + oy2 = border.CutOuterAtEnd ? y2 - G : (mEnd ? y2 + G : oy2); + oy1 = border.CutOuterAtStart ? y1 + G : (mStart ? y1 - G : oy1); + // Pull the outer line back so the diagonal of the cell to the right stays open. + if (border.NeighborDiagAtStart) oy1 = y1 + G + 0.5d; + if (border.NeighborDiagAtEnd) oy2 = y2 - G - 0.5d; } else if (border.LineType == LineType.DiagonalUp) { diff --git a/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs b/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs index cb34c9f799..6ca888bfa5 100644 --- a/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs +++ b/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs @@ -73,6 +73,18 @@ internal class PdfCellBorderData public bool NeighborDouble = false; + // The cell this border's OUTER line spills into has a diagonal reaching that end. + // When set, the outer line is pulled back there so the neighbour's X stays open. + // (Start/End follow the same convention as PerpAtStart/PerpAtEnd.) + public bool NeighborDiagAtStart = false; + public bool NeighborDiagAtEnd = false; + + // At a diagonal junction (only the two diagonally-opposite cells have borders meeting at the + // corner) the outer line's miter must NOT extend past the gridline, otherwise the two cells' + // corners fill the centre into a small solid square. When set, that end's outer miter is cut. + public bool CutOuterAtStart = false; + public bool CutOuterAtEnd = false; + public PdfCellBorderData(LineType LineType) { this.LineType = LineType; diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index b8953a792d..bed6e6a327 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -1647,6 +1647,53 @@ private static void SetDoubleBorderMiterFlags(Page page, int row, int col, PdfCe b.Bottom.NeighborDouble = IsDoubleTop(CellAt(page, row + 1, col)); b.Left.NeighborDouble = IsDoubleRight(CellAt(page, row, col - 1)); b.Right.NeighborDouble = IsDoubleLeft(CellAt(page, row, col + 1)); + + // The OUTER line of a double border spills into the neighbour across that edge. If that + // neighbour has a diagonal reaching the shared corner, pull the outer line back there so + // the neighbour's X stays open (mirrors how the inner line is pulled back for this cell's + // own diagonal). Start/End follow the PerpAtStart/PerpAtEnd convention. + var nAbove = CellAt(page, row - 1, col); + var nBelow = CellAt(page, row + 1, col); + var nLeft = CellAt(page, row, col - 1); + var nRight = CellAt(page, row, col + 1); + b.Top.NeighborDiagAtStart = HasDiagUp(nAbove); // above's up-diagonal ends at this top-left + b.Top.NeighborDiagAtEnd = HasDiagDown(nAbove); // above's down-diagonal ends at this top-right + b.Bottom.NeighborDiagAtStart = HasDiagDown(nBelow); // below's down-diagonal ends at this bottom-left + b.Bottom.NeighborDiagAtEnd = HasDiagUp(nBelow); // below's up-diagonal ends at this bottom-right + b.Left.NeighborDiagAtStart = HasDiagDown(nLeft); // left's down-diagonal ends at this bottom-left + b.Left.NeighborDiagAtEnd = HasDiagUp(nLeft); // left's up-diagonal ends at this top-left + b.Right.NeighborDiagAtStart = HasDiagUp(nRight); // right's up-diagonal ends at this bottom-right + b.Right.NeighborDiagAtEnd = HasDiagDown(nRight); // right's down-diagonal ends at this top-right + + // Diagonal junction: the cell diagonally across a corner has the matching corner (both its + // borders meeting there). Cut this cell's outer miter at that corner so the two corners do + // not fill the centre into a small square. (Only affects a drawn outer line; on shared edges + // the outer is already suppressed.) Corners: TL=(row-1,col-1) BR, TR=(row-1,col+1) BL, + // BL=(row+1,col-1) TR, BR=(row+1,col+1) TL. + var dTL = CellAt(page, row - 1, col - 1); + var dTR = CellAt(page, row - 1, col + 1); + var dBL = CellAt(page, row + 1, col - 1); + var dBR = CellAt(page, row + 1, col + 1); + bool cutTL = CellHasBottomBorder(dTL) && CellHasRightBorder(dTL); + bool cutTR = CellHasBottomBorder(dTR) && CellHasLeftBorder(dTR); + bool cutBL = CellHasTopBorder(dBL) && CellHasRightBorder(dBL); + bool cutBR = CellHasTopBorder(dBR) && CellHasLeftBorder(dBR); + b.Top.CutOuterAtStart = cutTL; b.Top.CutOuterAtEnd = cutTR; // Top: Start=left(TL), End=right(TR) + b.Bottom.CutOuterAtStart = cutBL; b.Bottom.CutOuterAtEnd = cutBR; // Bottom: Start=left(BL), End=right(BR) + b.Left.CutOuterAtStart = cutBL; b.Left.CutOuterAtEnd = cutTL; // Left: Start=bottom(BL), End=top(TL) + b.Right.CutOuterAtStart = cutBR; b.Right.CutOuterAtEnd = cutTR; // Right: Start=bottom(BR), End=top(TR) + } + + // Does the cell carry an up-/down-diagonal border (any style)? Mirrors SetBorderStyle's diagonal source. + private static bool HasDiagUp(PdfCell cell) + { + var cs = cell?.CellStyle; if (cs == null) return false; + return cs.DiagonalUp && cs.Diagonal != null && cs.Diagonal.Style != ExcelBorderStyle.None; + } + private static bool HasDiagDown(PdfCell cell) + { + var cs = cell?.CellStyle; if (cs == null) return false; + return cs.DiagonalDown && cs.Diagonal != null && cs.Diagonal.Style != ExcelBorderStyle.None; } // Effective border style of a side == Double (xf wins over dxf, mirrors SetBorderStyle). diff --git a/src/EPPlus/Export/PdfExport/TextMapping/PdfTextMap.cs b/src/EPPlus/Export/PdfExport/TextMapping/PdfTextMap.cs index 7218d6190c..4d42233ac7 100644 --- a/src/EPPlus/Export/PdfExport/TextMapping/PdfTextMap.cs +++ b/src/EPPlus/Export/PdfExport/TextMapping/PdfTextMap.cs @@ -1005,10 +1005,16 @@ private static void ReconcileSharedBorders(PdfCellCollection map) if (next != null && !next.Hidden && !next.Merged && next.CellStyle != null) { var ns = next.CellStyle; - int here = EdgeRank(cs.xfRight, cs.dxfRight, cs.dxfRightElementOrder); - int there = EdgeRank(ns.xfLeft, ns.dxfLeft, ns.dxfLeftElementOrder); - if (here >= there) ns.SuppressLeft = true; // this cell's right wins - else cs.SuppressRight = true; // neighbour's left wins + // Two adjacent DOUBLE borders form ONE shared double: keep BOTH sides so each + // cell draws only its inner line (see PdfBorderRenderer.DrawDoubleBorder / + // NeighborDouble). Suppressing either side collapses it to a single line. + if (!(IsDoubleEdge(cs.xfRight, cs.dxfRight) && IsDoubleEdge(ns.xfLeft, ns.dxfLeft))) + { + int here = EdgeRank(cs.xfRight, cs.dxfRight, cs.dxfRightElementOrder); + int there = EdgeRank(ns.xfLeft, ns.dxfLeft, ns.dxfLeftElementOrder); + if (here >= there) ns.SuppressLeft = true; // this cell's right wins + else cs.SuppressRight = true; // neighbour's left wins + } } } @@ -1019,16 +1025,28 @@ private static void ReconcileSharedBorders(PdfCellCollection map) if (below != null && !below.Hidden && !below.Merged && below.CellStyle != null) { var bs = below.CellStyle; - int here = EdgeRank(cs.xfBottom, cs.dxfBottom, cs.dxfBottomElementOrder); - int there = EdgeRank(bs.xfTop, bs.dxfTop, bs.dxfTopElementOrder); - if (here >= there) bs.SuppressTop = true; // this cell's bottom wins - else cs.SuppressBottom = true; // cell-below's top wins + // Two adjacent DOUBLE borders form ONE shared double: keep BOTH sides (inner-only each). + if (!(IsDoubleEdge(cs.xfBottom, cs.dxfBottom) && IsDoubleEdge(bs.xfTop, bs.dxfTop))) + { + int here = EdgeRank(cs.xfBottom, cs.dxfBottom, cs.dxfBottomElementOrder); + int there = EdgeRank(bs.xfTop, bs.dxfTop, bs.dxfTopElementOrder); + if (here >= there) bs.SuppressTop = true; // this cell's bottom wins + else cs.SuppressBottom = true; // cell-below's top wins + } } } } } } + // Effective style of one edge is Double (user xf wins over conditional dxf). Mirrors PdfLayout.IsDouble*. + private static bool IsDoubleEdge(ExcelBorderItem xf, ExcelDxfBorderItem dxf) + { + if (xf != null && xf.Style != ExcelBorderStyle.None) return xf.Style == ExcelBorderStyle.Double; + if (dxf != null && dxf.Style.HasValue) return dxf.Style.Value == ExcelBorderStyle.Double; + return false; + } + private static int EdgeRank(ExcelBorderItem xf, ExcelDxfBorderItem dxf, int elementOrder) { // User-applied (xf) border is the highest source. From 39fc47f0f764df5e05fcabd0cfe8cee8db83f7a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Wed, 2 Sep 2026 09:22:59 +0200 Subject: [PATCH 26/26] fixed missing fill, content and borders in merged cells. --- src/EPPlus.Export.Pdf.Tests/PdfTests.cs | 2 +- src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs | 4 ++-- src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs index 8cccf39c70..77bc0972b9 100644 --- a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs +++ b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs @@ -57,7 +57,6 @@ private static long ParseStartXref(byte[] bytes, int pdfStart) public void SaveWorksheetAsPdfTest1() { using var p = OpenTemplatePackage("PDFTest.xlsx"); - p.Workbook.ConfigureFonts(x => x.OnFontEmbedding(f => FontEmbeddingDecision.Skip)); var ws = p.Workbook.Worksheets[0]; string path = _pdfPath + "WorksheetTest1.pdf"; ws.SaveAsPdf(path); @@ -885,6 +884,7 @@ public void HeaderFooterTest1() ws.SaveAsPdf(path); Assert.IsTrue(File.Exists(path), "PDF file was not created."); AssertLooksLikePdf(File.ReadAllBytes(path)); + } public void GetOriginX_CenteringOff_ReturnsContentBoundsLeft() { diff --git a/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs b/src/EPPlus.Export.Pdf/Settings/PdfPageSettings.cs index fe20194ede..76287e5a12 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 = true; - internal bool PrintAsText = true; + internal bool Debug = false; + internal bool PrintAsText = false; public PdfPageSettings(OpenTypeFontEngine fontEngine) { diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index 90c45854d8..f56e867d31 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -808,7 +808,7 @@ private static double[] BuildColumnXPositions(PdfPageSettings pageSettings, Page { int colCount = page.ToColumn - page.FromColumn + 1; var colX = new double[colCount]; - double x = GetOriginY(pageSettings, page) + page.HeadingWidth + page.PrintTitleWidth; + double x = GetOriginX(pageSettings, page) + page.HeadingWidth + page.PrintTitleWidth; for (int col = page.FromColumn; col <= page.ToColumn; col++) { colX[col - page.FromColumn] = x;