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
3 changes: 3 additions & 0 deletions src/EPPlus.Export.Pdf.Tests/EPPlus.Export.Pdf.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
</ItemGroup>

<ItemGroup>
<None Update="Fonts\BIZUDGothic-Regular.ttf">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Fonts\EBGaramond-Regular.ttf">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
Expand Down
Binary file not shown.
146 changes: 146 additions & 0 deletions src/EPPlus.Export.Pdf.Tests/VariationSequenceReproTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/*************************************************************************************************
Required Notice: Copyright (C) EPPlus Software AB.
This software is licensed under PolyForm Noncommercial License 1.0.0
and may only be used for noncommercial purposes
https://polyformproject.org/licenses/noncommercial/1.0.0/

A commercial license to use this software can be purchased at https://epplussoftware.com
*************************************************************************************************
Date Author Change
*************************************************************************************************
09/09/2026 EPPlus Software AB Unicode Variation Sequence visual repro sheet
*************************************************************************************************/
using EPPlus.Export.Pdf.Settings;
using EPPlus.Export.Pdf.Tests;
using EPPlus.Fonts.OpenType;
using OfficeOpenXml;
using OfficeOpenXml.Export.PdfExport;
using OfficeOpenXml.Style;
using System;
using System.IO;
using System.Text;

namespace EPPlus.Export.Pdf.Tests
{
/// <summary>
/// VISUAL repro for cmap format 14 (Unicode Variation Sequences) actually being consulted
/// during shaping, and preserved through subsetting and embedding - not an automated
/// regression test, since the point is to look at the two glyph shapes with your own eyes.
/// The three unit-test files already cover the automated side of this.
///
/// Uses BIZ UDGothic, a real Japanese font bundled with the test suite that has genuine
/// format-14 data - verified directly against the font's own bytes (not assumed): U+585A
/// (塚) has a registered NON-DEFAULT variation sequence under VS01 (U+FE00). The base
/// character alone resolves to glyph 3363 in this font; U+585A + VS01 resolves to glyph
/// 1399 - a different, hand-drawn glyph, not a font-side no-op.
///
/// What to look for in the exported PDF:
///
/// A2 "塚" The plain base character - glyph 3363, the font's default form.
/// A3 "塚" + VS01 The variation sequence - should render glyph 1399: a VISIBLY
/// different shape for the top of the right-hand component (this is
/// one of the standard textbook examples of a Japanese IVS/
/// hanyo-denshi variant pair).
///
/// If A2 and A3 look IDENTICAL, the variation sequence is silently falling back to the base
/// glyph somewhere in the pipeline - shaping, subsetting, or serialization.
/// </summary>
[TestClass]
public class VariationSequenceReproTests : PdfTestBase
{
// BIZUDGothic-Regular.ttf lives in the Fonts subfolder of the test project and is
// copied next to the test assembly at build time - no dependency on BIZ UDGothic being
// installed as a system font.
private static string FontsFolder => Path.Combine(AppContext.BaseDirectory, "Fonts");

private const string ReproFontFamily = "BIZ UDGothic";
private const float ReproFontSize = 72f;

// U+585A (塚): base char alone -> glyph 3363, base char + VS01 (U+FE00) -> glyph 1399.
// Both values read directly out of BIZUDGothic-Regular.ttf's own cmap tables.
private const string BaseChar = "\u585A";
private const string BaseCharPlusVs01 = "\u585A\uFE00";

private static OpenTypeFontEngine CreateEngine()
{
return new OpenTypeFontEngine(cfg =>
{
cfg.FontDirectories.Add(FontsFolder);
cfg.SearchSystemDirectories = false;
});
}

[TestMethod]
public void VariationSequence_BizUdGothic_ReproSheet()
{
using (var package = OpenPackage("VariationSequenceRepro.xlsx", true))
{
var sheet = package.Workbook.Worksheets.Add("VariationSequence");
BuildReproSheet(sheet);

var engine = CreateEngine();
var settings = new PdfPageSettings(engine);

// Written to disk for visual inspection - this is the whole point of the test.
SaveAsPdf(sheet, "VariationSequenceRepro", settings);

// Also export to a stream so the test fails loudly if the export itself breaks,
// rather than silently writing an unreadable file.
using (var stream = new MemoryStream())
{
new PdfCatalog(settings, sheet).Save(stream);
AssertLooksLikePdf(stream.ToArray());
}

SaveWorkbook("VariationSequenceRepro.xlsx", package);
}
}

private static void BuildReproSheet(ExcelWorksheet sheet)
{
sheet.Cells["A1"].Value = "Compare A2 (plain) vs A3 (base + VS01) - look for a different top-right stroke shape";
StyleHeader(sheet.Cells["A1"]);

sheet.Cells["A2"].Value = BaseChar;
sheet.Cells["A3"].Value = BaseCharPlusVs01;

StyleReference(sheet.Cells["A2:A3"]);
sheet.Cells.AutoFitColumns();

for (int row = 1; row <= 3; row++)
{
sheet.Row(row).CustomHeight = true;
sheet.Row(row).Height = 100;
}
}

private static void StyleReference(ExcelRange range)
{
range.Style.Font.Name = ReproFontFamily;
range.Style.Font.Size = ReproFontSize;
range.Style.HorizontalAlignment = ExcelHorizontalAlignment.Left;
range.Style.VerticalAlignment = ExcelVerticalAlignment.Bottom;
range.Style.Indent = 0;
range.Style.WrapText = false;
}

private static void StyleHeader(ExcelRange range)
{
range.Style.Font.Name = ReproFontFamily;
range.Style.Font.Size = 11f;
range.Style.Font.Bold = true;
}

private static void AssertLooksLikePdf(byte[] bytes)
{
Assert.IsTrue(bytes.Length > 0, "PDF output is empty.");

string head = Encoding.ASCII.GetString(bytes, 0, Math.Min(8, bytes.Length));
Assert.IsTrue(head.StartsWith("%PDF-"), $"Missing PDF header. Got: '{head}'");

int tailLength = Math.Min(8, bytes.Length);
string tail = Encoding.ASCII.GetString(bytes, bytes.Length - tailLength, tailLength);
Assert.IsTrue(tail.Contains("%%EOF"), "Missing %%EOF trailer marker.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,7 @@ Date Author Change
using EPPlus.Fonts.OpenType.Tables.Gpos.Data.Lookups.LookupType1;
using EPPlus.Fonts.OpenType.Tables.Gpos.Data.Lookups.LookupType2;
using EPPlus.Fonts.OpenType.Tables.Gpos.Data.Lookups.LookupType4;
using EPPlus.Fonts.OpenType.Tests.Helpers;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;

namespace EPPlus.Fonts.OpenType.Tests.Serialization
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*************************************************************************************************
Required Notice: Copyright (C) EPPlus Software AB.
This software is licensed under PolyForm Noncommercial License 1.0.0
and may only be used for noncommercial purposes
https://polyformproject.org/licenses/noncommercial/1.0.0/

A commercial license to use this software can be purchased at https://epplussoftware.com
*************************************************************************************************
Date Author Change
*************************************************************************************************
09/09/2026 EPPlus Software AB Unicode Variation Sequence subsetting parity test
*************************************************************************************************/
using EPPlus.Fonts.OpenType.Tables.Cmap;
using EPPlus.Fonts.OpenType.TextShaping;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using OfficeOpenXml.Interfaces.Fonts;
using System.Collections.Generic;

namespace EPPlus.Fonts.OpenType.Tests.Subsetting
{
/// <summary>
/// Parity test analogous to the measurement/render width-parity checks (see WP1's kerning
/// fix): verifies that shaping against the FULL font (the measurement path,
/// GetCellCollectionFromRange) and shaping against the ROUND-TRIPPED SUBSET font (the render
/// path - what actually ends up embedded in the PDF) agree on a Unicode Variation Sequence.
///
/// Glyph IDs are renumbered by subsetting, so this can't compare raw glyph IDs across the two
/// fonts. Instead it compares BEHAVIOR: does each font's own shaping still tell the
/// (base, selector) pair apart from the plain base character on its own terms?
///
/// "Round-tripped" means serialized to bytes and reloaded - exactly like a font that has gone
/// through PDF embedding. This exercises CmapSubsetProcessor's discovery/rewrite of the
/// variation-sequence data AND CmapTable's serialization of the format-14 subtable, both of
/// which are currently missing.
/// </summary>
[TestClass]
public class VariationSequenceSubsettingParityTests : FontTestBase
{
public override TestContext? TestContext { get; set; }

private const uint Vs01 = 0xFE00; // VARIATION SELECTOR-1 (BMP)

/// <summary>
/// Loads a private (non-cached) Roboto instance and registers 'A' + Vs01 as a variation
/// sequence resolving to whatever glyph 'B' already maps to (a distinct, real, existing
/// glyph - not a made-up one).
/// </summary>
private OpenTypeFont LoadFullFontWithSyntheticVariationSequence(out ushort glyphIdA, out ushort glyphIdB)
{
var font = TestFolderEngine.LoadFont("Roboto", FontSubFamily.Regular, true);

Assert.IsTrue(font.CmapTable.TryGetGlyphId('A', out glyphIdA), "Test font is expected to contain 'A'.");
Assert.IsTrue(font.CmapTable.TryGetGlyphId('B', out glyphIdB), "Test font is expected to contain 'B'.");
Assert.AreNotEqual(glyphIdA, glyphIdB, "Test relies on 'A' and 'B' mapping to different glyphs.");

var subtable14 = new CmapSubtable14();
subtable14.VariationSelectors.Add(new VariationSelector
{
VarSelector = Vs01,
NonDefaultUvsTable = new NonDefaultUvsTable
{
Mappings = new List<UvsMapping>
{
new UvsMapping { UnicodeValue = 'A', GlyphId = glyphIdB }
}
}
});
font.CmapTable.SubTables.Add(subtable14);

return font;
}

[TestMethod]
public void Shape_RoundTrippedSubsetFont_StillDistinguishesVariationSequenceFromPlainBaseChar()
{
const string textWithSequence = "A\uFE00";

var fullFont = LoadFullFontWithSyntheticVariationSequence(out ushort glyphIdA, out ushort glyphIdB);

// --- Measurement path: shape against the full font (GetCellCollectionFromRange today
// shapes here) ---
var fullShaper = new TextShaper(TestFolderEngine, fullFont);
var fullPlain = fullShaper.Shape("A");
var fullSequence = fullShaper.Shape(textWithSequence);

Assert.AreEqual(1, fullSequence.Glyphs.Length);
Assert.AreEqual(2, fullSequence.Glyphs[0].CharCount, "Sanity check: the full font must consume the pair as one glyph.");
Assert.AreNotEqual(fullPlain.Glyphs[0].GlyphId, fullSequence.Glyphs[0].GlyphId,
"Sanity check: the variation sequence must select a different glyph than plain 'A' in the full font.");

// --- Render path: subset for exactly this text, then round-trip (serialize + reload) -
// exactly what happens when the subset is embedded in, and later read back from, a PDF. ---
var subsetFont = fullFont.CreateSubset(textWithSequence);
var subsetBytes = subsetFont.Serialize();
var roundTrippedSubsetFont = new OpenTypeFont(subsetBytes);

var subsetShaper = new TextShaper(TestFolderEngine, roundTrippedSubsetFont);
var subsetPlain = subsetShaper.Shape("A");
var subsetSequence = subsetShaper.Shape(textWithSequence);

Assert.AreEqual(1, subsetSequence.Glyphs.Length);
Assert.AreEqual(2, subsetSequence.Glyphs[0].CharCount,
"The round-tripped SUBSET font must still consume the pair as one glyph. Today it silently " +
"falls back to the base glyph, because CmapSubsetProcessor doesn't discover/preserve format-14 " +
"data for the subset, and CmapTable.Serialize unconditionally drops format-14 subtables.");
Assert.AreNotEqual(subsetPlain.Glyphs[0].GlyphId, subsetSequence.Glyphs[0].GlyphId,
"The render path (round-tripped subset) must select a DIFFERENT glyph for the variation " +
"sequence than for plain 'A' - matching what the measurement path (full font) does above.");
}
}
}
Loading
Loading