diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs index d54afa7448..5ea7ab57f6 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs @@ -130,7 +130,7 @@ public void GenerateSvgForLineCharts() } [TestMethod] - public void GenerateSvgForCharts_SecondaryAxis() + public void GenerateSuperScript() { ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("ChartForSvg_SecondaryAxis.xlsx")) diff --git a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs index 54fa8f83d5..e2ce78fce8 100644 --- a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs @@ -3,6 +3,7 @@ using EPPlus.DrawingRenderer.RenderItems.SvgItem; using EPPlus.DrawingRenderer.Svg; using EPPlus.Export.ImageRenderer.RenderItems.SvgItem; +using EPPlus.Fonts.OpenType; using EPPlus.Fonts.OpenType.Integration.DataHolders; using EPPlus.Graphics; using System.Drawing; @@ -120,7 +121,9 @@ private void GenerateTextBodyFile(string fileName, GroupRenderItem baseGroup, Sv private SvgTextBodyRenderItem GenerateTextBody(GroupRenderItem baseGroup) { - var textBody = new SvgTextBodyRenderItem(baseGroup.Bounds, true); + var engine = new OpenTypeFontEngine(x => x.SearchSystemDirectories = true); + var renderContext = new RenderContext(() => engine); + var textBody = new SvgTextBodyRenderItem(renderContext, baseGroup.Bounds, true); var paragraph = textBody.AddParagraph("Hello"); paragraph.AddText(" There"); @@ -271,7 +274,9 @@ private RenderTextbox GenerateTextBox(out GroupRenderItem group) group = GenerateGroupRenderItem(); var textbox = new RenderTextbox(group.Bounds, 500d, 500d); - textbox.TextBody = new SvgTextBodyRenderItem(group.Bounds, true); + var engine = new OpenTypeFontEngine(x => x.SearchSystemDirectories = true); + var rc = new RenderContext(() => engine); + textbox.TextBody = new SvgTextBodyRenderItem(rc, group.Bounds, true); var paragraph = textbox.TextBody.AddParagraph("Hello"); paragraph.AddText(" There"); diff --git a/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs b/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs index 4a2dc0dd08..d56766ae27 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs @@ -15,6 +15,24 @@ namespace EPPlus.Export.ImageRenderer.Tests.Shape [TestClass] public sealed class ShapeToSvgTests : TestBase { + + [TestInitialize] + public void Initialize() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + } + + private OpenTypeFontEngine DefaultFontEngine + { + get { return new OpenTypeFontEngine(x => x.SearchSystemDirectories = true); } + } + + private ExcelPackage GetPackage() + { + var p = new ExcelPackage(); + p.Workbook.UseFontEngine(DefaultFontEngine); + return p; + } [TestMethod] public void GroupFill() @@ -29,11 +47,11 @@ public void GroupFill() foreach (var drawing in drawings) { - if(drawing is ExcelGroupShape) + if (drawing is ExcelGroupShape) { var gShape = (ExcelGroupShape)drawing; var gDrawings = gShape.Drawings; - foreach(var gDrawing in gDrawings) + foreach (var gDrawing in gDrawings) { SaveTextFileToWorkbook($"svg\\GroupFill{ix++}.svg", gDrawing.ToSvg()); } @@ -51,35 +69,15 @@ public void GroupFill() } } - [TestMethod] - public void SeveralFills() - { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); - using (var p = OpenTemplatePackage("ShapeWithFillNoFillAndDefault.xlsx")) - { - var ws = p.Workbook.Worksheets[0]; - var drawings = ws.Drawings; - - int ix = 0; - - foreach (var drawing in drawings) - { - var shapeCast = drawing.As.Shape; - var filltype = shapeCast.Fill.Style; - var svg = drawing.ToSvg(); - SaveTextFileToWorkbook($"svg\\manyFills{ix++}.svg", svg); - } - SaveAndCleanup(p); - } - } [TestMethod] public void Rect() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = OpenPackage("svg/rect.xlsx", true)) { + p.Workbook.UseFontEngine(DefaultFontEngine); var ws = p.Workbook.Worksheets.Add("Sheet1"); var d = ws.Drawings.AddShape("Shape1", OfficeOpenXml.Drawing.eShapeStyle.Rect); d.Text = "Rectangle Rectangle Rectangle Rectangle"; @@ -94,9 +92,9 @@ public void Rect() [TestMethod] public void AddShapeWithPatternFill() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("ShapeWithPattern.xlsx")) { + p.Workbook.UseFontEngine(DefaultFontEngine); var ws = p.Workbook.Worksheets[0]; var myShape = ws.Drawings[0].As.Shape; @@ -112,8 +110,7 @@ public void AddShapeWithPatternFill() [TestMethod] public void RoundRect() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); - using (var p = new ExcelPackage()) + using (var p = GetPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); var d = ws.Drawings.AddShape("Shape1", eShapeStyle.RoundRect); @@ -130,7 +127,7 @@ public void RoundRect() [TestMethod] public void Triangle() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); @@ -146,7 +143,6 @@ public void Triangle() [TestMethod] public void RightArrow() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); @@ -160,7 +156,6 @@ public void RightArrow() [TestMethod] public void SmileyFace() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); @@ -174,7 +169,6 @@ public void SmileyFace() [TestMethod] public void VerticalScroll() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); @@ -188,7 +182,6 @@ public void VerticalScroll() [TestMethod] public void CloudCallout() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); @@ -202,7 +195,6 @@ public void CloudCallout() [TestMethod] public void IrregularSeal2() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); @@ -216,7 +208,6 @@ public void IrregularSeal2() [TestMethod] public void LightningBolt() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); @@ -230,7 +221,6 @@ public void LightningBolt() [TestMethod] public void FlowChartMagneticTape() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); @@ -244,7 +234,6 @@ public void FlowChartMagneticTape() [TestMethod] public void MathNotEqual() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); @@ -258,7 +247,6 @@ public void MathNotEqual() [TestMethod] public void Sun() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); @@ -274,7 +262,6 @@ public void Sun() [TestMethod] public void Ellipse() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); @@ -290,8 +277,7 @@ public void Ellipse() [TestMethod] public void Heart() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); - using (var p = new ExcelPackage()) + using (var p = GetPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); var d = ws.Drawings.AddShape("Shape1", OfficeOpenXml.Drawing.eShapeStyle.Heart); @@ -306,7 +292,6 @@ public void Heart() [TestMethod] public void BevelRed() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); @@ -322,7 +307,6 @@ public void BevelRed() [TestMethod] public void Bevel() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); @@ -338,7 +322,6 @@ public void Bevel() [TestMethod] public void LeftBracket() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); @@ -354,7 +337,6 @@ public void LeftBracket() [TestMethod] public void CalloutQuadArrow() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); @@ -369,7 +351,6 @@ public void CalloutQuadArrow() [TestMethod] public void ActionButtonHome() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); @@ -384,7 +365,6 @@ public void ActionButtonHome() [TestMethod] public void ActionButtonMovie() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); @@ -399,7 +379,6 @@ public void ActionButtonMovie() [TestMethod] public void CustomPath() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage(@"svg\CustPath.xlsx")) { var ws = p.Workbook.Worksheets[0]; @@ -423,7 +402,6 @@ public void CustomPath() [TestMethod] public void GenerateAllShapes() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Shapes"); @@ -446,7 +424,6 @@ public void GenerateAllShapes() [TestMethod] public void TestShapes() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("margins.xlsx")) { var drawings = p.Workbook.Worksheets[0].Drawings; @@ -460,7 +437,6 @@ public void TestShapes() [TestMethod] public void GenerateSvgForGradientFilledShapes() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("GradientFillShapes.xlsx")) { var ws = p.Workbook.Worksheets[0]; @@ -481,7 +457,6 @@ public void GenerateSvgForGradientFilledShapes() [TestMethod] public void GenerateSvgForGradientRadialFilledShapes() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("GradiantRadial.xlsx")) { var ws = p.Workbook.Worksheets[0]; @@ -502,7 +477,6 @@ public void GenerateSvgForGradientRadialFilledShapes() [TestMethod] public void GenerateSvgForPatternFilledShapes() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("PatternFills.xlsx")) { var ws = p.Workbook.Worksheets[0]; @@ -519,7 +493,6 @@ public void GenerateSvgForPatternFilledShapes() [TestMethod] public void GenerateSvgForBlipFillShapes() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("BlipFills.xlsx")) { var ws = p.Workbook.Worksheets[0]; @@ -538,7 +511,6 @@ public void GenerateSvgForBlipFillShapes() [TestMethod] public void GenerateSvgForCircle() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("GradientRadialVerifyCircle.xlsx")) { var ws = p.Workbook.Worksheets[0]; @@ -559,7 +531,6 @@ public void GenerateSvgForCircle() [TestMethod] public void SuperScriptShape() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("Superscript.xlsx")) { var ws = p.Workbook.Worksheets[0]; @@ -579,7 +550,6 @@ public void SuperScriptShape() [TestMethod] public void SuperAndSubScript() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("SuperAndSubScript.xlsx")) { var ws = p.Workbook.Worksheets[0]; @@ -594,7 +564,6 @@ public void SuperAndSubScript() [TestMethod] public void OpenRightAligned() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("SimpleChartRightAlign.xlsx")) { var c = p.Workbook.Worksheets[0].Drawings[0]; @@ -606,7 +575,6 @@ public void OpenRightAligned() [TestMethod] public void TestStyling() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("MyCellsAdvanced.xlsx")) { var ws = p.Workbook.Worksheets[0]; @@ -635,7 +603,6 @@ public void TestStyling() [TestMethod] public void GenerateShapeCenteredParagraph() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenPackage("ShapeTestCentered.xlsx",true)) { var sheet = p.Workbook.Worksheets.Add("ShapeSheet"); @@ -701,7 +668,6 @@ public void GenerateShapeCenteredParagraph() [TestMethod] public void ChartAndShapeGreen() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("ShapeAndChartTestGreen.xlsx")) { var ws = p.Workbook.Worksheets[0]; @@ -721,7 +687,6 @@ public void ChartAndShapeGreen() [TestMethod] public void CreateChartsWithDifferentSize() { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenPackage("ChartWithDifferentSizes.xlsx", true)) { var ws = p.Workbook.Worksheets.Add("Chart1"); diff --git a/src/EPPlus.DrawingRenderer.Tests/TestFontMeasurer.cs b/src/EPPlus.DrawingRenderer.Tests/TestFontMeasurer.cs index 27a5333788..a92705daff 100644 --- a/src/EPPlus.DrawingRenderer.Tests/TestFontMeasurer.cs +++ b/src/EPPlus.DrawingRenderer.Tests/TestFontMeasurer.cs @@ -4,12 +4,30 @@ using EPPlus.Fonts.OpenType.Utils; using OfficeOpenXml.Interfaces.Drawing.Text; using OfficeOpenXml.Interfaces.Fonts; +using System.Drawing; namespace TestProject1 { [TestClass] public class TestFontMeasurer { + [TestInitialize] + public void Setup() + { + _systemFolderEngine = new OpenTypeFontEngine(x => x.SearchSystemDirectories = true); + } + + [TestCleanup] + public void Cleanup() + { + SystemFolderEngine.Dispose(); + _systemFolderEngine = null; + } + + private OpenTypeFontEngine? _systemFolderEngine; + + private OpenTypeFontEngine SystemFolderEngine => _systemFolderEngine ?? new OpenTypeFontEngine(x => x.SearchSystemDirectories = true); + [TestMethod] public void CompareFontMeasurer3() { @@ -20,7 +38,7 @@ public void CompareFontMeasurer3() Size = 72.0f, }; - var handler = new TextHandler(mf); + var handler = new TextHandler(SystemFolderEngine, mf); var testStr = "Hello there⁴₂"; @@ -56,7 +74,7 @@ public void TestWrapText() Style = MeasurementFontStyles.Regular }; - var handler = new TextHandler(mf); + var handler = new TextHandler(SystemFolderEngine, mf); var strings = handler.WrapText(testStr, MaxPixelWidth.PixelToPoint()); @@ -86,7 +104,7 @@ public void TestWrapTextLongContinous() Size = (float)fontSize, Style = MeasurementFontStyles.Regular }; - var handler = new TextHandler(mf); + var handler = new TextHandler(SystemFolderEngine, mf); var strings = handler.WrapText(testString, MaxPixelWidth.PixelToPoint()); @@ -139,7 +157,7 @@ public void SpaceCase() Style = MeasurementFontStyles.Regular, Size = 11.0f, }; - var layout = OpenTypeFonts.GetTextLayoutEngineForFont(mf); + var layout = SystemFolderEngine.GetTextLayoutEngineForFont(mf); var output = layout.WrapText(text, 11f, 39.4); var shaper = OpenTypeFonts.GetShaperForFont(mf); @@ -169,7 +187,7 @@ public void LoremIpsumTesting() Size = 11.0f, }; - var handler = new TextHandler(mf); + var handler = new TextHandler(SystemFolderEngine, mf); //float ptMax = 39.68503937007874015748031496063f; //float ptMax = 36.840393700787401574803149606299f - 1.5f; //float pixels = 53f; @@ -178,7 +196,8 @@ public void LoremIpsumTesting() ShapingOptions options = new ShapingOptions(); - var layout = OpenTypeFonts.GetTextLayoutEngineForFont(mf); + var engine = new OpenTypeFontEngine(x => x.SearchSystemDirectories = true); + var layout = engine.GetTextLayoutEngineForFont(mf); //var wrappedStrings = layout.WrapRichText(new List() { text }, new List() { mf }, 39.4f); @@ -231,7 +250,7 @@ public void WrapDifficultSpotSpace() Size = 11.0f, }; - var handler = new TextHandler(mf); + var handler = new TextHandler(SystemFolderEngine, mf); var wrappedStrings = handler.WrapText(text, pointWidth); @@ -252,7 +271,7 @@ public void LoremIpsum20Paragraphs() Size = 11.0f, }; - var handler = new TextHandler(mf); + var handler = new TextHandler(SystemFolderEngine, mf); double maxPixelWidth = 72d; @@ -299,7 +318,7 @@ public void LoremIpsum20ParagraphsMultipleFragments() Size = 11.0f, }; - var handler = new TextHandler(mf); + var handler = new TextHandler(SystemFolderEngine, mf); double maxPixelWidth = 72d; diff --git a/src/EPPlus.DrawingRenderer/RenderContext.cs b/src/EPPlus.DrawingRenderer/RenderContext.cs new file mode 100644 index 0000000000..fa2ebd031f --- /dev/null +++ b/src/EPPlus.DrawingRenderer/RenderContext.cs @@ -0,0 +1,51 @@ +using EPPlus.Fonts.OpenType; + +namespace EPPlus.DrawingRenderer +{ + /// + /// Carries rendering-wide resources down the drawing render stack (DrawingRenderer and + /// below), independent of output format (SVG, PDF). Owned by the workbook, created once + /// per workbook. The font engine is lazy-loaded on first use so constructing the context + /// is cheap; the expensive engine (and its font cache) is only built when something is + /// actually rendered. + /// + public class RenderContext : IDisposable + { + private readonly object _lock = new object(); + private readonly Func _engineFactory; + private OpenTypeFontEngine? _fontEngine; + + public RenderContext(Func engineFactory) + { + if (engineFactory == null) + throw new ArgumentNullException("engineFactory"); + _engineFactory = engineFactory; + } + + public OpenTypeFontEngine FontEngine + { + get + { + if (_fontEngine == null) + { + lock (_lock) + { + if (_fontEngine == null) + _fontEngine = _engineFactory(); + } + } + return _fontEngine; + } + } + + + public void Dispose() + { + if (_fontEngine != null) + { + try { _fontEngine.Dispose(); } catch { /* best effort */ } + _fontEngine = null; + } + } + } +} \ No newline at end of file diff --git a/src/EPPlus.DrawingRenderer/RenderItems/SvgItem/SvgParagraphRenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/SvgItem/SvgParagraphRenderItem.cs index 72081685e5..06599819d6 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/SvgItem/SvgParagraphRenderItem.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/SvgItem/SvgParagraphRenderItem.cs @@ -1,4 +1,5 @@ -using EPPlus.Export.ImageRenderer.RenderItems.Shared; +using EPPlus.DrawingRenderer; +using EPPlus.Export.ImageRenderer.RenderItems.Shared; using EPPlus.Fonts.OpenType.Integration.DataHolders; using EPPlus.Graphics; @@ -7,11 +8,11 @@ namespace EPPlus.DrawingRenderer.RenderItems.SvgItem { public class SvgParagraphRenderItem : ParagraphRenderItem { - public SvgParagraphRenderItem(RenderTextBody body, BoundingBox parent, string text, bool setDefaultFont = true) : base(parent, body, text, setDefaultFont) + public SvgParagraphRenderItem(RenderContext renderContext, RenderTextBody body, BoundingBox parent, string text, bool setDefaultFont = true) : base(renderContext, parent, body, text, setDefaultFont) { ImportStyles(); } - public SvgParagraphRenderItem(RenderTextBody textBody, BoundingBox parent, IRichTextFormatSimple rtFormat): base(parent, textBody, rtFormat) + public SvgParagraphRenderItem(RenderContext renderContext, RenderTextBody textBody, BoundingBox parent, IRichTextFormatSimple rtFormat) : base(renderContext, parent, textBody, rtFormat) { ImportStyles(); } @@ -42,4 +43,4 @@ protected override TextRunRenderItem CreateTextRun(BoundingBox parent, string di return new SvgTextRunRenderItem(parent, displayText, origRtIdx); } } -} +} \ No newline at end of file diff --git a/src/EPPlus.DrawingRenderer/RenderItems/SvgItem/SvgTextBodyRenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/SvgItem/SvgTextBodyRenderItem.cs index 7d5308eab5..8716dae0ce 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/SvgItem/SvgTextBodyRenderItem.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/SvgItem/SvgTextBodyRenderItem.cs @@ -8,23 +8,23 @@ namespace EPPlus.DrawingRenderer.RenderItems.SvgItem { public class SvgTextBodyRenderItem : RenderTextBody { - public SvgTextBodyRenderItem(BoundingBox parent, bool autoSize) : base(parent, autoSize) + public SvgTextBodyRenderItem(RenderContext renderContext, BoundingBox parent, bool autoSize) : base(renderContext, parent, autoSize) { } - public SvgTextBodyRenderItem(BoundingBox parent, double left, double top, double maxWidth, double maxHeight, bool clampedToParent = false, bool autoSize = false) : base(parent, left, top, maxWidth, maxHeight, clampedToParent, autoSize) + public SvgTextBodyRenderItem(RenderContext renderContext, BoundingBox parent, double left, double top, double maxWidth, double maxHeight, bool clampedToParent = false, bool autoSize = false) : base(renderContext, parent, left, top, maxWidth, maxHeight, clampedToParent, autoSize) { } protected override ParagraphRenderItem CreateParagraph(BoundingBox parent, string textIfEmpty = "") { - return new SvgParagraphRenderItem(this, parent, textIfEmpty); + return new SvgParagraphRenderItem(RenderContext, this, parent, textIfEmpty); } protected override ParagraphRenderItem CreateParagraph(BoundingBox parent, IRichTextFormatSimple richText) { - return new SvgParagraphRenderItem(this, parent, richText); + return new SvgParagraphRenderItem(RenderContext, this, parent, richText); } } } diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs index ad2c1494e2..3b54e73264 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs @@ -115,11 +115,16 @@ protected bool LinespacingIsExact } } + protected RenderContext RenderContext { get; private set; } + + + protected TextLineSpacing _lsType; protected double? _centerAdjustment; - protected ParagraphRenderItem(BoundingBox parent, bool setFallbackDefaultFont = true) : base(parent) + protected ParagraphRenderItem(RenderContext renderContext, BoundingBox parent, bool setFallbackDefaultFont = true) : base(parent) { + RenderContext = renderContext; Bounds.Name = "Paragraph"; if (setFallbackDefaultFont) { @@ -129,26 +134,30 @@ protected ParagraphRenderItem(BoundingBox parent, bool setFallbackDefaultFont = } } - protected ParagraphRenderItem(BoundingBox parent, RenderTextBody textBody, bool setFallbackDefaultFont = true) : this(parent, setFallbackDefaultFont) + protected ParagraphRenderItem(RenderContext renderContext, BoundingBox parent, RenderTextBody textBody, bool setFallbackDefaultFont = true) + : this(renderContext, parent, setFallbackDefaultFont) { InitBasedOnParent(textBody); Bounds.Name = "Paragraph"; } - protected ParagraphRenderItem(BoundingBox parent, RenderTextBody textBody, string text, bool setFallbackDefaultFont = true) : this(parent, textBody, setFallbackDefaultFont) + protected ParagraphRenderItem(RenderContext renderContext, BoundingBox parent, RenderTextBody textBody, string text, bool setFallbackDefaultFont = true) + : this(renderContext, parent, textBody, setFallbackDefaultFont) { _lsMultiplier = 1d; ImportLinesAndTextRunsBase(text); } - protected ParagraphRenderItem(BoundingBox parent, RenderTextBody textBody, IRichTextFormatSimple rtFormat) : this(parent, textBody, false) + protected ParagraphRenderItem(RenderContext renderContext, BoundingBox parent, RenderTextBody textBody, IRichTextFormatSimple rtFormat) + : this(renderContext, parent, textBody, false) { _lsMultiplier = 1d; DefaultParagraphFont = new FontFormatBase(rtFormat.Family, rtFormat.SubFamily, rtFormat.Size); AddRichText(rtFormat); } - protected ParagraphRenderItem(BoundingBox parent, RenderTextBody textBody, IRichTextFormatDrawing rtFormat) : this(parent, textBody, false) + protected ParagraphRenderItem(RenderContext renderContext, BoundingBox parent, RenderTextBody textBody, IRichTextFormatDrawing rtFormat) + : this(renderContext, parent, textBody, false) { AddRichText(rtFormat); } @@ -198,7 +207,7 @@ TextLineCollection WrapFragmentsToLines(List? fragments = nul { //This is highly innefficent. Really, LayoutSystem should be //Holding the fragments from the start/wrapping should only be done when textFragments are fully complete - _layoutSystem = new LayoutSystem(_textFragments); + _layoutSystem = new LayoutSystem(RenderContext.FontEngine, _textFragments); //if (fragments == null && _layoutSystem == null) //{ diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs index 49c3534530..f487503c84 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs @@ -33,16 +33,18 @@ public enum TextAnchoringType public abstract class RenderTextBody : GroupRenderItem { - public RenderTextBody(BoundingBox parent, bool autoSize) + public RenderTextBody(RenderContext renderContext, BoundingBox parent, bool autoSize) { + RenderContext = renderContext; Bounds.Parent = parent; AutoSize = autoSize; MaxWidth = parent.Width; MaxHeight = parent.Height; Bounds.Name = "Textbody"; } - public RenderTextBody(BoundingBox parent, double left, double top, double maxWidth, double maxHeight, bool clampedToParent = false, bool autoSize=false) : this(parent, autoSize) + public RenderTextBody(RenderContext renderContext, BoundingBox parent, double left, double top, double maxWidth, double maxHeight, bool clampedToParent = false, bool autoSize=false) : this(renderContext, parent, autoSize) { + RenderContext = renderContext; Bounds.Left = left; Bounds.Top = top; Bounds.Width = maxWidth; @@ -52,6 +54,7 @@ public RenderTextBody(BoundingBox parent, double left, double top, double maxWid Bounds.Name = "Textbody"; } + protected RenderContext RenderContext { get; private set; } public List Paragraphs { get; set; } = new List(); public TextAnchoringType VerticalAlignment = TextAnchoringType.Top; diff --git a/src/EPPlus.Fonts.OpenType.Benchmarks/RichTextBenchmarks.cs b/src/EPPlus.Fonts.OpenType.Benchmarks/RichTextBenchmarks.cs index 3f4dc9003f..f1181423e7 100644 --- a/src/EPPlus.Fonts.OpenType.Benchmarks/RichTextBenchmarks.cs +++ b/src/EPPlus.Fonts.OpenType.Benchmarks/RichTextBenchmarks.cs @@ -77,7 +77,7 @@ public void Setup() font.FullName, font.SubFamily, font.GlyfTable.Glyphs.Count)); var shaper = new TextShaper(fontEngine, font); - _layoutEngine = new TextLayoutEngine(shaper); + _layoutEngine = new TextLayoutEngine(fontEngine, shaper); Console.WriteLine("\nPre-warming font cache (Regular, Bold, Italic)..."); PrewarmFontCache(); diff --git a/src/EPPlus.Fonts.OpenType.Tests/EPPlus.Fonts.OpenType.Tests.csproj b/src/EPPlus.Fonts.OpenType.Tests/EPPlus.Fonts.OpenType.Tests.csproj index 6823f6fbcd..5df411d299 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/EPPlus.Fonts.OpenType.Tests.csproj +++ b/src/EPPlus.Fonts.OpenType.Tests/EPPlus.Fonts.OpenType.Tests.csproj @@ -56,6 +56,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest @@ -65,6 +68,13 @@ PreserveNewest + + PreserveNewest + + + + + diff --git a/src/EPPlus.Fonts.OpenType.Tests/FallbackFonts/FontProviderTests.cs b/src/EPPlus.Fonts.OpenType.Tests/FallbackFonts/FontProviderTests.cs index 80c6c84dab..b713ae797c 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/FallbackFonts/FontProviderTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/FallbackFonts/FontProviderTests.cs @@ -80,7 +80,7 @@ public void DefaultFontProvider_MixedTextAndEmoji_ShouldUseMultipleFonts() } [TestMethod] - public void DefaultFontProvider_EnsureLastFallbackDoesNotThrowOnExactAllowEmbed() + public void DefaultFontProvider_DefaultBehavior_DoesNotThrowOnFallback() { var engine = new OpenTypeFontEngine(cfg => { @@ -90,7 +90,9 @@ public void DefaultFontProvider_EnsureLastFallbackDoesNotThrowOnExactAllowEmbed( cfg.SetScriptFallback(UnicodeScript.Latin, "Archivo Narrow"); }); - engine.LeastRequiredAvailability = FontAvailability.ExactAllowEmbed; + // Default behaviour: rendering trusts the fallback chain and does not throw, + // even when the requested font resolves only via the embedded fallback. + // (RequireExactFont defaults to false.) var shaper = engine.GetTextShaper("Archivo Narrow", FontSubFamily.Regular); @@ -99,7 +101,7 @@ public void DefaultFontProvider_EnsureLastFallbackDoesNotThrowOnExactAllowEmbed( } [TestMethod] - public void DefaultFontProvider_EnsureLastFallbackThrowOnExact() + public void DefaultFontProvider_EnsureLastFallbackThrowsWhenExactRequired() { var engine = new OpenTypeFontEngine(cfg => { @@ -109,8 +111,8 @@ public void DefaultFontProvider_EnsureLastFallbackThrowOnExact() cfg.SetScriptFallback(UnicodeScript.Latin, "Archivo Narrow"); }); - engine.LeastRequiredAvailability = FontAvailability.Exact; - Assert.ThrowsExactly(() => { engine.GetTextShaper("Archivo Narrow", FontSubFamily.Regular); }); + engine.RequireExactFont = true; + Assert.ThrowsExactly(() => { engine.GetTextShaper("NonExistentFontFamily12345", FontSubFamily.Regular); }); } [TestMethod] diff --git a/src/EPPlus.Fonts.OpenType.Tests/Fonts/Roboto-ExtraLight.ttf b/src/EPPlus.Fonts.OpenType.Tests/Fonts/Roboto-ExtraLight.ttf new file mode 100644 index 0000000000..5e517b3baf Binary files /dev/null and b/src/EPPlus.Fonts.OpenType.Tests/Fonts/Roboto-ExtraLight.ttf differ diff --git a/src/EPPlus.Fonts.OpenType.Tests/Fonts/VariableFonts/ArchivoNarrow-VariableFont_wght.ttf b/src/EPPlus.Fonts.OpenType.Tests/Fonts/VariableFonts/ArchivoNarrow-VariableFont_wght.ttf new file mode 100644 index 0000000000..40f3c644f8 Binary files /dev/null and b/src/EPPlus.Fonts.OpenType.Tests/Fonts/VariableFonts/ArchivoNarrow-VariableFont_wght.ttf differ diff --git a/src/EPPlus.Fonts.OpenType.Tests/Integration/EnsureWrappingWithFreeFonts.cs b/src/EPPlus.Fonts.OpenType.Tests/Integration/EnsureWrappingWithFreeFonts.cs index f46748e66b..595896c5df 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/Integration/EnsureWrappingWithFreeFonts.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/Integration/EnsureWrappingWithFreeFonts.cs @@ -104,8 +104,6 @@ public void TestWrappingLoremIpsum20Paragraphs() cfg.SearchSystemDirectories = false; })).Value; - tEngine.LeastRequiredAvailability = FontAvailability.NotFound; - var tle = tEngine.GetTextLayoutEngineForFont(mf); var maxWidth = 54.1420d; diff --git a/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs index 0374c10028..cfc24d12d4 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs @@ -45,7 +45,7 @@ public void TestParagraphs() fragments.Add(currentFrag); } - var paragraph = new LayoutSystem(fragments); + var paragraph = new LayoutSystem(SystemFontsEngine, fragments); var styleRuns = paragraph.GetTextOfAllTextRuns(); Assert.AreEqual(lstOfRichText[0], styleRuns[0]); @@ -84,7 +84,7 @@ public void TestLayoutSystemParagraphChars() new TextFragment() {Text = lstOfRichText[0], Font = font } }; - var layout = new LayoutSystem(fragments); + var layout = new LayoutSystem(SystemFontsEngine, fragments); Assert.AreEqual(3, layout.GetParagraphSeparatorCount()); } @@ -133,7 +133,7 @@ public void TestParagraphs_DifficultCase() var maxSizePoints = Math.Round(300d, 0, MidpointRounding.AwayFromZero).PixelToPoint(); - var paragraph = new LayoutSystem(fragments); + var paragraph = new LayoutSystem(SystemFontsEngine, fragments); var wrappedLines = paragraph.Wrap(225d); var line1 = wrappedLines[0]; @@ -167,7 +167,7 @@ public void EnsureCorrectTotalIndex() fragments.Add(currentFrag); } - var paragraph = new LayoutSystem(fragments); + var paragraph = new LayoutSystem(SystemFontsEngine, fragments); var wrappedLines = paragraph.Wrap(225d); Assert.AreEqual("StrikeGoudy size", wrappedLines[1].Text); @@ -203,12 +203,12 @@ public void EnsureRTCharIdxBecomesCorrectWhenBreaking() fragments.Add(currentFrag); } - var shaper = OpenTypeFonts.GetShaperForFont(font2); + var shaper = TestFolderEngine.GetShaperForFont(font2); //var shapes = shaper.ShapeLight("WithAbsolutelyNoSpacesAtAllJustToBeDifficult"); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(TestFolderEngine, shaper); var wrappedLines = layout.WrapRichTextLines(fragments, 225d); - var paragraph = new LayoutSystem(fragments); + var paragraph = new LayoutSystem(TestFolderEngine, fragments); var wrappedLines2 = paragraph.Wrap(225d); //var layout = OpenTypeFonts.GetTextLayoutEngineForFont(font, FontFolders); //var wrappedLines = layout.WrapRichTextLines(fragments, 225d); @@ -249,7 +249,7 @@ public void EnsureWrappingSimplePlainTextCorrectly() fragments.Add(currentFrag); } - var paragraph = new LayoutSystem(fragments); + var paragraph = new LayoutSystem(TestFolderEngine, fragments); var lines = paragraph.Wrap(92.976377953d); @@ -274,7 +274,7 @@ public void EnsureWrappingRichTextAndGettingLineSpacing() List rtLst = new List() { rt, rtSecond, rtThird}; - var paragraph = new LayoutSystem(rtLst); + var paragraph = new LayoutSystem(TestFolderEngine, rtLst); var lines = paragraph.Wrap(92.976377953d); @@ -313,7 +313,7 @@ public void TestGetSection() List rtLst = new List() { rt, rtSecond, rtThird }; - var paragraph = new LayoutSystem(rtLst); + var paragraph = new LayoutSystem(TestFolderEngine, rtLst); var fulltext = paragraph.GetTextOfAllTextRuns(); @@ -337,7 +337,7 @@ public void TestGetSectionWithIndividualChars() List rtLst = new List() { rt, rtSecond, rtThird, rtFourth }; - var pIndividual = new LayoutSystem(rtLst); + var pIndividual = new LayoutSystem(TestFolderEngine, rtLst); var joinedInput = string.Join("", txtLst.ToArray()); var joinedOutput = string.Join("", pIndividual.GetTextOfAllTextRuns().ToArray()); @@ -356,7 +356,7 @@ public void TestGetSectionMixed() var rtFourth = new RichTextFormatBase(txtLstMixed[3], "Archivo Narrow", 18f); var rtLstMixed = new List() { rt, rtSecond, rtThird, rtFourth }; - var pMixed = new LayoutSystem(rtLstMixed); + var pMixed = new LayoutSystem(TestFolderEngine, rtLstMixed); var InMix = string.Join("", txtLstMixed.ToArray()); var OutMix = string.Join("", pMixed.GetTextOfAllTextRuns().ToArray()); @@ -371,7 +371,7 @@ public void TestGetSectionNumber2() var rt = new RichTextFormatBase(txtLstMixed[0], "Roboto", 12f); var rtLstMixed = new List() { rt }; - var pMixed = new LayoutSystem(rtLstMixed); + var pMixed = new LayoutSystem(TestFolderEngine, rtLstMixed); var InMix = string.Join("", txtLstMixed.ToArray()); var OutMix = string.Join("", pMixed.GetTextOfAllTextRuns().ToArray()); @@ -408,7 +408,7 @@ public void TestLayoutSystemMultipleParagraphs() new TextFragment() {Text = lstOfRichText[1], Font = font2 } }; - var layout = new LayoutSystem(fragments); + var layout = new LayoutSystem(SystemFontsEngine, fragments); Assert.AreEqual(5, layout.GetParagraphSeparatorCount()); } } diff --git a/src/EPPlus.Fonts.OpenType.Tests/Integration/TextLayoutEngineTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Integration/TextLayoutEngineTests.cs index 62758d02ef..039e2a368f 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/Integration/TextLayoutEngineTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/Integration/TextLayoutEngineTests.cs @@ -23,7 +23,7 @@ public void WrapText_ShortText_NoWrapping() // Arrange var font = SystemFontsEngine.LoadFont("Calibri", FontSubFamily.Regular); var shaper = SystemFontsEngine.GetTextShaper("Calibri"); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(SystemFontsEngine, shaper); // Act var lines = layout.WrapText("Hello", 11f, 1000); @@ -40,7 +40,7 @@ public void WrapText_LongText_WrapsAtSpaces() // Arrange var font = SystemFontsEngine.LoadFont("Calibri", FontSubFamily.Regular); var shaper = SystemFontsEngine.GetTextShaper("Calibri"); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(SystemFontsEngine, shaper); // Act - narrow width forces wrapping var lines = layout.WrapText("Hello world test", 11f, 50); @@ -63,7 +63,7 @@ public void WrapText_WithLineBreaks_PreservesBreaks() // Arrange var font = SystemFontsEngine.LoadFont("Calibri", FontSubFamily.Regular); var shaper = new TextShaper(SystemFontsEngine, font); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(SystemFontsEngine, shaper); // Act var lines = layout.WrapText("Line 1\r\nLine 2\nLine 3", 11f, 1000); @@ -89,7 +89,7 @@ public void WrapText_TestWhenOnExactWrapPlusSpaces2() var maxWidthPoints = 54d; ITextShaper shaper = SystemFontsEngine.GetTextShaper("Aptos Narrow"); - using var layoutEngine = new TextLayoutEngine(shaper); + using var layoutEngine = new TextLayoutEngine(SystemFontsEngine, shaper); var wrappedLines = layoutEngine.WrapText( text, 11f, @@ -111,7 +111,7 @@ public void WrapText_TestWhenOnExactWrap() var comparison = new List() { "nulla", "efficitur", "commodo", "sit amet non", "lacus. Proin", "viverra enim" }; ITextShaper shaper = SystemFontsEngine.GetTextShaper("Aptos Narrow"); - using var layoutEngine = new TextLayoutEngine(shaper); + using var layoutEngine = new TextLayoutEngine(SystemFontsEngine, shaper); var wrappedLines = layoutEngine.WrapText( text, 11f, @@ -134,7 +134,7 @@ public void WrapText_TestFragments() var savedStrings = SavedComparisonString.Split("\r\n"); ITextShaper shaper = new TextShaper(SystemFontsEngine, font); - using var layoutEngine = new TextLayoutEngine(shaper); + using var layoutEngine = new TextLayoutEngine(SystemFontsEngine, shaper); var wrappedLines = layoutEngine.WrapText( Lorem20Str, 11f, @@ -174,7 +174,7 @@ public void WrapText_WithPreExistingWidth_AccountsForIt() // Arrange var font = SystemFontsEngine.LoadFont("Calibri", FontSubFamily.Regular); var shaper = SystemFontsEngine.GetTextShaper("Calibri"); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(SystemFontsEngine, shaper); // Measure "Hello " to get its width var testShaper = SystemFontsEngine.GetTextShaper("Calibri"); @@ -196,7 +196,7 @@ public void WrapText_EmptyString_ReturnsEmptyLine() // Arrange var font = SystemFontsEngine.LoadFont("Calibri", FontSubFamily.Regular); var shaper = SystemFontsEngine.GetTextShaper("Calibri"); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(SystemFontsEngine, shaper); // Act var lines = layout.WrapText("", 11f, 1000); @@ -212,7 +212,7 @@ public void WrapText_WithKerning_MeasuresCorrectly() // Arrange var font = TestFolderEngine.LoadFont("Roboto", FontSubFamily.Regular); var shaper = TestFolderEngine.GetTextShaper("Roboto"); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(TestFolderEngine, shaper); // Act - "AV" has kerning in Roboto var withKerning = layout.WrapText("AV", 11f, 1000, ShapingOptions.Default); @@ -244,7 +244,7 @@ public void MyVeryGoodRichTextWrapper() //Text containing emoji var inputText = "My long and 😝😱 bothersome 😝😱 text"; var shapedText = (ShapedText)shaper.Shape(inputText); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(SystemFontsEngine, shaper); var text = layout.WrapText(inputText, 12, 20); @@ -262,7 +262,7 @@ public void WrapRichText_SingleFragment_BehavesLikeSingleFont() // Arrange var font = SystemFontsEngine.LoadFont("Calibri", FontSubFamily.Regular); var shaper = SystemFontsEngine.GetTextShaper("Calibri"); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(SystemFontsEngine, shaper); var fragments = new List { @@ -290,7 +290,7 @@ public void WrapRichText_MultipleFragments_ConcatenatesCorrectly() // Arrange var font = SystemFontsEngine.LoadFont("Calibri", FontSubFamily.Regular); var shaper = SystemFontsEngine.GetTextShaper("Calibri"); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(SystemFontsEngine, shaper); var fragments = new List { @@ -321,7 +321,7 @@ public void WrapRichText_DifferentFonts_WrapsCorrectly() // Arrange var font = SystemFontsEngine.LoadFont("Calibri", FontSubFamily.Regular); var shaper = new TextShaper(SystemFontsEngine, font); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(SystemFontsEngine, shaper); var fragments = new List { @@ -392,8 +392,8 @@ public void WrapLongRichTextWord() var fragment = new TextFragment() { Text = longWord, Font = mFont }; var fragLst = new List() { fragment }; - ITextShaper shaper = OpenTypeFonts.GetShaperForFont(mFont); - using var layout = new TextLayoutEngine(shaper); + ITextShaper shaper = SystemFontsEngine.GetShaperForFont(mFont); + using var layout = new TextLayoutEngine(SystemFontsEngine, shaper); var wrappedLines = layout.WrapRichText(fragLst, 54); @@ -452,7 +452,7 @@ public void WrapRichTextDifficultCase() lap = sw.ElapsedMilliseconds; - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(SystemFontsEngine, shaper); lap = sw.ElapsedMilliseconds; @@ -590,7 +590,7 @@ public void EnsureRichTextLineWrappingSameAsNonRichWhenNoWrap() var goudyFont = SystemFontsEngine.LoadFont(font22.Family, font22.SubFamily); ITextShaper shaper2 = new TextShaper(SystemFontsEngine, font); - using var layoutEngine = new TextLayoutEngine(shaper); + using var layoutEngine = new TextLayoutEngine(SystemFontsEngine, shaper); var wrappedLines = layoutEngine.WrapRichTextLines(comparatorFragments, 225d); Assert.AreEqual(pointsTotal, wrappedLines[0].Width); @@ -642,7 +642,7 @@ public void EnsureRichTextLineWrappingSameAsNonRichWhenNoWrapAndSpaceTrail() var goudyFont = SystemFontsEngine.LoadFont(font22.Family, font22.SubFamily); ITextShaper shaper2 = new TextShaper(SystemFontsEngine, font); - using var layoutEngine = new TextLayoutEngine(shaper); + using var layoutEngine = new TextLayoutEngine(SystemFontsEngine, shaper); var wrappedLines = layoutEngine.WrapRichTextLines(comparatorFragments, 225d); Assert.AreEqual(pointsTotal, wrappedLines[0].Width); @@ -703,7 +703,7 @@ public void EnsureLineFragmentsAreMeasuredCorrectlyWhenWrapping() var startFont = SystemFontsEngine.LoadFont(font2.FontFamily, GetFontSubType(font2.Style)); var shaper = new TextShaper(SystemFontsEngine, startFont); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(SystemFontsEngine, shaper); var wrappedLines = layout.WrapRichTextLines(fragments, maxSizePoints); @@ -765,7 +765,7 @@ public void TestParagraphs() GenerateTextFragments(lstOfRichText, fonts, ref fragments); - var paragraph = new LayoutSystem(fragments); + var paragraph = new LayoutSystem(SystemFontsEngine, fragments); var styleRuns = paragraph.GetTextOfAllTextRuns(); Assert.AreEqual(lstOfRichText[0], styleRuns[0]); @@ -804,7 +804,7 @@ public void TestLayoutSystemParagraphChars() new TextFragment() {Text = lstOfRichText[0], Font = font } }; - var layout = new LayoutSystem(fragments); + var layout = new LayoutSystem(SystemFontsEngine, fragments); Assert.AreEqual(3, layout.GetParagraphSeparatorCount()); } @@ -848,7 +848,7 @@ public void TestParagraphs_DifficultCase() var maxSizePoints = Math.Round(300d, 0, MidpointRounding.AwayFromZero).PixelToPoint(); - var paragraph = new LayoutSystem(fragments); + var paragraph = new LayoutSystem(SystemFontsEngine, fragments); var wrappedLines = paragraph.Wrap(225d); var line1 = wrappedLines[0]; @@ -877,7 +877,7 @@ public void EnsureCorrectTotalIndex() GenerateTextFragments(lstOfRichText, fonts, ref fragments); - var paragraph = new LayoutSystem(fragments); + var paragraph = new LayoutSystem(SystemFontsEngine, fragments); var wrappedLines = paragraph.Wrap(225d); Assert.AreEqual("StrikeGoudy size", wrappedLines[1].Text); @@ -908,9 +908,9 @@ public void EnsureRTCharIdxBecomesCorrectWhenBreaking() GenerateTextFragments(lstOfRichText, fonts, ref fragments); - var paragraph = new LayoutSystem(fragments); + var paragraph = new LayoutSystem(SystemFontsEngine, fragments); - var layout = OpenTypeFonts.GetTextLayoutEngineForFont(font); + var layout = SystemFontsEngine.GetTextLayoutEngineForFont(font); var wrappedLines = layout.WrapRichTextLines(fragments, 225d); Assert.AreEqual(5, wrappedLines[1].LineFragments[0].StartRtIdx); @@ -980,7 +980,7 @@ public void WrapRichTextDifficultCaseCompare() var startFont = SystemFontsEngine.LoadFont(font1.FontFamily, GetFontSubType(font1.Style)); var shaper = new TextShaper(SystemFontsEngine, startFont); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(SystemFontsEngine, shaper); var wrappedLines = layout.WrapRichTextLines(fragments, maxSizePoints); var measurer = SystemFontsEngine.GetTextLayoutEngineForFont(font1); @@ -1033,7 +1033,7 @@ public void WrapRichText_WordSpanningFragments_MeasuresCorrectly() // Arrange var font = SystemFontsEngine.LoadFont("Calibri", FontSubFamily.Regular); var shaper = new TextShaper(SystemFontsEngine, font); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(SystemFontsEngine, shaper); // "Hello" split across two fragments with different fonts var fragments = new List @@ -1066,7 +1066,7 @@ public void WrapRichText_WithLineBreaks_PreservesBreaks() // Arrange var font = SystemFontsEngine.LoadFont("Calibri", FontSubFamily.Regular); var shaper = new TextShaper(SystemFontsEngine, font); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(SystemFontsEngine, shaper); var fragments = new List { @@ -1100,7 +1100,7 @@ public void WrapRichText_EmptyFragments_HandlesGracefully() // Arrange var font = SystemFontsEngine.LoadFont("Calibri", FontSubFamily.Regular); var shaper = new TextShaper(SystemFontsEngine, font); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(SystemFontsEngine, shaper); var fragments = new List { @@ -1137,7 +1137,7 @@ public void WrapRichText_NullFragmentList_ReturnsEmptyLine() // Arrange var font = SystemFontsEngine.LoadFont("Calibri", FontSubFamily.Regular); var shaper = new TextShaper(SystemFontsEngine, font); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(SystemFontsEngine, shaper); // Act var lines = layout.WrapRichText(null, 1000); @@ -1200,7 +1200,7 @@ public void WrapText_Continous_Long_Word() var longWord = "pellentesquer"; ITextShaper shaper = new TextShaper(SystemFontsEngine, font); - using var layoutEngine = new TextLayoutEngine(shaper); + using var layoutEngine = new TextLayoutEngine(SystemFontsEngine, shaper); var wrappedLines = layoutEngine.WrapText( longWord, 11f, @@ -1220,7 +1220,7 @@ public void WrapRichText_MeasureCorrectly() // Arrange var font = SystemFontsEngine.LoadFont("Aptos Narrow", FontSubFamily.Regular); var shaper = new TextShaper(SystemFontsEngine,font); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(SystemFontsEngine, shaper); var fragments = new List { @@ -1257,7 +1257,7 @@ public void VerifyWrappingSingleChar() var maxWidthPt = 31.8125234375d; var gottenFont = SystemFontsEngine.LoadFont("Aptos Narrow", FontSubFamily.Regular); var shaper = new TextShaper(SystemFontsEngine, gottenFont); - var layout = new TextLayoutEngine(shaper); + var layout = new TextLayoutEngine(SystemFontsEngine, shaper); List fragments = new List() { new TextFragment() { Font = font1, Text = lstOfRichText[0] } }; diff --git a/src/EPPlus.Fonts.OpenType.Tests/Reading/TtfReadingTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Reading/TtfReadingTests.cs index 0b7ff81efd..511dff8e7d 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/Reading/TtfReadingTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/Reading/TtfReadingTests.cs @@ -117,7 +117,7 @@ public void ReadSixFonts() OpenTypeFont? calibri = SystemFontsEngine.LoadFont("Calibri", FontSubFamily.Italic); OpenTypeFont? aptos = SystemFontsEngine.LoadFont("Aptos Narrow", FontSubFamily.Bold); OpenTypeFont? timesNewRoman = SystemFontsEngine.LoadFont("Times New Roman", FontSubFamily.Regular); - OpenTypeFont? SS3 = TestFolderEngine.LoadFont("Source Sans 3", FontSubFamily.Bold); + OpenTypeFont? SS3 = TestFolderEngine.LoadFont("Source Sans 3", FontSubFamily.Regular); Assert.IsNotNull(gothic); Assert.AreEqual("BIZ UDGothic Bold", gothic.FullName); @@ -242,7 +242,7 @@ public void TestWrapText() Style = MeasurementFontStyles.Regular }; - var fontMeasurer = OpenTypeFonts.GetTextLayoutEngineForFont(mf); + var fontMeasurer = SystemFontsEngine.GetTextLayoutEngineForFont(mf); var strings = fontMeasurer.WrapText(testStr, mf.Size, MaxPixelWidth); Assert.AreEqual("hello the", strings[0]); diff --git a/src/EPPlus.Fonts.OpenType.Tests/Subsetting/BasicSubsettingTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Subsetting/BasicSubsettingTests.cs index 7055054c53..c75f92cad8 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/Subsetting/BasicSubsettingTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/Subsetting/BasicSubsettingTests.cs @@ -299,7 +299,8 @@ public void Subset_Ligatures_ShouldStillWork() [TestMethod] public void Subset_WithGposKerning_ShouldPreservePositioning() { - var font = OpenTypeFonts.LoadFont("Roboto Extra Light"); + var font = TestFolderEngine.LoadFont("Roboto Extra Light"); + //var font = OpenTypeFonts.LoadFont("Roboto Extra Light"); var chars = new[] { 'f', 'e', 'c', 'd', 'g', 'E', 'a', 'b', ' ' }; bool foundF_Original = font.CmapTable.TryGetGlyphId('f', out ushort fGlyphOrig); diff --git a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/TextShaperTests.cs b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/TextShaperTests.cs index 464f72f9b0..80382fce9d 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/TextShaping/TextShaperTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/TextShaping/TextShaperTests.cs @@ -685,8 +685,6 @@ public void Shape_PrecomposedVsDecomposed_SimilarWidth() [TestMethod] public void Shape_SourceSans3_SingleMark_PositionsCorrectly() { - TestFolderEngine.LeastRequiredAvailability = FontAvailability.NotFound; - // Arrange var shaper = TestFolderEngine.GetTextShaper("SourceSans3"); @@ -723,7 +721,6 @@ public void Shape_Cafe_HandlesDecomposed() cfg.SearchSystemDirectories = false; })).Value; - tEngine.LeastRequiredAvailability = FontAvailability.NotFound; // Arrange var shaper = tEngine.GetTextShaper("SourceSans3"); diff --git a/src/EPPlus.Fonts.OpenType.Tests/VariableFonts/VariableFontMatchingTests .cs b/src/EPPlus.Fonts.OpenType.Tests/VariableFonts/VariableFontMatchingTests .cs new file mode 100644 index 0000000000..dfcad40886 --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/VariableFonts/VariableFontMatchingTests .cs @@ -0,0 +1,132 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 06/25/2026 EPPlus Software AB Variable font matching tests + *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Scanner; +using OfficeOpenXml.Interfaces.Fonts; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace EPPlus.Fonts.OpenType.Tests.VariableFonts +{ + /// + /// Verifies that the font scanner treats variable fonts as capable of delivering only + /// their default named instance. A variable font must not masquerade as an exact match + /// for a non-default subfamily. + /// + /// Regression context: a developer had Archivo Narrow installed as a wght-axis variable + /// web font. The scanner returned that file for a Bold request, the font library then read + /// the default (Regular) instance, and the developer wrote asserts against those Regular + /// values. On machines without the variable font installed, resolution fell back to the + /// embedded static Archivo Narrow Bold and the asserts failed — a non-deterministic, + /// machine-dependent test failure. + /// + /// These tests are deliberately written against FontScannerV2 directly (not through an + /// engine) so they exercise exactly the matching logic that changed, with no dependency on + /// system-installed fonts and no interference from the Archivo Narrow special-case in + /// DefaultFontResolver. + /// + [TestClass] + public class VariableFontMatchingTests : FontTestBase + { + public override TestContext? TestContext { get; set; } + + // Variable (wght-axis) build of Archivo Narrow. Its default instance is Regular (wght 400). + private const string VariableFontFamily = "Archivo Narrow"; + private const string VariableFontFileName = "ArchivoNarrow-VariableFont_wght.ttf"; + + /// + /// The isolated directory containing only the variable font. Pointing the scanner here + /// (with system directories disabled) guarantees the variable face is the only candidate, + /// which makes the disqualification observable as a null result. + /// + private static List VariableFontDirectories + { + get { return new List { Path.Combine(FontFolder, "VariableFonts") }; } + } + + private static string VariableFontPath + { + get { return Path.Combine(FontFolder, "VariableFonts", VariableFontFileName); } + } + + [TestMethod] + public void ScanSingleFace_VariableFont_SetsIsVariable() + { + // The fvar table must be detected purely from the table directory. + var face = FontScannerV2.GetFace(VariableFontPath); + + Assert.IsNotNull(face, "Expected the variable font to be scanned."); + Assert.IsTrue(face.IsVariable, + "A font containing an 'fvar' table must be flagged as variable."); + } + + [TestMethod] + public void FindBestMatch_VariableFont_DefaultStyle_IsExactMatch() + { + // The default instance of this variable font IS Regular, so a Regular request is a + // legitimate exact match. This guards against over-penalising variable fonts: they + // must still satisfy a request for their default subfamily. + var match = FontScannerV2.FindBestMatch( + VariableFontDirectories, + VariableFontFamily, + FontSubFamily.Regular, + searchSystemDirectories: false); + + Assert.IsNotNull(match, + "A variable font must still match a request for its default (Regular) subfamily."); + Assert.IsTrue(match.IsVariable, "The matched face is expected to be variable."); + Assert.AreEqual(FontSubFamily.Regular, match.Subfamily); + Assert.IsTrue(match.IsExactMatch, + "A variable font matching its default subfamily must be reported as an exact match."); + } + + [TestMethod] + public void FindBestMatch_VariableFont_NonDefaultStyle_IsDisqualified() + { + // Bold is NOT the default instance. Without variation interpolation the file cannot + // deliver Bold, so the variable face must be disqualified. With no other candidate in + // the isolated directory, the scanner returns null — and crucially never returns the + // Regular face flagged as an exact Bold match (the original bug). + var match = FontScannerV2.FindBestMatch( + VariableFontDirectories, + VariableFontFamily, + FontSubFamily.Bold, + searchSystemDirectories: false); + + Assert.IsNull(match, + "A variable font whose default instance is not Bold must not be returned as a " + + "match for a Bold request when it is the only candidate."); + } + + [TestMethod] + public void FindBestMatch_VariableFont_NonDefaultStyle_IsNotExactMatch() + { + // Belt-and-braces companion to the disqualification test, phrased as the property we + // actually care about: even if some future change let a variable face survive as a + // low-scoring candidate for a non-default style, it must never be flagged exact. + var match = FontScannerV2.FindBestMatch( + VariableFontDirectories, + VariableFontFamily, + FontSubFamily.BoldItalic, + searchSystemDirectories: false); + + // Current behaviour: disqualified → null. If that ever changes, the match must at + // least not be exact. + if (match != null) + { + Assert.IsFalse(match.IsExactMatch, + "A variable font must never be an exact match for a non-default subfamily."); + } + } + } +} \ No newline at end of file diff --git a/src/EPPlus.Fonts.OpenType/DefaultFontProvider.cs b/src/EPPlus.Fonts.OpenType/DefaultFontProvider.cs index 34e4a25924..3640404e4c 100644 --- a/src/EPPlus.Fonts.OpenType/DefaultFontProvider.cs +++ b/src/EPPlus.Fonts.OpenType/DefaultFontProvider.cs @@ -13,7 +13,9 @@ Date Author Change 05/20/2026 EPPlus Software AB Script-classified fallback via engine reference *************************************************************************************************/ using EPPlus.Fonts.OpenType.FontResolver; +using OfficeOpenXml.Interfaces.Drawing.Text; using OfficeOpenXml.Interfaces.Fonts; +using OfficeOpenXml.Interfaces.RichText; using System; using System.Collections.Generic; @@ -141,9 +143,27 @@ public IEnumerable GetAllFonts() // Internal helpers // ----------------------------------------------------------------------------------------- + /// + /// Resolves a shaper for a different font through this provider's engine. Used by the + /// layout engine to shape rich-text fragments that switch typeface, so the lookup goes + /// through the same engine that created this provider — not the global OpenTypeFonts + /// singleton. Kept internal: the engine dependency stays encapsulated here rather than + /// leaking onto IFontProvider. + /// + internal ITextShaper GetShaperForFont(IFontFormatBase font) + { + return _engine.GetShaperForFont(font); + } + + internal ITextShaper GetShaperForFont(MeasurementFont font) + { + return _engine.GetShaperForFont(font); + } + /// /// Tries to find the glyph in a lazy-loaded embedded fallback font (Noto Emoji / Math). /// + /// private bool TryGlyphInLazyFallback( LazyFallbackFont lazy, uint codePoint, diff --git a/src/EPPlus.Fonts.OpenType/Integration/RichText/LayoutSystem.cs b/src/EPPlus.Fonts.OpenType/Integration/RichText/LayoutSystem.cs index 508f67c331..ae56ef5c7d 100644 --- a/src/EPPlus.Fonts.OpenType/Integration/RichText/LayoutSystem.cs +++ b/src/EPPlus.Fonts.OpenType/Integration/RichText/LayoutSystem.cs @@ -30,16 +30,21 @@ public class LayoutSystem TextLineCollection WrappedLineCollection; + private readonly OpenTypeFontEngine _engine; - public LayoutSystem(List preFragments): this(preFragments.Cast().ToList()) + public LayoutSystem(OpenTypeFontEngine engine, List preFragments) + : this(engine, preFragments.Cast().ToList()) { } - public LayoutSystem(IEnumerable preFragments) + public LayoutSystem(OpenTypeFontEngine engine, IEnumerable preFragments) { - InputFragments = new List(); + if (engine == null) + throw new ArgumentNullException("engine"); + _engine = engine; - foreach(var preFrag in preFragments) + InputFragments = new List(); + foreach (var preFrag in preFragments) { var frag = new TextFragmentBase(preFrag); InputFragments.Add(frag); @@ -47,8 +52,12 @@ public LayoutSystem(IEnumerable preFragments) InitializeLayout(); } - public LayoutSystem(IEnumerable fragments) + public LayoutSystem(OpenTypeFontEngine engine, IEnumerable fragments) { + if (engine == null) + throw new ArgumentNullException("engine"); + _engine = engine; + InputFragments = fragments.ToList(); InitializeLayout(); } @@ -186,7 +195,7 @@ void Shaping(bool shapeLight = true) foreach (var styleRun in StyleRuns) { var inputFrag = InputFragments[styleRun.FragmentIndex]; - var shaper = OpenTypeFonts.GetTextShaper(inputFrag.RichTextOptions.Family, inputFrag.RichTextOptions.SubFamily); + var shaper = _engine.GetTextShaper(inputFrag.RichTextOptions.Family, inputFrag.RichTextOptions.SubFamily); if (shapeLight) { @@ -219,7 +228,7 @@ void Shaping(bool shapeLight = true) var lastFragment = InputFragments[InputFragments.Count - 1]; var lastRun = StyleRuns[StyleRuns.Count - 1]; - var lastShaper = OpenTypeFonts.GetTextShaper(lastFragment.RichTextOptions.Family, lastFragment.RichTextOptions.SubFamily); + var lastShaper = _engine.GetTextShaper(lastFragment.RichTextOptions.Family, lastFragment.RichTextOptions.SubFamily); var lastShapedGlyphs = lastShaper.ShapeLight(lastRun.Text); double[] lastCharWidths = new double[lastRun.Length + 1]; lastShapedGlyphs.FillCharWidths((float)lastFragment.RichTextOptions.Size, lastCharWidths, lastRun.Length + 1); @@ -242,8 +251,8 @@ public TextLineCollection Wrap(double maxWidth) return new TextLineCollection(); } var inputRt = InputFragments[0]; - var shaper = OpenTypeFonts.GetTextShaper(inputRt.RichTextOptions.Family, inputRt.RichTextOptions.SubFamily); - var layoutEngine = new TextLayoutEngine(shaper); + var shaper = _engine.GetTextShaper(inputRt.RichTextOptions.Family, inputRt.RichTextOptions.SubFamily); + var layoutEngine = new TextLayoutEngine(_engine, shaper); var wrappedLines = layoutEngine.WrapRichTextRuns(StyleRuns, maxWidth); if(wrappedLines.Count > 1) diff --git a/src/EPPlus.Fonts.OpenType/Integration/TextHandler.cs b/src/EPPlus.Fonts.OpenType/Integration/TextHandler.cs index cec15d660c..2a5e44f0ea 100644 --- a/src/EPPlus.Fonts.OpenType/Integration/TextHandler.cs +++ b/src/EPPlus.Fonts.OpenType/Integration/TextHandler.cs @@ -11,10 +11,12 @@ public class TextHandler TextShaper _currentShaper; TextLayoutEngine _currentLayout; + OpenTypeFontEngine _fontEngine; - public TextHandler(MeasurementFont mf) + public TextHandler(OpenTypeFontEngine fontEngine, MeasurementFont mf) { CurrentFontSize = mf.Size; + _fontEngine = fontEngine; SetFont(mf); } @@ -26,8 +28,8 @@ public void SetFontSize(float fontSize) public void SetFont(MeasurementFont mf) { CurrentFontSize = mf.Size; - _currentShaper = (TextShaper)OpenTypeFonts.GetShaperForFont(mf); - _currentLayout = OpenTypeFonts.GetTextLayoutEngineForFont(mf); + _currentShaper = (TextShaper)_fontEngine.GetShaperForFont(mf); + _currentLayout = _fontEngine.GetTextLayoutEngineForFont(mf); } /// diff --git a/src/EPPlus.Fonts.OpenType/Integration/TextLayoutEngine.cs b/src/EPPlus.Fonts.OpenType/Integration/TextLayoutEngine.cs index c1bb3178a6..5b0d08037d 100644 --- a/src/EPPlus.Fonts.OpenType/Integration/TextLayoutEngine.cs +++ b/src/EPPlus.Fonts.OpenType/Integration/TextLayoutEngine.cs @@ -35,6 +35,7 @@ namespace EPPlus.Fonts.OpenType.Integration public partial class TextLayoutEngine : IDisposable { private readonly ITextShaper _shaper; + private readonly OpenTypeFontEngine _engine; // Space width cache - avoids repeated Shape(" ") calls private readonly Dictionary _spaceWidthCache; @@ -61,6 +62,17 @@ public TextLayoutEngine(ITextShaper shaper) _spaceWidthCache = new Dictionary(); } + /// + /// Creates a TextLayoutEngine backed by a font engine, enabling per-fragment font resolution + /// for multi-font rich text. + /// + public TextLayoutEngine(OpenTypeFontEngine engine, ITextShaper shaper) + { + _engine = engine ?? throw new ArgumentNullException(nameof(engine)); + _shaper = shaper ?? throw new ArgumentNullException(nameof(shaper)); + _spaceWidthCache = new Dictionary(); + } + public double GetLineHeightInPoints(float fontSize) { return _shaper.GetLineHeightInPoints(fontSize); @@ -230,14 +242,15 @@ private double MeasureText(string text, float fontSize, ShapingOptions options) return shaped.GetWidthInPoints(fontSize); } - private ITextShaper GetShaperForFont(MeasurementFont font) - { - return OpenTypeFonts.GetShaperForFont(font); - } - private ITextShaper GetShaperForFont(IFontFormatBase font) { - return OpenTypeFonts.GetShaperForFont(font); + if (_engine != null) + { + return _engine.GetShaperForFont(font); + } + + // No engine available (single-font constructor): the only shaper we have is our own. + return _shaper; } diff --git a/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs b/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs index 08c3584a8c..383263859a 100644 --- a/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs +++ b/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs @@ -105,11 +105,14 @@ public OpenTypeFontEngine(Action configure) // ----------------------------------------------------------------------------------------- // Public API // ----------------------------------------------------------------------------------------- - - public FontAvailability LeastRequiredAvailability { get; set; } = FontAvailability.ExactAllowEmbed; - bool RequireExactFoundFont { get { return LeastRequiredAvailability == FontAvailability.ExactAllowEmbed || LeastRequiredAvailability == FontAvailability.Exact; } } - bool RequireFamilyFont{ get { return RequireExactFoundFont || LeastRequiredAvailability == FontAvailability.FamilyOnly; } } - + + /// + /// When true, GetTextShaper throws if the requested font cannot be resolved to an exact match, + /// even though a fallback was found. Default is false: rendering trusts the fallback chain + /// (which always resolves to at least the embedded font) and never throws. Set to true only + /// for diagnostics or validation where a missing exact font should surface as an error. + /// + public bool RequireExactFont { get; set; } = false; //public FontAvailability FallBackAvailablility = FontAvailability.Exact; /// @@ -135,26 +138,14 @@ public TextShaper GetTextShaper(string fontName, FontSubFamily subFamily = FontS if (font == null) return null; - var availability = GetFontAvailability(fontName, subFamily); - if (RequireExactFoundFont && availability != FontAvailability.Exact) + if (RequireExactFont) { - var fullFontName = font.GetEnglishFontFamilyName(); - if(LeastRequiredAvailability == FontAvailability.ExactAllowEmbed) + var availability = GetFontAvailability(fontName, subFamily); + if (availability != FontAvailability.Exact) { - //If we fallbacked to the correct embedded font despite not finding it in specified folders there's no reason to throw. - if (fullFontName.Contains(fontName) == false) - { - throw new FileNotFoundException($"Could not find Font: {fontName} fallbacked to font: {fullFontName}"); - } + throw new FileNotFoundException( + $"Could not find Font: {fontName} {subFamily}. Resolved via fallback to: {font.GetEnglishFontFamilyName()} {font.SubFamily}."); } - else - { - throw new FileNotFoundException($"Could not find Font: {fontName} fallbacked to font: {fullFontName}"); - } - } - else if(RequireFamilyFont && availability != FontAvailability.FamilyOnly && availability != FontAvailability.Exact) - { - throw new FileNotFoundException($"Could not find Font subfamily: {subFamily} fallbacked to familyOnly font:{font.GetEnglishFontFamilyName()} subfamily:{font.SubFamily}"); } shaper = new TextShaper(this, font); @@ -167,12 +158,12 @@ public TextShaper GetTextShaper(string fontName, FontSubFamily subFamily = FontS public TextLayoutEngine GetTextLayoutEngine(string fontName, FontSubFamily subFamily = FontSubFamily.Regular) { var shaper = GetTextShaper(fontName, subFamily); - return new TextLayoutEngine(shaper); + return new TextLayoutEngine(this, shaper); } public TextLayoutEngine GetTextLayoutEngineForFont(IFontFormatBase font) { var shaper = GetShaperForFont(font); - return new TextLayoutEngine(shaper); + return new TextLayoutEngine(this, shaper); } public ITextShaper GetShaperForFont(IFontFormatBase font) @@ -183,7 +174,7 @@ public ITextShaper GetShaperForFont(IFontFormatBase font) public TextLayoutEngine GetTextLayoutEngineForFont(MeasurementFont font) { var shaper = GetShaperForFont(font); - return new TextLayoutEngine(shaper); + return new TextLayoutEngine(this, shaper); } public ITextShaper GetShaperForFont(MeasurementFont font) diff --git a/src/EPPlus.Fonts.OpenType/Scanner/FontFaceInfo.cs b/src/EPPlus.Fonts.OpenType/Scanner/FontFaceInfo.cs index 825c9cfb28..864b597125 100644 --- a/src/EPPlus.Fonts.OpenType/Scanner/FontFaceInfo.cs +++ b/src/EPPlus.Fonts.OpenType/Scanner/FontFaceInfo.cs @@ -79,6 +79,14 @@ public class FontFaceInfo /// public bool IsExactMatch { get; internal set; } + /// + /// True if this face is a variable font (i.e. the file contains an 'fvar' table). + /// A variable font can only be relied upon to deliver its default named instance unless + /// the variation tables are interpolated, which this library does not yet do. Matching + /// therefore treats a variable face as capable of delivering only its default subfamily. + /// + public bool IsVariable { get; internal set; } + /// /// Table directory for this face. /// @@ -121,6 +129,7 @@ internal FontFaceInfo Clone() Subfamily = Subfamily, FsSelection = FsSelection, IsExactMatch = IsExactMatch, + IsVariable = IsVariable, // carry variable-font flag into per-query copy TableRecords = TableRecords, // shared by reference — never mutated post-scan }; } diff --git a/src/EPPlus.Fonts.OpenType/Scanner/FontScannerV2.cs b/src/EPPlus.Fonts.OpenType/Scanner/FontScannerV2.cs index 0b51030f88..fca2175447 100644 --- a/src/EPPlus.Fonts.OpenType/Scanner/FontScannerV2.cs +++ b/src/EPPlus.Fonts.OpenType/Scanner/FontScannerV2.cs @@ -65,7 +65,11 @@ public static FontFaceInfo FindBestMatch( // callers, and IsExactMatch is per-query state, not a property of the font on disk. // Mutating the cached instance creates a race condition between parallel callers. var result = bestMatch.Clone(); - result.IsExactMatch = bestScore >= 9_000; + // An exact match requires BOTH the family name and the requested style to match. + // Family-normalized (9_000) + exact style (2_000) = 11_000 is the lowest exact score. + // A face matching only the family but approximating the style (the +500/+1000 branches) + // must not count as exact, or the resolver returns e.g. a Regular face for a Bold request. + result.IsExactMatch = bestScore >= 11_000; return result; } @@ -93,8 +97,17 @@ private static int CalculateMatchScore(FontFaceInfo face, string requestedFamily requestedNormalized.IndexOf(faceFamilyNormalized, StringComparison.Ordinal) >= 0) score += 1_000; + bool styleMatches = face.Subfamily == requestedStyle; + + // A variable font is only trustworthy for its default instance. If the requested style is + // not the face's default subfamily, this face cannot deliver it without variation + // interpolation (not yet implemented), so it must not win — disqualify it outright. + // This is what makes a variable "Archivo Narrow Regular" stop masquerading as a Bold match. + if (face.IsVariable && !styleMatches) + return -1; + // Style matching - if (face.Subfamily == requestedStyle) + if (styleMatches) score += 2_000; else if (requestedStyle == FontSubFamily.Regular || face.Subfamily == FontSubFamily.Regular) score += 500; diff --git a/src/EPPlus.Fonts.OpenType/Scanner/FontScannerV2Core.cs b/src/EPPlus.Fonts.OpenType/Scanner/FontScannerV2Core.cs index 62a75df9da..f8237e49ee 100644 --- a/src/EPPlus.Fonts.OpenType/Scanner/FontScannerV2Core.cs +++ b/src/EPPlus.Fonts.OpenType/Scanner/FontScannerV2Core.cs @@ -91,6 +91,12 @@ internal static FontFaceInfo ScanSingleFace(string filePath, long offset) }; info.TableRecords[record.Tag.Value] = record; } + + // A font is "variable" if it carries a font variations table. We only need to know that the + // table exists — not parse it — to decide that this face cannot be trusted to deliver a + // non-default subfamily. No extra I/O: the table directory is already in memory. + info.IsVariable = info.TableRecords.ContainsKey("fvar"); + if (info.TableRecords.TryGetValue("OS/2", out TableRecord os2Rec)) { try diff --git a/src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs b/src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs index d135aa8331..3af30c604e 100644 --- a/src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs +++ b/src/EPPlus.Fonts.OpenType/TextShaping/TextShaper.cs @@ -18,7 +18,9 @@ Date Author Change using EPPlus.Fonts.OpenType.TextShaping.Ligatures; using EPPlus.Fonts.OpenType.TextShaping.Positioning; using EPPlus.Fonts.OpenType.TextShaping.Substitutions; +using OfficeOpenXml.Interfaces.Drawing.Text; using OfficeOpenXml.Interfaces.Fonts; +using OfficeOpenXml.Interfaces.RichText; using System; using System.Collections.Generic; @@ -89,6 +91,30 @@ public TextShaper(IFontProvider fontProvider) _chainingContextualProcessor = new ChainingContextualProcessor(_primaryFont, _singleSubstitutionProcessor, _ligatureProcessor); } + /// + /// Resolves a shaper for a different font via this shaper's font provider. Returns null + /// when the provider is not engine-backed (e.g. a custom IFontProvider), in which case the + /// caller is expected to fall back. This lets a TextLayoutEngine shape multi-font rich text + /// through the engine that produced this shaper instead of the global singleton. + /// + internal ITextShaper GetShaperForFont(IFontFormatBase font) + { + var dfp = _fontProvider as DefaultFontProvider; + if (dfp != null) + return dfp.GetShaperForFont(font); + + return null; + } + + internal ITextShaper GetShaperForFont(MeasurementFont font) + { + var dfp = _fontProvider as DefaultFontProvider; + if (dfp != null) + return dfp.GetShaperForFont(font); + + return null; + } + #region Font Tracking API /// diff --git a/src/EPPlus.Interfaces/Fonts/FontAvailability.cs b/src/EPPlus.Interfaces/Fonts/FontAvailability.cs index be33bde183..213f632459 100644 --- a/src/EPPlus.Interfaces/Fonts/FontAvailability.cs +++ b/src/EPPlus.Interfaces/Fonts/FontAvailability.cs @@ -26,8 +26,6 @@ public enum FontAvailability FamilyOnly, /// The exact font family and subfamily is available. - Exact, - /// Exact but does not throw if the embedded fallback font is asked for - ExactAllowEmbed + Exact } } \ No newline at end of file diff --git a/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs b/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs index b17f1550cb..0aa862747f 100644 --- a/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs +++ b/src/EPPlus.Interfaces/Fonts/IEpplusFontConfiguration.cs @@ -17,10 +17,10 @@ Date Author Change namespace OfficeOpenXml.Interfaces.Fonts { /// - /// Global font configuration for EPPlus. - /// Accessed exclusively via OpenTypeFonts.Configure(Action<IEpplusFontConfiguration>). - /// Changes made inside a Configure callback are applied as a single transaction — - /// when the callback returns, the font resolver is rebuilt and all font caches are cleared. + /// Per-workbook font configuration for EPPlus. + /// Accessed via ExcelWorkbook.ConfigureFonts(Action<IEpplusFontConfiguration>). + /// Changes made inside a ConfigureFonts callback are applied as a single transaction — + /// when the callback returns, the font resolver is rebuilt and the workbook's font caches are cleared. /// public interface IEpplusFontConfiguration { @@ -82,4 +82,5 @@ public interface IEpplusFontConfiguration /// void Reset(); } + } \ No newline at end of file diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs index 2bbb9fafb4..48fd1a1ff1 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs @@ -179,7 +179,7 @@ private double GetTextHeight(ExcelChartAxisStandard ax) private double GetTextWidest(ExcelChartAxisStandard ax) { var mf = ax.Font.GetMeasureFont(); - var shaper = OpenTypeFonts.GetShaperForFont(mf); + var shaper = RenderContext.FontEngine.GetShaperForFont(mf); var tm = new OpenTypeFontTextMeasurer(shaper); var widest = 0f; @@ -300,7 +300,7 @@ private List GetAxisValueTextBoxes() var mf = Axis.Font.GetMeasureFont(); - var shaper = OpenTypeFonts.GetShaperForFont(mf); + var shaper = RenderContext.FontEngine.GetShaperForFont(mf); var tm = new OpenTypeFontTextMeasurer(shaper); var axisStyle = GetAxisStyleEntry(); diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs index 67b461e6a1..ae9f40a9b5 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs @@ -50,6 +50,8 @@ internal abstract class ChartDrawingObject : DrawingObject { internal ChartRenderer ChartRenderer; internal ExcelChart Chart => (ExcelChart)ChartRenderer.Drawing; + + internal RenderContext RenderContext => ChartRenderer.RenderContext; internal ChartDrawingObject(ChartRenderer chart) { ChartRenderer = chart; diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs index 7d53028a61..5ebbbf2a06 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs @@ -44,7 +44,7 @@ internal class ChartLegendRenderer : ChartDrawingObject internal ChartLegendRenderer(ChartRenderer sc, bool isDataLabelLegend = false) : base(sc) { var mf = Chart.Font.GetMeasureFont(); - var shaper = OpenTypeFonts.GetShaperForFont(mf); + var shaper = RenderContext.FontEngine.GetShaperForFont(mf); var _ttMeasurer = new OpenTypeFontTextMeasurer(shaper); if (sc.Chart.HasLegend == false && isDataLabelLegend == false || sc.Chart.Series.Count == 0) @@ -270,7 +270,7 @@ private void GetSerieSize(ExcelChartLegend l, int index, string text, ref double if (_ttMeasurer == null) { - _ttMeasurer = new OpenTypeFontTextMeasurer(OpenTypeFonts.GetShaperForFont(mf)); + _ttMeasurer = new OpenTypeFontTextMeasurer(RenderContext.FontEngine.GetShaperForFont(mf)); } var tm = _ttMeasurer.MeasureText(text, mf); @@ -488,7 +488,7 @@ private void SetTrendlineLegend(ExcelChart ct, int serieIndex, int entryIndex, D tbWidth = Rectangle.Bounds.Width - tbLeft; var tbHeight = entryHeight; - sls.Textbox = new DrawingTextBody(Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); + sls.Textbox = new DrawingTextBody(RenderContext, Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); var entry = Chart.Legend.Entries.FirstOrDefault(x => x.Index == entryIndex); var headerText = tl.GetName(serieIndex); @@ -547,7 +547,7 @@ private void SetPieLegend(ExcelChart ct, int index, DrawingLegendSerie pSls, eLe } var tbHeight = tm.Height; - sls.Textbox = new DrawingTextBody(Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); + sls.Textbox = new DrawingTextBody(RenderContext, Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); //var para = sc.Chart.Legend.TextBody.Paragraphs.FirstOrDefault(); sls.Textbox.ImportParagraph(Chart.Legend.TextBody.Paragraphs.FirstOrDefault(), 0, catValues[i].ToString()); sls.SeriesIcon = si; @@ -592,7 +592,7 @@ private void SetLineLegend(ExcelChart ct, int index, DrawingLegendSerie pSls, eL var tbWidth = Rectangle.Bounds.Width - tbLeft; var tbHeight = entryHeight; - sls.Textbox = new DrawingTextBody(Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); + sls.Textbox = new DrawingTextBody(RenderContext, Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); var entry = Chart.Legend.Entries.FirstOrDefault(x => x.Index == index); var headerText = s.GetHeaderText(index); @@ -638,7 +638,7 @@ private void SetBarLegend(ExcelChart ct, int index, DrawingLegendSerie pSls, eLe tbWidth = Rectangle.Bounds.Width - tbLeft; var tbHeight = tm.Height; - sls.Textbox = new DrawingTextBody(Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); + sls.Textbox = new DrawingTextBody(RenderContext, Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); //sls.Textbox.Bounds.Left = si.Bottom + MarginIconText; var entry = Chart.Legend.Entries.FirstOrDefault(x => x.Index == index); diff --git a/src/EPPlus/Drawing/Renderer/DrawingRenderer.cs b/src/EPPlus/Drawing/Renderer/DrawingRenderer.cs index 44eda3615e..9f1f5d2a33 100644 --- a/src/EPPlus/Drawing/Renderer/DrawingRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/DrawingRenderer.cs @@ -11,6 +11,7 @@ Date Author Change 27/11/2025 EPPlus Software AB EPPlus 9 *************************************************************************************************/ +using EPPlus.DrawingRenderer; using EPPlus.DrawingRenderer.RenderItems; using EPPlus.Export.ImageRenderer.Utils; using EPPlus.Export.Utils; @@ -33,8 +34,9 @@ internal DrawingRenderer(ExcelDrawing drawing) var wb = drawing._drawings.Worksheet.Workbook; Theme = wb.ThemeManager.GetOrCreateTheme(); + RenderContext = wb.RenderContext; - var shaper = OpenTypeFonts.GetTextShaper(Theme.FontScheme.MajorFont[0].Typeface); + var shaper = RenderContext.FontEngine.GetTextShaper(Theme.FontScheme.MajorFont[0].Typeface); TextMeasurer = new OpenTypeFontTextMeasurer(shaper); } @@ -53,6 +55,7 @@ internal DrawingRenderer() public ExcelTheme Theme { get;} public ExcelWorkbook Workbook => Drawing._drawings.Worksheet.Workbook; internal ITextMeasurer TextMeasurer { get; } + internal RenderContext RenderContext { get; } public List RenderItems { get; } = new List(); internal BoundingBox Bounds = new BoundingBox(); } diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs index eb29a9f132..804abe2ef5 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs @@ -25,9 +25,10 @@ internal class DrawingParagraphRenderItem : ParagraphRenderItem /// /// /// - public DrawingParagraphRenderItem(DrawingTextBody textBody, BoundingBox parent) : base(parent, textBody) + public DrawingParagraphRenderItem(RenderContext renderContext, DrawingTextBody textBody, BoundingBox parent) + : base(renderContext, parent, textBody) { - ParagraphLineSpacing = GetParagraphLineSpacingInPoints(100, (TextShaper)OpenTypeFonts.GetShaperForFont(DefaultParagraphFont), DefaultParagraphFont.Size); + ParagraphLineSpacing = GetParagraphLineSpacingInPoints(100, (TextShaper)RenderContext.FontEngine.GetShaperForFont(DefaultParagraphFont), DefaultParagraphFont.Size); } /// @@ -36,7 +37,8 @@ public DrawingParagraphRenderItem(DrawingTextBody textBody, BoundingBox parent) /// /// /// - public DrawingParagraphRenderItem(DrawingTextBody textBody, BoundingBox parent, string text) : this(textBody, parent) + public DrawingParagraphRenderItem(RenderContext renderContext, DrawingTextBody textBody, BoundingBox parent, string text) + : this(renderContext, textBody, parent) { ImportLinesAndTextRunsDefault(text); } @@ -48,22 +50,18 @@ public DrawingParagraphRenderItem(DrawingTextBody textBody, BoundingBox parent, /// /// /// - public DrawingParagraphRenderItem(DrawingTextBody textBody, BoundingBox parent, ExcelDrawingParagraph p, string textIfEmpty = null) : base(parent, textBody, false) + public DrawingParagraphRenderItem(RenderContext renderContext, DrawingTextBody textBody, BoundingBox parent, ExcelDrawingParagraph p, string textIfEmpty = null) + : base(renderContext, parent, textBody, false) { IsFirstParagraph = p == p._paragraphs[0]; ImportStyleInfo(textBody, p); HorizontalAlignment = (TextAlignment)(int)p.HorizontalAlignment; ImportMarginAndIndent(p); - //ImportAlignment(textBody.AutoSize, textBody.MaxWidth, parent.Width); - - //---Initialize / calculate lines and runs--- - //measurer must be set before AddLinesAndRichText + DefaultParagraphFont = new FontFormatBase(p.DefaultRunProperties.GetMeasureFont()); - //---Calculate linespacing--- ImportLineSpacing(p.LineSpacing.LineSpacingType, p.LineSpacing.Value); - //Import textruns or fallback text ImportLinesAndTextRuns(p, textIfEmpty); } @@ -222,7 +220,7 @@ private void ImportAlignment(bool isAutoSize, double maxWidth, double parentWidt private void ImportLineSpacing(eDrawingTextLineSpacing lsType, double lineSpacingValue) { _lsType = (TextLineSpacing)lsType; - var shaper = (TextShaper)OpenTypeFonts.GetShaperForFont(DefaultParagraphFont); + var shaper = (TextShaper)RenderContext.FontEngine.GetShaperForFont(DefaultParagraphFont); ParagraphLineSpacing = GetParagraphLineSpacingInPoints( lineSpacingValue, diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs index a3d5851673..3cbc496c86 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs @@ -1,4 +1,5 @@ -using EPPlus.DrawingRenderer.RenderItems; +using EPPlus.DrawingRenderer; +using EPPlus.DrawingRenderer.RenderItems; using EPPlus.DrawingRenderer.RenderItems.SvgItem; using EPPlus.Export.ImageRenderer.RenderItems.Shared; using EPPlus.Fonts.OpenType.Integration.DataHolders; @@ -26,14 +27,14 @@ public class DrawingTextBody : RenderTextBody internal ExcelTheme Theme { get; } - public DrawingTextBody(ExcelDrawing drawing, BoundingBox parent, bool autoSize, bool clampedToParent = false) : base(parent, autoSize) + public DrawingTextBody(RenderContext renderContext, ExcelDrawing drawing, BoundingBox parent, bool autoSize, bool clampedToParent = false) : base(renderContext, parent, autoSize) { _drawing = drawing; Theme = drawing._drawings.Worksheet.Workbook.ThemeManager.GetOrCreateTheme(); MaxWidth = parent.Width; MaxHeight = parent.Height; } - public DrawingTextBody(ExcelDrawing drawing, BoundingBox parent, double left, double top, double maxWidth, double maxHeight, bool clampedToParent = false, bool autoSize=false) : base(parent, autoSize) + public DrawingTextBody(RenderContext renderContext, ExcelDrawing drawing, BoundingBox parent, double left, double top, double maxWidth, double maxHeight, bool clampedToParent = false, bool autoSize=false) : base(renderContext, parent, autoSize) { _drawing = drawing; Theme = drawing._drawings.Worksheet.Workbook.ThemeManager.GetOrCreateTheme(); @@ -205,12 +206,12 @@ internal virtual void ImportTextBodyAndParagraphs(ExcelTextBody body, ExcelHoriz internal DrawingParagraphRenderItem CreateParagraph(DrawingTextBody textBody, BoundingBox parent) { - return new DrawingParagraphRenderItem(textBody, parent); + return new DrawingParagraphRenderItem(RenderContext, textBody, parent); } internal DrawingParagraphRenderItem CreateParagraph(DrawingTextBody textBody, ExcelDrawingParagraph paragraph, BoundingBox parent, string textIfEmpty = null) { - return new DrawingParagraphRenderItem(textBody, parent, paragraph, textIfEmpty); + return new DrawingParagraphRenderItem(RenderContext, textBody, parent, paragraph, textIfEmpty); } /// @@ -221,12 +222,12 @@ internal DrawingParagraphRenderItem CreateParagraph(DrawingTextBody textBody, Ex /// protected override ParagraphRenderItem CreateParagraph(BoundingBox parent, string textIfEmpty = "") { - return new DrawingParagraphRenderItem(this, parent, textIfEmpty); + return new DrawingParagraphRenderItem(RenderContext, this, parent, textIfEmpty); } protected override ParagraphRenderItem CreateParagraph(BoundingBox parent, IRichTextFormatSimple richText) { - var paragraph = new SvgParagraphRenderItem(this, parent, "", false); + var paragraph = new SvgParagraphRenderItem(RenderContext, this, parent, "", false); paragraph.AddRichText(richText); return paragraph; } diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs index ebbafe5446..13a6eac205 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs @@ -23,7 +23,8 @@ private void Init(ExcelDrawing drawing, BoundingBox parent, double maxWidth, dou { Parent = parent; _drawing= drawing; - TextBody = new DrawingTextBody(drawing, _marginGroup.Bounds, true); + var renderContext = drawing._drawings.Worksheet.Workbook.RenderContext; + TextBody = new DrawingTextBody(renderContext, drawing, _marginGroup.Bounds, true); TextBody.MaxWidth = maxWidth; TextBody.MaxHeight = maxHeight; } diff --git a/src/EPPlus/Drawing/Renderer/ShapeRenderer.cs b/src/EPPlus/Drawing/Renderer/ShapeRenderer.cs index 03323b4a2c..e00d01666c 100644 --- a/src/EPPlus/Drawing/Renderer/ShapeRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/ShapeRenderer.cs @@ -263,7 +263,7 @@ DrawingTextBody CreateTextBodyItem(ExcelTextBody bodyOrig) var grp = new GroupRenderItem(MarginTextBox.Bounds); RenderItems.Add(grp); - var txtBodyItem = new DrawingTextBody(Drawing, MarginTextBox.Bounds, MarginTextBox.Left, MarginTextBox.Top, MarginTextBox.Width, MarginTextBox.Height); + var txtBodyItem = new DrawingTextBody(RenderContext, Drawing, MarginTextBox.Bounds, MarginTextBox.Left, MarginTextBox.Top, MarginTextBox.Width, MarginTextBox.Height); txtBodyItem.ImportTextBodyAndParagraphs(bodyOrig); txtBodyItem.AppendRenderItems(grp.RenderItems); diff --git a/src/EPPlus/ExcelWorkbook.cs b/src/EPPlus/ExcelWorkbook.cs index 282f47ff65..6157d386d6 100644 --- a/src/EPPlus/ExcelWorkbook.cs +++ b/src/EPPlus/ExcelWorkbook.cs @@ -10,42 +10,45 @@ Date Author Change ************************************************************************************************* 01/27/2020 EPPlus Software AB Initial release EPPlus 5 *************************************************************************************************/ -using System; -using System.Xml; -using System.IO; -using System.Collections.Generic; -using System.Text; -using System.Globalization; -using System.Linq; -using OfficeOpenXml.VBA; -using OfficeOpenXml.FormulaParsing; -using OfficeOpenXml.FormulaParsing.LexicalAnalysis; -using OfficeOpenXml.Packaging.Ionic.Zip; -using OfficeOpenXml.Drawing.Theme; +using EPPlus.DrawingRenderer; +using EPPlus.Fonts.OpenType; +using OfficeOpenXml.CellPictures; using OfficeOpenXml.Compatibility; +using OfficeOpenXml.Constants; using OfficeOpenXml.Core.CellStore; -using OfficeOpenXml.Drawing.Slicer; -using OfficeOpenXml.ThreadedComments; -using OfficeOpenXml.Table; -using OfficeOpenXml.Table.PivotTable; +using OfficeOpenXml.Data.Connection; +using OfficeOpenXml.Data.CustomXml; +using OfficeOpenXml.DigitalSignatures; using OfficeOpenXml.Drawing; -using OfficeOpenXml.Constants; -using OfficeOpenXml.ExternalReferences; -using OfficeOpenXml.Packaging; -using OfficeOpenXml.Export.HtmlExport.Interfaces; +using OfficeOpenXml.Drawing.Slicer; +using OfficeOpenXml.Drawing.Theme; using OfficeOpenXml.Export.HtmlExport.Exporters; +using OfficeOpenXml.Export.HtmlExport.Interfaces; +using OfficeOpenXml.ExternalReferences; +using OfficeOpenXml.FormulaParsing; +using OfficeOpenXml.FormulaParsing.LexicalAnalysis; +using OfficeOpenXml.Interfaces.Fonts; using OfficeOpenXml.Metadata; +using OfficeOpenXml.Packaging; +using OfficeOpenXml.Packaging.Ionic.Zip; using OfficeOpenXml.RichData; -using OfficeOpenXml.Style; -using OfficeOpenXml.CellPictures; using OfficeOpenXml.RichData.IndexRelations; -using OfficeOpenXml.DigitalSignatures; -using OfficeOpenXml.Utils.XML; -using OfficeOpenXml.Utils.TypeConversion; -using OfficeOpenXml.Utils.FileUtils; +using OfficeOpenXml.Style; +using OfficeOpenXml.Table; +using OfficeOpenXml.Table.PivotTable; +using OfficeOpenXml.ThreadedComments; using OfficeOpenXml.Utils.EnumUtils; -using OfficeOpenXml.Data.CustomXml; -using OfficeOpenXml.Data.Connection; +using OfficeOpenXml.Utils.FileUtils; +using OfficeOpenXml.Utils.TypeConversion; +using OfficeOpenXml.Utils.XML; +using OfficeOpenXml.VBA; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; namespace OfficeOpenXml { @@ -1456,6 +1459,64 @@ public int? DefaultThemeVersion } } } + + RenderContext _renderContext = null; + + /// + /// Rendering-wide resources for this workbook (font engine, etc.), flowed down the drawing + /// render stack. Created lazily with a default font engine if none has been configured. + /// Internal: callers configure fonts via rather than touching + /// this directly. + /// + internal RenderContext RenderContext + { + get + { + if (_renderContext == null) + { + _renderContext = new RenderContext(() => new OpenTypeFontEngine()); + } + return _renderContext; + } + set + { + _renderContext = value; + } + } + + /// + /// Configures the fonts used when rendering drawings (charts, shapes) from this workbook to + /// image formats such as SVG. Use this to add font directories, control whether system font + /// directories are searched, or register fallback chains. + /// + /// A callback that configures the font settings for this workbook. + /// + /// The configuration is applied when this workbook first renders a drawing. Call this before + /// rendering. The font engine is per workbook configuring one workbook does not affect any + /// other workbook or any global state. + /// + public void ConfigureFonts(Action configure) + { + if (configure == null) + throw new ArgumentNullException("configure"); + + RenderContext = new RenderContext(() => new OpenTypeFontEngine(configure)); + } + + /// + /// Supplies a pre-built font engine for this workbook's rendering. Intended for advanced + /// scenarios and testing where a specific engine instance must be used. Per workbook never + /// global. + /// + /// The font engine to use for this workbook. + internal void UseFontEngine(OpenTypeFontEngine engine) + { + if (engine == null) + throw new ArgumentNullException("engine"); + + RenderContext = new RenderContext(() => engine); + } + bool _fullPrecision; /// /// If false, EPPlus will round cell values to the number of decimals as displayed in the cell by using the cells number format when calculating the workbook. @@ -2214,6 +2275,11 @@ public void Dispose() _formulaParser.Dispose(); _formulaParser = null; } + if (_renderContext != null) + { + _renderContext.Dispose(); + _renderContext = null; + } } /// diff --git a/src/EPPlusTest/ConfigureFontsTests.cs b/src/EPPlusTest/ConfigureFontsTests.cs new file mode 100644 index 0000000000..dc9f86f7e1 --- /dev/null +++ b/src/EPPlusTest/ConfigureFontsTests.cs @@ -0,0 +1,147 @@ +using EPPlus.Fonts.OpenType; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; +using OfficeOpenXml.Interfaces.Fonts; +using System; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace EPPlusTest +{ + [TestClass] + public class ConfigureFontsTests : FontTestBase + { + private static string RenderTextbox(ExcelWorksheet sheet, string text, string fontName) + { + var tb = sheet.Drawings.AddTextbox("txtBox1"); + tb.Text = text; + tb.Font.LatinFont = fontName; + return tb.ToSvg(); + } + + private static string RenderTextbox(ExcelPackage package, string text, string fontName) + { + var sheet = package.Workbook.Worksheets.Add("Sheet1"); + return RenderTextbox(sheet, text, fontName); + } + + private static string RenderRobotoTextbox(ExcelPackage package) + { + return RenderTextbox(package, "Hello", "Roboto"); + } + + // Pulls the first tspan baseline y out of the SVG. It's computed from the resolved font's + // ascent, so it differs between fonts — unlike font-size, which is just the requested size. + private static double GetFirstTspanBaselineY(string svg) + { + var m = Regex.Match(svg, @"]*\by\s*=""([0-9.]+)px"""); + Assert.IsTrue(m.Success, "Expected a tspan with a y position in the SVG."); + return double.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture); + } + + [TestMethod] + public void ConfigureFonts_Null_ThrowsArgumentNullException() + { + using var package = new ExcelPackage(); + Assert.ThrowsExactly( + () => package.Workbook.ConfigureFonts(null)); + } + + [TestMethod] + public void ConfigureFonts_ConfiguredDirectory_ResolvesRoboto() + { + using var package = new ExcelPackage(); + package.Workbook.ConfigureFonts(cfg => + { + cfg.FontDirectories.Add(FontFolder); + cfg.SearchSystemDirectories = false; + }); + + var svg = RenderRobotoTextbox(package); + + // The requested name always appears; the real proof is that rendering with the + // directory configured does not throw and produces a measurable tspan. + StringAssert.Contains(svg, "font-family=\"Roboto"); + Assert.IsTrue(GetFirstTspanBaselineY(svg) > 0); + } + + [TestMethod] + public void ConfigureFonts_IsPerWorkbook() + { + // Workbook A: Roboto directory available, system search off → resolves Roboto. + using var packageA = new ExcelPackage(); + packageA.Workbook.ConfigureFonts(cfg => + { + cfg.FontDirectories.Add(FontFolder); + cfg.SearchSystemDirectories = false; + }); + + // Workbook B: no Roboto directory, system search off → falls back to the + // embedded Archivo Narrow, which has different metrics than Roboto. + using var packageB = new ExcelPackage(); + packageB.Workbook.ConfigureFonts(cfg => + { + cfg.SearchSystemDirectories = false; + }); + + var svgA = RenderRobotoTextbox(packageA); + var svgB = RenderRobotoTextbox(packageB); + + var baselineA = GetFirstTspanBaselineY(svgA); + var baselineB = GetFirstTspanBaselineY(svgB); + + // Different resolved fonts → different measured size. This proves the configuration + // affected resolution per workbook, not just that the requested name was echoed. + Assert.AreNotEqual(baselineA, baselineB, + "Workbook A (Roboto) and workbook B (embedded fallback) must measure differently, proving per-workbook font resolution."); + } + + [TestMethod] + public void ConfigureFonts_FontFallbacks_AreApplied() + { + // A: maps a non-existent font name to Roboto via the user fallback chain. + using var packageA = new ExcelPackage(); + packageA.Workbook.ConfigureFonts(cfg => + { + cfg.FontDirectories.Add(FontFolder); + cfg.SearchSystemDirectories = false; + cfg.FontFallbacks["NoSuchFont"] = new[] { "Roboto" }; + }); + + // B: same missing font, no fallback chain → resolves via built-in chain to embedded. + using var packageB = new ExcelPackage(); + packageB.Workbook.ConfigureFonts(cfg => + { + cfg.FontDirectories.Add(FontFolder); + cfg.SearchSystemDirectories = false; + }); + + var baselineA = GetFirstTspanBaselineY(RenderTextbox(packageA, "Hello", "NoSuchFont")); + var baselineB = GetFirstTspanBaselineY(RenderTextbox(packageB, "Hello", "NoSuchFont")); + + Assert.AreNotEqual(baselineA, baselineB, + "The user fallback chain should route the missing font to Roboto, measuring differently than the built-in fallback."); + } + + [TestMethod] + public void SetScriptFallback_RoutesHanGlyphsToConfiguredFont() + { + var engine = new OpenTypeFontEngine(cfg => + { + cfg.FontDirectories.Add(FontFolder); + cfg.SearchSystemDirectories = false; + cfg.SetScriptFallback(UnicodeScript.Han, "BIZ UDGothic"); + }); + + var shaper = engine.GetTextShaper("Open Sans"); // Latin-only primary + var result = shaper.Shape("日本語"); + var usedFonts = shaper.GetUsedFonts().ToList(); + + // The Han glyphs could not come from Open Sans; they must have been routed + // to the configured BIZ UDGothic via the script fallback. + Assert.IsTrue(usedFonts.Any(f => f.FullName.Contains("BIZ UDGothic") || f.FullName.Contains("BIZUDGothic")), + "Han glyphs should be routed to the configured script-fallback font."); + } + } +} \ No newline at end of file diff --git a/src/EPPlusTest/Core/SharedFormulasTest.cs b/src/EPPlusTest/Core/SharedFormulasTest.cs index 501e30a0a3..474df34f35 100644 --- a/src/EPPlusTest/Core/SharedFormulasTest.cs +++ b/src/EPPlusTest/Core/SharedFormulasTest.cs @@ -17,6 +17,7 @@ public static void Init(TestContext context) public static void Cleanup() { } + [TestMethod] public void SharedFormulasShouldNotEffectFullColumn() { diff --git a/src/EPPlusTest/Drawing/TextMeasuring/ReadMeasureTests.cs b/src/EPPlusTest/Drawing/TextMeasuring/ReadMeasureTests.cs index cb8cdb64c8..1eef5b295d 100644 --- a/src/EPPlusTest/Drawing/TextMeasuring/ReadMeasureTests.cs +++ b/src/EPPlusTest/Drawing/TextMeasuring/ReadMeasureTests.cs @@ -131,7 +131,8 @@ public void WrapMultipleFragments_LongPlusEndWord() fonts.Add(mf2); fonts.Add(mf2); - var txtMeasurer = OpenTypeFonts.GetTextLayoutEngineForFont(mf); + var engine = new OpenTypeFontEngine(x => x.SearchSystemDirectories = true); + var txtMeasurer = engine.GetTextLayoutEngineForFont(mf); var maxWidth = 114d; diff --git a/src/EPPlusTest/EPPlus.Test.csproj b/src/EPPlusTest/EPPlus.Test.csproj index 253c182219..bf3fde5e35 100644 --- a/src/EPPlusTest/EPPlus.Test.csproj +++ b/src/EPPlusTest/EPPlus.Test.csproj @@ -73,6 +73,15 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + PreserveNewest diff --git a/src/EPPlusTest/FontTestBase.cs b/src/EPPlusTest/FontTestBase.cs new file mode 100644 index 0000000000..bc1609d1cf --- /dev/null +++ b/src/EPPlusTest/FontTestBase.cs @@ -0,0 +1,21 @@ +using EPPlus.Fonts.OpenType; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace EPPlusTest +{ + public class FontTestBase : TestBase + { + protected string FontFolder + { + get + { + return Path.Combine(AppContext.BaseDirectory, "Fonts"); + } + } + } +} diff --git a/src/EPPlusTest/Fonts/BIZUDGothic-Regular.ttf b/src/EPPlusTest/Fonts/BIZUDGothic-Regular.ttf new file mode 100644 index 0000000000..030a7c96fd Binary files /dev/null and b/src/EPPlusTest/Fonts/BIZUDGothic-Regular.ttf differ diff --git a/src/EPPlusTest/Fonts/OpenSans-Regular.ttf b/src/EPPlusTest/Fonts/OpenSans-Regular.ttf new file mode 100644 index 0000000000..134d225f66 Binary files /dev/null and b/src/EPPlusTest/Fonts/OpenSans-Regular.ttf differ diff --git a/src/EPPlusTest/Fonts/Roboto-Regular.ttf b/src/EPPlusTest/Fonts/Roboto-Regular.ttf new file mode 100644 index 0000000000..ddf4bfacb3 Binary files /dev/null and b/src/EPPlusTest/Fonts/Roboto-Regular.ttf differ