From c19244b801431741c03f1b67ae1f6e75a82753dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Tue, 18 Aug 2026 16:00:30 +0200 Subject: [PATCH 01/39] Read jpeg drawings for pdf export. --- .../Export/PdfExport/Data/PdfDrawing.cs | 12 ++++++++ .../Export/PdfExport/Data/PdfWorksheet.cs | 2 +- src/EPPlus/Export/PdfExport/PdfCatalog.cs | 29 +++++++++++++++---- 3 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 src/EPPlus/Export/PdfExport/Data/PdfDrawing.cs diff --git a/src/EPPlus/Export/PdfExport/Data/PdfDrawing.cs b/src/EPPlus/Export/PdfExport/Data/PdfDrawing.cs new file mode 100644 index 000000000..a4c0942ae --- /dev/null +++ b/src/EPPlus/Export/PdfExport/Data/PdfDrawing.cs @@ -0,0 +1,12 @@ +ο»Ώusing OfficeOpenXml.Drawing; + +namespace OfficeOpenXml.Export.PdfExport.Data +{ + internal class PdfDrawing + { + public ExcelPicture Picture { get; } + public byte[] ImageBytes => Picture.Image.ImageBytes; // raw JPEG stream, embeds verbatim later + public ePictureType PictureType => Picture.Image.Type.Value; + public PdfDrawing(ExcelPicture picture) { Picture = picture; } + } +} diff --git a/src/EPPlus/Export/PdfExport/Data/PdfWorksheet.cs b/src/EPPlus/Export/PdfExport/Data/PdfWorksheet.cs index 9261792ba..ebcfb216a 100644 --- a/src/EPPlus/Export/PdfExport/Data/PdfWorksheet.cs +++ b/src/EPPlus/Export/PdfExport/Data/PdfWorksheet.cs @@ -22,7 +22,7 @@ namespace OfficeOpenXml.Export.PdfExport.Data internal class PdfWorksheet { public Dictionary CommentsAndNotesCollections = new Dictionary(); - + public List Drawings = new List(); public List Ranges = null; //Rename this public PdfRange CommentsAndNotes; public PdfHeaderFooterCollection HeaderFooters = null; diff --git a/src/EPPlus/Export/PdfExport/PdfCatalog.cs b/src/EPPlus/Export/PdfExport/PdfCatalog.cs index f82db724f..94b4b89bb 100644 --- a/src/EPPlus/Export/PdfExport/PdfCatalog.cs +++ b/src/EPPlus/Export/PdfExport/PdfCatalog.cs @@ -11,13 +11,14 @@ 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 EPPlus.Export.Pdf; -using EPPlus.Export.Pdf.Settings; -using EPPlus.Export.Pdf.Resources; +using OfficeOpenXml.Drawing; using OfficeOpenXml.Export.PdfExport.Data; using OfficeOpenXml.Export.PdfExport.Layout; using OfficeOpenXml.Export.PdfExport.RowResize; @@ -25,10 +26,10 @@ Date Author Change using OfficeOpenXml.Export.PdfExport.TextShaping; using System; using System.Collections.Generic; -using System.Diagnostics; -using System.IO; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics; +using System.IO; using System.Linq; namespace OfficeOpenXml.Export.PdfExport @@ -380,6 +381,7 @@ internal PdfWorksheet GetPdfWorksheet(PdfPageSettings pageSettings, ExcelWorkshe GetPrintTitles(pageSettings, pdfSheet); GetHeaderFooter(pageSettings, pdfSheet); GetCommentsAndNotes(pageSettings, pdfSheet); + ReadDrawings(pdfSheet); return pdfSheet; } @@ -395,6 +397,7 @@ private PdfWorksheet GetPdfWorksheet(PdfPageSettings pageSettings, ExcelRangeBas GetPrintTitles(pageSettings, pdfSheet); GetHeaderFooter(pageSettings, pdfSheet); GetCommentsAndNotes(pageSettings, pdfSheet); + ReadDrawings(pdfSheet); return pdfSheet; } @@ -442,6 +445,7 @@ private PdfWorksheet GetPdfWorksheet(PdfPageSettings pageSettings, ExcelWorkshee GetPrintTitles(pageSettings, pdfSheet); GetHeaderFooter(pageSettings, pdfSheet); GetCommentsAndNotes(pageSettings, pdfSheet); + ReadDrawings(pdfSheet); return pdfSheet; } @@ -568,5 +572,20 @@ private void GetCommentsAndNotes(PdfPageSettings pageSettings, PdfWorksheet pdfS pdfSheet.CommentsAndNotes = GetMaps(cnPageSettings, pdfSheet, pdfSheet.CommentsAndNotes); } } + + private void ReadDrawings(PdfWorksheet pdfSheet) + { + var worksheet = pdfSheet.Worksheet; + if (worksheet?.Drawings == null) return; + foreach (var drawing in worksheet.Drawings) + { + if (drawing is ExcelPicture picture) + { + var image = picture.Image; + if (image?.ImageBytes != null && image.Type.HasValue) + pdfSheet.Drawings.Add(new PdfDrawing(picture)); + } + } + } } } \ No newline at end of file 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 02/39] #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 a9382a780..4ff14c101 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 2e7289e10..5c4a8c9e7 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 511dff8e7..22e0d83e2 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 000000000..0fa58c285 --- /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 e39aca5b5..4aa7e1494 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 641933d28..216c7f1fc 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 4de8871ca..e5df082f7 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 000000000..e0a63fbf9 --- /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 000000000..79a18e6fd --- /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 f35b72711..b7d3053e2 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 ce51d90eb..68c753b62 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 e75381d5d..62d2e9bc5 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 000000000..a9df0272c --- /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 000000000..545880d08 --- /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 000000000..740d49637 --- /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 0aa862747..ef0980b2b 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 03/39] 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 0fa58c285..537623af5 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 216c7f1fc..9f098c792 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 7b119d2e26f599c331e1cf3a8b70e9b36cb4c5b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Wed, 19 Aug 2026 14:41:14 +0200 Subject: [PATCH 04/39] Layoutint image. --- .../Layout/PdfImageLayout.cs | 29 +++++++++++++ src/EPPlus/Export/PdfExport/Data/PageData.cs | 1 + .../Export/PdfExport/Layout/PdfLayout.cs | 42 +++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 src/EPPlus.Export.Pdf/Layout/PdfImageLayout.cs diff --git a/src/EPPlus.Export.Pdf/Layout/PdfImageLayout.cs b/src/EPPlus.Export.Pdf/Layout/PdfImageLayout.cs new file mode 100644 index 000000000..454bd1888 --- /dev/null +++ b/src/EPPlus.Export.Pdf/Layout/PdfImageLayout.cs @@ -0,0 +1,29 @@ +ο»Ώ/************************************************************************************************* + 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.Graphics; +using System.Diagnostics; + +namespace EPPlus.Export.Pdf.Layout +{ + [DebuggerDisplay("Image: {Name}")] + internal class PdfImageLayout : Transform + { + public byte[] ImageBytes; + + public PdfImageLayout(double x, double y, double width, double height) + : base(x, y - height, width, height) + { + Z = 5; // paint above cell fills, text and borders + } + } +} \ No newline at end of file diff --git a/src/EPPlus/Export/PdfExport/Data/PageData.cs b/src/EPPlus/Export/PdfExport/Data/PageData.cs index 6a7d8c510..153e34908 100644 --- a/src/EPPlus/Export/PdfExport/Data/PageData.cs +++ b/src/EPPlus/Export/PdfExport/Data/PageData.cs @@ -50,6 +50,7 @@ internal struct Pages public string HeadingFontName; public float HeadingFontSize; public ExcelFill HeadingFill; + public List Drawings; 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 f4c4f1a9a..fa99ecb43 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -17,6 +17,7 @@ Date Author Change using EPPlus.Fonts.OpenType.Integration; using EPPlus.Fonts.OpenType.Integration.DataHolders; using EPPlus.Graphics; +using OfficeOpenXml.Drawing; using OfficeOpenXml.Export.PdfExport.Data; using OfficeOpenXml.Export.PdfExport.TextShaping; using OfficeOpenXml.Interfaces.Fonts; @@ -197,6 +198,7 @@ internal static Transform GetCatalog(PdfPageSettings pageSettings, PdfDictionari y -= rowHeight; x = contentStartX; //pageSettings.ContentBounds.Left; } + AddImages(page, pageLayout, pdfPages[i].Drawings, contentStartX, contentStartY); if (page.HeaderFooters != null) { bool isVeryFirstPage = (i == 0 && j == 0); @@ -636,6 +638,45 @@ private static void SetBorderStyle(PdfCellStyle style, PdfCellBorderLayout borde (EPPlus.Export.Pdf.Enums.ExcelBorderStyle)diagUpStyle, diagUpColor, (EPPlus.Export.Pdf.Enums.ExcelBorderStyle)diagDownStyle, diagDownColor); } + + private static void AddImages(Page page, PdfPageLayout pageLayout, List drawings, double contentStartX, double contentStartY) + { + if (drawings == null) return; + foreach (var drawing in drawings) + { + if (drawing.PictureType != ePictureType.Jpg) continue; // JPEG first + var pic = drawing.Picture; + if (pic.From == null) continue; // only cell-anchored pictures for now + int imgRow = pic.From.Row + 1; // From.Row / From.Column are 0-based + int imgCol = pic.From.Column + 1; + if (imgRow < page.FromRow || imgRow > page.ToRow) continue; + if (imgCol < page.FromColumn || imgCol > page.ToColumn) continue; + + // Left edge: content origin + full widths of the columns before the anchor + EMU offset. + double x = contentStartX; + for (int c = page.FromColumn; c < imgCol; c++) + x += page.Map[page.FromRow, c]?.ColumnWidth ?? 0d; + x += pic.From.ColumnOff / (double)ExcelDrawing.EMU_PER_POINT; + + // Top edge (Y-up): content origin - full heights of the rows above the anchor - EMU offset. + double y = contentStartY; + for (int r = page.FromRow; r < imgRow; r++) + y -= page.RowHeights[r - page.FromRow]; + y -= pic.From.RowOff / (double)ExcelDrawing.EMU_PER_POINT; + + // Size: rendered pixel size (already resolved from the anchor by EPPlus) β†’ points. + double width = pic.GetPixelWidth() * ExcelDrawing.EMU_PER_PIXEL / (double)ExcelDrawing.EMU_PER_POINT; + double height = pic.GetPixelHeight() * ExcelDrawing.EMU_PER_PIXEL / (double)ExcelDrawing.EMU_PER_POINT; + + var image = new PdfImageLayout(x, y, width, height) + { + ImageBytes = drawing.ImageBytes, + Name = "Image_" + pic.Name, + }; + pageLayout.AddChild(image); + } + } + private static int GetTotalPages(List pdfPages) { int totalPages = 0; @@ -664,6 +705,7 @@ internal static List GetPages(PdfPageSettings pageSettings, PdfWorksheet[ pages.HeadingFontName = pdfSheet.NormalStyle.Style.Font.Name; pages.HeadingFontSize = pdfSheet.NormalStyle.Style.Font.Size; pages.HeadingFill = pdfSheet.NormalStyle.Style.Fill; + pages.Drawings = pdfSheet.Drawings; PagesCollection.Add(pages); } if (pdfSheet.CommentsAndNotes.Range != null) From 1aca478ee1155a1729d4cd40b3009ffabf1ade30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Wed, 19 Aug 2026 16:59:51 +0200 Subject: [PATCH 05/39] render to pdf. --- .../DocumentObjects/PdfContentStream.cs | 9 +++ .../DocumentObjects/PdfImageXObject.cs | 81 +++++++++++++++++++ .../DocumentObjects/PdfPage.cs | 9 +++ src/EPPlus.Export.Pdf/ExcelPdf.cs | 15 ++++ .../Resources/PdfDictionaries.cs | 22 +++++ .../Resources/PdfImageResource .cs | 27 +++++++ 6 files changed, 163 insertions(+) create mode 100644 src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs create mode 100644 src/EPPlus.Export.Pdf/Resources/PdfImageResource .cs diff --git a/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs b/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs index cdddc3a9d..6f647da54 100644 --- a/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs +++ b/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs @@ -310,6 +310,15 @@ public void AddInnerGridLines(Transform pageLayout) commands.Add($"% Gridlines End"); } + public void AddImage(string label, double x, double y, double width, double height) + { + commands.Add($"% Image Start: {label}"); + commands.Add("q"); + commands.Add($"{width.ToPdfString()} 0 0 {height.ToPdfString()} {x.ToPdfString()} {y.ToPdfString()} cm"); + commands.Add($"/{label} Do"); + commands.Add("Q"); + commands.Add($"% Image End: {label}"); + } public void AddPrintTitleGridLines(Transform pageLayout) { if (pageLayout is not PdfPageLayout pl) return; diff --git a/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs b/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs new file mode 100644 index 000000000..c9d919fd4 --- /dev/null +++ b/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs @@ -0,0 +1,81 @@ +ο»Ώusing System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace EPPlus.Export.Pdf.DocumentObjects +{ + internal class PdfImageXObject : PdfObject + { + private readonly byte[] _jpeg; + internal int Width { get; } + internal int Height { get; } + internal string ColorSpace { get; } + + public PdfImageXObject(int objectNumber, byte[] jpegBytes, int version = 0) + : base(objectNumber, version) + { + _jpeg = jpegBytes; + ReadJpegInfo(jpegBytes, out int width, out int height, out int components); + Width = width; + Height = height; + ColorSpace = components == 1 ? "DeviceGray" : components == 4 ? "DeviceCMYK" : "DeviceRGB"; + } + + private string DictHeader() + { + return "<< /Type /XObject /Subtype /Image" + + $" /Width {Width} /Height {Height}" + + $" /ColorSpace /{ColorSpace} /BitsPerComponent 8" + + $" /Filter /DCTDecode /Length {_jpeg.Length} >>"; + } + + internal override string RenderDictionary() + { + // Debug/text dump only β€” never the real output β€” so the binary body is elided. + return DictHeader() + $"\nstream\n<{_jpeg.Length} bytes of JPEG data>\nendstream"; + } + + internal override void RenderDictionary(BinaryWriter bw) + { + WriteAscii(bw, DictHeader() + "\nstream\n"); + bw.Write(_jpeg); // raw JPEG β€” not Flate-compressed (already DCT-coded) + WriteAscii(bw, "\nendstream"); + } + + // Minimal JPEG reader: walk the marker segments to the Start-Of-Frame and read the frame's + // height, width and component count. Handles baseline and progressive SOFs. + private static void ReadJpegInfo(byte[] d, out int width, out int height, out int components) + { + width = 0; height = 0; components = 3; + if (d == null || d.Length < 4 || d[0] != 0xFF || d[1] != 0xD8) return; // not a JPEG + int i = 2; + while (i + 1 < d.Length) + { + if (d[i] != 0xFF) { i++; continue; } + byte marker = d[i + 1]; + if (marker == 0xFF) { i++; continue; } // fill byte + // Standalone markers without a length: SOI, EOI, RSTn, TEM. + if (marker == 0xD8 || marker == 0xD9 || (marker >= 0xD0 && marker <= 0xD7) || marker == 0x01) + { + i += 2; continue; + } + if (i + 3 >= d.Length) return; + int segLen = (d[i + 2] << 8) | d[i + 3]; + // SOF markers hold the frame size: C0..CF except C4 (DHT), C8 (JPG ext), CC (DAC). + if (marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC) + { + if (i + 9 >= d.Length) return; + height = (d[i + 5] << 8) | d[i + 6]; + width = (d[i + 7] << 8) | d[i + 8]; + components = d[i + 9]; + return; + } + if (segLen < 2) return; + i += 2 + segLen; + } + } + } +} diff --git a/src/EPPlus.Export.Pdf/DocumentObjects/PdfPage.cs b/src/EPPlus.Export.Pdf/DocumentObjects/PdfPage.cs index a7632d8c2..bc587a2cb 100644 --- a/src/EPPlus.Export.Pdf/DocumentObjects/PdfPage.cs +++ b/src/EPPlus.Export.Pdf/DocumentObjects/PdfPage.cs @@ -17,6 +17,7 @@ Date Author Change using System.IO; using System.Linq; using System.Text; +using static System.Net.Mime.MediaTypeNames; namespace EPPlus.Export.Pdf.DocumentObjects { @@ -44,6 +45,8 @@ internal override string RenderDictionary() var patterns = string.Join(" ", patternEntries); var shadingEntries = dictionaries.Shadings.Select(s => $"/{s.Value.Label} {s.Value.objectNumber} 0 R").ToArray(); var shadings = string.Join(" ", shadingEntries); + var imageEntries = dictionaries.Images.Select(im => $"/{im.Value.Label} {im.Value.objectNumber} 0 R").ToArray(); + var images = string.Join(" ", imageEntries); var contentEntries = contentObjectNumbers.Select(con => $"{con} 0 R").ToArray(); StringBuilder sb = new StringBuilder(); sb.AppendFormat($"<< /Type /Page\n" + @@ -51,12 +54,14 @@ internal override string RenderDictionary() bool hasFont = !string.IsNullOrEmpty(fonts); bool hasPattern = !string.IsNullOrEmpty(patterns); bool hasShading = !string.IsNullOrEmpty(shadings); + bool hasImage = !string.IsNullOrEmpty(images); if (hasFont || hasPattern || hasShading) { sb.AppendFormat($" /Resources <<\n"); if (hasFont ) sb.AppendFormat($" /Font << {fonts} >>\n"); if (hasPattern) sb.AppendFormat($" /Pattern << {patterns} >>\n"); if (hasShading) sb.AppendFormat($" /Shading << {shadings} >>\n"); + if (hasImage) sb.AppendFormat($" /XObject << {images} >>\n"); sb.AppendFormat($" >>\n"); } sb.AppendFormat($" /MediaBox [ 0 0 {Size.WidthPu.ToPdfString()} {Size.HeightPu.ToPdfString()} ]\n" + @@ -72,6 +77,8 @@ internal override void RenderDictionary(BinaryWriter bw) var patterns = string.Join(" ", patternEntries); var shadingEntries = dictionaries.Shadings.Select(s => $"/{s.Value.Label} {s.Value.objectNumber} 0 R").ToArray(); var shadings = string.Join(" ", shadingEntries); + var imageEntries = dictionaries.Images.Select(im => $"/{im.Value.Label} {im.Value.objectNumber} 0 R").ToArray(); + var images = string.Join(" ", imageEntries); var contentEntries = contentObjectNumbers.Select(con => $"{con} 0 R").ToArray(); StringBuilder sb = new StringBuilder(); sb.AppendFormat($"<< /Type /Page\n" + @@ -79,12 +86,14 @@ internal override void RenderDictionary(BinaryWriter bw) bool hasFont = !string.IsNullOrEmpty(fonts); bool hasPattern = !string.IsNullOrEmpty(patterns); bool hasShading = !string.IsNullOrEmpty(shadings); + bool hasImage = !string.IsNullOrEmpty(images); if (hasFont || hasPattern || hasShading) { sb.AppendFormat($" /Resources <<\n"); if (hasFont ) sb.AppendFormat($" /Font << {fonts} >>\n"); if (hasPattern) sb.AppendFormat($" /Pattern << {patterns} >>\n"); if (hasShading) sb.AppendFormat($" /Shading << {shadings} >>\n"); + if (hasImage) sb.AppendFormat($" /XObject << {images} >>\n"); sb.AppendFormat($" >>\n"); } sb.AppendFormat($" /MediaBox [ 0 0 {Size.WidthPu.ToPdfString()} {Size.HeightPu.ToPdfString()} ]\n" + diff --git a/src/EPPlus.Export.Pdf/ExcelPdf.cs b/src/EPPlus.Export.Pdf/ExcelPdf.cs index 95b5627be..ca0ae0abc 100644 --- a/src/EPPlus.Export.Pdf/ExcelPdf.cs +++ b/src/EPPlus.Export.Pdf/ExcelPdf.cs @@ -119,6 +119,15 @@ private void AddShadingsData() } } + //Add Images + private void AddImageData() + { + foreach (var image in _dictionaries.Images) + { + _document.Add(image.Value.GetImageObject(_document.Count + 1)); + } + } + //Create Page private PdfPage AddPage(int pagesObjectNumber, List contentObjectNumbers, PdfPageSettings settings) { @@ -179,6 +188,11 @@ private void AddContent(Transform pageLayout, PdfPage page) contentStream.AddCommand($"% CELL BORDER : {border.Name}"); contentStream.AddBorderLayout(border); } + foreach (PdfImageLayout image in pageLayout.ChildObjects.OfType()) + { + var imageResource = _dictionaries.AddImage(image.ImageBytes); + contentStream.AddImage(imageResource.Label, image.LocalPosition.X, image.LocalPosition.Y, image.Size.X, image.Size.Y); + } //Close the clipping rectangle. contentStream.AddCommand("Q"); contentStream.AddCommand($"% Margin Clip End"); @@ -281,6 +295,7 @@ internal void CreatePdf(PdfPageSettings pageSettings, PdfDictionaries dictionari AddContent(pageLayout, page); pages.pageObjectNumbers.Add(page.objectNumber); } + AddImageData(); var info = AddInfoObject(); _debugString = ""; //write to pdf diff --git a/src/EPPlus.Export.Pdf/Resources/PdfDictionaries.cs b/src/EPPlus.Export.Pdf/Resources/PdfDictionaries.cs index 6bfc199d0..e4af6b7c9 100644 --- a/src/EPPlus.Export.Pdf/Resources/PdfDictionaries.cs +++ b/src/EPPlus.Export.Pdf/Resources/PdfDictionaries.cs @@ -25,6 +25,7 @@ internal class PdfDictionaries internal readonly Dictionary Fonts = new Dictionary(); internal readonly Dictionary Patterns = new Dictionary(); internal readonly Dictionary Shadings = new Dictionary(); + internal readonly Dictionary Images = new Dictionary(); internal Dictionary ShapedProviders = new Dictionary(); // Cache mapping a requested (family, subfamily) to the canonical FontKey of @@ -86,5 +87,26 @@ internal PdfFontResource GetFont(PdfPageSettings pageSettings, string fontName, } return Fonts[key]; } + + internal PdfImageResource AddImage(byte[] imageBytes) + { + var key = GetImageKey(imageBytes); + if (!Images.TryGetValue(key, out var res)) + { + int label = 1; + if (Images.Count > 0) label = Images.Last().Value.labelNumber + 1; + res = new PdfImageResource(label, imageBytes); + Images.Add(key, res); + } + return res; + } + + private static string GetImageKey(byte[] bytes) + { + using (var sha = System.Security.Cryptography.SHA1.Create()) + { + return System.Convert.ToBase64String(sha.ComputeHash(bytes)); + } + } } } \ No newline at end of file diff --git a/src/EPPlus.Export.Pdf/Resources/PdfImageResource .cs b/src/EPPlus.Export.Pdf/Resources/PdfImageResource .cs new file mode 100644 index 000000000..943f54661 --- /dev/null +++ b/src/EPPlus.Export.Pdf/Resources/PdfImageResource .cs @@ -0,0 +1,27 @@ +ο»Ώusing EPPlus.Export.Pdf.DocumentObjects; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace EPPlus.Export.Pdf.Resources +{ + internal class PdfImageResource : PdfResource + { + internal int objectNumber; + internal readonly byte[] ImageBytes; + + public PdfImageResource(int labelNumber, byte[] imageBytes) + : base("Im", labelNumber) + { + ImageBytes = imageBytes; + } + + public PdfImageXObject GetImageObject(int objectNumber, int version = 0) + { + this.objectNumber = objectNumber; + return new PdfImageXObject(objectNumber, ImageBytes, version); + } + } +} From 9fd57a687b78853cb1304faf952254f77de710b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Thu, 20 Aug 2026 13:55:29 +0200 Subject: [PATCH 06/39] jpegs can now overlap several pages --- src/EPPlus.Export.Pdf/Layout/ImageDrawInfo.cs | 18 +++ src/EPPlus/Export/PdfExport/Data/PageData.cs | 3 +- .../Export/PdfExport/Layout/PdfLayout.cs | 136 ++++++++++++++---- 3 files changed, 130 insertions(+), 27 deletions(-) create mode 100644 src/EPPlus.Export.Pdf/Layout/ImageDrawInfo.cs diff --git a/src/EPPlus.Export.Pdf/Layout/ImageDrawInfo.cs b/src/EPPlus.Export.Pdf/Layout/ImageDrawInfo.cs new file mode 100644 index 000000000..f5344a6cf --- /dev/null +++ b/src/EPPlus.Export.Pdf/Layout/ImageDrawInfo.cs @@ -0,0 +1,18 @@ +ο»Ώusing System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace EPPlus.Export.Pdf.Layout +{ + internal struct ImageDrawInfo + { + public double X; + public double Y; + public double Width; + public double Height; + public byte[] ImageBytes; + public string Name; + } +} diff --git a/src/EPPlus/Export/PdfExport/Data/PageData.cs b/src/EPPlus/Export/PdfExport/Data/PageData.cs index 153e34908..377813bdf 100644 --- a/src/EPPlus/Export/PdfExport/Data/PageData.cs +++ b/src/EPPlus/Export/PdfExport/Data/PageData.cs @@ -31,6 +31,7 @@ internal struct Page public PdfCellCollection Map; public PdfHeaderFooterCollection HeaderFooters; public Dictionary MergedCells; + public List Images; public List PrintTitleCells; public List PrintTitleGridLines; public List PrintTitleHeadings; @@ -50,7 +51,7 @@ internal struct Pages public string HeadingFontName; public float HeadingFontSize; public ExcelFill HeadingFill; - public List Drawings; + //public List Drawings; 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 fa99ecb43..407cd79c0 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -198,7 +198,18 @@ internal static Transform GetCatalog(PdfPageSettings pageSettings, PdfDictionari y -= rowHeight; x = contentStartX; //pageSettings.ContentBounds.Left; } - AddImages(page, pageLayout, pdfPages[i].Drawings, contentStartX, contentStartY); + //AddImages(page, pageLayout, pdfPages[i].Drawings, contentStartX, contentStartY); + if (page.Images != null) + { + foreach (var img in page.Images) + { + pageLayout.AddChild(new PdfImageLayout(img.X, img.Y, img.Width, img.Height) + { + ImageBytes = img.ImageBytes, + Name = img.Name, + }); + } + } if (page.HeaderFooters != null) { bool isVeryFirstPage = (i == 0 && j == 0); @@ -639,42 +650,114 @@ private static void SetBorderStyle(PdfCellStyle style, PdfCellBorderLayout borde (EPPlus.Export.Pdf.Enums.ExcelBorderStyle)diagDownStyle, diagDownColor); } - private static void AddImages(Page page, PdfPageLayout pageLayout, List drawings, double contentStartX, double contentStartY) + //private static void AddImages(Page page, PdfPageLayout pageLayout, List drawings, double contentStartX, double contentStartY) + //{ + // if (drawings == null) return; + // foreach (var drawing in drawings) + // { + // if (drawing.PictureType != ePictureType.Jpg) continue; // JPEG first + // var pic = drawing.Picture; + // if (pic.From == null) continue; // only cell-anchored pictures for now + // int imgRow = pic.From.Row + 1; // From.Row / From.Column are 0-based + // int imgCol = pic.From.Column + 1; + // if (imgRow < page.FromRow || imgRow > page.ToRow) continue; + // if (imgCol < page.FromColumn || imgCol > page.ToColumn) continue; + + // // Left edge: content origin + full widths of the columns before the anchor + EMU offset. + // double x = contentStartX; + // for (int c = page.FromColumn; c < imgCol; c++) + // x += page.Map[page.FromRow, c]?.ColumnWidth ?? 0d; + // x += pic.From.ColumnOff / (double)ExcelDrawing.EMU_PER_POINT; + + // // Top edge (Y-up): content origin - full heights of the rows above the anchor - EMU offset. + // double y = contentStartY; + // for (int r = page.FromRow; r < imgRow; r++) + // y -= page.RowHeights[r - page.FromRow]; + // y -= pic.From.RowOff / (double)ExcelDrawing.EMU_PER_POINT; + + // // Size: rendered pixel size (already resolved from the anchor by EPPlus) β†’ points. + // double width = pic.GetPixelWidth() * ExcelDrawing.EMU_PER_PIXEL / (double)ExcelDrawing.EMU_PER_POINT; + // double height = pic.GetPixelHeight() * ExcelDrawing.EMU_PER_PIXEL / (double)ExcelDrawing.EMU_PER_POINT; + + // var image = new PdfImageLayout(x, y, width, height) + // { + // ImageBytes = drawing.ImageBytes, + // Name = "Image_" + pic.Name, + // }; + // pageLayout.AddChild(image); + // } + //} + + internal static Pages PrecomputeImages(PdfPageSettings pageSettings, PdfRange range, Pages pdfPages, List drawings) + { + if (drawings == null || drawings.Count == 0) return pdfPages; + + var colPrefix = new double[range.ColWidths.Count + 1]; + for (int i = 0; i < range.ColWidths.Count; i++) + colPrefix[i + 1] = colPrefix[i] + range.ColWidths[i]; + var rowPrefix = new double[range.RowHeights.Count + 1]; + for (int i = 0; i < range.RowHeights.Count; i++) + rowPrefix[i + 1] = rowPrefix[i] + range.RowHeights[i].Height; + + for (int i = 0; i < pdfPages.Page.Length; i++) + pdfPages.Page[i] = PrecomputePageImages(pageSettings, range, pdfPages.Page[i], drawings, colPrefix, rowPrefix); + return pdfPages; + } + + private static Page PrecomputePageImages(PdfPageSettings pageSettings, PdfRange range, Page page, List drawings, double[] colPrefix, double[] rowPrefix) { - if (drawings == null) return; + page.Images = new List(); + int fromCol = range.Range._fromCol; + int fromRow = range.Range._fromRow; + + // This page's window in absolute (range-local) point space. + double pageAbsLeft = colPrefix[page.FromColumn - fromCol]; + double pageAbsRight = colPrefix[page.ToColumn - fromCol + 1]; + double pageAbsTop = rowPrefix[page.FromRow - fromRow]; + double pageAbsBottom = rowPrefix[page.ToRow - fromRow + 1]; + + double contentStartX = pageSettings.ContentBounds.Left + page.HeadingWidth + page.PrintTitleWidth; + double contentStartY = pageSettings.ContentBounds.Top - page.HeadingHeight - page.PrintTitleHeight; + foreach (var drawing in drawings) { if (drawing.PictureType != ePictureType.Jpg) continue; // JPEG first var pic = drawing.Picture; - if (pic.From == null) continue; // only cell-anchored pictures for now - int imgRow = pic.From.Row + 1; // From.Row / From.Column are 0-based - int imgCol = pic.From.Column + 1; - if (imgRow < page.FromRow || imgRow > page.ToRow) continue; - if (imgCol < page.FromColumn || imgCol > page.ToColumn) continue; - - // Left edge: content origin + full widths of the columns before the anchor + EMU offset. - double x = contentStartX; - for (int c = page.FromColumn; c < imgCol; c++) - x += page.Map[page.FromRow, c]?.ColumnWidth ?? 0d; - x += pic.From.ColumnOff / (double)ExcelDrawing.EMU_PER_POINT; - - // Top edge (Y-up): content origin - full heights of the rows above the anchor - EMU offset. - double y = contentStartY; - for (int r = page.FromRow; r < imgRow; r++) - y -= page.RowHeights[r - page.FromRow]; - y -= pic.From.RowOff / (double)ExcelDrawing.EMU_PER_POINT; - - // Size: rendered pixel size (already resolved from the anchor by EPPlus) β†’ points. + if (pic.From == null) continue; // cell-anchored only + + int imgColLocal = (pic.From.Column + 1) - fromCol; // From.Row/Column are 0-based + int imgRowLocal = (pic.From.Row + 1) - fromRow; + if (imgColLocal < 0 || imgColLocal >= colPrefix.Length - 1) continue; // anchor outside range + if (imgRowLocal < 0 || imgRowLocal >= rowPrefix.Length - 1) continue; + + // Absolute picture rectangle in range-local point space. + double imgLeft = colPrefix[imgColLocal] + pic.From.ColumnOff / (double)ExcelDrawing.EMU_PER_POINT; + double imgTop = rowPrefix[imgRowLocal] + pic.From.RowOff / (double)ExcelDrawing.EMU_PER_POINT; double width = pic.GetPixelWidth() * ExcelDrawing.EMU_PER_PIXEL / (double)ExcelDrawing.EMU_PER_POINT; double height = pic.GetPixelHeight() * ExcelDrawing.EMU_PER_PIXEL / (double)ExcelDrawing.EMU_PER_POINT; + double imgRight = imgLeft + width; + double imgBottom = imgTop + height; + + // Only place the picture on pages its rectangle actually overlaps (both axes). + if (imgLeft >= pageAbsRight || imgRight <= pageAbsLeft) continue; + if (imgTop >= pageAbsBottom || imgBottom <= pageAbsTop) continue; - var image = new PdfImageLayout(x, y, width, height) + // Position relative to THIS page's content origin. When the picture starts on an + // earlier page the offset is negative; the page's margin clip trims the overflow. + double x = contentStartX + (imgLeft - pageAbsLeft); + double y = contentStartY - (imgTop - pageAbsTop); + + page.Images.Add(new ImageDrawInfo { + X = x, + Y = y, + Width = width, + Height = height, ImageBytes = drawing.ImageBytes, Name = "Image_" + pic.Name, - }; - pageLayout.AddChild(image); + }); } + return page; } private static int GetTotalPages(List pdfPages) @@ -702,10 +785,11 @@ internal static List GetPages(PdfPageSettings pageSettings, PdfWorksheet[ pages = PrecomputeMergedCells(pageSettings, range, pages); pages = PrecomputeSpillCells(pageSettings, range, pages); pages = PrecomputePrintTitleCells(pageSettings, pdfSheet, range, pages); + pages = PrecomputeImages(pageSettings, range, pages, pdfSheet.Drawings); pages.HeadingFontName = pdfSheet.NormalStyle.Style.Font.Name; pages.HeadingFontSize = pdfSheet.NormalStyle.Style.Font.Size; pages.HeadingFill = pdfSheet.NormalStyle.Style.Fill; - pages.Drawings = pdfSheet.Drawings; + //pages.Drawings = pdfSheet.Drawings; PagesCollection.Add(pages); } if (pdfSheet.CommentsAndNotes.Range != null) 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 07/39] 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 6bfc199d0..d8c40e83c 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 4ff14c101..e502ec50b 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 5416f1887..d3d6efbc4 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 5c4a8c9e7..000000000 --- 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 000000000..0aca385d9 --- /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 537623af5..f0190de76 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 9f098c792..434bdd9b9 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 000000000..a046e7d2a --- /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 000000000..7f5a6454a --- /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 000000000..1b0fe4f65 --- /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 f82db724f..9b87793ef 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 94a77fb46..2b0c52b17 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 94101b2be5755bc3280f6320eae656b97caecf50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Fri, 21 Aug 2026 16:41:20 +0200 Subject: [PATCH 08/39] fixed width issue --- .../Export/PdfExport/Layout/PdfLayout.cs | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index 407cd79c0..a25b59711 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -703,7 +703,10 @@ internal static Pages PrecomputeImages(PdfPageSettings pageSettings, PdfRange ra pdfPages.Page[i] = PrecomputePageImages(pageSettings, range, pdfPages.Page[i], drawings, colPrefix, rowPrefix); return pdfPages; } - + private static double PointsFromEmu(long emu) => emu / (double)ExcelDrawing.EMU_PER_POINT; + private static double PointsFromPixels(double pixels) => pixels * ExcelDrawing.EMU_PER_PIXEL / (double)ExcelDrawing.EMU_PER_POINT; + private static double ColumnEdge(double[] colPrefix, int localCol) => colPrefix[Math.Max(0, Math.Min(localCol, colPrefix.Length - 1))]; + private static double RowEdge(double[] rowPrefix, int localRow) => rowPrefix[Math.Max(0, Math.Min(localRow, rowPrefix.Length - 1))]; private static Page PrecomputePageImages(PdfPageSettings pageSettings, PdfRange range, Page page, List drawings, double[] colPrefix, double[] rowPrefix) { page.Images = new List(); @@ -731,12 +734,21 @@ private static Page PrecomputePageImages(PdfPageSettings pageSettings, PdfRange if (imgRowLocal < 0 || imgRowLocal >= rowPrefix.Length - 1) continue; // Absolute picture rectangle in range-local point space. - double imgLeft = colPrefix[imgColLocal] + pic.From.ColumnOff / (double)ExcelDrawing.EMU_PER_POINT; - double imgTop = rowPrefix[imgRowLocal] + pic.From.RowOff / (double)ExcelDrawing.EMU_PER_POINT; - double width = pic.GetPixelWidth() * ExcelDrawing.EMU_PER_PIXEL / (double)ExcelDrawing.EMU_PER_POINT; - double height = pic.GetPixelHeight() * ExcelDrawing.EMU_PER_PIXEL / (double)ExcelDrawing.EMU_PER_POINT; - double imgRight = imgLeft + width; - double imgBottom = imgTop + height; + double imgLeft = colPrefix[imgColLocal] + PointsFromEmu(pic.From.ColumnOff); + double imgTop = rowPrefix[imgRowLocal] + PointsFromEmu(pic.From.RowOff); + double imgRight, imgBottom; + if (pic.To != null) + { + imgRight = ColumnEdge(colPrefix, (pic.To.Column + 1) - fromCol) + PointsFromEmu(pic.To.ColumnOff); + imgBottom = RowEdge(rowPrefix, (pic.To.Row + 1) - fromRow) + PointsFromEmu(pic.To.RowOff); + } + else + { + imgRight = imgLeft + PointsFromPixels(pic.GetPixelWidth()); + imgBottom = imgTop + PointsFromPixels(pic.GetPixelHeight()); + } + double width = imgRight - imgLeft; + double height = imgBottom - imgTop; // Only place the picture on pages its rectangle actually overlaps (both axes). if (imgLeft >= pageAbsRight || imgRight <= pageAbsLeft) continue; From 40388a85024aece05b7e5932dd51160c4b62dd62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Mon, 24 Aug 2026 10:48:38 +0200 Subject: [PATCH 09/39] support for absolute anchor --- .../Export/PdfExport/Layout/PdfLayout.cs | 71 +++++++++++++------ 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index a25b59711..48480472d 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -17,6 +17,7 @@ Date Author Change using EPPlus.Fonts.OpenType.Integration; using EPPlus.Fonts.OpenType.Integration.DataHolders; using EPPlus.Graphics; +using EPPlus.Graphics.Units; using OfficeOpenXml.Drawing; using OfficeOpenXml.Export.PdfExport.Data; using OfficeOpenXml.Export.PdfExport.TextShaping; @@ -688,7 +689,7 @@ private static void SetBorderStyle(PdfCellStyle style, PdfCellBorderLayout borde // } //} - internal static Pages PrecomputeImages(PdfPageSettings pageSettings, PdfRange range, Pages pdfPages, List drawings) + internal static Pages PrecomputeImages(PdfPageSettings pageSettings, PdfRange range, Pages pdfPages, List drawings, double zeroCharWidth) { if (drawings == null || drawings.Count == 0) return pdfPages; @@ -699,15 +700,27 @@ internal static Pages PrecomputeImages(PdfPageSettings pageSettings, PdfRange ra for (int i = 0; i < range.RowHeights.Count; i++) rowPrefix[i + 1] = rowPrefix[i] + range.RowHeights[i].Height; + // Point distance from cell A1 to the range's top-left, so an absolute-anchored picture + // (positioned in EMU from A1) can be shifted into the same range-local space the cell + // anchors use. Zero when the range starts at A1, which is the common case. Column widths + // use the exporter's basis (ZeroCharWidth); row heights are already points. + var ws = range.Range.Worksheet; + double rangeOriginX = 0d; + for (int c = 1; c < range.Range._fromCol; c++) + rangeOriginX += ws.Column(c).Hidden ? 0d : UnitConversion.ExcelColumnWidthToPoints(ws.Column(c).Width, zeroCharWidth); + double rangeOriginY = 0d; + for (int r = 1; r < range.Range._fromRow; r++) + rangeOriginY += ws.Row(r).Hidden ? 0d : ws.Row(r).Height; + for (int i = 0; i < pdfPages.Page.Length; i++) - pdfPages.Page[i] = PrecomputePageImages(pageSettings, range, pdfPages.Page[i], drawings, colPrefix, rowPrefix); + pdfPages.Page[i] = PrecomputePageImages(pageSettings, range, pdfPages.Page[i], drawings, colPrefix, rowPrefix, rangeOriginX, rangeOriginY); return pdfPages; } private static double PointsFromEmu(long emu) => emu / (double)ExcelDrawing.EMU_PER_POINT; private static double PointsFromPixels(double pixels) => pixels * ExcelDrawing.EMU_PER_PIXEL / (double)ExcelDrawing.EMU_PER_POINT; private static double ColumnEdge(double[] colPrefix, int localCol) => colPrefix[Math.Max(0, Math.Min(localCol, colPrefix.Length - 1))]; private static double RowEdge(double[] rowPrefix, int localRow) => rowPrefix[Math.Max(0, Math.Min(localRow, rowPrefix.Length - 1))]; - private static Page PrecomputePageImages(PdfPageSettings pageSettings, PdfRange range, Page page, List drawings, double[] colPrefix, double[] rowPrefix) + private static Page PrecomputePageImages(PdfPageSettings pageSettings, PdfRange range, Page page, List drawings, double[] colPrefix, double[] rowPrefix, double rangeOriginX, double rangeOriginY) { page.Images = new List(); int fromCol = range.Range._fromCol; @@ -726,26 +739,44 @@ private static Page PrecomputePageImages(PdfPageSettings pageSettings, PdfRange { if (drawing.PictureType != ePictureType.Jpg) continue; // JPEG first var pic = drawing.Picture; - if (pic.From == null) continue; // cell-anchored only - - int imgColLocal = (pic.From.Column + 1) - fromCol; // From.Row/Column are 0-based - int imgRowLocal = (pic.From.Row + 1) - fromRow; - if (imgColLocal < 0 || imgColLocal >= colPrefix.Length - 1) continue; // anchor outside range - if (imgRowLocal < 0 || imgRowLocal >= rowPrefix.Length - 1) continue; - - // Absolute picture rectangle in range-local point space. - double imgLeft = colPrefix[imgColLocal] + PointsFromEmu(pic.From.ColumnOff); - double imgTop = rowPrefix[imgRowLocal] + PointsFromEmu(pic.From.RowOff); - double imgRight, imgBottom; - if (pic.To != null) + double imgLeft, imgTop, imgRight, imgBottom; + if (pic.From != null) + { + // One-cell / two-cell: top-left is a cell + EMU offset. Resolve against the SAME + // column-width and row-height arrays the grid is drawn with, so the picture can't + // disagree with its columns (GetPixelWidth would use a different digit-width basis). + int imgColLocal = (pic.From.Column + 1) - fromCol; // From.Row/Column are 0-based + int imgRowLocal = (pic.From.Row + 1) - fromRow; + if (imgColLocal < 0 || imgColLocal >= colPrefix.Length - 1) continue; // anchor outside range + if (imgRowLocal < 0 || imgRowLocal >= rowPrefix.Length - 1) continue; + imgLeft = colPrefix[imgColLocal] + PointsFromEmu(pic.From.ColumnOff); + imgTop = rowPrefix[imgRowLocal] + PointsFromEmu(pic.From.RowOff); + if (pic.To != null) + { + // Two-cell: bottom-right is another cell + offset (same grid basis). + imgRight = ColumnEdge(colPrefix, (pic.To.Column + 1) - fromCol) + PointsFromEmu(pic.To.ColumnOff); + imgBottom = RowEdge(rowPrefix, (pic.To.Row + 1) - fromRow) + PointsFromEmu(pic.To.RowOff); + } + else + { + // One-cell: size is the intrinsic ext. GetPixelWidth/Height reduces to an exact + // EMU->point conversion here, so there is no digit-width basis to disagree with. + imgRight = imgLeft + PointsFromPixels(pic.GetPixelWidth()); + imgBottom = imgTop + PointsFromPixels(pic.GetPixelHeight()); + } + } + else if (pic.Position != null && pic.Size != null) { - imgRight = ColumnEdge(colPrefix, (pic.To.Column + 1) - fromCol) + PointsFromEmu(pic.To.ColumnOff); - imgBottom = RowEdge(rowPrefix, (pic.To.Row + 1) - fromRow) + PointsFromEmu(pic.To.RowOff); + // Absolute anchor: fixed EMU position from cell A1 + fixed ext. Shift by the range + // origin so it lands in the same range-local space as the cell anchors. + imgLeft = PointsFromEmu(pic.Position.X) - rangeOriginX; + imgTop = PointsFromEmu(pic.Position.Y) - rangeOriginY; + imgRight = imgLeft + PointsFromEmu(pic.Size.Width); + imgBottom = imgTop + PointsFromEmu(pic.Size.Height); } else { - imgRight = imgLeft + PointsFromPixels(pic.GetPixelWidth()); - imgBottom = imgTop + PointsFromPixels(pic.GetPixelHeight()); + continue; // no usable anchor (e.g. grouped / chart-relative) } double width = imgRight - imgLeft; double height = imgBottom - imgTop; @@ -797,7 +828,7 @@ internal static List GetPages(PdfPageSettings pageSettings, PdfWorksheet[ pages = PrecomputeMergedCells(pageSettings, range, pages); pages = PrecomputeSpillCells(pageSettings, range, pages); pages = PrecomputePrintTitleCells(pageSettings, pdfSheet, range, pages); - pages = PrecomputeImages(pageSettings, range, pages, pdfSheet.Drawings); + pages = PrecomputeImages(pageSettings, range, pages, pdfSheet.Drawings, pdfSheet.ZeroCharWidth); pages.HeadingFontName = pdfSheet.NormalStyle.Style.Font.Name; pages.HeadingFontSize = pdfSheet.NormalStyle.Style.Font.Size; pages.HeadingFill = pdfSheet.NormalStyle.Style.Fill; 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 10/39] 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 a7f2fe816..b2a1f06c0 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 a2b449a32..5682e42e1 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 8ab741f0c..c106befc4 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 434bdd9b9..000000000 --- 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 a046e7d2a..bac0dea09 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 9b87793ef..5c30cbf63 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 2b0c52b17..bcd046ff8 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 9c651bea3649740b3f8a8d4ebf4bffc0f68bc9fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Mon, 24 Aug 2026 17:12:07 +0200 Subject: [PATCH 11/39] unhardcoded jpg --- src/EPPlus.Export.Pdf.Tests/PdfTests.cs | 11 ++++ .../DocumentObjects/PdfImageXObject.cs | 61 +++++++++++++++---- .../Export/PdfExport/Layout/PdfLayout.cs | 3 +- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs index 8ab741f0c..51d5b137b 100644 --- a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs +++ b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs @@ -552,6 +552,17 @@ public void TableDiff() wb.SaveAsPdf(path, ws0); } + [TestMethod] + public void PictureOutside() + { + using var p = OpenTemplatePackage("Pdf_picture_outside.xlsx"); + var wb = p.Workbook; + var ws0 = wb.Worksheets[0]; + ws0.PrinterSettings.ShowGridLines = true; + string path = _pdfPath + "PictureOutside.pdf"; + wb.SaveAsPdf(path, ws0); + } + [TestMethod] public void EPPlusToPdf() { diff --git a/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs b/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs index c9d919fd4..5f9fa3df2 100644 --- a/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs +++ b/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection.Emit; using System.Text; using System.Threading.Tasks; @@ -9,47 +10,73 @@ namespace EPPlus.Export.Pdf.DocumentObjects { internal class PdfImageXObject : PdfObject { - private readonly byte[] _jpeg; + private readonly byte[] _bytes; internal int Width { get; } internal int Height { get; } internal string ColorSpace { get; } + internal string Filter { get; } + internal string Decode { get; private set; } - public PdfImageXObject(int objectNumber, byte[] jpegBytes, int version = 0) + public PdfImageXObject(int objectNumber, byte[] imageBytes, int version = 0) : base(objectNumber, version) { - _jpeg = jpegBytes; - ReadJpegInfo(jpegBytes, out int width, out int height, out int components); - Width = width; - Height = height; - ColorSpace = components == 1 ? "DeviceGray" : components == 4 ? "DeviceCMYK" : "DeviceRGB"; + _bytes = imageBytes; + if (IsJpeg(imageBytes)) + { + // A JPEG embeds verbatim: /DCTDecode is exactly the JPEG's own coding. + Filter = "DCTDecode"; + ReadJpegInfo(imageBytes, out int width, out int height, out int components, out bool adobe); + Width = width; + Height = height; + if (components == 4) + { + ColorSpace = "DeviceCMYK"; + // Adobe writes CMYK JPEGs with every channel inverted; flip them back so the + // picture doesn't render as a negative. Straight (non-Adobe) CMYK is left as-is. + if (adobe) Decode = "[ 1 0 1 0 1 0 1 0 ]"; + } + else + { + ColorSpace = components == 1 ? "DeviceGray" : "DeviceRGB"; + } + } + else + { + // Unsupported encodings are screened out in PrecomputeImages; keep a safe default so + // an unexpected byte stream can't crash the export (future formats add a branch above). + Filter = "DCTDecode"; + ColorSpace = "DeviceRGB"; + } } + private static bool IsJpeg(byte[] d) => d != null && d.Length > 2 && d[0] == 0xFF && d[1] == 0xD8; + private string DictHeader() { return "<< /Type /XObject /Subtype /Image" + $" /Width {Width} /Height {Height}" + $" /ColorSpace /{ColorSpace} /BitsPerComponent 8" + - $" /Filter /DCTDecode /Length {_jpeg.Length} >>"; + $" /Filter /{Filter} /Length {_bytes.Length} >>"; } internal override string RenderDictionary() { // Debug/text dump only β€” never the real output β€” so the binary body is elided. - return DictHeader() + $"\nstream\n<{_jpeg.Length} bytes of JPEG data>\nendstream"; + return DictHeader() + $"\nstream\n<{_bytes.Length} bytes of image data>\nendstream"; } internal override void RenderDictionary(BinaryWriter bw) { WriteAscii(bw, DictHeader() + "\nstream\n"); - bw.Write(_jpeg); // raw JPEG β€” not Flate-compressed (already DCT-coded) + bw.Write(_bytes); // raw JPEG β€” not Flate-compressed (already DCT-coded) WriteAscii(bw, "\nendstream"); } // Minimal JPEG reader: walk the marker segments to the Start-Of-Frame and read the frame's // height, width and component count. Handles baseline and progressive SOFs. - private static void ReadJpegInfo(byte[] d, out int width, out int height, out int components) + private static void ReadJpegInfo(byte[] d, out int width, out int height, out int components, out bool adobe) { - width = 0; height = 0; components = 3; + width = 0; height = 0; components = 3; adobe = false; if (d == null || d.Length < 4 || d[0] != 0xFF || d[1] != 0xD8) return; // not a JPEG int i = 2; while (i + 1 < d.Length) @@ -64,6 +91,14 @@ private static void ReadJpegInfo(byte[] d, out int width, out int height, out in } if (i + 3 >= d.Length) return; int segLen = (d[i + 2] << 8) | d[i + 3]; + // Adobe APP14 marker (FF EE) with an "Adobe" payload: Adobe-written, so 4-channel + // data is stored inverted (the caller adds /Decode to correct it). APP14 precedes SOF. + if (marker == 0xEE && i + 8 < d.Length && + d[i + 4] == (byte)'A' && d[i + 5] == (byte)'d' && d[i + 6] == (byte)'o' && + d[i + 7] == (byte)'b' && d[i + 8] == (byte)'e') + { + adobe = true; + } // SOF markers hold the frame size: C0..CF except C4 (DHT), C8 (JPG ext), CC (DAC). if (marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC) { @@ -73,7 +108,7 @@ private static void ReadJpegInfo(byte[] d, out int width, out int height, out in components = d[i + 9]; return; } - if (segLen < 2) return; + if (segLen < 2) return; // malformed i += 2 + segLen; } } diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index 48480472d..97d6886e8 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -720,6 +720,7 @@ internal static Pages PrecomputeImages(PdfPageSettings pageSettings, PdfRange ra private static double PointsFromPixels(double pixels) => pixels * ExcelDrawing.EMU_PER_PIXEL / (double)ExcelDrawing.EMU_PER_POINT; private static double ColumnEdge(double[] colPrefix, int localCol) => colPrefix[Math.Max(0, Math.Min(localCol, colPrefix.Length - 1))]; private static double RowEdge(double[] rowPrefix, int localRow) => rowPrefix[Math.Max(0, Math.Min(localRow, rowPrefix.Length - 1))]; + private static bool IsSupportedPicture(ePictureType type) => type == ePictureType.Jpg; private static Page PrecomputePageImages(PdfPageSettings pageSettings, PdfRange range, Page page, List drawings, double[] colPrefix, double[] rowPrefix, double rangeOriginX, double rangeOriginY) { page.Images = new List(); @@ -737,7 +738,7 @@ private static Page PrecomputePageImages(PdfPageSettings pageSettings, PdfRange foreach (var drawing in drawings) { - if (drawing.PictureType != ePictureType.Jpg) continue; // JPEG first + if (!IsSupportedPicture(drawing.PictureType)) continue; var pic = drawing.Picture; double imgLeft, imgTop, imgRight, imgBottom; if (pic.From != null) 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 12/39] 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 b2a1f06c0..c30f7b9b7 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 64e96421e..34ec0a4f8 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 e502ec50b..3893ca053 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 5c30cbf63..66085bc9e 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 13/39] 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 8ab741f0c..4f05b1de4 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 5416f1887..d3d6efbc4 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 6a7d8c510..ab3056461 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 d2f9f50e7..8b432e073 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 f4c4f1a9a..27f1f7ae5 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 159b638bd085e57d76a89c15aab0a4f58a3bc1f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Tue, 25 Aug 2026 16:39:18 +0200 Subject: [PATCH 14/39] Added png. working on supporting png with alpha --- .../DocumentObjects/PdfImageXObject.cs | 181 ++++++++++++++++-- src/EPPlus/Export/PdfExport/PdfCatalog.cs | 18 +- 2 files changed, 181 insertions(+), 18 deletions(-) diff --git a/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs b/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs index 5f9fa3df2..4ccbe1675 100644 --- a/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs +++ b/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs @@ -1,10 +1,17 @@ -ο»Ώusing System; -using System.Collections.Generic; +ο»Ώ/************************************************************************************************* + 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 System.IO; -using System.Linq; -using System.Reflection.Emit; using System.Text; -using System.Threading.Tasks; namespace EPPlus.Export.Pdf.DocumentObjects { @@ -14,49 +21,133 @@ internal class PdfImageXObject : PdfObject internal int Width { get; } internal int Height { get; } internal string ColorSpace { get; } + internal int BitsPerComponent { get; } internal string Filter { get; } internal string Decode { get; private set; } + internal string DecodeParms { get; } + + internal bool HasSoftMask { get; } + internal byte[] SoftMaskData { get; } + internal int SoftMaskObjectNumber { get; set; } public PdfImageXObject(int objectNumber, byte[] imageBytes, int version = 0) : base(objectNumber, version) { - _bytes = imageBytes; if (IsJpeg(imageBytes)) { + _bytes = imageBytes; // A JPEG embeds verbatim: /DCTDecode is exactly the JPEG's own coding. Filter = "DCTDecode"; + BitsPerComponent = 8; ReadJpegInfo(imageBytes, out int width, out int height, out int components, out bool adobe); Width = width; Height = height; if (components == 4) { - ColorSpace = "DeviceCMYK"; + ColorSpace = "/DeviceCMYK"; // Adobe writes CMYK JPEGs with every channel inverted; flip them back so the // picture doesn't render as a negative. Straight (non-Adobe) CMYK is left as-is. if (adobe) Decode = "[ 1 0 1 0 1 0 1 0 ]"; } else { - ColorSpace = components == 1 ? "DeviceGray" : "DeviceRGB"; + ColorSpace = components == 1 ? "/DeviceGray" : "/DeviceRGB"; + } + } + else if (IsPng(imageBytes)) + { + ReadPngHeader(imageBytes, out int width, out int height, out int bitDepth, out int colorType, out int _); + Width = width; + Height = height; + Filter = "FlateDecode"; + if (colorType == 6 || colorType == 4) + { + // Alpha channel present: decode the PNG and split colour from alpha. The colour + // samples become this image; the alpha rides along as a grayscale soft mask. + BitsPerComponent = 8; + ColorSpace = colorType == 6 ? "/DeviceRGB" : "/DeviceGray"; + DecodePngWithAlpha(imageBytes, width, height, colorType, out byte[] color, out byte[] alpha); + _bytes = color; // raw colour samples, re-deflated (no PNG predictor) + SoftMaskData = alpha; // raw alpha, re-deflated -> companion /SMask object + HasSoftMask = true; + } + else + { + // Opaque (0/2/3): keep the compressed pixel data as-is. The concatenated IDAT is a + // complete zlib stream of PNG-filtered rows β€” exactly what /FlateDecode + a PNG + // predictor expect β€” so the viewer does the inflate and un-filter for us. + BitsPerComponent = bitDepth; + _bytes = ReadPngIdat(imageBytes, out byte[] palette); + int colors; + switch (colorType) + { + case 0: // greyscale + ColorSpace = "/DeviceGray"; + colors = 1; + break; + case 3: // palette index -> RGB lookup table carried inline + int hival = palette == null || palette.Length < 3 ? 0 : (palette.Length / 3) - 1; + ColorSpace = "[ /Indexed /DeviceRGB " + hival + " <" + ToHex(palette) + "> ]"; + colors = 1; + break; + default: // colour type 2 (truecolour RGB) + ColorSpace = "/DeviceRGB"; + colors = 3; + break; + } + // Predictor 15 = "PNG optimum" (any of the five row filters), described by the + // pixel layout so the viewer can reverse the per-row filtering. + DecodeParms = "<< /Predictor 15 /Colors " + colors + + " /BitsPerComponent " + bitDepth + + " /Columns " + width + " >>"; } } else { // Unsupported encodings are screened out in PrecomputeImages; keep a safe default so // an unexpected byte stream can't crash the export (future formats add a branch above). + _bytes = imageBytes; Filter = "DCTDecode"; - ColorSpace = "DeviceRGB"; + BitsPerComponent = 8; + ColorSpace = "/DeviceRGB"; } + + } + + internal static bool CanEmbed(byte[] imageBytes) + { + if (IsJpeg(imageBytes)) return true; + if (IsPng(imageBytes)) + { + if (!ReadPngHeader(imageBytes, out int _, out int _, out int _, out int colorType, out int interlace)) + return false; + if (interlace != 0) return false; + return colorType == 0 || colorType == 2 || colorType == 3; + } + return false; } private static bool IsJpeg(byte[] d) => d != null && d.Length > 2 && d[0] == 0xFF && d[1] == 0xD8; + private static readonly byte[] _pngSignature = { 137, 80, 78, 71, 13, 10, 26, 10 }; + private static bool IsPng(byte[] d) + { + if (d == null || d.Length<_pngSignature.Length) return false; + for (int i = 0; i<_pngSignature.Length; i++) + if (d[i] != _pngSignature[i]) return false; + return true; + } + private string DictHeader() { + string decode = string.IsNullOrEmpty(Decode) ? "" : $" /Decode {Decode}"; + string decodeParms = string.IsNullOrEmpty(DecodeParms) ? "" : $" /DecodeParms {DecodeParms}"; return "<< /Type /XObject /Subtype /Image" + $" /Width {Width} /Height {Height}" + - $" /ColorSpace /{ColorSpace} /BitsPerComponent 8" + - $" /Filter /{Filter} /Length {_bytes.Length} >>"; + $" /ColorSpace {ColorSpace} /BitsPerComponent {BitsPerComponent}" + + decode + + $" /Filter /{Filter}" + decodeParms + + $" /Length {_bytes.Length} >>"; } internal override string RenderDictionary() @@ -112,5 +203,73 @@ private static void ReadJpegInfo(byte[] d, out int width, out int height, out in i += 2 + segLen; } } + + private static bool ReadPngHeader(byte[] d, out int width, out int height, out int bitDepth, out int colorType, out int interlace) + { + width = height = bitDepth = colorType = interlace = 0; + if (!IsPng(d)) return false; + int p = _pngSignature.Length; // first chunk starts after the signature + if (p + 8 + 13 > d.Length) return false; + if (Ascii(d, p + 4, 4) != "IHDR") return false; + int q = p + 8; // IHDR chunk data + width = ReadBE32(d, q); + height = ReadBE32(d, q + 4); + bitDepth = d[q + 8]; + colorType = d[q + 9]; + // q+10 compression, q+11 filter (both always 0), q+12 interlace (0 none, 1 Adam7). + interlace = d[q + 12]; + return true; + } + + // Walk the chunk list and return the concatenated IDAT data (the zlib pixel stream) plus the + // palette, if any. The zlib stream can be split across several IDAT chunks, so it is stitched + // back together in order. + private static byte[] ReadPngIdat(byte[] d, out byte[] palette) + { + palette = null; + using (var idat = new MemoryStream()) + { + int p = _pngSignature.Length; + while (p + 8 <= d.Length) + { + int len = ReadBE32(d, p); + string type = Ascii(d, p + 4, 4); + int dataStart = p + 8; + if (len < 0 || dataStart + len + 4 > d.Length) break; // truncated / malformed + if (type == "PLTE") + { + palette = new byte[len]; + System.Array.Copy(d, dataStart, palette, 0, len); + } + else if (type == "IDAT") + { + idat.Write(d, dataStart, len); + } + else if (type == "IEND") + { + break; + } + p = dataStart + len + 4; // skip data + 4-byte CRC + } + return idat.ToArray(); + } + } + + private static readonly char[] _hex = "0123456789ABCDEF".ToCharArray(); + private static string ToHex(byte[] bytes) + { + if (bytes == null) return ""; + var sb = new StringBuilder(bytes.Length * 2); + foreach (var b in bytes) + { + sb.Append(_hex[b >> 4]); + sb.Append(_hex[b & 0x0F]); + } + return sb.ToString(); + } + + // Big-endian 32-bit read (PNG stores all integers most-significant byte first). + private static int ReadBE32(byte[] d, int i) => (d[i] << 24) | (d[i + 1] << 16) | (d[i + 2] << 8) | d[i + 3]; + private static string Ascii(byte[] d, int i, int len) => Encoding.ASCII.GetString(d, i, len); } } diff --git a/src/EPPlus/Export/PdfExport/PdfCatalog.cs b/src/EPPlus/Export/PdfExport/PdfCatalog.cs index 94b4b89bb..7edde5ddc 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.Drawing; using OfficeOpenXml.Export.PdfExport.Data; @@ -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; @@ -463,7 +457,17 @@ private List GetRanges(ExcelWorksheet worksheet) else { var range = worksheet.DimensionByVisibility; - var pdfRange = new PdfRange(range, true); + int toRow = range?.End.Row ?? 1; + int toCol = range?.End.Column ?? 1; + foreach (var drawing in worksheet.Drawings) + { + drawing.GetToBounds(out int drawToRow, out _, out int drawToCol, out _); + if (drawToRow + 1 > toRow) toRow = drawToRow + 1; + if (drawToCol + 1 > toCol) toCol = drawToCol + 1; + } + if (toRow > ExcelPackage.MaxRows) toRow = ExcelPackage.MaxRows; + if (toCol > ExcelPackage.MaxColumns) toCol = ExcelPackage.MaxColumns; + var pdfRange = new PdfRange(worksheet.Cells[1, 1, toRow, toCol], true); pdfRange.ExtendColumns = true; ranges.Add(pdfRange); } From b694baa0dfa2284e706c7b08f8a373738e887981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Wed, 26 Aug 2026 11:15:28 +0200 Subject: [PATCH 15/39] transparent png in pdf progress --- .../DocumentObjects/PdfImageXObject.cs | 120 +++++++++++++++++- src/EPPlus.Export.Pdf/ExcelPdf.cs | 14 +- 2 files changed, 130 insertions(+), 4 deletions(-) diff --git a/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs b/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs index 4ccbe1675..a770af2b1 100644 --- a/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs +++ b/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs @@ -10,6 +10,7 @@ Date Author Change ************************************************************************************************* 27/11/2025 EPPlus Software AB EPPlus 9 *************************************************************************************************/ +using OfficeOpenXml.Packaging.Ionic.Zlib; using System.IO; using System.Text; @@ -114,15 +115,32 @@ public PdfImageXObject(int objectNumber, byte[] imageBytes, int version = 0) } + + private PdfImageXObject(int objectNumber, int version, byte[] deflatedGray, int width, int height) + : base(objectNumber, version) + { + _bytes = deflatedGray; + Width = width; + Height = height; + BitsPerComponent = 8; + ColorSpace = "/DeviceGray"; + Filter = "FlateDecode"; + } + + internal static PdfImageXObject CreateSoftMask(int objectNumber, byte[] deflatedGray, int width, int height) + => new PdfImageXObject(objectNumber, 0, deflatedGray, width, height); + internal static bool CanEmbed(byte[] imageBytes) { if (IsJpeg(imageBytes)) return true; if (IsPng(imageBytes)) { - if (!ReadPngHeader(imageBytes, out int _, out int _, out int _, out int colorType, out int interlace)) + if (!ReadPngHeader(imageBytes, out int _, out int _, out int bitDepth, out int colorType, out int interlace)) return false; - if (interlace != 0) return false; - return colorType == 0 || colorType == 2 || colorType == 3; + if (interlace != 0) return false; // Adam7 not handled + if (colorType == 0 || colorType == 2 || colorType == 3) return true; // opaque, verbatim + if (colorType == 4 || colorType == 6) return bitDepth == 8; // alpha -> decode + soft mask + return false; } return false; } @@ -140,11 +158,13 @@ private static bool IsPng(byte[] d) private string DictHeader() { + string smask = HasSoftMask ? $" /SMask {SoftMaskObjectNumber} 0 R" : ""; string decode = string.IsNullOrEmpty(Decode) ? "" : $" /Decode {Decode}"; string decodeParms = string.IsNullOrEmpty(DecodeParms) ? "" : $" /DecodeParms {DecodeParms}"; return "<< /Type /XObject /Subtype /Image" + $" /Width {Width} /Height {Height}" + $" /ColorSpace {ColorSpace} /BitsPerComponent {BitsPerComponent}" + + smask + decode + $" /Filter /{Filter}" + decodeParms + $" /Length {_bytes.Length} >>"; @@ -255,6 +275,100 @@ private static byte[] ReadPngIdat(byte[] d, out byte[] palette) } } + private static void DecodePngWithAlpha(byte[] pngBytes, int width, int height, int colorType, + out byte[] deflatedColor, out byte[] deflatedAlpha) + { + int channels = colorType == 6 ? 4 : 2; // RGBA or grey+alpha + int colorChannels = colorType == 6 ? 3 : 1; + byte[] filtered = ZlibDecompress(ReadPngIdat(pngBytes, out byte[] _)); + + int stride = width * channels; // 8-bit: one byte per channel + var color = new byte[width * height * colorChannels]; + var alpha = new byte[width * height]; + var prev = new byte[stride]; + var cur = new byte[stride]; + int pos = 0, ci = 0, ai = 0; + for (int y = 0; y < height; y++) + { + int filter = pos < filtered.Length ? filtered[pos++] : 0; // per-row filter type byte + for (int x = 0; x < stride; x++) + { + int raw = pos < filtered.Length ? filtered[pos++] : 0; + int a = x >= channels ? cur[x - channels] : 0; // reconstructed byte to the left + int b = prev[x]; // byte above + int c = x >= channels ? prev[x - channels] : 0; // byte above-left + int val; + switch (filter) + { + case 1: val = raw + a; break; // Sub + case 2: val = raw + b; break; // Up + case 3: val = raw + ((a + b) >> 1); break; // Average + case 4: val = raw + Paeth(a, b, c); break; // Paeth + default: val = raw; break; // None + } + cur[x] = (byte)(val & 0xFF); + } + // De-interleave this row: colour bytes to the image, the last channel to the mask. + for (int x = 0; x < width; x++) + { + int p = x * channels; + if (colorType == 6) + { + color[ci++] = cur[p]; + color[ci++] = cur[p + 1]; + color[ci++] = cur[p + 2]; + alpha[ai++] = cur[p + 3]; + } + else + { + color[ci++] = cur[p]; + alpha[ai++] = cur[p + 1]; + } + } + var swap = prev; prev = cur; cur = swap; // this row becomes "previous" for the next + } + deflatedColor = ZlibCompress(color); + deflatedAlpha = ZlibCompress(alpha); + } + + // PNG Paeth predictor (integer, no Math dependency). + private static int Paeth(int a, int b, int c) + { + int p = a + b - c; + int pa = p > a ? p - a : a - p; + int pb = p > b ? p - b : b - p; + int pc = p > c ? p - c : c - p; + if (pa <= pb && pa <= pc) return a; + return pb <= pc ? b : c; + } + + // zlib (RFC 1950) round-trips: PNG IDAT and PDF /FlateDecode are both zlib streams, so the + // same codec decompresses the IDAT and compresses the split colour / alpha back. + private static byte[] ZlibDecompress(byte[] data) + { + using (var input = new MemoryStream(data)) + using (var z = new ZlibStream(input, CompressionMode.Decompress)) + using (var output = new MemoryStream()) + { + byte[] buffer = new byte[8192]; + int n; + while ((n = z.Read(buffer, 0, buffer.Length)) > 0) output.Write(buffer, 0, n); + return output.ToArray(); + } + } + + private static byte[] ZlibCompress(byte[] data) + { + using (var output = new MemoryStream()) + { + using (var z = new ZlibStream(output, CompressionMode.Compress, CompressionLevel.BestCompression, true)) + { + z.Write(data, 0, data.Length); + } // disposing flushes the final bytes + Adler-32 into output + return output.ToArray(); + } + } + private static readonly char[] _hex = "0123456789ABCDEF".ToCharArray(); private static string ToHex(byte[] bytes) { diff --git a/src/EPPlus.Export.Pdf/ExcelPdf.cs b/src/EPPlus.Export.Pdf/ExcelPdf.cs index ca0ae0abc..4b445abe4 100644 --- a/src/EPPlus.Export.Pdf/ExcelPdf.cs +++ b/src/EPPlus.Export.Pdf/ExcelPdf.cs @@ -124,7 +124,19 @@ private void AddImageData() { foreach (var image in _dictionaries.Images) { - _document.Add(image.Value.GetImageObject(_document.Count + 1)); + var img = image.Value.GetImageObject(_document.Count + 1); + if (img.HasSoftMask) + { + // Alpha PNG: the alpha channel is a separate grayscale /SMask object. Add it + // first, then point the image at it and shift the image (and the page /XObject + // reference in image.Value) to the next slot so all three numbers agree. + var mask = PdfImageXObject.CreateSoftMask(_document.Count + 1, img.SoftMaskData, img.Width, img.Height); + _document.Add(mask); + img.SoftMaskObjectNumber = mask.objectNumber; + img.objectNumber = _document.Count + 1; + image.Value.objectNumber = img.objectNumber; + } + _document.Add(img); } } 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 16/39] 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 000000000..2304d16e7 --- /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 000000000..6ca194d05 --- /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 f8237e49e..fe4573955 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 f29a296eb..b389889a5 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 ce421c92ea16893861f9e4e5e0a7fe9ec42b0e3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Wed, 26 Aug 2026 14:00:02 +0200 Subject: [PATCH 17/39] png progress --- src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs | 9 +++++++++ src/EPPlus.Export.Pdf/DocumentObjects/PdfPage.cs | 3 +++ src/EPPlus.Export.Pdf/ExcelPdf.cs | 1 + src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs | 4 ++-- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs b/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs index a770af2b1..1aab5c14b 100644 --- a/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs +++ b/src/EPPlus.Export.Pdf/DocumentObjects/PdfImageXObject.cs @@ -145,6 +145,15 @@ internal static bool CanEmbed(byte[] imageBytes) return false; } + internal static bool ProducesSoftMask(byte[] imageBytes) + { + if (!IsPng(imageBytes)) return false; + if (!ReadPngHeader(imageBytes, out int _, out int _, out int bitDepth, out int colorType, out int interlace)) + return false; + if (interlace != 0) return false; + return (colorType == 4 || colorType == 6) && bitDepth == 8; + } + private static bool IsJpeg(byte[] d) => d != null && d.Length > 2 && d[0] == 0xFF && d[1] == 0xD8; private static readonly byte[] _pngSignature = { 137, 80, 78, 71, 13, 10, 26, 10 }; diff --git a/src/EPPlus.Export.Pdf/DocumentObjects/PdfPage.cs b/src/EPPlus.Export.Pdf/DocumentObjects/PdfPage.cs index bc587a2cb..f90371b2a 100644 --- a/src/EPPlus.Export.Pdf/DocumentObjects/PdfPage.cs +++ b/src/EPPlus.Export.Pdf/DocumentObjects/PdfPage.cs @@ -27,6 +27,7 @@ internal class PdfPage : PdfObject internal readonly List contentObjectNumbers; PdfDictionaries dictionaries; internal PdfPageSize Size; + internal bool HasTransparency; public PdfPage(int objectNumber, int parentObjectNumber, List contentObjectNumbers, PdfPageSize size, PdfDictionaries dictionaries, int version = 0) : base(objectNumber, version) @@ -96,6 +97,8 @@ internal override void RenderDictionary(BinaryWriter bw) if (hasImage) sb.AppendFormat($" /XObject << {images} >>\n"); sb.AppendFormat($" >>\n"); } + if (HasTransparency) + sb.AppendFormat($" /Group << /Type /Group /S /Transparency /CS /DeviceRGB >>\n"); sb.AppendFormat($" /MediaBox [ 0 0 {Size.WidthPu.ToPdfString()} {Size.HeightPu.ToPdfString()} ]\n" + $" /Contents [ {string.Join(" ", contentEntries)} ] >>"); WriteAscii(bw, sb.ToString()); diff --git a/src/EPPlus.Export.Pdf/ExcelPdf.cs b/src/EPPlus.Export.Pdf/ExcelPdf.cs index 4b445abe4..48a5cddb8 100644 --- a/src/EPPlus.Export.Pdf/ExcelPdf.cs +++ b/src/EPPlus.Export.Pdf/ExcelPdf.cs @@ -204,6 +204,7 @@ private void AddContent(Transform pageLayout, PdfPage page) { var imageResource = _dictionaries.AddImage(image.ImageBytes); contentStream.AddImage(imageResource.Label, image.LocalPosition.X, image.LocalPosition.Y, image.Size.X, image.Size.Y); + if (PdfImageXObject.ProducesSoftMask(image.ImageBytes)) page.HasTransparency = true; } //Close the clipping rectangle. contentStream.AddCommand("Q"); diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index 97d6886e8..8f9661a2d 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -10,6 +10,7 @@ Date Author Change ************************************************************************************************* 27/11/2025 EPPlus Software AB EPPlus 9 *************************************************************************************************/ +using EPPlus.Export.Pdf.DocumentObjects; using EPPlus.Export.Pdf.Helpers; using EPPlus.Export.Pdf.Layout; using EPPlus.Export.Pdf.Resources; @@ -720,7 +721,6 @@ internal static Pages PrecomputeImages(PdfPageSettings pageSettings, PdfRange ra private static double PointsFromPixels(double pixels) => pixels * ExcelDrawing.EMU_PER_PIXEL / (double)ExcelDrawing.EMU_PER_POINT; private static double ColumnEdge(double[] colPrefix, int localCol) => colPrefix[Math.Max(0, Math.Min(localCol, colPrefix.Length - 1))]; private static double RowEdge(double[] rowPrefix, int localRow) => rowPrefix[Math.Max(0, Math.Min(localRow, rowPrefix.Length - 1))]; - private static bool IsSupportedPicture(ePictureType type) => type == ePictureType.Jpg; private static Page PrecomputePageImages(PdfPageSettings pageSettings, PdfRange range, Page page, List drawings, double[] colPrefix, double[] rowPrefix, double rangeOriginX, double rangeOriginY) { page.Images = new List(); @@ -738,7 +738,7 @@ private static Page PrecomputePageImages(PdfPageSettings pageSettings, PdfRange foreach (var drawing in drawings) { - if (!IsSupportedPicture(drawing.PictureType)) continue; + if (!PdfImageXObject.CanEmbed(drawing.ImageBytes)) continue; var pic = drawing.Picture; double imgLeft, imgTop, imgRight, imgBottom; if (pic.From != null) From 5031a1f97b2439ff46ff04b07fec401039b61556 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Wed, 26 Aug 2026 16:30:01 +0200 Subject: [PATCH 18/39] 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 4f05b1de4..18fa9a4f2 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 27f1f7ae5..61048bbac 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 19/39] 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 0f67718f9..8a0dc819e 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 34ec0a4f8..60693fc59 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 000000000..52de10081 --- /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 2304d16e7..84de0df93 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 7c6198b17..5c9d5cffc 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 e5df082f7..751811440 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 bac0dea09..b7be7ccf0 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 bcd046ff8..2f71206f7 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 20/39] 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 8b432e073..ca4e94313 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 61048bbac..f97904e83 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 21/39] 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 fe20194ed..76287e5a1 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 22/39] 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 a566064de..141f00e95 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 76287e5a1..fe20194ed 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 e6fe821c3..b8edcb35c 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 23/39] 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 458d1bb62..be8439488 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 e6fe821c3..16309e94f 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 0cca173b4..174420715 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 0f6e9be03..8ac65d2c0 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 5e9746909..7218d6190 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 24/39] 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 a566064de..e4b3e03c0 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 cdddc3a9d..854fa67d2 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 76d47b756..bbd4640d8 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 25/39] 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 0d1421fd8..8810b92da 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 26/39] 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 16309e94f..baabb200c 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 27/39] 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 141f00e95..3e700671b 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 28/39] 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 000000000..2bd487f7f --- /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 000000000..b822c2612 --- /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 64e96421e..f86a6897a 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 ba565f6b1..f82a8f1f5 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 29/39] 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 de630d55a..aa56809c5 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 5af7cc401..9d24cd987 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 baabb200c..99314b073 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 30/39] 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 aa56809c5..bae876690 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 9d24cd987..6324c8fb5 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 99314b073..38cfe0971 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 31/39] 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 bae876690..e92608553 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 6324c8fb5..cb34c9f79 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 38cfe0971..b8953a792 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 32/39] 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 3e700671b..4cd23487e 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 cdddc3a9d..c1fc73abe 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 64e96421e..864a47ad6 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 76d47b756..1d3951ecc 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 b8edcb35c..014299d65 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 33/39] 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 c1fc73abe..9f897bfb9 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 014299d65..f7cb0c2a8 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 34/39] 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 4cd23487e..4fa1cf91a 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 fe20194ed..76287e5a1 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 f7cb0c2a8..2131c5bbb 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 35/39] 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 e92608553..0c385f71c 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 cb34c9f79..6ca888bfa 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 b8953a792..bed6e6a32 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 7218d6190..4d42233ac 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 36/39] 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 8cccf39c7..77bc0972b 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 fe20194ed..76287e5a1 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 90c45854d..f56e867d3 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; From 044ba1d342216d32357819577f76689c091e41c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Wed, 2 Sep 2026 16:09:54 +0200 Subject: [PATCH 37/39] image in header footer progress. --- src/EPPlus.Export.Pdf/ExcelPdf.cs | 8 +++ .../Layout/PdfHeaderFooter.cs | 19 +++++++ .../Layout/PdfImageLayout.cs | 2 + .../Export/PdfExport/Layout/PdfLayout.cs | 25 +++++++++ .../TextMapping/PdfHeaderFooterCollection.cs | 51 ++++++++++++++++++- 5 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/EPPlus.Export.Pdf/ExcelPdf.cs b/src/EPPlus.Export.Pdf/ExcelPdf.cs index 19480effa..7e587a50a 100644 --- a/src/EPPlus.Export.Pdf/ExcelPdf.cs +++ b/src/EPPlus.Export.Pdf/ExcelPdf.cs @@ -226,6 +226,7 @@ private void AddContent(PdfPageLayout pageLayout, PdfPage page) } foreach (PdfImageLayout image in pageLayout.ChildObjects.OfType()) { + if (image.IsHeaderFooter) continue; var imageResource = _dictionaries.AddImage(image.ImageBytes); contentStream.AddImage(imageResource.Label, image.LocalPosition.X, image.LocalPosition.Y, image.Size.X, image.Size.Y); if (PdfImageXObject.ProducesSoftMask(image.ImageBytes)) page.HasTransparency = true; @@ -257,6 +258,13 @@ private void AddContent(PdfPageLayout pageLayout, PdfPage page) { contentStream.AddCellContentLayout(hf, _dictionaries, pageSettings); } + foreach (PdfImageLayout image in pageLayout.ChildObjects.OfType()) + { + if (!image.IsHeaderFooter) continue; + var imageResource = _dictionaries.AddImage(image.ImageBytes); + contentStream.AddImage(imageResource.Label, image.LocalPosition.X, image.LocalPosition.Y, image.Size.X, image.Size.Y); + if (PdfImageXObject.ProducesSoftMask(image.ImageBytes)) page.HasTransparency = true; + } foreach (var titleCell in printTitleLayouts) { contentStream.AddCommand($"% PRINT TITLE : {titleCell.Name}"); diff --git a/src/EPPlus.Export.Pdf/Layout/PdfHeaderFooter.cs b/src/EPPlus.Export.Pdf/Layout/PdfHeaderFooter.cs index 1765ee5c6..dc4d74a44 100644 --- a/src/EPPlus.Export.Pdf/Layout/PdfHeaderFooter.cs +++ b/src/EPPlus.Export.Pdf/Layout/PdfHeaderFooter.cs @@ -55,4 +55,23 @@ public PdfHeaderFooter(List textFormats, List pageNumberIndex Section = section; } } + + internal class PdfHeaderFooterImage + { + public HeaderFooterType PageType; + public HeaderFooterAlignment Alignment; + public HeaderFooterSection Section; + public byte[] ImageBytes; + public double Width; + public double Height; + + public PdfHeaderFooterImage(byte[] imageBytes, double width, double height, HeaderFooterType type, HeaderFooterAlignment alignment, HeaderFooterSection section) + { + ImageBytes = imageBytes; + Width = width; + Height = height; + PageType = type; + Alignment = alignment; + Section = section; } + } } diff --git a/src/EPPlus.Export.Pdf/Layout/PdfImageLayout.cs b/src/EPPlus.Export.Pdf/Layout/PdfImageLayout.cs index 454bd1888..3fca93698 100644 --- a/src/EPPlus.Export.Pdf/Layout/PdfImageLayout.cs +++ b/src/EPPlus.Export.Pdf/Layout/PdfImageLayout.cs @@ -20,6 +20,8 @@ internal class PdfImageLayout : Transform { public byte[] ImageBytes; + public bool IsHeaderFooter; + public PdfImageLayout(double x, double y, double width, double height) : base(x, y - height, width, height) { diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index ad1668d1b..c1940d1a7 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -322,6 +322,31 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio text.GidsAndCharMap(dictionaries); pageLayout.AddChild(text); } + double hfContentWidth = pageSettings.PageSize.WidthPu - pageSettings.Margins.LeftPu - pageSettings.Margins.RightPu; + foreach (var hfSection in new[] { HeaderFooterSection.Header, HeaderFooterSection.Footer }) + { + foreach (var hfAlign in new[] { HeaderFooterAlignment.Left, HeaderFooterAlignment.Center, HeaderFooterAlignment.Right }) + { + var hfImg = page.HeaderFooters.GetImage(hfType, hfSection, hfAlign); + if (hfImg == null) continue; + if (!PdfImageXObject.CanEmbed(hfImg.ImageBytes)) continue; // only formats we can embed + double iw = hfImg.Width, ih = hfImg.Height; + double ix = hfAlign == HeaderFooterAlignment.Left + ? pageSettings.Margins.LeftPu + : hfAlign == HeaderFooterAlignment.Right + ? pageSettings.PageSize.WidthPu - pageSettings.Margins.RightPu - iw + : pageSettings.Margins.LeftPu + (hfContentWidth - iw) / 2d; + double iTop = hfSection == HeaderFooterSection.Header + ? pageSettings.PageSize.HeightPu - pageSettings.Margins.HeaderPu + : pageSettings.Margins.FooterPu + ih; + pageLayout.AddChild(new PdfImageLayout(ix, iTop, iw, ih) + { + ImageBytes = hfImg.ImageBytes, + IsHeaderFooter = true, + Name = $"{hfSection}{hfAlign}Image", + }); + } + } } PdfGridlinesLayout.AddGridLines(pageSettings, pages[j], pageLayout, borderOnly: !pageSettings.ShowGridLines || pdfPages[i].IsCommentsPage); pageLayout.ChildObjects.Sort((a, b) => diff --git a/src/EPPlus/Export/PdfExport/TextMapping/PdfHeaderFooterCollection.cs b/src/EPPlus/Export/PdfExport/TextMapping/PdfHeaderFooterCollection.cs index 8ac65d2c0..5c2840af1 100644 --- a/src/EPPlus/Export/PdfExport/TextMapping/PdfHeaderFooterCollection.cs +++ b/src/EPPlus/Export/PdfExport/TextMapping/PdfHeaderFooterCollection.cs @@ -11,8 +11,9 @@ Date Author Change 27/11/2025 EPPlus Software AB EPPlus 9 *************************************************************************************************/ using EPPlus.Export.Pdf.Layout; -using EPPlus.Export.Pdf.Settings; using EPPlus.Export.Pdf.Resources; +using EPPlus.Export.Pdf.Settings; +using OfficeOpenXml.Drawing.Vml; using OfficeOpenXml.Export.PdfExport.Data; using System.Collections.Generic; using System.Linq; @@ -22,6 +23,7 @@ namespace OfficeOpenXml.Export.PdfExport.TextMapping internal class PdfHeaderFooterCollection { public List PdfHeaderFooterEntries = new List(); + public List PdfHeaderFooterImages = new List(); public bool ScaleWithDocument = false; public bool AlignWithMargins = false; public bool HasFirstPage = false; @@ -156,6 +158,53 @@ public PdfHeaderFooterCollection(PdfPageSettings pageSettings, PdfDictionaries d entry.Content.ContentAligmnet = PdfTextMap.GetAlignmentData(entry); PdfHeaderFooterEntries.Add(entry); } + if (headerFooter?.Pictures != null) + { + foreach (ExcelVmlDrawingPicture picture in headerFooter.Pictures) + { + var bytes = picture?.Image?.ImageBytes; + if (bytes == null) continue; + if (!TryDecodeSlot(picture.Id, out var type, out var section, out var alignment)) continue; + PdfHeaderFooterImages.Add(new PdfHeaderFooterImage(bytes, picture.Width, picture.Height, type, alignment, section)); + } + } + } + + public PdfHeaderFooterImage GetImage(HeaderFooterType type, HeaderFooterSection section, HeaderFooterAlignment alignment) + { + return PdfHeaderFooterImages.FirstOrDefault(e => + e.PageType == type && e.Section == section && e.Alignment == alignment); + } + + // Decode a header/footer picture Id (e.g. "LH", "CFEVEN", "RHFIRST") into its slot. + // Layout: [alignment L/C/R][section H/F][optional variant EVEN|FIRST]; no variant = Odd. + private static bool TryDecodeSlot(string id, out HeaderFooterType type, out HeaderFooterSection section, out HeaderFooterAlignment alignment) + { + type = HeaderFooterType.Odd; + section = HeaderFooterSection.Header; + alignment = HeaderFooterAlignment.Left; + if (string.IsNullOrEmpty(id) || id.Length < 2) return false; + + switch (id[0]) + { + case 'L': alignment = HeaderFooterAlignment.Left; break; + case 'C': alignment = HeaderFooterAlignment.Center; break; + case 'R': alignment = HeaderFooterAlignment.Right; break; + default: return false; + } + + string code = id.Substring(1); + if (code[0] == 'H') section = HeaderFooterSection.Header; + else if (code[0] == 'F') section = HeaderFooterSection.Footer; + else return false; + + string variant = code.Substring(1); + if (variant.Length == 0) type = HeaderFooterType.Odd; + else if (variant == "EVEN") type = HeaderFooterType.Even; + else if (variant == "FIRST") type = HeaderFooterType.First; + else return false; + + return true; } public PdfHeaderFooter Get(HeaderFooterType type, HeaderFooterSection section, HeaderFooterAlignment alignment) From 55d13638e335160428027251521170a38e2f38c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Wed, 2 Sep 2026 17:04:11 +0200 Subject: [PATCH 38/39] fixed issue where every other row of table did not get a fill --- src/EPPlus.Export.Pdf.Tests/PdfTests.cs | 15 ++++++++ .../PdfExport/TextMapping/PdfTextMap.cs | 35 ++++++++++++++++--- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs index a0e96fb8b..45d76e7c7 100644 --- a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs +++ b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs @@ -942,5 +942,20 @@ public void GetClampedCellWidth_CellFitsWithinPage_ReturnsCellWidthUnchanged() Assert.AreEqual(51.71d, PdfLayout.GetClampedCellWidth(s, 126.31d, 51.71d), 0.0001); } + + + + + [TestMethod] + public void LargeTableTest1() + { + using var p = OpenTemplatePackage("BlazorSample1 (12).xlsx"); + var ws = p.Workbook.Worksheets[1]; + string path = _pdfPath + "LargeTableTest.pdf"; + ws.SaveAsPdf(path); + Assert.IsTrue(File.Exists(path), "PDF file was not created."); + AssertLooksLikePdf(File.ReadAllBytes(path)); + } + } } diff --git a/src/EPPlus/Export/PdfExport/TextMapping/PdfTextMap.cs b/src/EPPlus/Export/PdfExport/TextMapping/PdfTextMap.cs index 4d42233ac..40e8ca83f 100644 --- a/src/EPPlus/Export/PdfExport/TextMapping/PdfTextMap.cs +++ b/src/EPPlus/Export/PdfExport/TextMapping/PdfTextMap.cs @@ -232,13 +232,21 @@ private static void GetFillStyles(ExcelRangeBase cell, PdfCellStyle cellStyle, D } else if (table.ShowRowStripes) { - var fill = (tableRow & 1) == 0 ? tableStyle.SecondRowStripe.Style.Fill : tableStyle.FirstRowStripe.Style.Fill; - if (fill.HasValue) cellStyle.dxfFill = fill; + var stripe = (tableRow & 1) == 0 + ? tableStyle.SecondRowStripe.Style.Fill + : tableStyle.FirstRowStripe.Style.Fill; + cellStyle.dxfFill = FillIsPaintable(stripe) + ? stripe + : tableStyle.WholeTable.Style.Fill; } else if (table.ShowColumnStripes) { - var fill = (tableCol & 1) != 0 ? tableStyle.SecondColumnStripe.Style.Fill : tableStyle.FirstColumnStripe.Style.Fill; - if (fill.HasValue) cellStyle.dxfFill = fill; + var stripe = (tableCol & 1) != 0 + ? tableStyle.SecondColumnStripe.Style.Fill + : tableStyle.FirstColumnStripe.Style.Fill; + cellStyle.dxfFill = FillIsPaintable(stripe) + ? stripe + : tableStyle.WholeTable.Style.Fill; } } } @@ -1092,5 +1100,24 @@ private static ExcelTableNamedStyle GetTableStyle(ExcelTable table, Dictionary Date: Thu, 3 Sep 2026 15:46:55 +0200 Subject: [PATCH 39/39] fixed image in header footer positioning. --- .../Layout/PdfHeaderFooter.cs | 6 + .../Export/PdfExport/Layout/PdfLayout.cs | 178 ++++++++++++++---- .../TextMapping/PdfHeaderFooterCollection.cs | 48 ----- .../PdfExport/TextMapping/PdfTextMap.cs | 22 ++- 4 files changed, 173 insertions(+), 81 deletions(-) diff --git a/src/EPPlus.Export.Pdf/Layout/PdfHeaderFooter.cs b/src/EPPlus.Export.Pdf/Layout/PdfHeaderFooter.cs index dc4d74a44..f3bb91d1d 100644 --- a/src/EPPlus.Export.Pdf/Layout/PdfHeaderFooter.cs +++ b/src/EPPlus.Export.Pdf/Layout/PdfHeaderFooter.cs @@ -44,6 +44,12 @@ internal class PdfHeaderFooter public List NumberOfPagesIndexes = new List(); public List PageNumberIndexes = new List(); + public byte[] ImageBytes; + public double ImageWidth; + public double ImageHeight; + public int ImageFragmentIndex = -1; + public bool HasImage => ImageBytes != null; + public PdfHeaderFooter(List textFormats, List pageNumberIndexes, List numberOfPagesIndexes, HeaderFooterType type, HeaderFooterAlignment alignment, HeaderFooterSection section) { Content = new PdfCellBase(); diff --git a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs index c1940d1a7..7e2a83585 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -241,7 +241,7 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio //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) + if (leftH != null && !leftH.HasImage) { SubstitutePageNumbers(pageSettings, dictionaries, leftH, displayedPageNumber, totalPages); var ascent = leftH.Content.TextLines[0].LargestAscent; @@ -254,7 +254,7 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio pageLayout.AddChild(text); } var centerH = page.HeaderFooters.Get(hfType, HeaderFooterSection.Header, HeaderFooterAlignment.Center); - if (centerH != null) + if (centerH != null && !centerH.HasImage) { SubstitutePageNumbers(pageSettings, dictionaries, centerH, displayedPageNumber, totalPages); var ascent = centerH.Content.TextLines[0].LargestAscent; @@ -268,7 +268,7 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio pageLayout.AddChild(text); } var rightH = page.HeaderFooters.Get(hfType, HeaderFooterSection.Header, HeaderFooterAlignment.Right); - if (rightH != null) + if (rightH != null && !rightH.HasImage) { SubstitutePageNumbers(pageSettings, dictionaries, rightH, displayedPageNumber, totalPages); var ascent = rightH.Content.TextLines[0].LargestAscent; @@ -281,7 +281,7 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio pageLayout.AddChild(text); } var leftF = page.HeaderFooters.Get(hfType, HeaderFooterSection.Footer, HeaderFooterAlignment.Left); - if (leftF != null) + if (leftF != null && !leftF.HasImage) { SubstitutePageNumbers(pageSettings, dictionaries, leftF, displayedPageNumber, totalPages); int last = leftF.Content.TextLines.Count - 1; @@ -295,7 +295,7 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio pageLayout.AddChild(text); } var centerF = page.HeaderFooters.Get(hfType, HeaderFooterSection.Footer, HeaderFooterAlignment.Center); - if (centerF != null) + if (centerF != null && !centerF.HasImage) { SubstitutePageNumbers(pageSettings, dictionaries, centerF, displayedPageNumber, totalPages); int last = centerF.Content.TextLines.Count - 1; @@ -309,7 +309,7 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio pageLayout.AddChild(text); } var rightF = page.HeaderFooters.Get(hfType, HeaderFooterSection.Footer, HeaderFooterAlignment.Right); - if (rightF != null) + if (rightF != null && !rightF.HasImage) { SubstitutePageNumbers(pageSettings, dictionaries, rightF, displayedPageNumber, totalPages); int last = rightF.Content.TextLines.Count - 1; @@ -322,29 +322,95 @@ internal static Transform GetCatalog(int firstPageNumber, PdfDictionaries dictio text.GidsAndCharMap(dictionaries); pageLayout.AddChild(text); } + double hfContentLeft = pageSettings.Margins.LeftPu; double hfContentWidth = pageSettings.PageSize.WidthPu - pageSettings.Margins.LeftPu - pageSettings.Margins.RightPu; foreach (var hfSection in new[] { HeaderFooterSection.Header, HeaderFooterSection.Footer }) { foreach (var hfAlign in new[] { HeaderFooterAlignment.Left, HeaderFooterAlignment.Center, HeaderFooterAlignment.Right }) { - var hfImg = page.HeaderFooters.GetImage(hfType, hfSection, hfAlign); - if (hfImg == null) continue; - if (!PdfImageXObject.CanEmbed(hfImg.ImageBytes)) continue; // only formats we can embed - double iw = hfImg.Width, ih = hfImg.Height; - double ix = hfAlign == HeaderFooterAlignment.Left - ? pageSettings.Margins.LeftPu - : hfAlign == HeaderFooterAlignment.Right - ? pageSettings.PageSize.WidthPu - pageSettings.Margins.RightPu - iw - : pageSettings.Margins.LeftPu + (hfContentWidth - iw) / 2d; - double iTop = hfSection == HeaderFooterSection.Header + var hf = page.HeaderFooters.Get(hfType, hfSection, hfAlign); + if (hf == null || !hf.HasImage) continue; + + // Fill page-number placeholders before measuring/splitting; + // the recorded indexes are into the full fragment list. + ApplyPageNumbers(hf, displayedPageNumber, totalPages); + + // ── horizontal: split the run at the image ── + var frags = hf.Content.TextFragments; + int splitAt = hf.ImageFragmentIndex; + if (splitAt < 0 || splitAt > frags.Count) splitAt = frags.Count; + var beforeFrags = frags.GetRange(0, splitAt); + var afterFrags = frags.GetRange(splitAt, frags.Count - splitAt); + + double beforeWidth, beforeAscent, beforeDescent; + double afterWidth, afterAscent, afterDescent; + var beforeRun = ShapeHeaderFooterRun(pageSettings, dictionaries, beforeFrags, hfSection, out beforeWidth, out beforeAscent, out beforeDescent); + var afterRun = ShapeHeaderFooterRun(pageSettings, dictionaries, afterFrags, hfSection, out afterWidth, out afterAscent, out afterDescent); + + bool canEmbed = PdfImageXObject.CanEmbed(hf.ImageBytes); + double imgW = canEmbed ? hf.ImageWidth : 0d; + double imgH = canEmbed ? hf.ImageHeight : 0d; + + double runWidth = beforeWidth + imgW + afterWidth; + double runStart; + switch (hfAlign) + { + case HeaderFooterAlignment.Left: + runStart = hfContentLeft; + break; + case HeaderFooterAlignment.Right: + runStart = hfContentLeft + hfContentWidth - runWidth; + break; + default: // Center + runStart = hfContentLeft + (hfContentWidth - runWidth) / 2d; + break; + } + + // ── vertical: image anchored at the margin, text dropped to the image bottom ── + // Shared text metrics so the before-text and after-text sit on one line. + double ascent = System.Math.Max(beforeAscent, afterAscent); + double descent = System.Math.Max(beforeDescent, afterDescent); + + // The image is anchored at the outer margin edge and grows inward, so no margin ◄──── this whole + // space is lost: a header image's top sits on the header line, a footer image's ◄──── block is the + // bottom on the footer line. (This is also where an image-only section sits.) ◄──── latest change + double imgTop = hfSection == HeaderFooterSection.Header ? pageSettings.PageSize.HeightPu - pageSettings.Margins.HeaderPu - : pageSettings.Margins.FooterPu + ih; - pageLayout.AddChild(new PdfImageLayout(ix, iTop, iw, ih) + : pageSettings.Margins.FooterPu + imgH; + double imgBottom = imgTop - imgH; + + // Text baseline: line the text's bottom up with the image's bottom, but never let + // the text rise past the normal top-of-band position (only matters when an image is + // shorter than the text). With no embeddable image (imgH == 0) this reduces to the + // normal header/footer text position. + double naturalBaseline = hfSection == HeaderFooterSection.Header + ? pageSettings.PageSize.HeightPu - pageSettings.Margins.HeaderPu - ascent + : pageSettings.Margins.FooterPu + descent; + double textY = System.Math.Min(naturalBaseline, imgBottom + descent); // ◄──── end of latest change + + // ── emit before-text, image, after-text left-to-right ── + double cursor = runStart; + if (beforeRun != null) { - ImageBytes = hfImg.ImageBytes, - IsHeaderFooter = true, - Name = $"{hfSection}{hfAlign}Image", - }); + pageLayout.AddChild(PlaceHeaderFooterRun(pageSettings, dictionaries, beforeRun, cursor, textY)); + } + cursor += beforeWidth; + + if (canEmbed) + { + pageLayout.AddChild(new PdfImageLayout(cursor, imgTop, imgW, imgH) // ◄──── imgTop now the margin-anchored value above + { + ImageBytes = hf.ImageBytes, + IsHeaderFooter = true, + Name = $"{hfSection}{hfAlign}Image", + }); + } + cursor += imgW; + + if (afterRun != null) + { + pageLayout.AddChild(PlaceHeaderFooterRun(pageSettings, dictionaries, afterRun, cursor, textY)); + } } } } @@ -1689,22 +1755,70 @@ private static Pages GetHeaderFooter(PdfRange range, Pages pdfPages, PdfWorkshee return pdfPages; } + //private static void SubstitutePageNumbers(PdfPageSettings pageSettings, PdfDictionaries dictionaries, PdfHeaderFooter hf, int pageNumber, int totalPages) + //{ + // if (hf == null) return; + // if (hf.PageNumberIndexes.Count > 0) + // { + // foreach (var idx in hf.PageNumberIndexes) + // hf.Content.TextFragments[idx].Text = pageNumber.ToString(); + // } + // if (hf.NumberOfPagesIndexes.Count > 0) + // { + // foreach (var idx in hf.NumberOfPagesIndexes) + // hf.Content.TextFragments[idx].Text = totalPages.ToString(); + // } + // PdfTextShaper.ShapeText(pageSettings, dictionaries, hf.Content); + //} private static void SubstitutePageNumbers(PdfPageSettings pageSettings, PdfDictionaries dictionaries, PdfHeaderFooter hf, int pageNumber, int totalPages) { if (hf == null) return; - if (hf.PageNumberIndexes.Count > 0) - { - foreach (var idx in hf.PageNumberIndexes) - hf.Content.TextFragments[idx].Text = pageNumber.ToString(); - } - if (hf.NumberOfPagesIndexes.Count > 0) - { - foreach (var idx in hf.NumberOfPagesIndexes) - hf.Content.TextFragments[idx].Text = totalPages.ToString(); - } + ApplyPageNumbers(hf, pageNumber, totalPages); // ◄──── was two inline foreach loops PdfTextShaper.ShapeText(pageSettings, dictionaries, hf.Content); } + // Fills the page-number / number-of-pages placeholders in place, without shaping. Used both by the ◄──── new method + // plain-text path (which shapes afterwards) and the inline-image path (which shapes the split sub-runs). + private static void ApplyPageNumbers(PdfHeaderFooter hf, int pageNumber, int totalPages) + { + if (hf == null) return; + foreach (var idx in hf.PageNumberIndexes) + hf.Content.TextFragments[idx].Text = pageNumber.ToString(); + foreach (var idx in hf.NumberOfPagesIndexes) + hf.Content.TextFragments[idx].Text = totalPages.ToString(); + } + + private static PdfHeaderFooter ShapeHeaderFooterRun(PdfPageSettings pageSettings, PdfDictionaries dictionaries, + List frags, HeaderFooterSection section, + out double width, out double ascent, out double descent) + { + width = 0d; ascent = 0d; descent = 0d; + if (frags == null || frags.Count == 0) return null; + var sub = new PdfHeaderFooter(frags, new List(), new List(), HeaderFooterType.Odd, HeaderFooterAlignment.Left, section); + sub.Content.ContentAligmnet = new PdfCellAlignmentData + { + HorizontalAlignment = EPPlus.Export.Pdf.Enums.ExcelHorizontalAlignment.Left + }; + PdfTextShaper.ShapeText(pageSettings, dictionaries, sub.Content); + if (sub.Content.TextLines == null || sub.Content.TextLines.Count == 0 + || sub.Content.TextLines.LineFragments == null || sub.Content.TextLines.LineFragments.Count == 0 + || sub.Content.TotalTextLength <= 0d) + return null; + width = sub.Content.TextLines[0].Width; + ascent = sub.Content.TextLines[0].LargestAscent; + descent = sub.Content.TextLines[0].LargestDescent; + return sub; + } + + private static PdfCellContentLayout PlaceHeaderFooterRun(PdfPageSettings pageSettings, PdfDictionaries dictionaries, + PdfHeaderFooter run, double x, double baselineY) + { + var layout = new PdfCellContentLayout(pageSettings, dictionaries, run, x, baselineY, 0, 0); + layout.IsHeaderFooter = true; + layout.GidsAndCharMap(dictionaries); + return layout; + } + private static void AddIncomingSpill(Page page, PdfRange range, int fromRow, int toRow, int windowFromCol, int windowToCol, double windowOriginX, double windowOriginY, bool isPrintTitle) { if (page.SpillCells == null) return; // initialised by the caller diff --git a/src/EPPlus/Export/PdfExport/TextMapping/PdfHeaderFooterCollection.cs b/src/EPPlus/Export/PdfExport/TextMapping/PdfHeaderFooterCollection.cs index 5c2840af1..1d345ccd8 100644 --- a/src/EPPlus/Export/PdfExport/TextMapping/PdfHeaderFooterCollection.cs +++ b/src/EPPlus/Export/PdfExport/TextMapping/PdfHeaderFooterCollection.cs @@ -23,7 +23,6 @@ namespace OfficeOpenXml.Export.PdfExport.TextMapping internal class PdfHeaderFooterCollection { public List PdfHeaderFooterEntries = new List(); - public List PdfHeaderFooterImages = new List(); public bool ScaleWithDocument = false; public bool AlignWithMargins = false; public bool HasFirstPage = false; @@ -158,53 +157,6 @@ public PdfHeaderFooterCollection(PdfPageSettings pageSettings, PdfDictionaries d entry.Content.ContentAligmnet = PdfTextMap.GetAlignmentData(entry); PdfHeaderFooterEntries.Add(entry); } - if (headerFooter?.Pictures != null) - { - foreach (ExcelVmlDrawingPicture picture in headerFooter.Pictures) - { - var bytes = picture?.Image?.ImageBytes; - if (bytes == null) continue; - if (!TryDecodeSlot(picture.Id, out var type, out var section, out var alignment)) continue; - PdfHeaderFooterImages.Add(new PdfHeaderFooterImage(bytes, picture.Width, picture.Height, type, alignment, section)); - } - } - } - - public PdfHeaderFooterImage GetImage(HeaderFooterType type, HeaderFooterSection section, HeaderFooterAlignment alignment) - { - return PdfHeaderFooterImages.FirstOrDefault(e => - e.PageType == type && e.Section == section && e.Alignment == alignment); - } - - // Decode a header/footer picture Id (e.g. "LH", "CFEVEN", "RHFIRST") into its slot. - // Layout: [alignment L/C/R][section H/F][optional variant EVEN|FIRST]; no variant = Odd. - private static bool TryDecodeSlot(string id, out HeaderFooterType type, out HeaderFooterSection section, out HeaderFooterAlignment alignment) - { - type = HeaderFooterType.Odd; - section = HeaderFooterSection.Header; - alignment = HeaderFooterAlignment.Left; - if (string.IsNullOrEmpty(id) || id.Length < 2) return false; - - switch (id[0]) - { - case 'L': alignment = HeaderFooterAlignment.Left; break; - case 'C': alignment = HeaderFooterAlignment.Center; break; - case 'R': alignment = HeaderFooterAlignment.Right; break; - default: return false; - } - - string code = id.Substring(1); - if (code[0] == 'H') section = HeaderFooterSection.Header; - else if (code[0] == 'F') section = HeaderFooterSection.Footer; - else return false; - - string variant = code.Substring(1); - if (variant.Length == 0) type = HeaderFooterType.Odd; - else if (variant == "EVEN") type = HeaderFooterType.Even; - else if (variant == "FIRST") type = HeaderFooterType.First; - else return false; - - return true; } public PdfHeaderFooter Get(HeaderFooterType type, HeaderFooterSection section, HeaderFooterAlignment alignment) diff --git a/src/EPPlus/Export/PdfExport/TextMapping/PdfTextMap.cs b/src/EPPlus/Export/PdfExport/TextMapping/PdfTextMap.cs index 40e8ca83f..cc9b7a26d 100644 --- a/src/EPPlus/Export/PdfExport/TextMapping/PdfTextMap.cs +++ b/src/EPPlus/Export/PdfExport/TextMapping/PdfTextMap.cs @@ -663,9 +663,24 @@ internal static PdfHeaderFooter GetTextFormats(PdfPageSettings pageSettings, Pdf var textFragments = new List(); List NumberOfPagesIndexes = new List(); List PageNumberIndexes = new List(); + byte[] imageBytes = null; + double imageWidth = 0, imageHeight = 0; + int imageFragmentIndex = -1; for (int i = 1; i < textCollection.Count; i++) { var hf = textCollection[i]; + if (hf.FormatCode == ExcelHeaderFooterFormattingCodes.Image) + { + var pic = textCollection.Picture; + if (pic?.Image?.ImageBytes != null) + { + imageBytes = pic.Image.ImageBytes; + imageWidth = pic.Width; + imageHeight = pic.Height; + imageFragmentIndex = textFragments.Count; + } + continue; + } var textFrag = new TextFragment(); textFrag.Font = new RichTextFormatSimple(); textFrag.Font.Family = string.IsNullOrEmpty(hf.FontName) ? ns.Style.Font.Name : hf.FontName; @@ -718,7 +733,12 @@ internal static PdfHeaderFooter GetTextFormats(PdfPageSettings pageSettings, Pdf dictionaries.AddFont(pageSettings, textFrag.Font.Family, textFrag.Font.SubFamily, textFrag.Text); if (NumberOfPagesIndexes.Count > 0 || PageNumberIndexes.Count > 0) dictionaries.AddFont(pageSettings, textFrag.Font.Family, textFrag.Font.SubFamily, "1234567890"); } - return new PdfHeaderFooter(textFragments, PageNumberIndexes, NumberOfPagesIndexes, type, alignment, section); + var result = new PdfHeaderFooter(textFragments, PageNumberIndexes, NumberOfPagesIndexes, type, alignment, section); + result.ImageBytes = imageBytes; + result.ImageWidth = imageWidth; + result.ImageHeight = imageHeight; + result.ImageFragmentIndex = imageFragmentIndex; + return result; } ///