diff --git a/src/EPPlus.Export.Pdf.Tests/FontTests.cs b/src/EPPlus.Export.Pdf.Tests/FontTests.cs index a7f2fe8169..c30f7b9b79 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,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); @@ -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) diff --git a/src/EPPlus.Export.Pdf.Tests/PdfTestBase.cs b/src/EPPlus.Export.Pdf.Tests/PdfTestBase.cs index a2b449a329..5682e42e1f 100644 --- a/src/EPPlus.Export.Pdf.Tests/PdfTestBase.cs +++ b/src/EPPlus.Export.Pdf.Tests/PdfTestBase.cs @@ -21,5 +21,28 @@ protected void SaveAsPdf(ExcelWorksheet sheet, string pdfFileName) var path = Path.Combine(_pdfPath, pdfFileName); sheet.SaveAsPdf(path); } + + protected void SaveAsPdf(ExcelWorkbook wb, string pdfFileName) + { + if (!pdfFileName.ToLower().EndsWith(".pdf")) + { + pdfFileName += ".pdf"; + } + var path = Path.Combine(_pdfPath, pdfFileName); + wb.SaveAsPdf(path); + } + + protected void SaveAsPdf(ExcelWorkbook wb, string pdfFileName, params ExcelRangeBase[] ranges) + { + if (!pdfFileName.ToLower().EndsWith(".pdf")) + { + pdfFileName += ".pdf"; + } + var path = Path.Combine(_pdfPath, pdfFileName); + if (ranges.Count() > 1) + wb.SaveAsPdf(path, ranges); + else + ranges[0].SaveAsPdf(path); + } } } diff --git a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs index a566064de5..002cafb66f 100644 --- a/src/EPPlus.Export.Pdf.Tests/PdfTests.cs +++ b/src/EPPlus.Export.Pdf.Tests/PdfTests.cs @@ -11,10 +11,14 @@ 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.Interfaces.Fonts; +using OfficeOpenXml.Export.PdfExport.Data; +using OfficeOpenXml.Export.PdfExport.Layout; +using OfficeOpenXml.FormulaParsing.Excel.Functions.Information; using OfficeOpenXml.Export.PdfExport.Settings; using OfficeOpenXml.Style; using System.Diagnostics; @@ -506,6 +510,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")] @@ -761,5 +875,61 @@ 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)); + } + + [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); + } + + [TestMethod] + public void GetClampedCellWidth_CellFitsWithinPage_ReturnsCellWidthUnchanged() + { + 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/DocumentObjects/Functions/PdfPostScriptCalculatorFunction.cs b/src/EPPlus.Export.Pdf/DocumentObjects/Functions/PdfPostScriptCalculatorFunction.cs new file mode 100644 index 0000000000..2bd487f7f8 --- /dev/null +++ b/src/EPPlus.Export.Pdf/DocumentObjects/Functions/PdfPostScriptCalculatorFunction.cs @@ -0,0 +1,112 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 27/11/2025 EPPlus Software AB EPPlus 9 + *************************************************************************************************/ +using EPPlus.Export.Pdf.Helpers; +using EPPlus.Export.Pdf.Layout; +using System.Drawing; +using System.IO; +using System.Text; + +namespace EPPlus.Export.Pdf.DocumentObjects.Functions +{ + /// + /// FunctionType 4 (PostScript calculator). Maps a 2-D point (u,v) in the unit domain to an + /// RGB colour using the "box" (rectangular) gradient parameter Excel uses for path gradients: + /// t = max(|u-fx|/dx, |v-fy|/dy). Unlike the Type 2/3 functions, a Type 4 function is a + /// stream object, so it must be an indirect object and be referenced by the shading (N 0 R). + /// + internal class PdfPostScriptCalculatorFunction : PdfFunction + { + private readonly string _code; + + public PdfPostScriptCalculatorFunction(int objectNumber, PdfCellGradientFillData gradientFillData, int version = 0) + : base(objectNumber, version) + { + _code = BuildBoxGradientCode(gradientFillData); + } + + private static string BuildBoxGradientCode(PdfCellGradientFillData g) + { + // Focus point (fx,fy) and half-extents (dx,dy) in the shading's unit domain (v is "up"). + GetFocus(g, out double fx, out double fy, out double dx, out double dy); + + // Colours are normalised to 0..1 for DeviceRGB. Color1 = focus (t=0), Color2 = edge (t=1). + double r0 = g.Color1.GetR(), g0 = g.Color1.GetG(), b0 = g.Color1.GetB(); + double r1 = g.Color2.GetR(), g1 = g.Color2.GetG(), b1 = g.Color2.GetB(); + + var sb = new StringBuilder(); + sb.Append("{ "); + // Stack in: u v (v on top). Compute t = max(|u-fx|/dx, |v-fy|/dy), then clamp to [0,1]. + sb.Append($"{fy.ToPdfString()} sub abs {dy.ToPdfString()} div "); // |v-fy|/dy + sb.Append("exch "); + sb.Append($"{fx.ToPdfString()} sub abs {dx.ToPdfString()} div "); // |u-fx|/dx + sb.Append("2 copy lt { exch } if pop "); // -> max + sb.Append("dup 1 gt { pop 1 } if "); // clamp high (abs keeps >= 0) + + if (!g.Color3.Equals(Color.Empty)) + { + double rm = g.Color3.GetR(), gm = g.Color3.GetG(), bm = g.Color3.GetB(); + sb.Append("dup 0.5 le { 2 mul "); // t in [0,0.5] -> s = t*2 + AppendRamp(sb, r0, g0, b0, rm, gm, bm); // Color1 -> Color3 + sb.Append("} { 0.5 sub 2 mul "); // t in (0.5,1] -> s = (t-0.5)*2 + AppendRamp(sb, rm, gm, bm, r1, g1, b1); // Color3 -> Color2 + sb.Append("} ifelse "); + } + else + { + AppendRamp(sb, r0, g0, b0, r1, g1, b1); // Color1 -> Color2 + } + sb.Append("}"); + return sb.ToString(); + } + + // Given parameter s in [0,1] on the stack, leave R G B where each = c0 + s*(c1 - c0). + private static void AppendRamp(StringBuilder sb, + double r0, double g0, double b0, double r1, double g1, double b1) + { + sb.Append($"dup {r1.ToPdfString()} {r0.ToPdfString()} sub mul {r0.ToPdfString()} add exch "); + sb.Append($"dup {g1.ToPdfString()} {g0.ToPdfString()} sub mul {g0.ToPdfString()} add exch "); + sb.Append($"{b1.ToPdfString()} {b0.ToPdfString()} sub mul {b0.ToPdfString()} add "); + } + + // The five Excel presets (four corners + centre). fillToRect insets are stored in + // Left/Right/Top/Bottom; Top==0 means the focus is at the top edge (v == 1 in unit space). + private static void GetFocus(PdfCellGradientFillData g, out double fx, out double fy, out double dx, out double dy) + { + if (g.Left == 0.5 && g.Right == 0.5 && g.Top == 0.5 && g.Bottom == 0.5) + { + fx = 0.5; fy = 0.5; dx = 0.5; dy = 0.5; // from centre + } + else + { + fx = g.Left == 0 ? 0d : 1d; // left inset 0 -> focus at left edge + fy = g.Top == 0 ? 1d : 0d; // top inset 0 -> focus at top edge (v up) + dx = 1d; dy = 1d; // from a corner + } + } + + internal override string RenderDictionary() + { + return "<< /FunctionType 4 /Domain [ 0 1 0 1 ] /Range [ 0 1 0 1 0 1 ] " + + $"/Length {Encoding.ASCII.GetByteCount(_code)} >>\nstream\n{_code}\nendstream"; + } + + internal override void RenderDictionary(BinaryWriter bw) + { + var bytes = Encoding.ASCII.GetBytes(_code); + WriteAscii(bw, "<< /FunctionType 4 /Domain [ 0 1 0 1 ] /Range [ 0 1 0 1 0 1 ] " + + $"/Length {bytes.Length} >>\nstream\n"); + bw.Write(bytes); + WriteAscii(bw, "\nendstream"); + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs b/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs index de630d55ac..0c385f71c9 100644 --- a/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs +++ b/src/EPPlus.Export.Pdf/DocumentObjects/PdfBorderRenderer.cs @@ -196,207 +196,357 @@ 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; // parallel offset AND corner miter amount + + // 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) { - ////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. + 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; } - - //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; + // 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) { - 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 (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; - - 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; + // 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) { - //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. + DiagonalUpFactor = 0.5d; DiagonalDownFactor = 0.5d; + ix1 = x1 + G; ix2 = x2 + G; + 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 + 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; + // 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) { - 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 (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; + // 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) { - 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 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"); @@ -404,13 +554,20 @@ 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"); } + 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/DocumentObjects/PdfContentStream.cs b/src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs index cdddc3a9d5..bb39fd0d4e 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; } @@ -362,11 +364,12 @@ 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; 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 +384,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, 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.Pdf/DocumentObjects/Shadings/PdfFunctionBasedShading.cs b/src/EPPlus.Export.Pdf/DocumentObjects/Shadings/PdfFunctionBasedShading.cs new file mode 100644 index 0000000000..b822c2612c --- /dev/null +++ b/src/EPPlus.Export.Pdf/DocumentObjects/Shadings/PdfFunctionBasedShading.cs @@ -0,0 +1,54 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 27/11/2025 EPPlus Software AB EPPlus 9 + *************************************************************************************************/ +using EPPlus.Export.Pdf.Helpers; +using EPPlus.Export.Pdf.Layout; +using System.IO; +using System.Linq; +using System.Text; + +namespace EPPlus.Export.Pdf.DocumentObjects.Shadings +{ + /// + /// ShadingType 1 (function-based). The colour at each point comes from a 2-in / 3-out + /// function of (u,v) evaluated over the unit Domain; the shading pattern's Matrix maps that + /// unit square onto the cell. Used for Excel path (rectangular / "box") gradients, which have + /// no native PDF shading. The Function is a stream object referenced indirectly. + /// + internal class PdfFunctionBasedShading : PdfShading + { + internal double[] Domain = [0d, 1d, 0d, 1d]; + internal int FunctionObjectNumber; + + public PdfFunctionBasedShading(int objectNumber, PdfCellGradientFillData gradientFillData, int version = 0) + : base(objectNumber, version) + { + ColorSpace = DeviceColorSpace.DeviceRGB; + } + + private string Build() + { + var domainStr = string.Join(" ", Domain.Select(w => w.ToPdfString()).ToArray()); + var sb = new StringBuilder(); + sb.AppendFormat($"<< /Type /Shading\n" + + $" /ShadingType 1\n" + + $" /ColorSpace /{ColorSpace.ToString()}\n" + + $" /Domain [ {domainStr} ]\n" + + $" /Function {FunctionObjectNumber} 0 R >>"); + return sb.ToString(); + } + + internal override string RenderDictionary() => Build(); + + internal override void RenderDictionary(BinaryWriter bw) => WriteAscii(bw, Build()); + } +} \ No newline at end of file diff --git a/src/EPPlus.Export.Pdf/ExcelPdf.cs b/src/EPPlus.Export.Pdf/ExcelPdf.cs index 64e96421ed..40d6cf0018 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; @@ -18,6 +19,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; @@ -44,14 +46,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) @@ -70,7 +77,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) @@ -111,7 +120,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); @@ -162,7 +183,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/PdfBorderData.cs b/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs index 5af7cc4015..6ca888bfa5 100644 --- a/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs +++ b/src/EPPlus.Export.Pdf/Layout/PdfBorderData.cs @@ -65,6 +65,26 @@ 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 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.Pdf/Layout/PdfCellContentLayout.cs b/src/EPPlus.Export.Pdf/Layout/PdfCellContentLayout.cs index 76d47b756c..25268d1d59 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) @@ -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: diff --git a/src/EPPlus.Export.Pdf/Resources/PdfDictionaries.cs b/src/EPPlus.Export.Pdf/Resources/PdfDictionaries.cs index 6bfc199d03..d8c40e83c4 100644 --- a/src/EPPlus.Export.Pdf/Resources/PdfDictionaries.cs +++ b/src/EPPlus.Export.Pdf/Resources/PdfDictionaries.cs @@ -10,13 +10,15 @@ Date Author Change ************************************************************************************************* 27/11/2025 EPPlus Software AB EPPlus 9 08/17/2026 EPPlus Software AB Canonical FontKey + resolve cache + 08/20/2026 EPPlus Software AB Document-wide subsetting via DocumentFontSubsetBuilder *************************************************************************************************/ +using EPPlus.Export.Pdf.Settings; using EPPlus.Fonts.OpenType; using EPPlus.Fonts.OpenType.Integration; +using EPPlus.Fonts.OpenType.Subsetting; using OfficeOpenXml.Interfaces.Fonts; using System.Collections.Generic; using System.Linq; -using EPPlus.Export.Pdf.Settings; namespace EPPlus.Export.Pdf.Resources { @@ -27,6 +29,10 @@ internal class PdfDictionaries internal readonly Dictionary Shadings = new Dictionary(); internal Dictionary ShapedProviders = new Dictionary(); + // One document-wide subset builder, replacing the per-font FontSubsetManager. Owns all + // fallback resolution, embedding-restriction decisions, and shared subset construction. + private DocumentFontSubsetBuilder _subsetBuilder; + // Cache mapping a requested (family, subfamily) to the canonical FontKey of // the loaded font. Case-insensitive on the requested family so casing in the // source workbook resolves to the same key. Ensures the font is only loaded @@ -61,30 +67,63 @@ internal FontKey ResolveFontKey(PdfPageSettings pageSettings, string family, Fon return key; } - public void AddFont(PdfPageSettings pageSettings, string FontName, FontSubFamily SubFamily, string Text) + // CHANGE 1: AddFont now only feeds the builder. It no longer creates a PdfFontResource — + // resources are created later, per ACTUAL font, during shaping. We still resolve the + // requested key so it is registered in _requestedToKey for later provider wiring. + public void AddFont(PdfPageSettings pageSettings, string fontName, FontSubFamily subFamily, string text) { - var key = ResolveFontKey(pageSettings, FontName, SubFamily); - if (!Fonts.ContainsKey(key)) + EnsureBuilder(pageSettings); + ResolveFontKey(pageSettings, fontName, subFamily); // register the requested key + _subsetBuilder.AddText(fontName, subFamily, text); + } + + private void EnsureBuilder(PdfPageSettings pageSettings) + { + if (_subsetBuilder == null) + _subsetBuilder = new DocumentFontSubsetBuilder(pageSettings.FontEngine); + } + + // CHANGE 2: new. Runs the single document-wide build, then wires one shaping provider per + // requested font. Call once, after all text is collected, before shaping. Replaces the + // old per-font CreateSubsettedProvider loop in PdfCatalog. + internal void BuildSubsets(PdfPageSettings pageSettings) + { + if (_subsetBuilder == null) return; // no text was collected + _subsetBuilder.Build(); + + foreach (var requestedKey in _requestedToKey.Values.Distinct()) { - int label = 1; - if (Fonts.Count > 0) - { - label = Fonts.Last().Value.labelNumber + 1; - } - Fonts.Add(key, new PdfFontResource(FontName, SubFamily, label, pageSettings)); + var provider = _subsetBuilder.GetShapingProvider(requestedKey.Family, requestedKey.SubFamily); + if (provider != null) + ShapedProviders[requestedKey] = provider; } - var manger = Fonts[key].fontSubsetManager; - manger.AddText(Text); } + // CHANGE 3: GetFont is used by the renderer for METRICS only (glyph font selection is done + // per-glyph via FontIdMap). After skipping, the requested font may not be embedded, so we + // translate the requested font to the ACTUAL primary that renders it (the shaping + // provider's primary) and return that resource. internal PdfFontResource GetFont(PdfPageSettings pageSettings, string fontName, FontSubFamily subFamily) { - var key = ResolveFontKey(pageSettings, fontName, subFamily); - if (!Fonts.ContainsKey(key)) + var requestedKey = ResolveFontKey(pageSettings, fontName, subFamily); + + // Preferred path: translate requested -> actual via the shaping provider's primary. + IFontProvider provider; + if (ShapedProviders.TryGetValue(requestedKey, out provider) && provider.PrimaryFont != null) { - throw new KeyNotFoundException("Font: " + key + " is missing from dictionary."); + var actual = provider.PrimaryFont; + var actualKey = new FontKey(actual.GetEnglishFontFamilyName(), actual.NameTable.GetSubfamilyEnum()); + PdfFontResource viaProvider; + if (Fonts.TryGetValue(actualKey, out viaProvider)) + return viaProvider; } - return Fonts[key]; + + // Fallback: the requested font was embedded under its own identity (not skipped). + PdfFontResource direct; + if (Fonts.TryGetValue(requestedKey, out direct)) + return direct; + + throw new KeyNotFoundException("Font: " + requestedKey + " is missing from dictionary."); } } } \ No newline at end of file diff --git a/src/EPPlus.Export.Pdf/Resources/PdfFontResource.cs b/src/EPPlus.Export.Pdf/Resources/PdfFontResource.cs index a9382a780a..3893ca0538 100644 --- a/src/EPPlus.Export.Pdf/Resources/PdfFontResource.cs +++ b/src/EPPlus.Export.Pdf/Resources/PdfFontResource.cs @@ -35,7 +35,6 @@ internal class PdfFontResource : PdfResource internal int fontWidthObjectNumber = -1; internal int cidSetObjectNumber = -1; internal OpenTypeFont fontData; - private OpenTypeFontEngine _fontEngine; private int firstChar = 32; private int lastChar = 255; private CIDSystemInfo cidSystemInfo = null; @@ -45,15 +44,14 @@ 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. @@ -92,7 +90,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.Export.Pdf/Resources/PdfShadingResource.cs b/src/EPPlus.Export.Pdf/Resources/PdfShadingResource.cs index ba565f6b16..f82a8f1f50 100644 --- a/src/EPPlus.Export.Pdf/Resources/PdfShadingResource.cs +++ b/src/EPPlus.Export.Pdf/Resources/PdfShadingResource.cs @@ -28,7 +28,7 @@ public PdfShadingResource(int labelNumber, PdfCellFillData cellFillData) CellFillData = cellFillData; } - public PdfShading GetShadingObject(int objectNumber, int version = 0) + public PdfShading GetShadingObject(int objectNumber, int functionObjectNumber = 0, int version = 0) { this.objectNumber = objectNumber; if (CellFillData.GradientFillData != null) @@ -41,9 +41,9 @@ public PdfShading GetShadingObject(int objectNumber, int version = 0) } else if (CellFillData.GradientFillData.GradientType == ExcelFillGradientType.Path) { - var prs = new PdfRadialShading(objectNumber, CellFillData.GradientFillData, version); - prs.Coords = CellFillData.GradientFillData.coords; - return prs; + var fbs = new PdfFunctionBasedShading(objectNumber, CellFillData.GradientFillData, version); + fbs.FunctionObjectNumber = functionObjectNumber; + return fbs; } } return null; diff --git a/src/EPPlus.Fonts.OpenType.Tests/FallbackFonts/EmbeddedFontsTests.cs b/src/EPPlus.Fonts.OpenType.Tests/FallbackFonts/EmbeddedFontsTests.cs new file mode 100644 index 0000000000..52de10081f --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/FallbackFonts/EmbeddedFontsTests.cs @@ -0,0 +1,27 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml.Interfaces.Fonts; +using System; +using System.Linq; + +namespace EPPlus.Fonts.OpenType.Tests.FallbackFonts +{ + [TestClass] + public class EmbeddedFontsTests : FontTestBase + { + public override TestContext? TestContext { get; set; } + + [TestMethod] + public void BundledFamilies_MatchesEmbeddedResources() + { + Assert.IsTrue(EmbeddedFonts.IsBundledFamily( + EmbeddedFonts.LoadNotoEmoji().GetEnglishFontFamilyName())); + Assert.IsTrue(EmbeddedFonts.IsBundledFamily( + EmbeddedFonts.LoadNotoMath().GetEnglishFontFamilyName())); + foreach (FontSubFamily sf in Enum.GetValues(typeof(FontSubFamily))) + { + Assert.IsTrue(EmbeddedFonts.IsBundledFamily( + EmbeddedFonts.LoadArchivoNarrow(sf).GetEnglishFontFamilyName()), sf.ToString()); + } + } + } +} diff --git a/src/EPPlus.Fonts.OpenType.Tests/FontScanning/ArialBlackTests.cs b/src/EPPlus.Fonts.OpenType.Tests/FontScanning/ArialBlackTests.cs new file mode 100644 index 0000000000..84de0df93f --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/FontScanning/ArialBlackTests.cs @@ -0,0 +1,92 @@ +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 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() + { + 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 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() + { + var directories = System.Array.Empty(); + var allFaces = FontScannerV2.EnumerateAllFaces( + EPPlus.Fonts.OpenType.FontResolver.DefaultFontLocations.GetLocationsCollection( + directories, searchSystemDirectories: true)); + + bool foundAny = false; + foreach (var face in allFaces) + { + if (face.FamilyName != null && + face.FamilyName.IndexOf("black", System.StringComparison.OrdinalIgnoreCase) >= 0) + { + foundAny = true; + Console.WriteLine( + "FamilyName='{0}' SubfamilyName='{1}' Subfamily={2} FsSelection=0x{3:X4} FilePath={4}", + face.FamilyName, face.SubfamilyName, face.Subfamily, face.FsSelection, face.FilePath); + } + } + + Assert.IsTrue(foundAny, "No installed face with 'black' in the family name was found at all."); + } + } +} diff --git a/src/EPPlus.Fonts.OpenType.Tests/FontScanning/NameTableSubfamilyTests.cs b/src/EPPlus.Fonts.OpenType.Tests/FontScanning/NameTableSubfamilyTests.cs new file mode 100644 index 0000000000..6ca194d05f --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/FontScanning/NameTableSubfamilyTests.cs @@ -0,0 +1,360 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 08/26/2026 EPPlus Software AB Initial tests for NameTable family/subfamily naming + *************************************************************************************************/ +using EPPlus.Fonts.OpenType.FontLocalization; +using EPPlus.Fonts.OpenType.Tables.Name; +using OfficeOpenXml.Interfaces.Fonts; + +namespace EPPlus.Fonts.OpenType.Tests.FontScanning +{ + /// + /// Unit tests for how NameTable resolves family and subfamily names — bare table instances, + /// no font file loading, so they stay fast and deterministic regardless of installed fonts. + /// + /// THE PAIR PRINCIPLE (what most of these tests defend) + /// ----------------------------------------------------- + /// OpenType fonts can carry two parallel, complete naming systems: + /// + /// legacy / RIBBI nameID 1 (family) + nameID 2 (subfamily) + /// typographic nameID 16 (family) + nameID 17 (subfamily) + /// + /// Both describe the same file correctly, but they are PAIRS and must never be mixed. + /// Arial Black (ariblk.ttf) reads: + /// + /// nameID 1 = "Arial Black" nameID 2 = "Regular" + /// nameID 16 = "Arial" nameID 17 = "Black" + /// + /// Taking the family from one system and the subfamily from the other yields the pair + /// "Arial Black" + "Black", which exists in neither system. That was the original bug: + /// a request for "Arial Black" + Regular matched the family but not the style, so + /// IsExactMatch was false and DefaultFontResolver fell through to the built-in fallback + /// chain ("Arial Black" -> "Liberation Sans" -> "Arial") and returned plain Arial, even + /// though Arial Black was installed. + /// + /// EPPlus uses the legacy/RIBBI system, because FontSubFamily's four values + /// (Regular/Bold/Italic/BoldItalic) ARE the RIBBI model, and nameID 2 is guaranteed by + /// the spec to be one of those four. It is also the view Windows, GDI and Excel present. + /// + [TestClass] + public class NameTableSubfamilyTests : FontTestBase + { + public override TestContext? TestContext { get; set; } + + #region The pair principle — real ariblk.ttf field layout + + /// + /// The exact four-field layout found in C:\Windows\Fonts\ariblk.ttf. This is the + /// regression test for the original bug and the single most important test here. + /// + [TestMethod] + public void ArialBlackLayout_ResolvesToLegacyPair_NotAMixOfBothSystems() + { + var nameTable = CreateNameTable( + MakeEnglishRecord(NameRecordTypes.FontFamilyName, "Arial Black"), + MakeEnglishRecord(NameRecordTypes.FontSubfamilyName, "Regular"), + MakeEnglishRecord(NameRecordTypes.TypographicFamilyName, "Arial"), + MakeEnglishRecord(NameRecordTypes.TypographicSubfamilyName, "Black")); + + var family = nameTable.GetFamilyName(); + var subfamily = nameTable.GetSubfamilyName(); + + // Both halves must come from the SAME system. Asserting them together (rather than + // in two separate tests) is deliberate: the failure mode is a mismatched pair, and + // either value on its own looks perfectly reasonable. + Assert.AreEqual("Arial Black", family, + "Family must come from nameID 1 (legacy), not nameID 16 ('Arial')."); + Assert.AreEqual("Regular", subfamily, + "Subfamily must come from nameID 2 (legacy), not nameID 17 ('Black'). " + + "Reading nameID 17 here produces the impossible pair 'Arial Black' + 'Black'."); + Assert.AreEqual(FontSubFamily.Regular, nameTable.GetSubfamilyEnum()); + } + + /// + /// Guard against someone "fixing" GetSubfamilyName to prefer the newer nameID 17. + /// Deliberately makes the two systems disagree so preferring 17 is unmistakable. + /// + [TestMethod] + public void GetSubfamilyName_DoesNotPreferTypographicSubfamily17() + { + var nameTable = CreateNameTable( + MakeEnglishRecord(NameRecordTypes.FontSubfamilyName, "Regular"), + MakeEnglishRecord(NameRecordTypes.TypographicSubfamilyName, "Black")); + + Assert.AreEqual("Regular", nameTable.GetSubfamilyName(), + "nameID 17 must not win over nameID 2. nameID 17 belongs to the typographic " + + "system (paired with nameID 16) and carries weights outside the RIBBI model."); + } + + /// + /// Mirror of the above for the family side — guards the ID1-over-ID16 priority that + /// GetFamilyName's (previously contradictory) doc comment used to describe backwards. + /// + [TestMethod] + public void GetFamilyName_DoesNotPreferTypographicFamily16() + { + var nameTable = CreateNameTable( + MakeEnglishRecord(NameRecordTypes.FontFamilyName, "Arial Black"), + MakeEnglishRecord(NameRecordTypes.TypographicFamilyName, "Arial")); + + Assert.AreEqual("Arial Black", nameTable.GetFamilyName(), + "nameID 16 must not win over nameID 1, or 'Arial Black' collapses into the " + + "'Arial' family and can no longer be resolved as a distinct font."); + } + + #endregion + + #region Typographic system as last resort — only when the legacy field is absent + + [TestMethod] + public void GetSubfamilyName_NoNameId2_FallsBackToTypographicSubfamily17() + { + // A font that omits nameID 2 entirely. Then nameID 17 is all we have, and using + // it is correct — the pair principle only forbids mixing when BOTH are present. + var nameTable = CreateNameTable( + MakeEnglishRecord(NameRecordTypes.TypographicSubfamilyName, "Bold")); + + Assert.AreEqual("Bold", nameTable.GetSubfamilyName()); + Assert.AreEqual(FontSubFamily.Bold, nameTable.GetSubfamilyEnum()); + } + + [TestMethod] + public void GetFamilyName_NoNameId1_FallsBackToTypographicFamily16() + { + var nameTable = CreateNameTable( + MakeEnglishRecord(NameRecordTypes.TypographicFamilyName, "Arial")); + + Assert.AreEqual("Arial", nameTable.GetFamilyName()); + } + + #endregion + + #region English must win over localized records + + /// + /// ariblk.ttf carries 75 name records, including a dozen localized nameID 2 values + /// ("Normal", "obycejne", "Standard", "Kanonika", "Obychnyy", "Arrunta", ...). + /// Picking whichever comes first in file order happens to work for ariblk.ttf, but + /// that is luck, not a guarantee — file order is entirely up to the font vendor. + /// + [TestMethod] + public void GetSubfamilyName_LocalizedRecordFirst_StillPrefersEnglish() + { + var nameTable = CreateNameTable( + MakeLocalizedRecord(NameRecordTypes.FontSubfamilyName, "Fet"), // sv-SE + MakeEnglishRecord(NameRecordTypes.FontSubfamilyName, "Bold")); + + Assert.AreEqual("Bold", nameTable.GetSubfamilyName(), + "A localized nameID 2 appearing earlier in the table must not beat the " + + "English one, or the subfamily string becomes unparseable by the enum mapping."); + Assert.AreEqual(FontSubFamily.Bold, nameTable.GetSubfamilyEnum()); + } + + [TestMethod] + public void GetFamilyName_LocalizedRecordFirst_StillPrefersEnglish() + { + var nameTable = CreateNameTable( + MakeLocalizedRecord(NameRecordTypes.FontFamilyName, "Arial Svart"), + MakeEnglishRecord(NameRecordTypes.FontFamilyName, "Arial Black")); + + Assert.AreEqual("Arial Black", nameTable.GetFamilyName()); + } + + [TestMethod] + public void GetSubfamilyName_OnlyLocalizedAvailable_UsesItRatherThanNothing() + { + // No English record at all — better to return the localized string than to fall + // through to the typographic system or the "Regular" default. + var nameTable = CreateNameTable( + MakeLocalizedRecord(NameRecordTypes.FontSubfamilyName, "Normal")); + + Assert.AreEqual("Normal", nameTable.GetSubfamilyName()); + Assert.AreEqual(FontSubFamily.Regular, nameTable.GetSubfamilyEnum(), + "'Normal' is one of the recognized Regular spellings."); + } + + #endregion + + #region GetSubfamilyEnum — RIBBI mapping (regression guards) + + [TestMethod] + public void GetSubfamilyEnum_Regular_ReturnsRegular() + { + Assert.AreEqual(FontSubFamily.Regular, EnumFor("Regular")); + } + + [TestMethod] + public void GetSubfamilyEnum_Bold_ReturnsBold() + { + Assert.AreEqual(FontSubFamily.Bold, EnumFor("Bold")); + } + + [TestMethod] + public void GetSubfamilyEnum_Italic_ReturnsItalic() + { + Assert.AreEqual(FontSubFamily.Italic, EnumFor("Italic")); + } + + [TestMethod] + public void GetSubfamilyEnum_BoldItalic_ReturnsBoldItalic() + { + Assert.AreEqual(FontSubFamily.BoldItalic, EnumFor("Bold Italic")); + } + + [TestMethod] + public void GetSubfamilyEnum_Oblique_ReturnsItalic() + { + Assert.AreEqual(FontSubFamily.Italic, EnumFor("Oblique")); + } + + [TestMethod] + public void GetSubfamilyEnum_AlternateRegularSpellings_ReturnRegular() + { + Assert.AreEqual(FontSubFamily.Regular, EnumFor("Normal")); + Assert.AreEqual(FontSubFamily.Regular, EnumFor("Roman")); + Assert.AreEqual(FontSubFamily.Regular, EnumFor("Book")); + } + + #endregion + + #region GetSubfamilyEnum — weight names beyond Bold (defense in depth) + + // With the nameID 2 priority fixed, a well-formed font never reaches these branches: + // nameID 2 is always a RIBBI name. They still matter for fonts that omit nameID 2 and + // fall back to nameID 17, which is where weights like "Black" or "Light" show up. + + [TestMethod] + public void GetSubfamilyEnum_WeightNamesBeyondBold_ReturnRegularNotBold() + { + // These are separate typographic weights, already distinguished by the family + // name (e.g. "Arial Black"). Within the 4-value enum their base instance is + // Regular. Mapping them to Bold would disqualify an exact Regular match, and + // would let a Bold request be satisfied by a far heavier face than intended. + Assert.AreEqual(FontSubFamily.Regular, EnumFor("Black"), "Black"); + Assert.AreEqual(FontSubFamily.Regular, EnumFor("Heavy"), "Heavy"); + Assert.AreEqual(FontSubFamily.Regular, EnumFor("Demi"), "Demi"); + Assert.AreEqual(FontSubFamily.Regular, EnumFor("Light"), "Light"); + Assert.AreEqual(FontSubFamily.Regular, EnumFor("Medium"), "Medium"); + } + + [TestMethod] + public void GetSubfamilyEnum_BlackItalic_ReturnsItalic() + { + // Bodoni MT Black and Segoe UI Black both ship a "Black Italic" face. The weight + // is dropped (no enum value for it) but the italic axis is real and must survive. + Assert.AreEqual(FontSubFamily.Italic, EnumFor("Black Italic")); + } + + [TestMethod] + public void GetSubfamilyEnum_SemiBold_ReturnsBold() + { + // "Semibold" legitimately contains the substring "bold", unlike Black/Heavy/Demi, + // and Bold is the closest of the four values. This behaviour is intentional. + Assert.AreEqual(FontSubFamily.Bold, EnumFor("SemiBold")); + } + + [TestMethod] + public void GetSubfamilyEnum_WeightNameBeyondBold_DoesNotConsultFsSelection() + { + // Regression guard for a subtle trap: if the weight-name branch falls through to + // the OS/2 fsSelection fallback instead of returning Regular explicitly, the bug + // reappears through a different path. Vendors commonly set fsSelection's BOLD bit + // on Black/Heavy faces as a legacy hint for apps that can't read the name table. + const ushort fsSelectionBold = 0x0020; + var nameTable = CreateNameTable( + MakeEnglishRecord(NameRecordTypes.TypographicSubfamilyName, "Black")); + nameTable.Os2FsSelection = fsSelectionBold; + + Assert.AreEqual(FontSubFamily.Regular, nameTable.GetSubfamilyEnum(), + "The name table gave a usable answer, so fsSelection must not be consulted."); + } + + #endregion + + #region fsSelection fallback — only when the name table has nothing usable + + [TestMethod] + public void GetSubfamilyEnum_NoSubfamilyRecords_FallsBackToFsSelection() + { + const ushort bold = 0x0020; + const ushort italic = 0x0001; + + Assert.AreEqual(FontSubFamily.Regular, EnumForFsSelection(0)); + Assert.AreEqual(FontSubFamily.Bold, EnumForFsSelection(bold)); + Assert.AreEqual(FontSubFamily.Italic, EnumForFsSelection(italic)); + Assert.AreEqual(FontSubFamily.BoldItalic, EnumForFsSelection((ushort)(bold | italic))); + } + + #endregion + + #region Helpers + + private static FontSubFamily EnumFor(string subfamilyName) + { + return CreateNameTable( + MakeEnglishRecord(NameRecordTypes.FontSubfamilyName, subfamilyName)) + .GetSubfamilyEnum(); + } + + private static FontSubFamily EnumForFsSelection(ushort fsSelection) + { + var nameTable = CreateNameTable(); + nameTable.Os2FsSelection = fsSelection; + return nameTable.GetSubfamilyEnum(); + } + + private static NameTable CreateNameTable(params NameRecord[] records) + { + return new NameTable { NameRecords = records }; + } + + /// + /// Builds a Windows/en-US name record. GetEnglishName() matches on LanguageMapping, + /// not on the raw languageID, so LanguageMapping must be populated for the + /// English-preference tests to mean anything. + /// + private static NameRecord MakeEnglishRecord(NameRecordTypes type, string name) + { + const int enUs = 0x0409; + return new NameRecord + { + RecordType = type, + nameId = (ushort)type, + platformId = 3, // Windows + encodingId = 1, // Unicode BMP + languageID = enUs, + Name = name, + LanguageMapping = new LanguageMapping { code = enUs, Language = Languages.English } + }; + } + + /// + /// Builds a non-English name record. The specific language is irrelevant to the logic + /// under test — all that matters is that it is not Languages.English. + /// + private static NameRecord MakeLocalizedRecord(NameRecordTypes type, string name) + { + const int svSe = 0x041D; + return new NameRecord + { + RecordType = type, + nameId = (ushort)type, + platformId = 3, + encodingId = 1, + languageID = svSe, + Name = name, + LanguageMapping = new LanguageMapping { code = svSe, Language = Languages.Swedish } + }; + } + + #endregion + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType.Tests/FontSubsetManagerTests.cs b/src/EPPlus.Fonts.OpenType.Tests/FontSubsetManagerTests.cs deleted file mode 100644 index 2e7289e10f..0000000000 --- a/src/EPPlus.Fonts.OpenType.Tests/FontSubsetManagerTests.cs +++ /dev/null @@ -1,133 +0,0 @@ -using EPPlus.Fonts.OpenType; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System.Collections.Generic; -using System.Linq; - -namespace EPPlus.Fonts.OpenType.Tests -{ - [TestClass] - public class FontSubsetManagerTests : FontTestBase - { - public override TestContext? TestContext { get; set; } - - // Helper: Load a real font for testing - private OpenTypeFont LoadTestFont() - { - // Adjust path to a font available in your test environment - return TestFolderEngine.LoadFont("Roboto"); - } - - [TestMethod] - public void CreateSubsettedProvider_WithAsciiText_ReturnsSubsettedPrimaryFont() - { - // Arrange - var font = LoadTestFont(); - var manager = new FontSubsetManager(TestFolderEngine, font); - - // Act - manager.AddText("Hello World"); - var provider = manager.CreateSubsettedProvider(); - - // Assert - The subset should be a different (smaller) font instance - var subsetFont = provider.PrimaryFont; - Assert.IsNotNull(subsetFont); - Assert.IsTrue(subsetFont.IsSubset, "Primary font should be subsetted"); - - // Verify the subset contains the glyphs we need - foreach (char c in "Hello World") - { - ushort glyphId; - Assert.IsTrue( - subsetFont.CmapTable.TryGetGlyphId(c, out glyphId), - $"Subset should contain glyph for '{c}'"); - Assert.AreNotEqual((ushort)0, glyphId, $"Glyph for '{c}' should not be .notdef"); - } - } - - [TestMethod] - public void CreateSubsettedProvider_WithEmoji_SubsetsFallbackFont() - { - // Arrange - var font = LoadTestFont(); - var provider = new DefaultFontProvider(TestFolderEngine, font); - var manager = new FontSubsetManager(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(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/Reading/TtfReadingTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Reading/TtfReadingTests.cs index 511dff8e7d..22e0d83e2e 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/Reading/TtfReadingTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/Reading/TtfReadingTests.cs @@ -12,6 +12,7 @@ Date Author Change *************************************************************************************************/ using EPPlus.Fonts.OpenType.FontResolver; using EPPlus.Fonts.OpenType.Scanner; +using EPPlus.Fonts.OpenType.Tables.Os2; using EPPlus.Fonts.OpenType.Tests.Helpers; using OfficeOpenXml.Interfaces.Drawing.Text; using OfficeOpenXml.Interfaces.Fonts; @@ -60,7 +61,7 @@ public void ReadSourceSans3Otf() struct LicenseDataHolder() { public string? FontName; - public ushort LicenseType; + public FsTypeFlags LicenseType; public string? LTypeString; } @@ -71,20 +72,20 @@ struct LicenseDataHolder() /// 4: Preview & Print embedding: the font may be embedded, and may be temporarily loaded on other systems for purposes of viewing or printing the document. Documents containing Preview & Print fonts must be opened "read-only"; no edits can be applied to the document. /// 8: Editable embedding: the font may be embedded, and may be temporarily loaded on other systems. As with Preview & Print embedding, documents containing Editable fonts may be opened for reading. In addition, editing is permitted, including ability to format new text using the embedded font, and changes may be saved. /// - string GetFsString(ushort fsId) + string GetFsString(FsTypeFlags fsType) { - switch (fsId) + switch (fsType & (FsTypeFlags)Os2Table.FsTypeUsageMask) { - case 0: + case FsTypeFlags.Installable: return "Installable Embedding"; - case 2: + case FsTypeFlags.RestrictedLicense: return "Restricted Licence Embedding"; - case 4: + case FsTypeFlags.PreviewPrint: return "Preview & Print Embedding"; - case 8: + case FsTypeFlags.Editable: return "Editable Embedding"; default: - return $"UNKNOWN VALUE: '{fsId}' POTENTIALLY CORRUPT FONT"; + return $"UNKNOWN VALUE: '{(ushort)fsType}' POTENTIALLY CORRUPT FONT"; } } @@ -160,7 +161,8 @@ public void ReadAllOTFFonts() Assert.AreEqual(Scanner.FontFormat.Otf, allFontsList[i].Format); } - var fontsThatCannotBeEmbedded = dataHolder.Where(x => x.LicenseType == 2); + var fontsThatCannotBeEmbedded = dataHolder.Where( + x => (x.LicenseType & (FsTypeFlags)Os2Table.FsTypeUsageMask) == FsTypeFlags.RestrictedLicense); Assert.AreEqual(0, fontsThatCannotBeEmbedded.Count()); } @@ -211,7 +213,8 @@ public void ReadAllTTFFonts() Assert.AreEqual(Scanner.FontFormat.Ttf, allFontsList[i].Format); } - var fontsThatCannotBeEmbedded = dataHolder.Where(x => x.LicenseType == 2); + var fontsThatCannotBeEmbedded = dataHolder.Where( + x => (x.LicenseType & (FsTypeFlags)Os2Table.FsTypeUsageMask) == FsTypeFlags.RestrictedLicense); Assert.AreEqual(0, fontsThatCannotBeEmbedded.Count()); } diff --git a/src/EPPlus.Fonts.OpenType.Tests/Subsetting/DocumentFontSubsetBuilderTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Subsetting/DocumentFontSubsetBuilderTests.cs new file mode 100644 index 0000000000..0aca385d97 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/Subsetting/DocumentFontSubsetBuilderTests.cs @@ -0,0 +1,198 @@ +using EPPlus.Fonts.OpenType.Subsetting; +using OfficeOpenXml.Interfaces.Fonts; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace EPPlus.Fonts.OpenType.Tests.Subsetting +{ + [TestClass] + public class DocumentFontSubsetBuilderTests : FontTestBase + { + public override TestContext? TestContext { get; set; } + + private static DocumentFontSubsetBuilder CreateBuilderWithCallback( + Func callback) + { + var engine = new OpenTypeFontEngine(cfg => + { + foreach (var folder in FontFolders) + cfg.FontDirectories.Add(folder); + cfg.SearchSystemDirectories = false; + cfg.OnFontEmbedding(callback); + }); + return new DocumentFontSubsetBuilder(engine); + } + + private static Func SkipByName(string namePart) + { + return info => info.FontName != null && info.FontName.Contains(namePart) + ? FontEmbeddingDecision.Skip + : FontEmbeddingDecision.Default; + } + + [TestMethod] + public void Build_SkippedPrimary_NextFontBecomesPrimary() + { + var builder = CreateBuilderWithCallback(SkipByName("Roboto")); + builder.AddText("Roboto", FontSubFamily.Regular, "Hello"); + builder.Build(); + + var provider = builder.GetShapingProvider("Roboto", FontSubFamily.Regular); + + Assert.IsNotNull(provider.PrimaryFont); + StringAssert.DoesNotMatch( + provider.PrimaryFont.GetEnglishFontFamilyName(), + new System.Text.RegularExpressions.Regex("Roboto"), + "A skipped primary must not remain the provider's primary font."); + } + + [TestMethod] + public void Build_SkippedPrimary_AllTextSkipped_UsesLastResort() + { + var builder = CreateBuilderWithCallback(SkipByName("Roboto")); + builder.AddText("Roboto", FontSubFamily.Regular, "Hello"); + builder.Build(); + + var provider = builder.GetShapingProvider("Roboto", FontSubFamily.Regular); + + StringAssert.Contains( + provider.PrimaryFont.GetEnglishFontFamilyName(), + "Archivo", + "When the whole chain is skipped, the last-resort font must become primary."); + + ushort glyphId; + Assert.IsTrue( + provider.PrimaryFont.CmapTable.TryGetGlyphId('H', out glyphId) && glyphId != 0, + "Latin glyphs must be carried by the last-resort font after redistribution."); + } + + [TestMethod] + public void Build_SkippedPrimary_PrefersChainFontOverLastResort() + { + // Roboto skipped, but its text is an emoji that a chain fallback (Noto Emoji) covers. + // The emoji must be carried by that chain font — NOT by the Archivo last resort. + // (Han/CJK cannot be used here until script fallback is wired into the provider chain.) + var builder = CreateBuilderWithCallback(SkipByName("Roboto")); + builder.AddText("Roboto", FontSubFamily.Regular, char.ConvertFromUtf32(0x1F600)); // 😀 + builder.Build(); + + var provider = builder.GetShapingProvider("Roboto", FontSubFamily.Regular); + + ushort glyphId; + Assert.IsTrue( + provider.PrimaryFont.CmapTable.TryGetGlyphId(0x1F600, out glyphId) && glyphId != 0, + "The emoji must be carried by the chain fallback, not the last resort."); + } + + [TestMethod] + public void Build_SharedFallback_AllPrimariesSkipped_ProduceSingleConsistentSubset() + { + // The A1/B1/C1 regression, as a unit test: three primaries, all skipped, all collapsing + // to the same last-resort font. That font must be ONE shared subset containing every + // routed glyph — not three colliding subsets. + var builder = CreateBuilderWithCallback(info => FontEmbeddingDecision.Skip); // skip everything + builder.AddText("Roboto", FontSubFamily.Regular, "A"); + builder.AddText("Open Sans", FontSubFamily.Regular, "B"); + builder.AddText("Mulish", FontSubFamily.Regular, "C"); + builder.Build(); + + var embedded = builder.GetFontsToEmbed().ToList(); + + // Exactly one font embedded (the shared last resort), carrying A, B and C. + Assert.AreEqual(1, embedded.Count, "All skipped primaries must collapse to one shared font."); + var shared = embedded[0].Font; + + foreach (var ch in new[] { 'A', 'B', 'C' }) + { + ushort glyphId; + Assert.IsTrue( + shared.CmapTable.TryGetGlyphId(ch, out glyphId) && glyphId != 0, + "Shared subset must carry '" + ch + "' from all three primaries."); + } + } + + private const string TestFamily = "Roboto"; + + [TestMethod] + public void AddText_WithNullOrEmpty_DoesNotThrow() + { + var builder = new DocumentFontSubsetBuilder(TestFolderEngine); + builder.AddText(TestFamily, FontSubFamily.Regular, null); + builder.AddText(TestFamily, FontSubFamily.Regular, ""); + // No text was ever added, so Build has nothing to do — it must not throw either. + builder.Build(); + } + + [TestMethod] + public void Build_WithAsciiText_ReturnsSubsettedPrimaryFont() + { + var builder = new DocumentFontSubsetBuilder(TestFolderEngine); + builder.AddText(TestFamily, FontSubFamily.Regular, "Hello"); + builder.Build(); + + var provider = builder.GetShapingProvider(TestFamily, FontSubFamily.Regular); + + Assert.IsNotNull(provider); + Assert.IsTrue(provider.PrimaryFont.IsSubset, + "Ascii text through the primary font must yield a subsetted primary."); + } + + [TestMethod] + public void Build_WithMultipleAddTextCalls_CollectsAllCodePoints() + { + var builder = new DocumentFontSubsetBuilder(TestFolderEngine); + builder.AddText(TestFamily, FontSubFamily.Regular, "abc"); + builder.AddText(TestFamily, FontSubFamily.Regular, "def"); + builder.Build(); + + var provider = builder.GetShapingProvider(TestFamily, FontSubFamily.Regular); + + // Every code point from every AddText call must survive into the subset. + foreach (var ch in "abcdef") + { + ushort glyphId; + Assert.IsTrue( + provider.PrimaryFont.CmapTable.TryGetGlyphId(ch, out glyphId) && glyphId != 0, + "Subset must contain '" + ch + "' collected across multiple AddText calls."); + } + } + + [TestMethod] + public void Build_WithEmoji_SubsetsFallbackFont() + { + var builder = new DocumentFontSubsetBuilder(TestFolderEngine); + builder.AddText(TestFamily, FontSubFamily.Regular, char.ConvertFromUtf32(0x1F600)); // 😀 + builder.Build(); + + // The emoji routes to the Noto Emoji fallback, which must appear among the embedded + // fonts and carry the glyph. + var embedded = builder.GetFontsToEmbed().ToList(); + + bool emojiCarried = embedded.Any(sf => + { + ushort glyphId; + return sf.Font.CmapTable.TryGetGlyphId(0x1F600, out glyphId) && glyphId != 0; + }); + + Assert.IsTrue(emojiCarried, "The emoji fallback font must be subsetted and embedded."); + } + + [TestMethod] + public void Build_UnusedFallbackFontsAreExcluded() + { + // Pure ascii: only the primary is needed. No emoji/math fallback should be embedded. + var builder = new DocumentFontSubsetBuilder(TestFolderEngine); + builder.AddText(TestFamily, FontSubFamily.Regular, "Hello"); + builder.Build(); + + var embedded = builder.GetFontsToEmbed().ToList(); + + Assert.AreEqual(1, embedded.Count, + "Only the primary font should be embedded when no fallback was needed."); + StringAssert.Contains(embedded[0].Family, "Roboto"); + } + } +} diff --git a/src/EPPlus.Fonts.OpenType.Tests/Subsetting/FontEmbeddingPolicyTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Subsetting/FontEmbeddingPolicyTests.cs new file mode 100644 index 0000000000..f0190de767 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/Subsetting/FontEmbeddingPolicyTests.cs @@ -0,0 +1,174 @@ +using EPPlus.Fonts.OpenType.Subsetting; +using EPPlus.Fonts.OpenType.Tables.Os2; +using OfficeOpenXml; +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 Build_EmbedWholeDecision_EmbedsWholeFontNotSubset() + { + // 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.EmbedWhole + : FontEmbeddingDecision.Default); + + var builder = new DocumentFontSubsetBuilder(engine); + builder.AddText("Roboto", FontSubFamily.Regular, "Hello"); + builder.Build(); + + var provider = builder.GetShapingProvider("Roboto", FontSubFamily.Regular); + + Assert.IsFalse(provider.PrimaryFont.IsSubset, + "An EmbedWhole font must be embedded whole, not subsetted."); + } + } +} diff --git a/src/EPPlus.Fonts.OpenType/EmbeddedFonts.cs b/src/EPPlus.Fonts.OpenType/EmbeddedFonts.cs index 7c6198b17e..5c9d5cffc0 100644 --- a/src/EPPlus.Fonts.OpenType/EmbeddedFonts.cs +++ b/src/EPPlus.Fonts.OpenType/EmbeddedFonts.cs @@ -75,6 +75,31 @@ private static OpenTypeFont LoadCached(string resourceName) } } + // The families EPPlus ships as embedded resources. Kept as names rather than instances: + // a bundled font also reaches the engine as a fresh OpenTypeFont built from + // IFontResolver.ResolveFont's byte[], which is not reference-equal to the cached instance. + private static readonly string[] _bundledFamilies = new string[] + { + "Archivo Narrow", + "Noto Emoji", + "Noto Sans Math" + }; + + /// + /// True if the family is one EPPlus distributes as an embedded resource. All four + /// Archivo Narrow styles are covered by the family name alone. + /// + internal static bool IsBundledFamily(string familyName) + { + if (string.IsNullOrEmpty(familyName)) return false; + for (int i = 0; i < _bundledFamilies.Length; i++) + { + if (string.Equals(_bundledFamilies[i], familyName, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + /// /// Reads all bytes from a stream into a byte array. /// .NET 3.5 compatible (no CopyTo available). diff --git a/src/EPPlus.Fonts.OpenType/EpplusFontConfiguration.cs b/src/EPPlus.Fonts.OpenType/EpplusFontConfiguration.cs index e39aca5b5e..4aa7e14946 100644 --- a/src/EPPlus.Fonts.OpenType/EpplusFontConfiguration.cs +++ b/src/EPPlus.Fonts.OpenType/EpplusFontConfiguration.cs @@ -33,6 +33,9 @@ internal class EpplusFontConfiguration : IEpplusFontConfiguration private readonly Dictionary _scriptFallbacks = new Dictionary(); + private Func _onFontEmbedding; + + public EpplusFontConfiguration() { SearchSystemDirectories = true; @@ -51,6 +54,21 @@ public IList FontDirectories /// public IFontResolver FontResolver { get; set; } + /// + public void OnFontEmbedding(Func callback) + { + _onFontEmbedding = callback; + } + + /// + /// Returns the registered embedding-decision callback, or null if none is configured. + /// Consumed by the font engine when resolving how a font should be embedded. + /// + internal Func GetEmbeddingCallback() + { + return _onFontEmbedding; + } + /// public IDictionary FontFallbacks { diff --git a/src/EPPlus.Fonts.OpenType/FontSubsetManager.cs b/src/EPPlus.Fonts.OpenType/FontSubsetManager.cs deleted file mode 100644 index 641933d282..0000000000 --- a/src/EPPlus.Fonts.OpenType/FontSubsetManager.cs +++ /dev/null @@ -1,145 +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 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 FontSubsetManager - { - private readonly IFontProvider _sourceProvider; - - // Code points collected per font (key = original font instance) - private readonly Dictionary> _codePointsByFont = - new Dictionary>(); - - public FontSubsetManager(IFontProvider sourceProvider) - { - if (sourceProvider == null) - throw new ArgumentNullException("sourceProvider"); - - _sourceProvider = sourceProvider; - } - - public FontSubsetManager(OpenTypeFontEngine engine, OpenTypeFont font) - : this(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 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) - { - var originalFont = kvp.Key; - var codePoints = kvp.Value; - - if (codePoints.Count == 0) - continue; - - try - { - var chars = CodePointUtil.CodePointsToString(codePoints); - var subset = originalFont.CreateSubset(chars); - subsetMap[originalFont] = subset; - } - catch (Exception ex) - { - // If subsetting fails, use the original font - System.Diagnostics.Debug.WriteLine( - $"Warning: Could not subset '{originalFont.NameTable?.GetFullFontName()}': {ex.Message}"); - subsetMap[originalFont] = originalFont; - } - } - - // Build new provider with subsetted fonts, preserving fallback order - var subsetPrimary = subsetMap.ContainsKey(primaryFont) - ? subsetMap[primaryFont] - : primaryFont; - - 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]; - - if (subsetMap.ContainsKey(originalFallback)) - { - provider.AddFallback(subsetMap[originalFallback]); - } - // If no code points were collected for this fallback, skip it entirely - } - - return provider; - } - } -} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs b/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs index 4de8871caf..7518114408 100644 --- a/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs +++ b/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs @@ -20,6 +20,7 @@ Date Author Change using OfficeOpenXml.Interfaces.RichText; using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; namespace EPPlus.Fonts.OpenType @@ -374,6 +375,44 @@ public FontAvailability GetFontAvailability( : FontAvailability.NotFound; } + internal FontEmbeddingDecision ResolveEmbeddingDecision(OpenTypeFont font) + { + var restriction = font.Os2Table != null + ? 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) + { + var decision = callback(new FontEmbeddingInfo(fontName, restriction)); + if (decision != FontEmbeddingDecision.Default) + 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) + { + 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 +464,7 @@ internal static List GetLocationsCollection( return DefaultFontLocations.GetLocationsCollection(fontDirectories, searchSystemDirectories); } + // I OpenTypeFontEngine private void ThrowIfDisposed() { if (_disposed) diff --git a/src/EPPlus.Fonts.OpenType/Scanner/FontScannerV2Core.cs b/src/EPPlus.Fonts.OpenType/Scanner/FontScannerV2Core.cs index f8237e49ee..fe45739553 100644 --- a/src/EPPlus.Fonts.OpenType/Scanner/FontScannerV2Core.cs +++ b/src/EPPlus.Fonts.OpenType/Scanner/FontScannerV2Core.cs @@ -99,14 +99,28 @@ internal static FontFaceInfo ScanSingleFace(string filePath, long offset) if (info.TableRecords.TryGetValue("OS/2", out TableRecord os2Rec)) { - try + // fsSelection sits at byte offset 62 in the OS/2 table, after sFamilyClass (30), + // panose[10] (32-41), ulUnicodeRange1-4 (42-57) and achVendID (58-61). + // Reading at offset 32 returns the first two PANOSE bytes instead. + const int fsSelectionOffset = 62; + + // Every OS/2 version (0 and up) is at least 78 bytes, so a table too short to hold + // fsSelection is malformed. Check up front rather than relying on the read throwing. + if (os2Rec.Length >= fsSelectionOffset + 2) { - fs.Position = info.OffsetInFile + os2Rec.Offset + 32; - info.FsSelection = reader.ReadUInt16BigEndian(); + try + { + fs.Position = info.OffsetInFile + os2Rec.Offset + fsSelectionOffset; + info.FsSelection = reader.ReadUInt16BigEndian(); + } + catch + { + // Om tabellen är korrupt eller för kort → ignorera, behåll 0 + info.FsSelection = 0; + } } - catch + else { - // Om tabellen är korrupt eller för kort → ignorera, behåll 0 info.FsSelection = 0; } } diff --git a/src/EPPlus.Fonts.OpenType/Subsetting/DocumentFontSubsetBuilder.cs b/src/EPPlus.Fonts.OpenType/Subsetting/DocumentFontSubsetBuilder.cs new file mode 100644 index 0000000000..b7be7ccf06 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType/Subsetting/DocumentFontSubsetBuilder.cs @@ -0,0 +1,235 @@ +/************************************************************************************************* + 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 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)) + { + 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(kvp.Key.SubFamily); + + 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(kvp.Key.SubFamily); + 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(FontSubFamily subFamily) + { + var font = EmbeddedFonts.LoadArchivoNarrow(subFamily); + _decisionByIdentity[IdentityOf(font)] = FontEmbeddingDecision.Subset; + return font; + } + + // Resolves and caches the embedding decision for a font, keyed by identity so the user's + // OnFontEmbedding hook fires at most once per FontKey. A NoEmbedding font throws here (via + // ResolveEmbeddingDecision), exactly as in the old per-font path. + private FontEmbeddingDecision DecisionForFont(OpenTypeFont font) + { + var id = IdentityOf(font); + FontEmbeddingDecision decision; + if (!_decisionByIdentity.TryGetValue(id, out decision)) + { + decision = _engine.ResolveEmbeddingDecision(font); + _decisionByIdentity[id] = decision; + } + return decision; + } + + // Looks up an already-resolved decision by identity. Every identity in fontByIdentity passed + // through DecisionForFont during phase 1, so it is always present here. + private FontEmbeddingDecision DecisionForIdentity(FontKey id) + { + return _decisionByIdentity[id]; + } + + // Canonical identity from the pre-subset font instance: family + subfamily. + private static FontKey IdentityOf(OpenTypeFont font) + { + return new FontKey(font.GetEnglishFontFamilyName(), font.NameTable.GetSubfamilyEnum()); + } + + /// + /// The subsetted fonts to embed — one per distinct font identity used in the document. + /// Skipped fonts are absent; each shared fallback appears once. Call after Build(). + /// + public IEnumerable GetFontsToEmbed() + { + RequireBuilt(); + foreach (var kvp in _sharedSubsetByIdentity) + yield return new SubsettedFont(kvp.Key.Family, kvp.Key.SubFamily, kvp.Value); + } + + /// + /// The provider a given requested font shapes against, wired to the shared subsets. + /// Returns null if that font was never added. Call after Build(). + /// + public IFontProvider GetShapingProvider(string family, FontSubFamily subFamily) + { + RequireBuilt(); + IFontProvider provider; + return _providerByRequest.TryGetValue(new FontKey(family, subFamily), out provider) + ? provider : null; + } + + private void RequireBuilt() + { + if (!_built) + throw new InvalidOperationException("Call Build() before reading results."); + } + + private sealed class RequestedFont + { + public FontKey Key { get; private set; } + public OpenTypeFont Primary { get; private set; } + public HashSet CodePoints { get; private set; } + public RequestedFont(FontKey key, OpenTypeFont primary) + { Key = key; Primary = primary; CodePoints = new HashSet(); } + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/Subsetting/SingleFontSubsetter.cs b/src/EPPlus.Fonts.OpenType/Subsetting/SingleFontSubsetter.cs new file mode 100644 index 0000000000..7f5a6454a5 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType/Subsetting/SingleFontSubsetter.cs @@ -0,0 +1,56 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 08/20/2026 EPPlus Software AB Single-font subsetter extracted from FontSubsetManager + *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Utils; +using System; +using System.Collections.Generic; + +namespace EPPlus.Fonts.OpenType +{ + /// + /// Subsets one font down to a given set of code points. This is a low-level building block: + /// it does not resolve fallback chains and makes no embedding-policy decisions — the caller + /// owns all of that. Kept separate so it can be unit-tested in isolation and reused by any + /// component that needs to reduce a single font. + /// + internal sealed class SingleFontSubsetter + { + /// + /// Produces a subset of containing only the glyphs required for + /// . Returns the font unchanged when it is already a subset + /// or when no code points are supplied. If subsetting fails, the original font is returned + /// so the caller always receives an embeddable instance. + /// + public OpenTypeFont Subset(OpenTypeFont font, HashSet codePoints) + { + if (font == null) + throw new ArgumentNullException("font"); + + if (font.IsSubset || codePoints == null || codePoints.Count == 0) + return font; + + try + { + var chars = CodePointUtil.CodePointsToString(codePoints); + return font.CreateSubset(chars); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine( + "Warning: could not subset '" + + (font.NameTable != null ? font.NameTable.GetFullFontName() : "(unknown)") + + "': " + ex.Message); + return font; + } + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/Subsetting/SubsettedFont.cs b/src/EPPlus.Fonts.OpenType/Subsetting/SubsettedFont.cs new file mode 100644 index 0000000000..1b0fe4f655 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType/Subsetting/SubsettedFont.cs @@ -0,0 +1,45 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 10/07/2025 EPPlus Software AB EPPlus.Fonts.OpenType 1.0 + *************************************************************************************************/ +using OfficeOpenXml.Interfaces.Fonts; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace EPPlus.Fonts.OpenType.Subsetting +{ + public sealed class SubsettedFont + { + /// + /// Constructor + /// + /// canonical, pre-subset family name + /// + /// the subsetted instance to embed + internal SubsettedFont(string family, FontSubFamily subFamily, OpenTypeFont font) + { + Family = family; SubFamily = subFamily; Font = font; + } + + /// + /// canonical, pre-subset family name + /// + public string Family { get; } + public FontSubFamily SubFamily { get; } + /// + /// the subsetted instance to embed + /// + public OpenTypeFont Font { get; } + } +} diff --git a/src/EPPlus.Fonts.OpenType/Tables/Name/NameTable.cs b/src/EPPlus.Fonts.OpenType/Tables/Name/NameTable.cs index f29a296ebf..b389889a5e 100644 --- a/src/EPPlus.Fonts.OpenType/Tables/Name/NameTable.cs +++ b/src/EPPlus.Fonts.OpenType/Tables/Name/NameTable.cs @@ -327,42 +327,56 @@ public string GetFullFontName() } /// - /// Returns the preferred font family name using OpenType specification priority. - /// Prefers Typographic Family (16) over regular Family (1). + /// Returns the font family name in the legacy/RIBBI naming system (nameID 1), which pairs + /// with GetSubfamilyName()'s nameID 2. Together they form the family+style view that Windows, + /// GDI and Excel present, and the one FontSubFamily's four values are defined against. + /// + /// nameID 16 (Typographic Family) is deliberately NOT preferred, even though it is the newer + /// field: it belongs to the *other* naming system, paired with nameID 17. Arial Black reads + /// "Arial Black" + "Regular" as (1+2) but "Arial" + "Black" as (16+17). Preferring 16 here + /// while GetSubfamilyName() reads 2 (or vice versa) mixes the two systems and produces a pair + /// that exists in neither, breaking font matching. nameID 16 is used only as a last resort, + /// for fonts that omit nameID 1 entirely. /// public string GetFamilyName() { - //// Typographic Family (16) first - //string name = GetFirstNonEmpty(NameRecordTypes.TypographicFamilyName); - //if (!string.IsNullOrEmpty(name)) - // return name; - - // Then regular Family Name (1) - string name = GetFirstNonEmpty(NameRecordTypes.FontFamilyName); + // Legacy Family Name (1), English first: NameRecords can hold one localized nameID 1 + // per language, and GetFirstNonEmpty returns whichever comes first in file order. + string name = GetEnglishName(NameRecordTypes.FontFamilyName); if (!string.IsNullOrEmpty(name)) return name; - // Typographic Family (16) first - name = GetFirstNonEmpty(NameRecordTypes.TypographicFamilyName); + name = GetFirstNonEmpty(NameRecordTypes.FontFamilyName); if (!string.IsNullOrEmpty(name)) return name; - - // Fallback to English + // Last resort only: the typographic family, for fonts that omit nameID 1. name = GetEnglishName(NameRecordTypes.TypographicFamilyName); if (!string.IsNullOrEmpty(name)) return name; - return GetEnglishName(NameRecordTypes.FontFamilyName) ?? "Unknown Family"; + return GetFirstNonEmpty(NameRecordTypes.TypographicFamilyName) ?? "Unknown Family"; } /// - /// Returns the preferred subfamily name. - /// Prefers Typographic Subfamily (17) over regular Subfamily (2). + /// Returns the subfamily name in the legacy/RIBBI naming system (nameID 2), which pairs + /// with GetFamilyName()'s nameID 1. nameID 2 is guaranteed by the OpenType spec to be one + /// of "Regular"/"Bold"/"Italic"/"Bold Italic", which is exactly the FontSubFamily model. + /// + /// nameID 17 (Typographic Subfamily) is deliberately NOT preferred: it belongs to the + /// *other* naming system, paired with nameID 16. Arial Black reads "Arial Black" + "Regular" + /// as (1+2) but "Arial" + "Black" as (16+17). Mixing them yields the pair "Arial Black" + + /// "Black", which exists in neither system and matches no style request. + /// + /// Returns null when the font carries no subfamily name at all. Callers that need a display + /// string apply their own default; GetSubfamilyEnum relies on null to know it should fall + /// back to OS/2 fsSelection instead. /// public string GetSubfamilyName() { - string name = GetFirstNonEmpty(NameRecordTypes.TypographicSubfamilyName); + // Legacy Subfamily (2), English first: a font can carry one localized nameID 2 per + // language, and GetFirstNonEmpty returns whichever happens to come first in file order. + string name = GetEnglishName(NameRecordTypes.FontSubfamilyName); if (!string.IsNullOrEmpty(name)) return name; @@ -370,19 +384,21 @@ public string GetSubfamilyName() if (!string.IsNullOrEmpty(name)) return name; + // Last resort only: the typographic subfamily, for fonts that omit nameID 2. name = GetEnglishName(NameRecordTypes.TypographicSubfamilyName); if (!string.IsNullOrEmpty(name)) return name; - return GetEnglishName(NameRecordTypes.FontSubfamilyName) ?? "Regular"; + return GetFirstNonEmpty(NameRecordTypes.TypographicSubfamilyName); } public FontSubFamily GetSubfamilyEnum() { string subfamily = GetSubfamilyName(); + // No subfamily name in the name table at all → fall back to OS/2 fsSelection. if (string.IsNullOrEmpty(subfamily)) - goto UseFsSelection; + return GetSubfamilyFromFsSelection(); string lower = subfamily.ToLowerInvariant(); @@ -398,14 +414,24 @@ public FontSubFamily GetSubfamilyEnum() if (lower.Contains("bold") && lower.Contains("italic")) return FontSubFamily.BoldItalic; - if (lower.Contains("bold") || lower.Contains("heavy") || lower.Contains("black") || lower.Contains("demi")) + if (lower.Contains("bold")) return FontSubFamily.Bold; if (lower.Contains("italic") || lower.Contains("oblique")) return FontSubFamily.Italic; - // Om name-tabellen är konstig → fallback till OS/2 - UseFsSelection: - return GetSubfamilyFromFsSelection(); + // Weight names beyond "Bold" (Black, Heavy, Demi, Light, Medium, etc.) don't fit the + // 4-value RIBBI model and are NOT treated as Bold here. Fonts using these names + // (e.g. "Arial Black", "Segoe UI Black") already distinguish themselves via FamilyName, + // so their base instance is Regular within FontSubFamily. Mapping them to Bold would + // falsely disqualify an exact match against a Regular request, sending the resolver + // into the fallback chain even though the font is installed. + // + // Note that we return Regular here rather than consulting fsSelection: the name table + // did give us an answer, it just isn't expressible in four values. fsSelection is not a + // tie-breaker for that case — vendors commonly set its BOLD bit on Black/Heavy faces as + // a legacy hint for apps that can't read the name table, so consulting it here would + // silently re-introduce this exact bug through a different path. + return FontSubFamily.Regular; } private FontSubFamily GetSubfamilyFromFsSelection() diff --git a/src/EPPlus.Fonts.OpenType/Tables/Os2/FsSelectionFlags.cs b/src/EPPlus.Fonts.OpenType/Tables/Os2/FsSelectionFlags.cs new file mode 100644 index 0000000000..e0a63fbf9d --- /dev/null +++ b/src/EPPlus.Fonts.OpenType/Tables/Os2/FsSelectionFlags.cs @@ -0,0 +1,32 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 10/07/2026 EPPlus Software AB EPPlus.Fonts.OpenType 1.0 + *************************************************************************************************/ +using System; + +namespace EPPlus.Fonts.OpenType.Tables.Os2 +{ + [Flags] + public enum FsSelectionFlags : ushort + { + Italic = 1 << 0, // Bit 0 + Underscore = 1 << 1, // Bit 1 + Negative = 1 << 2, // Bit 2 + Outlined = 1 << 3, // Bit 3 + Strikeout = 1 << 4, // Bit 4 + Bold = 1 << 5, // Bit 5 + Regular = 1 << 6, // Bit 6 + UseTypoMetrics = 1 << 7, // Bit 7 + WWS = 1 << 8, // Bit 8 + Oblique = 1 << 9 // Bit 9 + // Bits 10-15 are reserved + } +} diff --git a/src/EPPlus.Fonts.OpenType/Tables/Os2/FsTypeFlags.cs b/src/EPPlus.Fonts.OpenType/Tables/Os2/FsTypeFlags.cs new file mode 100644 index 0000000000..79a18e6fdd --- /dev/null +++ b/src/EPPlus.Fonts.OpenType/Tables/Os2/FsTypeFlags.cs @@ -0,0 +1,33 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 10/07/2026 EPPlus Software AB EPPlus.Fonts.OpenType 1.0 + *************************************************************************************************/ +using System; + +namespace EPPlus.Fonts.OpenType.Tables.Os2 +{ + [Flags] + public enum FsTypeFlags : ushort + { + /// Installable embedding (no restrictions). Bits 0-3 all clear. + Installable = 0x0000, + /// Restricted License embedding. Bit 1. + RestrictedLicense = 0x0002, + /// Preview & Print embedding. Bit 2. + PreviewPrint = 0x0004, + /// Editable embedding. Bit 3. + Editable = 0x0008, + /// No subsetting: font must be embedded whole, not subsetted. Bit 8. + NoSubsetting = 0x0100, + /// Bitmap embedding only: only bitmap data may be embedded. Bit 9. + BitmapEmbeddingOnly = 0x0200, + } +} diff --git a/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2Table.cs b/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2Table.cs index f35b727110..b7d3053e29 100644 --- a/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2Table.cs +++ b/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2Table.cs @@ -11,9 +11,10 @@ Date Author Change 10/07/2025 EPPlus Software AB EPPlus.Fonts.OpenType 1.0 *************************************************************************************************/ -using System; using EPPlus.Fonts.OpenType; using EPPlus.Fonts.OpenType.Utils; +using OfficeOpenXml.Interfaces.Fonts; +using System; namespace EPPlus.Fonts.OpenType.Tables.Os2 { @@ -44,13 +45,34 @@ public class Os2Table : FontTableBase public ushort usWidthClass { get; set; } /// - /// Indicates font embedding licensing rights for the font. The interpretation of flags is as follows: - /// 0: Installable embedding: the font may be embedded, and may be permanently installed for use on a remote systems, or for use by other users. - /// 2: Restricted License embedding: the font must not be modified, embedded or exchanged in any manner without first obtaining explicit permission of the legal owner. - /// 4: Preview & Print embedding: the font may be embedded, and may be temporarily loaded on other systems for purposes of viewing or printing the document. Documents containing Preview & Print fonts must be opened “read-only”; no edits can be applied to the document. - /// 8: Editable embedding: the font may be embedded, and may be temporarily loaded on other systems. As with Preview & Print embedding, documents containing Editable fonts may be opened for reading. In addition, editing is permitted, including ability to format new text using the embedded font, and changes may be saved. + /// Indicates font embedding licensing rights for the font. See + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2#fst + /// Bits 0-3 form a mutually-exclusive usage-permission level; bits 8 and 9 + /// are independent flags. Interpret with masks, not equality — e.g. + /// (fsType & FsTypeUsageMask) == FsTypeFlags.RestrictedLicense, or + /// (fsType & FsTypeFlags.NoSubsetting) != 0. + /// + public FsTypeFlags fsType { get; set; } + + /// + /// Mask covering the mutually-exclusive usage-permission bits (0-3) of . + /// Use this to isolate the usage level before comparing against a specific + /// value, since those bits are not independent flags. + /// + internal const ushort FsTypeUsageMask = 0x000F; + + /// + /// Interprets into the embedding restriction the font declares. + /// Pure interpretation — carries no policy about what EPPlus does with it. /// - public ushort fsType { get; set; } + public FontEmbeddingRestriction GetEmbeddingRestriction() + { + if ((fsType & (FsTypeFlags)FsTypeUsageMask) == FsTypeFlags.RestrictedLicense) + return FontEmbeddingRestriction.NoEmbedding; + if ((fsType & FsTypeFlags.NoSubsetting) != 0) + return FontEmbeddingRestriction.NoSubsetting; + return FontEmbeddingRestriction.None; + } /// /// The recommended horizontal size in font design units for subscripts for this font. @@ -138,21 +160,7 @@ public class Os2Table : FontTableBase /// See https://docs.microsoft.com/en-us/typography/opentype/spec/os2#fss /// public FsSelectionFlags fsSelection { get; set; } - [Flags] - public enum FsSelectionFlags : ushort - { - Italic = 1 << 0, // Bit 0 - Underscore = 1 << 1, // Bit 1 - Negative = 1 << 2, // Bit 2 - Outlined = 1 << 3, // Bit 3 - Strikeout = 1 << 4, // Bit 4 - Bold = 1 << 5, // Bit 5 - Regular = 1 << 6, // Bit 6 - UseTypoMetrics = 1 << 7, // Bit 7 - WWS = 1 << 8, // Bit 8 - Oblique = 1 << 9 // Bit 9 - // Bits 10-15 are reserved - } + //public FsSelectionFlags SelectionFlags => (FsSelectionFlags)fsSelection; @@ -212,7 +220,7 @@ internal override void SerializeInternal(FontsBinaryWriter writer, FontSerializa writer.WriteInt16BigEndian(xAvgCharWidth); writer.WriteUInt16BigEndian(usWeightClass); writer.WriteUInt16BigEndian(usWidthClass); - writer.WriteUInt16BigEndian(fsType); + writer.WriteUInt16BigEndian((ushort)fsType); writer.WriteInt16BigEndian(ySubscriptXSize); writer.WriteInt16BigEndian(ySubscriptYSize); writer.WriteInt16BigEndian(ySubscriptXOffset); diff --git a/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2TableLoader.cs b/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2TableLoader.cs index ce51d90ebd..68c753b62e 100644 --- a/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2TableLoader.cs +++ b/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2TableLoader.cs @@ -82,7 +82,7 @@ protected override Os2Table LoadInternal() xAvgCharWidth = xAvgCharWidth, usWeightClass = usWeightClass, usWidthClass = usWidthClass, - fsType = fsType, + fsType = (FsTypeFlags)fsType, ySubscriptXSize = ySubscriptXSize, ySubscriptYSize = ySubscriptYSize, ySubscriptXOffset = ySubscriptXOffset, @@ -100,7 +100,7 @@ protected override Os2Table LoadInternal() UnicodeRange3 = ucr3, UnicodeRange4 = ucr4, achVendId = achVendId, - fsSelection = (Os2Table.FsSelectionFlags)fsSelection, + fsSelection = (FsSelectionFlags)fsSelection, usFirstCharIndex = usFirstCharIndex, usLastCharIndex = usLastCharIndex, sTypoAscender = sTypoAscender, diff --git a/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2Validator.cs b/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2Validator.cs index e75381d5d7..62d2e9bc54 100644 --- a/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2Validator.cs +++ b/src/EPPlus.Fonts.OpenType/Tables/Os2/Os2Validator.cs @@ -56,7 +56,7 @@ public override TableValidationResult Validate(Os2Table table, FontValidationCon } // fsType basic info - if ((table.fsType & 0x0002) != 0) + if ((table.fsType & FsTypeFlags.RestrictedLicense) != 0) { result.AddMessage(FontValidationSeverity.Information, "Font has restricted embedding (fsType bit 1 set)."); @@ -106,20 +106,20 @@ public override TableValidationResult Validate(Os2Table table, FontValidationCon // ------------------------- // Embedding permissions - if ((table.fsType & 0x0002) != 0) + if ((table.fsType & (FsTypeFlags)Os2Table.FsTypeUsageMask) == FsTypeFlags.RestrictedLicense) { result.AddMessage(FontValidationSeverity.Error, - "Embedding is restricted (fsType bit 1 set). Subsetting cannot proceed."); + "Embedding is restricted (fsType Restricted License). Subsetting cannot proceed."); } - if ((table.fsType & 0x0008) != 0) + if ((table.fsType & FsTypeFlags.NoSubsetting) != 0) { result.AddMessage(FontValidationSeverity.Error, - "No subsetting allowed (fsType bit 3 set)."); + "No subsetting allowed (fsType NoSubsetting bit set). Font must be embedded whole."); } - if ((table.fsType & 0x0004) != 0) + if ((table.fsType & (FsTypeFlags)Os2Table.FsTypeUsageMask) == FsTypeFlags.PreviewPrint) { result.AddMessage(FontValidationSeverity.Warning, - "Preview & Print embedding only (fsType bit 2 set). Check usage context."); + "Preview & Print embedding only. Check usage context."); } // Metrics must be valid diff --git a/src/EPPlus.Interfaces/Fonts/FontEmbeddingDecision.cs b/src/EPPlus.Interfaces/Fonts/FontEmbeddingDecision.cs new file mode 100644 index 0000000000..a9df0272c0 --- /dev/null +++ b/src/EPPlus.Interfaces/Fonts/FontEmbeddingDecision.cs @@ -0,0 +1,37 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 10/07/2026 EPPlus Software AB EPPlus.Fonts.OpenType 1.0 + *************************************************************************************************/ +namespace OfficeOpenXml.Interfaces.Fonts +{ + /// + /// The action EPPlus takes for a font when preparing it for embedding. + /// Returned from the callback registered via + /// . + /// + public enum FontEmbeddingDecision + { + /// + /// Follow the font's declared fsType: throw for a Restricted License font, + /// embed whole for a no-subsetting font, subset otherwise. + /// + Default, + /// + /// Subset the font regardless of fsType. By choosing this, the caller asserts + /// they hold the rights to embed and subset the font. + /// + Subset, + /// Embed the whole font without subsetting. + EmbedWhole, + /// Do not embed the font; a fallback/substitute is used instead. + Skip, + } +} diff --git a/src/EPPlus.Interfaces/Fonts/FontEmbeddingInfo.cs b/src/EPPlus.Interfaces/Fonts/FontEmbeddingInfo.cs new file mode 100644 index 0000000000..545880d088 --- /dev/null +++ b/src/EPPlus.Interfaces/Fonts/FontEmbeddingInfo.cs @@ -0,0 +1,34 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 10/07/2026 EPPlus Software AB EPPlus.Fonts.OpenType 1.0 + *************************************************************************************************/ + +namespace OfficeOpenXml.Interfaces.Fonts +{ + /// + /// Information passed to the + /// callback so the caller can decide how a font should be embedded. + /// + public class FontEmbeddingInfo + { + public FontEmbeddingInfo(string fontName, FontEmbeddingRestriction restriction) + { + FontName = fontName; + Restriction = restriction; + } + + /// The full name of the font being prepared for embedding. + public string FontName { get; private set; } + + /// The restriction the font declares via its OS/2 fsType field. + public FontEmbeddingRestriction Restriction { get; private set; } + } +} diff --git a/src/EPPlus.Interfaces/Fonts/FontEmbeddingRestriction.cs b/src/EPPlus.Interfaces/Fonts/FontEmbeddingRestriction.cs new file mode 100644 index 0000000000..740d496379 --- /dev/null +++ b/src/EPPlus.Interfaces/Fonts/FontEmbeddingRestriction.cs @@ -0,0 +1,28 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 10/07/2026 EPPlus Software AB EPPlus.Fonts.OpenType 1.0 + *************************************************************************************************/ +namespace OfficeOpenXml.Interfaces.Fonts +{ + /// + /// The embedding/subsetting restriction a font declares via its OS/2 fsType field. + /// This is a pure interpretation of fsType — it carries no policy about what EPPlus does. + /// + public enum FontEmbeddingRestriction + { + /// Font may be embedded and subsetted freely. + None, + /// Font may be embedded, but must be embedded whole — not subsetted. + NoSubsetting, + /// Font must not be embedded at all (Restricted License). + NoEmbedding, + } +} diff --git a/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs b/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs index 0aa862747f..ef0980b2bc 100644 --- a/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs +++ b/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs @@ -12,6 +12,7 @@ Date Author Change 05/06/2026 EPPlus Software AB Property-based transactional configuration 05/20/2026 EPPlus Software AB Added per-script glyph fallback configuration *************************************************************************************************/ +using System; using System.Collections.Generic; namespace OfficeOpenXml.Interfaces.Fonts @@ -81,6 +82,20 @@ public interface IEpplusFontConfiguration /// /// void Reset(); + + /// + /// Registers a callback invoked for each font that is about to be embedded, letting the + /// caller override how EPPlus handles the font's declared embedding restriction (fsType). + /// Return to keep EPPlus's standard behaviour. + /// + /// + /// A font may declare that it must not be embedded (Restricted License) or must not be + /// subsetted. By returning or + /// , the caller asserts they hold the rights + /// to do so; EPPlus cannot verify any licence the caller may have obtained from the font's + /// owner. Only one callback is active; a later call replaces the earlier one. + /// + void OnFontEmbedding(Func callback); } } \ No newline at end of file diff --git a/src/EPPlus/Export/PdfExport/Data/PageData.cs b/src/EPPlus/Export/PdfExport/Data/PageData.cs index 458d1bb62c..fe2040ac67 100644 --- a/src/EPPlus/Export/PdfExport/Data/PageData.cs +++ b/src/EPPlus/Export/PdfExport/Data/PageData.cs @@ -40,6 +40,8 @@ internal struct Page public double[] RowHeights; public double HeadingWidth; public double HeadingHeight; + public double UsedWidth; + public double UsedHeight; } internal struct Pages @@ -56,6 +58,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/PdfGridlinesLayout.cs b/src/EPPlus/Export/PdfExport/Layout/PdfGridlinesLayout.cs index d2f9f50e7d..ca4e94313f 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfGridlinesLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfGridlinesLayout.cs @@ -50,7 +50,7 @@ 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++) { var cell = page.Map[page.FromRow, page.FromColumn + ci]; @@ -61,9 +61,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 +73,9 @@ 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 = 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 e6fe821c38..af5c011052 100644 --- a/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs +++ b/src/EPPlus/Export/PdfExport/Layout/PdfLayout.cs @@ -27,7 +27,7 @@ Date Author Change using System.Drawing; namespace OfficeOpenXml.Export.PdfExport.Layout -{ +{ internal struct PrintTitleCellDraw { public PdfCell Cell; @@ -74,27 +74,36 @@ 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++) { var page = pages[j]; PdfPageLayout pageLayout = new PdfPageLayout(0d, 0d, 0d, 0d); - pageLayout.Settings = pageSettings; + pageLayout.Settings = pageSettings; pageLayout.isCommentsPage = pdfPages[i].IsCommentsPage; pageLayout.HeadingWidth = page.HeadingWidth; pageLayout.HeadingHeight = page.HeadingHeight; 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 = 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); @@ -111,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) { @@ -163,8 +177,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 +188,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); } } @@ -195,6 +211,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; @@ -204,12 +221,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 +241,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 +255,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 +268,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 +282,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 +296,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 +316,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; @@ -313,7 +345,7 @@ private static void SetFill(PdfDictionaries dictionaries, PdfCellStyle cellStyle var patternStyle = dxfFill.PatternType != null ? (ExcelFillStyle)dxfFill.PatternType : ExcelFillStyle.Solid; if (patternStyle == ExcelFillStyle.Solid) { - fill.SetFill( PdfColor.SetColorFromHex(dxfFill.BackgroundColor.LookupColor())); + fill.SetFill(PdfColor.SetColorFromHex(dxfFill.BackgroundColor.LookupColor())); } else if (patternStyle != ExcelFillStyle.None) { @@ -331,7 +363,7 @@ private static void SetFill(PdfDictionaries dictionaries, PdfCellStyle cellStyle var top = dxfFill.Gradient.Top == null ? 0 : (double)dxfFill.Gradient.Top; var bottom = dxfFill.Gradient.Bottom == null ? 0 : (double)dxfFill.Gradient.Bottom; var left = dxfFill.Gradient.Left == null ? 0 : (double)dxfFill.Gradient.Left; - var right = dxfFill.Gradient.Right == null ? 0 : (double)dxfFill.Gradient.Right; + var right = dxfFill.Gradient.Right == null ? 0 : (double)dxfFill.Gradient.Right; fill.SetGradient(dictionaries, (EPPlus.Export.Pdf.Enums.ExcelFillGradientType)gradientType, color1, color2, color3, degree, top, bottom, left, right); } } @@ -352,7 +384,7 @@ private static void SetFill(PdfDictionaries dictionaries, PdfCellStyle cellStyle } else if (xfFill.PatternType != ExcelFillStyle.None) { - var patternStyle = xfFill.PatternType; + var patternStyle = xfFill.PatternType; var bkgc = PdfColor.SetColorFromHex(xfFill.PatternColor.Rgb == null ? "#FFFFFFFF" : xfFill.PatternColor.LookupColor()); var patc = PdfColor.SetColorFromHex(xfFill.BackgroundColor.LookupColor()); fill.SetPattern(dictionaries, (EPPlus.Export.Pdf.Enums.ExcelFillStyle)patternStyle, bkgc, patc); @@ -463,7 +495,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); @@ -475,7 +507,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; @@ -485,7 +517,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; } } @@ -656,8 +688,8 @@ internal static List GetPages(PdfPageSettings[] sheetSettings, PdfWorkshe List PagesCollection = new List(); for (int si = 0; si < pdfSheets.Length; si++) { - var pdfSheet= pdfSheets[si]; - var pageSettings = sheetSettings[si]; + var pdfSheet = pdfSheets[si]; + var pageSettings = sheetSettings[si]; for (int ri = 0; ri < pdfSheet.Ranges.Count; ri++) { @@ -673,6 +705,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) @@ -682,9 +715,11 @@ 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; + pages.Settings = pageSettings; + pages.SheetIndex = si; PagesCollection.Add(pages); } } @@ -726,7 +761,7 @@ 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++) { drawY -= range.RowHeights[r - range.Range._fromRow].Height; @@ -775,7 +810,7 @@ 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 = GetOriginX(pageSettings, page) + page.HeadingWidth + page.PrintTitleWidth; for (int col = page.FromColumn; col <= page.ToColumn; col++) { colX[col - page.FromColumn] = x; @@ -827,7 +862,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; @@ -835,7 +870,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; @@ -845,7 +880,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; @@ -856,7 +891,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; @@ -899,7 +934,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 @@ -916,7 +951,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 }); @@ -926,24 +961,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; @@ -1348,6 +1383,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; @@ -1420,6 +1462,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; @@ -1566,14 +1622,133 @@ 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; } 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)); + + private static void SetDoubleBorderMiterFlags(Page page, int row, int col, PdfCellBorderLayout border) + { + var b = border.BorderData; + + // 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); + b.Bottom.PerpAtEnd = VBorderAt(page, row, col + 1) || VBorderAt(page, row + 1, col + 1); + b.Left.PerpAtStart = HBorderBelow(page, row, col) || HBorderBelow(page, row, col - 1); + b.Left.PerpAtEnd = HBorderAbove(page, row, col) || HBorderAbove(page, row, col - 1); + 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 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)); + + // 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). + 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) { var cs = cell?.CellStyle; if (cs == null) return false; @@ -1623,5 +1798,33 @@ 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); + } + + internal static double GetClampedCellWidth(PdfPageSettings pageSettings, double cellX, double cellWidth) + { + return System.Math.Min(cellWidth, pageSettings.PageSize.WidthPu - cellX); + } } } diff --git a/src/EPPlus/Export/PdfExport/PdfCatalog.cs b/src/EPPlus/Export/PdfExport/PdfCatalog.cs index 0cca173b41..4dab28018e 100644 --- a/src/EPPlus/Export/PdfExport/PdfCatalog.cs +++ b/src/EPPlus/Export/PdfExport/PdfCatalog.cs @@ -11,23 +11,16 @@ 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; using OfficeOpenXml.Export.PdfExport.RowResize; -using OfficeOpenXml.Export.PdfExport.Settings; using OfficeOpenXml.Export.PdfExport.TextMapping; 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; @@ -81,30 +74,22 @@ private void HandleWorksheetCollection(PdfPageSettings pageSettings, ExcelWorksh // Match the single-worksheet path: resolve the default font before building. pageSettings.defaultFontName = worksheets[0].Workbook.ThemeManager.GetOrCreateTheme().FontScheme.MinorFont[0].Typeface; - // One settings object per worksheet, each from its own printer settings. - var sheetSettings = new PdfPageSettings[worksheets.Length]; - for (int i = 0; i < worksheets.Length; i++) - { - sheetSettings[i] = GetPdfSettings.GetPdfSettingsForSheet(pageSettings, worksheets[i].PrinterSettings); - } - PdfWorksheet[] pdfSheets = null; try { - //// Collect text for every worksheet. - pdfSheets = GetPdfWorksheets(sheetSettings, worksheets); + // Collect text for every worksheet. + pdfSheets = GetPdfWorksheets(pageSettings, worksheets); - //// Shape text and auto-fit rows per sheet. - for (int i = 0; i < pdfSheets.Length; i++) + BuildSubsets(pageSettings); + + foreach (var pdfSheet in pdfSheets) { - ShapeTextInPdfWorksheet(sheetSettings[i], pdfSheets[i]); - PdfCalculateRowHeight.ResizeRowHeights(pdfSheets[i]); + ShapeTextInPdfWorksheet(pageSettings, pdfSheet); + PdfCalculateRowHeight.ResizeRowHeights(pdfSheet); } // One layout spanning all sheets and their ranges. - var layout = GetLayout(sheetSettings, pdfSheets); - - // Write the PDF document. + var layout = GetLayout(pageSettings, pdfSheets); writePdf(layout); } finally @@ -141,51 +126,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 + // 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); @@ -216,10 +180,10 @@ private void BuildPdfFromRange(PdfPageSettings pageSettings, ExcelRangeBase rang try { pdfSheet = GetPdfWorksheet(pageSettings, range); + 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 @@ -259,22 +223,17 @@ private void HandleRangeCollection(PdfPageSettings pageSettings, ExcelRangeBase[ PdfWorksheet[] pdfSheets = null; try { - // One PdfWorksheet per worksheet, each carrying all of its ranges. pdfSheets = GetPdfWorksheets(pageSettings, ranges); + BuildSubsets(pageSettings); + foreach (var pdfSheet in pdfSheets) { ShapeTextInPdfWorksheet(pageSettings, pdfSheet); PdfCalculateRowHeight.ResizeRowHeights(pdfSheet); } - var sheetSettings = new PdfPageSettings[pdfSheets.Length]; - for (int i = 0; i < sheetSettings.Length; i++) - { - // Ranges within one export share the same printer settings today. - sheetSettings[i] = pageSettings; - } - var layout = GetLayout(sheetSettings, pdfSheets); + var layout = GetLayout(pageSettings, pdfSheets); writePdf(layout); } finally @@ -296,6 +255,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; } @@ -304,46 +265,44 @@ internal PdfCellCollection GetCellCollectionFromRange(PdfPageSettings pageSettin //Private Methods private Action WriteToFile(PdfPageSettings pageSettings, string fileName) - { - return layout => new ExcelPdf().CreatePdf( - PdfDocumentSettings.From(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( - PdfDocumentSettings.From(pageSettings), _dictionaries, layout, stream); + { + return layout => new ExcelPdf().CreatePdf(PdfDocumentSettings.From(pageSettings), _dictionaries, layout, stream); } //Create Layout Methods - private Transform GetLayout(PdfPageSettings[] sheetSettings, PdfWorksheet[] pdfSheets) + private Transform GetLayout(PdfPageSettings pageSettings, PdfWorksheet[] 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) { - // Single sheet: one settings object, one sheet. - var Layout = PdfLayout.GetLayout(new[] { pageSettings }, _dictionaries, new[] { pdfSheet }); + PdfWorksheet[] pdfSheets = new PdfWorksheet[1] { pdfSheet }; + var sheetSettings = new PdfPageSettings[1] { pageSettings }; + var Layout = PdfLayout.GetLayout(sheetSettings, _dictionaries, pdfSheets); return Layout; } - //Shape Text Methods + // 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 one sheet using the already-built providers. Call after BuildSubsets. internal void ShapeTextInPdfWorksheet(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(); - } - - // Pass 3: shape text using the pre-built providers IterateCells(pdfSheet, cell => PdfTextShaper.ShapeText(pageSettings, _dictionaries, cell)); } @@ -450,10 +409,8 @@ private PdfWorksheet GetPdfWorksheet(PdfPageSettings pageSettings, ExcelWorkshee } if (pageSettings.ShowHeadings && _addTextForHeadings) - { _dictionaries.AddFont(pageSettings, pdfSheet.NormalStyle.Style.Font.Name, pdfSheet.GetSubFamilyFromNormalStyle, "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"); - _addTextForHeadings = false; - } + _addTextForHeadings = false; GetMaps(pageSettings, pdfSheet, pdfSheet.Ranges); GetPrintTitles(pageSettings, pdfSheet); @@ -462,16 +419,6 @@ private PdfWorksheet GetPdfWorksheet(PdfPageSettings pageSettings, ExcelWorkshee return pdfSheet; } - private PdfWorksheet[] GetPdfWorksheets(PdfPageSettings[] sheetSettings, ExcelWorksheet[] worksheets) - { - PdfWorksheet[] pdfSheets = new PdfWorksheet[worksheets.Length]; - for (int i = 0; i < pdfSheets.Length; i++) - { - pdfSheets[i] = GetPdfWorksheet(sheetSettings[i], worksheets[i]); - } - return pdfSheets; - } - private List GetRanges(ExcelWorksheet worksheet) { List ranges = new List(); diff --git a/src/EPPlus/Export/PdfExport/RowResize/PdfCalculateRowHeight.cs b/src/EPPlus/Export/PdfExport/RowResize/PdfCalculateRowHeight.cs index 0d1421fd8e..8810b92daa 100644 --- a/src/EPPlus/Export/PdfExport/RowResize/PdfCalculateRowHeight.cs +++ b/src/EPPlus/Export/PdfExport/RowResize/PdfCalculateRowHeight.cs @@ -48,28 +48,36 @@ public static void ResizeRange(ref PdfRange range) } int row = range.Range._fromRow + rowIdx; double maxRequired = rowHeight.Height; - bool hasWrappedCell = false; + bool grew = false; for (int colIdx = 0; colIdx < range.ColWidths.Count; colIdx++) { int col = range.Range._fromCol + colIdx; var cell = range.Map[row, col]; if (cell == null || cell.Hidden) continue; - if (cell.Merged) - continue; - if (cell.ContentAligmnet.ShrinkToFit) - continue; - if (!cell.ContentAligmnet.WrapText) - continue; if (cell.TextLines == null || cell.TextLines.Count == 0) continue; + if (cell.ContentAligmnet == null || cell.ContentAligmnet.ShrinkToFit) + continue; - hasWrappedCell = true; - double required = GetRequiredHeightFromLines(cell); + double required; + if (cell.Merged) + { + if (cell.MergedAddress == null || cell.MergedAddress.Start.Row != cell.MergedAddress.End.Row) + continue; + required = GetMaxLineHeight(cell); + } + else + { + required = GetRequiredHeightFromLines(cell); + } if (required > maxRequired) + { maxRequired = required; + grew = true; + } } - if (hasWrappedCell) + if (grew) { rowHeight.Height = maxRequired; range.RowHeights[rowIdx] = rowHeight; @@ -88,5 +96,16 @@ private static double GetRequiredHeightFromLines(PdfCell cell) } return total; } + + private static double GetMaxLineHeight(PdfCell cell) + { + double max = 0d; + foreach (var line in cell.TextLines) + { + double h = line.LargestAscent + line.LargestDescent; + if (h > max) max = h; + } + return max; + } } } diff --git a/src/EPPlus/Export/PdfExport/TextMapping/PdfHeaderFooterCollection.cs b/src/EPPlus/Export/PdfExport/TextMapping/PdfHeaderFooterCollection.cs index 0f6e9be031..8ac65d2c03 100644 --- a/src/EPPlus/Export/PdfExport/TextMapping/PdfHeaderFooterCollection.cs +++ b/src/EPPlus/Export/PdfExport/TextMapping/PdfHeaderFooterCollection.cs @@ -24,11 +24,15 @@ internal class PdfHeaderFooterCollection public List PdfHeaderFooterEntries = new List(); public bool ScaleWithDocument = false; public bool AlignWithMargins = false; + public bool HasFirstPage = false; + public bool HasOddEvenPages = false; public PdfHeaderFooterCollection(PdfPageSettings pageSettings, PdfDictionaries dictionaries, PdfWorksheet pdfSheet, ExcelHeaderFooter headerFooter) { bool differentFirst = pdfSheet.Worksheet.HeaderFooter.differentFirst; bool differentOddEven = pdfSheet.Worksheet.HeaderFooter.differentOddEven; + HasFirstPage = differentFirst; + HasOddEvenPages = differentOddEven; bool AlignWithMargins = pdfSheet.Worksheet.HeaderFooter.AlignWithMargins; bool ScaleWithDocument = pdfSheet.Worksheet.HeaderFooter.ScaleWithDocument; PdfHeaderFooter entry = null; @@ -161,5 +165,12 @@ public PdfHeaderFooter Get(HeaderFooterType type, HeaderFooterSection section, H e.Section == section && e.Alignment == alignment); } + + public HeaderFooterType GetPageType(int physicalPageIndex) + { + if (physicalPageIndex == 1 && HasFirstPage) return HeaderFooterType.First; + if (physicalPageIndex % 2 == 0 && HasOddEvenPages) return HeaderFooterType.Even; + return HeaderFooterType.Odd; + } } } diff --git a/src/EPPlus/Export/PdfExport/TextMapping/PdfTextMap.cs b/src/EPPlus/Export/PdfExport/TextMapping/PdfTextMap.cs index 5e97469096..4d42233ac7 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; @@ -1037,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 + } } } @@ -1051,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. @@ -1084,5 +1070,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; + } } } diff --git a/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs b/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs index 94a77fb467..2f71206f73 100644 --- a/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs +++ b/src/EPPlus/Export/PdfExport/TextShaping/PdfTextShaper.cs @@ -10,16 +10,17 @@ Date Author Change ************************************************************************************************* 27/11/2025 EPPlus Software AB EPPlus 9 *************************************************************************************************/ +using EPPlus.Export.Pdf.Layout; +using EPPlus.Export.Pdf.Resources; +using EPPlus.Export.Pdf.Settings; using EPPlus.Fonts.OpenType; using EPPlus.Fonts.OpenType.Integration; using EPPlus.Fonts.OpenType.TextShaping; -using EPPlus.Export.Pdf.Resources; -using EPPlus.Export.Pdf.Settings; -using EPPlus.Export.Pdf.Layout; using OfficeOpenXml.Export.PdfExport.Data; using OfficeOpenXml.Interfaces.Fonts; using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; namespace OfficeOpenXml.Export.PdfExport.TextShaping @@ -29,19 +30,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 - 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); - } - } - // Pass 2: shape text using already-built providers from PdfDictionaries.ShapedProviders public static void ShapeText(PdfPageSettings pageSettings, PdfDictionaries dictionaries, PdfCell cell) { @@ -55,9 +43,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)) @@ -91,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); @@ -159,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);