Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 30 additions & 7 deletions src/EPPlus.Export.Pdf.Tests/FontTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand All @@ -77,12 +100,12 @@ 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);
excelPdf.SetDocumentSettingsForTest(PdfDocumentSettings.From(settings));
excelPdf.SetDictionariesForTest(dictionaries);

excelPdf.AddFontData();

var fontResource = dictionaries.GetFont(settings, TestFontName, FontSubFamily.Regular);
Expand Down Expand Up @@ -126,12 +149,12 @@ 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);
excelPdf.SetDocumentSettingsForTest(PdfDocumentSettings.From(settings));
excelPdf.SetDictionariesForTest(dictionaries);

excelPdf.AddFontData();

foreach (var obj in excelPdf._document)
Expand Down
23 changes: 23 additions & 0 deletions src/EPPlus.Export.Pdf.Tests/PdfTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
112 changes: 112 additions & 0 deletions src/EPPlus.Export.Pdf.Tests/PdfTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Date Author Change
using EPPlus.Export.Pdf.Settings.PdfPageSizes;
using OfficeOpenXml;
using OfficeOpenXml.Export.PdfExport;
using OfficeOpenXml.Interfaces.Fonts;
using OfficeOpenXml.Export.PdfExport.Settings;
using OfficeOpenXml.Style;
using System.Diagnostics;
Expand Down Expand Up @@ -53,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);
Expand Down Expand Up @@ -506,6 +508,116 @@ public void SaveRangeToNonWritableStreamThrowsTest()
Assert.ThrowsExactly<ArgumentException>(() => 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")]
Expand Down
24 changes: 16 additions & 8 deletions src/EPPlus.Export.Pdf/ExcelPdf.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -44,14 +45,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)
Expand All @@ -70,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)
Expand Down
71 changes: 55 additions & 16 deletions src/EPPlus.Export.Pdf/Resources/PdfDictionaries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -27,6 +29,10 @@ internal class PdfDictionaries
internal readonly Dictionary<string, PdfShadingResource> Shadings = new Dictionary<string, PdfShadingResource>();
internal Dictionary<FontKey, IFontProvider> ShapedProviders = new Dictionary<FontKey, IFontProvider>();

// 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
Expand Down Expand Up @@ -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.");
}
}
}
Loading