Explorar o código

Merge pull request #341 from QuestPDF/2022.9

2022.9 Release
Marcin Ziąbek %!s(int64=3) %!d(string=hai) anos
pai
achega
ee6249a658

+ 1 - 1
QuestPDF.Examples/TextBenchmark.cs

@@ -111,7 +111,7 @@ namespace QuestPDF.Examples
             {
                 page.Margin(50);
                 
-                page.Content().Column(column =>
+                page.Content().PaddingVertical(10).Column(column =>
                 {
                     column.Item().Element(Title);
                     column.Item().PageBreak();

+ 39 - 0
QuestPDF.Examples/TextExamples.cs

@@ -2,6 +2,7 @@
 using System.Linq;
 using System.Text;
 using NUnit.Framework;
+using QuestPDF.Elements.Text;
 using QuestPDF.Examples.Engine;
 using QuestPDF.Fluent;
 using QuestPDF.Helpers;
@@ -618,5 +619,43 @@ namespace QuestPDF.Examples
                         .FontSize(20);
                 });
         }
+        
+        [Test]
+        public void FontFallback()
+        {
+            RenderingTest
+                .Create()
+                .ProduceImages()
+                .ShowResults()
+                .RenderDocument(container =>
+                {
+                    container.Page(page =>
+                    {
+                        page.Margin(50);
+                        page.PageColor(Colors.White);
+                        page.DefaultTextStyle(x => x
+                            .Fallback(y => y.FontFamily("Segoe UI Emoji")
+                                .Fallback(y => y.FontFamily("Microsoft YaHei"))));
+
+                        page.Size(PageSizes.A4);
+
+                        page.Content().Text(t =>
+                        {
+                            t.Line("This is normal text.");
+                            t.EmptyLine();
+                            
+                            t.Line("Following line should use font fallback:");
+                            t.Line("中文文本");
+                            t.EmptyLine();
+                            
+                            t.Line("The following line contains a mix of known and unknown characters.");
+                            t.Line("Mixed line: This 中文 is 文文 a mixed 本 本 line 本 中文文本!");
+                            t.EmptyLine();
+
+                            t.Line("Emojis work out of the box because of font fallback: 😊😅🥳👍❤😍👌");
+                        });
+                    });
+                });
+        }
     }
 }

+ 1 - 1
QuestPDF.Previewer/QuestPDF.Previewer.csproj

@@ -4,7 +4,7 @@
         <Authors>MarcinZiabek</Authors>
         <Company>CodeFlint</Company>
         <PackageId>QuestPDF.Previewer</PackageId>
-        <Version>2022.8.0</Version>
+        <Version>2022.9.0</Version>
         <PackAsTool>true</PackAsTool>
         <ToolCommandName>questpdf-previewer</ToolCommandName>
         <PackageDescription>QuestPDF is an open-source, modern and battle-tested library that can help you with generating PDF documents by offering friendly, discoverable and predictable C# fluent API.</PackageDescription>

+ 5 - 10
QuestPDF/Drawing/DocumentGenerator.cs

@@ -172,7 +172,7 @@ namespace QuestPDF.Drawing
                 {
                     if (textBlockItem is TextBlockSpan textSpan)
                     {
-                        textSpan.Style.ApplyGlobalStyle(documentDefaultTextStyle);
+                        textSpan.Style = textSpan.Style.ApplyGlobalStyle(documentDefaultTextStyle);
                     }
                     else if (textBlockItem is TextBlockElement textElement)
                     {
@@ -184,18 +184,13 @@ namespace QuestPDF.Drawing
             }
 
             if (content is DynamicHost dynamicHost)
-                dynamicHost.TextStyle.ApplyGlobalStyle(documentDefaultTextStyle);
-            
-            var targetTextStyle = documentDefaultTextStyle;
+                dynamicHost.TextStyle = dynamicHost.TextStyle.ApplyGlobalStyle(documentDefaultTextStyle);
             
             if (content is DefaultTextStyle defaultTextStyleElement)
-            {
-                defaultTextStyleElement.TextStyle.ApplyParentStyle(documentDefaultTextStyle);
-                targetTextStyle = defaultTextStyleElement.TextStyle;
-            }
-            
+               documentDefaultTextStyle = defaultTextStyleElement.TextStyle.ApplyGlobalStyle(documentDefaultTextStyle);
+
             foreach (var child in content.GetChildren())
-                ApplyDefaultTextStyle(child, targetTextStyle);
+                ApplyDefaultTextStyle(child, documentDefaultTextStyle);
         }
     }
 }

+ 5 - 0
QuestPDF/Drawing/Exceptions/DocumentDrawingException.cs

@@ -4,6 +4,11 @@ namespace QuestPDF.Drawing.Exceptions
 {
     public class DocumentDrawingException : Exception
     {
+        internal DocumentDrawingException(string message) : base(message)
+        {
+            
+        }
+        
         internal DocumentDrawingException(string message, Exception inner) : base(message, inner)
         {
             

+ 13 - 13
QuestPDF/Drawing/FontManager.cs

@@ -14,13 +14,13 @@ namespace QuestPDF.Drawing
 {
     public static class FontManager
     {
-        private static ConcurrentDictionary<string, FontStyleSet> StyleSets = new();
-        private static ConcurrentDictionary<object, SKFontMetrics> FontMetrics = new();
-        private static ConcurrentDictionary<object, SKPaint> FontPaints = new();
-        private static ConcurrentDictionary<string, SKPaint> ColorPaints = new();
-        private static ConcurrentDictionary<object, Font> ShaperFonts = new();
-        private static ConcurrentDictionary<object, SKFont> Fonts = new();
-        private static ConcurrentDictionary<object, TextShaper> TextShapers = new();
+        private static readonly ConcurrentDictionary<string, FontStyleSet> StyleSets = new();
+        private static readonly ConcurrentDictionary<TextStyle, SKFontMetrics> FontMetrics = new();
+        private static readonly ConcurrentDictionary<TextStyle, SKPaint> FontPaints = new();
+        private static readonly ConcurrentDictionary<string, SKPaint> ColorPaints = new();
+        private static readonly ConcurrentDictionary<TextStyle, Font> ShaperFonts = new();
+        private static readonly ConcurrentDictionary<TextStyle, SKFont> Fonts = new();
+        private static readonly ConcurrentDictionary<TextStyle, TextShaper> TextShapers = new();
 
         static FontManager()
         {
@@ -110,7 +110,7 @@ namespace QuestPDF.Drawing
 
         internal static SKPaint ToPaint(this TextStyle style)
         {
-            return FontPaints.GetOrAdd(style.PaintKey, key => Convert(style));
+            return FontPaints.GetOrAdd(style, Convert);
 
             static SKPaint Convert(TextStyle style)
             {
@@ -172,14 +172,14 @@ namespace QuestPDF.Drawing
 
         internal static SKFontMetrics ToFontMetrics(this TextStyle style)
         {
-            return FontMetrics.GetOrAdd(style.FontMetricsKey, key => style.NormalPosition().ToPaint().FontMetrics);
+            return FontMetrics.GetOrAdd(style, key => key.NormalPosition().ToPaint().FontMetrics);
         }
 
         internal static Font ToShaperFont(this TextStyle style)
         {
-            return ShaperFonts.GetOrAdd(style.PaintKey, _ =>
+            return ShaperFonts.GetOrAdd(style, key =>
             {
-                var typeface = style.ToPaint().Typeface;
+                var typeface = key.ToPaint().Typeface;
 
                 using var harfBuzzBlob = typeface.OpenStream(out var ttcIndex).ToHarfBuzzBlob();
                 
@@ -200,12 +200,12 @@ namespace QuestPDF.Drawing
         
         internal static TextShaper ToTextShaper(this TextStyle style)
         {
-            return TextShapers.GetOrAdd(style.PaintKey, _ => new TextShaper(style));
+            return TextShapers.GetOrAdd(style, key => new TextShaper(key));
         }
         
         internal static SKFont ToFont(this TextStyle style)
         {
-            return Fonts.GetOrAdd(style.PaintKey, _ => style.ToPaint().ToFont());
+            return Fonts.GetOrAdd(style, key => key.ToPaint().ToFont());
         }
     }
 }

+ 0 - 8
QuestPDF/Drawing/TextMeasurement.cs

@@ -1,8 +0,0 @@
-namespace QuestPDF.Drawing
-{
-    internal struct TextMeasurement
-    {
-        public int LineIndex { get; set; }
-        public float FragmentWidth { get; set; }
-    }
-}

+ 0 - 17
QuestPDF/Drawing/TextShaper.cs

@@ -55,9 +55,6 @@ namespace QuestPDF.Drawing
                 xOffset += glyphPositions[i].XAdvance * scaleX;
                 yOffset += glyphPositions[i].YAdvance * scaleY;
             }
-
-            if (Settings.CheckIfAllTextGlyphsAreAvailableInSpecifiedFont)
-                CheckIfAllGlyphsAreAvailable(glyphs, text);
             
             return new TextShapingResult(glyphs);
         }
@@ -78,20 +75,6 @@ namespace QuestPDF.Drawing
             else
                 throw new NotSupportedException("TextEncoding of type GlyphId is not supported.");
         }
-        
-        void CheckIfAllGlyphsAreAvailable(ShapedGlyph[] glyphs, string originalText)
-        {
-            var containsMissingGlyphs = glyphs.Any(x => x.Codepoint == default);
-            
-            if (!containsMissingGlyphs)
-                return;
-            
-            throw new ArgumentException(
-                $"Detected missing font glyphs while rendering text. " +
-                $"This means that the document contains text with characters not present in the assigned font. " +
-                $"Such characters are replaced by placeholders, usually visible as empty rectangles. " +
-                $"Font family used: {TextStyle.FontFamily}. Issue detected in text: '{originalText}'");
-        }
     }
     
     internal struct ShapedGlyph

+ 1 - 1
QuestPDF/Elements/Dynamic.cs

@@ -11,7 +11,7 @@ namespace QuestPDF.Elements
         private DynamicComponentProxy Child { get; }
         private object InitialComponentState { get; set; }
 
-        internal TextStyle TextStyle { get; } = new();
+        internal TextStyle TextStyle { get; set; } = TextStyle.Default;
 
         public DynamicHost(DynamicComponentProxy child)
         {

+ 150 - 0
QuestPDF/Elements/Text/FontFallback.cs

@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using QuestPDF.Drawing;
+using QuestPDF.Drawing.Exceptions;
+using QuestPDF.Elements.Text.Items;
+using QuestPDF.Fluent;
+using QuestPDF.Infrastructure;
+using SkiaSharp;
+
+namespace QuestPDF.Elements.Text
+{
+    internal static class FontFallback
+    {
+        public struct TextRun
+        {
+            public string Content { get; set; }
+            public TextStyle Style { get; set; }
+        }
+
+        public class FallbackOption
+        {
+            public TextStyle Style { get; set; }
+            public SKFont Font { get; set; }
+            public SKTypeface Typeface { get; set; }
+        }
+
+        private static SKFontManager FontManager => SKFontManager.Default;
+
+        public static IEnumerable<TextRun> SplitWithFontFallback(this string text, TextStyle textStyle)
+        {
+            var fallbackOptions = GetFallbackOptions(textStyle).ToArray();
+            
+            var spanStartIndex = 0;
+            var spanFallbackOption = fallbackOptions[0];
+            
+            for (var i = 0; i < text.Length; i += char.IsSurrogatePair(text, i) ? 2 : 1)
+            {
+                var codepoint = char.ConvertToUtf32(text, i);
+                var newFallbackOption = MatchFallbackOption(fallbackOptions, codepoint);
+
+                if (newFallbackOption == spanFallbackOption)
+                    continue;
+
+                yield return new TextRun
+                {
+                    Content = text.Substring(spanStartIndex, i - spanStartIndex),
+                    Style = spanFallbackOption.Style
+                };
+
+                spanStartIndex = i;
+                spanFallbackOption = newFallbackOption;
+            }
+            
+            if (spanStartIndex > text.Length)
+                yield break;
+            
+            yield return new TextRun
+            {
+                Content = text.Substring(spanStartIndex, text.Length - spanStartIndex),
+                Style = spanFallbackOption.Style
+            };
+
+            static IEnumerable<FallbackOption> GetFallbackOptions(TextStyle? textStyle)
+            {
+                while (textStyle != null)
+                {
+                    var font = textStyle.ToFont();
+                    
+                    yield return new FallbackOption
+                    {
+                        Style = textStyle,
+                        Font = font,
+                        Typeface = font.Typeface
+                    };
+
+                    textStyle = textStyle.Fallback;
+                }
+            }
+
+            static FallbackOption MatchFallbackOption(ICollection<FallbackOption> fallbackOptions, int codepoint)
+            {
+                foreach (var fallbackOption in fallbackOptions)
+                {
+                    if (fallbackOption.Font.ContainsGlyph(codepoint))
+                        return fallbackOption;
+                }
+
+                throw CreateNotMatchingFontException(codepoint);
+            }
+
+            static Exception CreateNotMatchingFontException(int codepoint)
+            {
+                var character = char.ConvertFromUtf32(codepoint);
+                var unicode = $"U-{codepoint:X4}";
+
+                var proposedFonts = FindFontsContainingGlyph(codepoint);
+                var proposedFontsFormatted = proposedFonts.Any() ? string.Join(", ", proposedFonts) : "no fonts available";
+                
+                return new DocumentDrawingException(
+                    $"Could not find an appropriate font fallback for glyph: {unicode} '{character}'. " +
+                    $"Font families available on current environment that contain this glyph: {proposedFontsFormatted}. " +
+                    $"Possible solutions: " +
+                    $"1) Use one of the listed fonts as the primary font in your document. " +
+                    $"2) Configure the fallback TextStyle using the 'TextStyle.Fallback' method with one of the listed fonts. ");
+            }
+            
+            static IEnumerable<string> FindFontsContainingGlyph(int codepoint)
+            {
+                var fontManager = SKFontManager.Default;
+
+                return fontManager
+                    .GetFontFamilies()
+                    .Select(fontManager.MatchFamily)
+                    .Where(x => x.ContainsGlyph(codepoint))
+                    .Select(x => x.FamilyName);
+            }
+        }
+
+        public static IEnumerable<ITextBlockItem> ApplyFontFallback(this ICollection<ITextBlockItem> textBlockItems)
+        {
+            foreach (var textBlockItem in textBlockItems)
+            {
+                if (textBlockItem is TextBlockSpan textBlockSpan and not TextBlockPageNumber)
+                {
+                    if (!Settings.CheckIfAllTextGlyphsAreAvailable && textBlockSpan.Style.Fallback == null)
+                    {
+                        yield return textBlockSpan;
+                        continue;
+                    }
+                    
+                    var textRuns = textBlockSpan.Text.SplitWithFontFallback(textBlockSpan.Style);
+
+                    foreach (var textRun in textRuns)
+                    {
+                        yield return new TextBlockSpan
+                        {
+                            Text = textRun.Content,
+                            Style = textRun.Style
+                        };
+                    }
+                }
+                else
+                {
+                    yield return textBlockItem;
+                }
+            }
+        }
+    }
+}

+ 1 - 1
QuestPDF/Elements/Text/Items/TextBlockSpan.cs

@@ -14,7 +14,7 @@ namespace QuestPDF.Elements.Text.Items
     internal class TextBlockSpan : ITextBlockItem
     {
         public string Text { get; set; }
-        public TextStyle Style { get; set; } = new();
+        public TextStyle Style { get; set; } = TextStyle.Default;
         public TextShapingResult? TextShapingResult { get; set; }
 
         private Dictionary<(int startIndex, float availableWidth), TextMeasurementResult?> MeasureCache = new ();

+ 12 - 0
QuestPDF/Elements/Text/TextBlock.cs

@@ -18,8 +18,11 @@ namespace QuestPDF.Elements.Text
         private Queue<ITextBlockItem> RenderingQueue { get; set; }
         private int CurrentElementIndex { get; set; }
 
+        private bool FontFallbackApplied { get; set; } = false;
+        
         public void ResetState()
         {
+            ApplyFontFallback();
             InitializeQueue();
             CurrentElementIndex = 0;
 
@@ -37,6 +40,15 @@ namespace QuestPDF.Elements.Text
                 foreach (var item in Items)
                     RenderingQueue.Enqueue(item);
             }
+
+            void ApplyFontFallback()
+            {
+                if (FontFallbackApplied)
+                    return;
+
+                Items = Items.ApplyFontFallback().ToList();
+                FontFallbackApplied = true;
+            }
         }
 
         internal override SpacePlan Measure(Size availableSpace)

+ 37 - 43
QuestPDF/Fluent/TextExtensions.cs

@@ -12,11 +12,18 @@ namespace QuestPDF.Fluent
 {
     public class TextSpanDescriptor
     {
-        internal TextStyle TextStyle { get; }
+        internal TextStyle TextStyle = TextStyle.Default;
+        internal Action<TextStyle> AssignTextStyle { get; }
 
-        internal TextSpanDescriptor(TextStyle textStyle)
+        internal TextSpanDescriptor(Action<TextStyle> assignTextStyle)
         {
-            TextStyle = textStyle;
+            AssignTextStyle = assignTextStyle;
+        }
+
+        internal void MutateTextStyle(Func<TextStyle, TextStyle> handler)
+        {
+            TextStyle = handler(TextStyle);
+            AssignTextStyle(TextStyle);
         }
     }
 
@@ -24,16 +31,17 @@ namespace QuestPDF.Fluent
     
     public class TextPageNumberDescriptor : TextSpanDescriptor
     {
-        internal PageNumberFormatter FormatFunction { get; private set; } = x => x?.ToString() ?? string.Empty;
-
-        internal TextPageNumberDescriptor(TextStyle textStyle) : base(textStyle)
+        internal Action<PageNumberFormatter> AssignFormatFunction { get; }
+        
+        internal TextPageNumberDescriptor(Action<TextStyle> assignTextStyle, Action<PageNumberFormatter> assignFormatFunction) : base(assignTextStyle)
         {
-            
+            AssignFormatFunction = assignFormatFunction;
+            AssignFormatFunction(x => x?.ToString());
         }
 
         public TextPageNumberDescriptor Format(PageNumberFormatter formatter)
         {
-            FormatFunction = formatter ?? FormatFunction;
+            AssignFormatFunction(formatter);
             return this;
         }
     }
@@ -41,7 +49,7 @@ namespace QuestPDF.Fluent
     public class TextDescriptor
     {
         private ICollection<TextBlock> TextBlocks { get; } = new List<TextBlock>();
-        private TextStyle DefaultStyle { get; set; } = TextStyle.Default;
+        private TextStyle? DefaultStyle { get; set; }
         internal HorizontalAlignment Alignment { get; set; } = HorizontalAlignment.Left;
         private float Spacing { get; set; } = 0f;
 
@@ -91,19 +99,15 @@ namespace QuestPDF.Fluent
         
         public TextSpanDescriptor Span(string? text)
         {
-            var style = DefaultStyle.Clone();
-            var descriptor = new TextSpanDescriptor(style);
-
             if (text == null)
-                return descriptor;
+                return new TextSpanDescriptor(_ => { });
  
             var items = text
                 .Replace("\r", string.Empty)
                 .Split(new[] { '\n' }, StringSplitOptions.None)
                 .Select(x => new TextBlockSpan
                 {
-                    Text = x,
-                    Style = style
+                    Text = x
                 })
                 .ToList();
 
@@ -118,7 +122,7 @@ namespace QuestPDF.Fluent
                 .ToList()
                 .ForEach(TextBlocks.Add);
 
-            return descriptor;
+            return new TextSpanDescriptor(x => items.ForEach(y => y.Style = x));
         }
 
         public TextSpanDescriptor Line(string? text)
@@ -134,16 +138,10 @@ namespace QuestPDF.Fluent
         
         private TextPageNumberDescriptor PageNumber(Func<IPageContext, int?> pageNumber)
         {
-            var style = DefaultStyle.Clone();
-            var descriptor = new TextPageNumberDescriptor(style);
-            
-            AddItemToLastTextBlock(new TextBlockPageNumber
-            {
-                Source = context => descriptor.FormatFunction(pageNumber(context)),
-                Style = style
-            });
+            var textBlockItem = new TextBlockPageNumber();
+            AddItemToLastTextBlock(textBlockItem);
             
-            return descriptor;
+            return new TextPageNumberDescriptor(x => textBlockItem.Style = x, x => textBlockItem.Source = context => x(pageNumber(context)));
         }
 
         public TextPageNumberDescriptor CurrentPageNumber()
@@ -187,20 +185,17 @@ namespace QuestPDF.Fluent
             if (IsNullOrEmpty(sectionName))
                 throw new ArgumentException("Section name cannot be null or empty", nameof(sectionName));
 
-            var style = DefaultStyle.Clone();
-            var descriptor = new TextSpanDescriptor(style);
-            
             if (IsNullOrEmpty(text))
-                return descriptor;
-            
-            AddItemToLastTextBlock(new TextBlockSectionLink
+                return new TextSpanDescriptor(_ => { });
+
+            var textBlockItem = new TextBlockSectionLink
             {
-                Style = style,
                 Text = text,
                 SectionName = sectionName
-            });
+            };
 
-            return descriptor;
+            AddItemToLastTextBlock(textBlockItem);
+            return new TextSpanDescriptor(x => textBlockItem.Style = x);
         }
         
         [Obsolete("This element has been renamed since version 2022.3. Please use the SectionLink method.")]
@@ -214,20 +209,17 @@ namespace QuestPDF.Fluent
             if (IsNullOrEmpty(url))
                 throw new ArgumentException("Url cannot be null or empty", nameof(url));
 
-            var style = DefaultStyle.Clone();
-            var descriptor = new TextSpanDescriptor(style);
-
             if (IsNullOrEmpty(text))
-                return descriptor;
+                return new TextSpanDescriptor(_ => { });
             
-            AddItemToLastTextBlock(new TextBlockHyperlink
+            var textBlockItem = new TextBlockHyperlink
             {
-                Style = style,
                 Text = text,
                 Url = url
-            });
+            };
 
-            return descriptor;
+            AddItemToLastTextBlock(textBlockItem);
+            return new TextSpanDescriptor(x => textBlockItem.Style = x);
         }
         
         [Obsolete("This element has been renamed since version 2022.3. Please use the Hyperlink method.")]
@@ -251,7 +243,9 @@ namespace QuestPDF.Fluent
         internal void Compose(IContainer container)
         {
             TextBlocks.ToList().ForEach(x => x.Alignment = Alignment);
-            container = container.DefaultTextStyle(DefaultStyle);
+            
+            if (DefaultStyle != null)
+                container = container.DefaultTextStyle(DefaultStyle);
 
             if (TextBlocks.Count == 1)
             {

+ 47 - 34
QuestPDF/Fluent/TextSpanDescriptorExtensions.cs

@@ -11,120 +11,135 @@ namespace QuestPDF.Fluent
             if (style == null)
                 return descriptor;
             
-            descriptor.TextStyle.OverrideStyle(style);
+            descriptor.MutateTextStyle(x => x.OverrideStyle(style));
             return descriptor;
         }
         
+        public static T Fallback<T>(this T descriptor, TextStyle? value = null) where T : TextSpanDescriptor
+        {
+            descriptor.TextStyle.Fallback = value;
+            return descriptor;
+        }
+        
+        public static T Fallback<T>(this T descriptor, Func<TextStyle, TextStyle> handler) where T : TextSpanDescriptor
+        {
+            return descriptor.Fallback(handler(TextStyle.Default));
+        }
+        
         public static T FontColor<T>(this T descriptor, string value) where T : TextSpanDescriptor
         {
-            descriptor.TextStyle.Color = value;
+            descriptor.MutateTextStyle(x => x.FontColor(value));
             return descriptor;
         }
         
         public static T BackgroundColor<T>(this T descriptor, string value) where T : TextSpanDescriptor
         {
-            descriptor.TextStyle.BackgroundColor = value;
+            descriptor.MutateTextStyle(x => x.BackgroundColor(value));
             return descriptor;
         }
         
         public static T FontFamily<T>(this T descriptor, string value) where T : TextSpanDescriptor
         {
-            descriptor.TextStyle.FontFamily = value;
+            descriptor.MutateTextStyle(x => x.FontFamily(value));
             return descriptor;
         }
         
         public static T FontSize<T>(this T descriptor, float value) where T : TextSpanDescriptor
         {
-            descriptor.TextStyle.Size = value;
+            descriptor.MutateTextStyle(x => x.FontSize(value));
             return descriptor;
         }
         
         public static T LineHeight<T>(this T descriptor, float value) where T : TextSpanDescriptor
         {
-            descriptor.TextStyle.LineHeight = value;
+            descriptor.MutateTextStyle(x => x.LineHeight(value));
             return descriptor;
         }
         
         public static T Italic<T>(this T descriptor, bool value = true) where T : TextSpanDescriptor
         {
-            descriptor.TextStyle.IsItalic = value;
+            descriptor.MutateTextStyle(x => x.Italic(value));
             return descriptor;
         }
         
         public static T Strikethrough<T>(this T descriptor, bool value = true) where T : TextSpanDescriptor
         {
-            descriptor.TextStyle.HasStrikethrough = value;
+            descriptor.MutateTextStyle(x => x.Strikethrough(value));
             return descriptor;
         }
         
         public static T Underline<T>(this T descriptor, bool value = true) where T : TextSpanDescriptor
         {
-            descriptor.TextStyle.HasUnderline = value;
+            descriptor.MutateTextStyle(x => x.Underline(value));
             return descriptor;
         }
 
         public static T WrapAnywhere<T>(this T descriptor, bool value = true) where T : TextSpanDescriptor
         {
-            descriptor.TextStyle.WrapAnywhere = value;
+            descriptor.MutateTextStyle(x => x.WrapAnywhere(value));
             return descriptor;
         }
 
         #region Weight
         
-        public static T Weight<T>(this T descriptor, FontWeight weight) where T : TextSpanDescriptor
-        {
-            descriptor.TextStyle.FontWeight = weight;
-            return descriptor;
-        }
-        
         public static T Thin<T>(this T descriptor) where T : TextSpanDescriptor
         {
-            return descriptor.Weight(FontWeight.Thin);
+            descriptor.MutateTextStyle(x => x.Thin());
+            return descriptor;
         }
         
         public static T ExtraLight<T>(this T descriptor) where T : TextSpanDescriptor
         {
-            return descriptor.Weight(FontWeight.ExtraLight);
+            descriptor.MutateTextStyle(x => x.ExtraLight());
+            return descriptor;
         }
         
         public static T Light<T>(this T descriptor) where T : TextSpanDescriptor
         {
-            return descriptor.Weight(FontWeight.Light);
+            descriptor.MutateTextStyle(x => x.Light());
+            return descriptor;
         }
         
         public static T NormalWeight<T>(this T descriptor) where T : TextSpanDescriptor
         {
-            return descriptor.Weight(FontWeight.Normal);
+            descriptor.MutateTextStyle(x => x.NormalWeight());
+            return descriptor;
         }
         
         public static T Medium<T>(this T descriptor) where T : TextSpanDescriptor
         {
-            return descriptor.Weight(FontWeight.Medium);
+            descriptor.MutateTextStyle(x => x.Medium());
+            return descriptor;
         }
         
         public static T SemiBold<T>(this T descriptor) where T : TextSpanDescriptor
         {
-            return descriptor.Weight(FontWeight.SemiBold);
+            descriptor.MutateTextStyle(x => x.SemiBold());
+            return descriptor;
         }
         
         public static T Bold<T>(this T descriptor) where T : TextSpanDescriptor
         {
-            return descriptor.Weight(FontWeight.Bold);
+            descriptor.MutateTextStyle(x => x.Bold());
+            return descriptor;
         }
         
         public static T ExtraBold<T>(this T descriptor) where T : TextSpanDescriptor
         {
-            return descriptor.Weight(FontWeight.ExtraBold);
+            descriptor.MutateTextStyle(x => x.ExtraBold());
+            return descriptor;
         }
         
         public static T Black<T>(this T descriptor) where T : TextSpanDescriptor
         {
-            return descriptor.Weight(FontWeight.Black);
+            descriptor.MutateTextStyle(x => x.Black());
+            return descriptor;
         }
         
         public static T ExtraBlack<T>(this T descriptor) where T : TextSpanDescriptor
         {
-            return descriptor.Weight(FontWeight.ExtraBlack);
+            descriptor.MutateTextStyle(x => x.ExtraBlack());
+            return descriptor;
         }
 
         #endregion
@@ -132,24 +147,22 @@ namespace QuestPDF.Fluent
         #region Position
         public static T NormalPosition<T>(this T descriptor) where T : TextSpanDescriptor
         {
-            return descriptor.Position(FontPosition.Normal);
+            descriptor.MutateTextStyle(x => x.NormalPosition());
+            return descriptor;
         }
 
         public static T Subscript<T>(this T descriptor) where T : TextSpanDescriptor
         {
-            return descriptor.Position(FontPosition.Subscript);
+            descriptor.MutateTextStyle(x => x.Subscript());
+            return descriptor;
         }
 
         public static T Superscript<T>(this T descriptor) where T : TextSpanDescriptor
         {
-            return descriptor.Position(FontPosition.Superscript);
-        }
-
-        private static T Position<T>(this T descriptor, FontPosition fontPosition) where T : TextSpanDescriptor
-        {
-            descriptor.TextStyle.FontPosition = fontPosition;
+            descriptor.MutateTextStyle(x => x.Superscript());
             return descriptor;
         }
+        
         #endregion
     }
 }

+ 26 - 21
QuestPDF/Fluent/TextStyleExtensions.cs

@@ -6,14 +6,6 @@ namespace QuestPDF.Fluent
 {
     public static class TextStyleExtensions
     {
-        private static TextStyle Mutate(this TextStyle style, Action<TextStyle> handler)
-        {
-            style = style.Clone();
-            
-            handler(style);
-            return style;
-        }
-        
         [Obsolete("This element has been renamed since version 2022.3. Please use the FontColor method.")]
         public static TextStyle Color(this TextStyle style, string value)
         {
@@ -22,12 +14,12 @@ namespace QuestPDF.Fluent
         
         public static TextStyle FontColor(this TextStyle style, string value)
         {
-            return style.Mutate(x => x.Color = value);
+            return style.Mutate(TextStyleProperty.Color, value);
         }
         
         public static TextStyle BackgroundColor(this TextStyle style, string value)
         {
-            return style.Mutate(x => x.BackgroundColor = value);
+            return style.Mutate(TextStyleProperty.BackgroundColor, value);
         }
         
         [Obsolete("This element has been renamed since version 2022.3. Please use the FontFamily method.")]
@@ -38,7 +30,7 @@ namespace QuestPDF.Fluent
         
         public static TextStyle FontFamily(this TextStyle style, string value)
         {
-            return style.Mutate(x => x.FontFamily = value);
+            return style.Mutate(TextStyleProperty.FontFamily, value);
         }
         
         [Obsolete("This element has been renamed since version 2022.3. Please use the FontSize method.")]
@@ -49,39 +41,39 @@ namespace QuestPDF.Fluent
         
         public static TextStyle FontSize(this TextStyle style, float value)
         {
-            return style.Mutate(x => x.Size = value);
+            return style.Mutate(TextStyleProperty.Size, value);
         }
         
         public static TextStyle LineHeight(this TextStyle style, float value)
         {
-            return style.Mutate(x => x.LineHeight = value);
+            return style.Mutate(TextStyleProperty.LineHeight, value);
         }
         
         public static TextStyle Italic(this TextStyle style, bool value = true)
         {
-            return style.Mutate(x => x.IsItalic = value);
+            return style.Mutate(TextStyleProperty.IsItalic, value);
         }
         
         public static TextStyle Strikethrough(this TextStyle style, bool value = true)
         {
-            return style.Mutate(x => x.HasStrikethrough = value);
+            return style.Mutate(TextStyleProperty.HasStrikethrough, value);
         }
         
         public static TextStyle Underline(this TextStyle style, bool value = true)
         {
-            return style.Mutate(x => x.HasUnderline = value);
+            return style.Mutate(TextStyleProperty.HasUnderline, value);
         }
         
         public static TextStyle WrapAnywhere(this TextStyle style, bool value = true)
         {
-            return style.Mutate(x => x.WrapAnywhere = value);
+            return style.Mutate(TextStyleProperty.WrapAnywhere, value);
         }
 
         #region Weight
         
         public static TextStyle Weight(this TextStyle style, FontWeight weight)
         {
-            return style.Mutate(x => x.FontWeight = weight);
+            return style.Mutate(TextStyleProperty.FontWeight, weight);
         }
         
         public static TextStyle Thin(this TextStyle style)
@@ -137,6 +129,7 @@ namespace QuestPDF.Fluent
         #endregion
 
         #region Position
+        
         public static TextStyle NormalPosition(this TextStyle style)
         {
             return style.Position(FontPosition.Normal);
@@ -154,11 +147,23 @@ namespace QuestPDF.Fluent
 
         private static TextStyle Position(this TextStyle style, FontPosition fontPosition)
         {
-            if (style.FontPosition == fontPosition)
-                return style;
+            return style.Mutate(TextStyleProperty.FontPosition, fontPosition);
+        }
+        
+        #endregion
 
-            return style.Mutate(t => t.FontPosition = fontPosition);
+        #region Fallback
+        
+        public static TextStyle Fallback(this TextStyle style, TextStyle? value = null)
+        {
+            return style.Mutate(TextStyleProperty.Fallback, value);
+        }
+        
+        public static TextStyle Fallback(this TextStyle style, Func<TextStyle, TextStyle> handler)
+        {
+            return style.Fallback(handler(TextStyle.Default));
         }
+
         #endregion
     }
 }

+ 7 - 62
QuestPDF/Infrastructure/TextStyle.cs

@@ -3,10 +3,8 @@ using QuestPDF.Helpers;
 
 namespace QuestPDF.Infrastructure
 {
-    public class TextStyle
+    public record TextStyle
     {
-        internal bool HasGlobalStyleApplied { get; private set; }
-        
         internal string? Color { get; set; }
         internal string? BackgroundColor { get; set; }
         internal string? FontFamily { get; set; }
@@ -19,13 +17,9 @@ namespace QuestPDF.Infrastructure
         internal bool? HasUnderline { get; set; }
         internal bool? WrapAnywhere { get; set; }
 
-        internal object PaintKey { get; private set; }
-        internal object FontMetricsKey { get; private set; }
-        
-        // REVIEW: Should this be a method call that news up a TextStyle,
-        // or can it be a static variable?
-        // (style mutations seem to create a clone anyway)
-        internal static readonly TextStyle LibraryDefault = new TextStyle
+        internal TextStyle? Fallback { get; set; }
+
+        internal static TextStyle LibraryDefault { get; } = new()
         {
             Color = Colors.Black,
             BackgroundColor = Colors.Transparent,
@@ -37,59 +31,10 @@ namespace QuestPDF.Infrastructure
             IsItalic = false,
             HasStrikethrough = false,
             HasUnderline = false,
-            WrapAnywhere = false
+            WrapAnywhere = false,
+            Fallback = null
         };
 
-        // it is important to create new instances for the DefaultTextStyle element to work correctly
-        public static TextStyle Default => new TextStyle();
-        
-        internal void ApplyGlobalStyle(TextStyle globalStyle)
-        {
-            if (HasGlobalStyleApplied)
-                return;
-            
-            HasGlobalStyleApplied = true;
-
-            ApplyParentStyle(globalStyle);
-            PaintKey ??= (FontFamily, Size, FontWeight, FontPosition, IsItalic, Color);
-            FontMetricsKey ??= (FontFamily, Size, FontWeight, IsItalic);
-        }
-        
-        internal void ApplyParentStyle(TextStyle parentStyle)
-        {
-            Color ??= parentStyle.Color;
-            BackgroundColor ??= parentStyle.BackgroundColor;
-            FontFamily ??= parentStyle.FontFamily;
-            Size ??= parentStyle.Size;
-            LineHeight ??= parentStyle.LineHeight;
-            FontWeight ??= parentStyle.FontWeight;
-            FontPosition ??= parentStyle.FontPosition;
-            IsItalic ??= parentStyle.IsItalic;
-            HasStrikethrough ??= parentStyle.HasStrikethrough;
-            HasUnderline ??= parentStyle.HasUnderline;
-            WrapAnywhere ??= parentStyle.WrapAnywhere;
-        }
-
-        internal void OverrideStyle(TextStyle parentStyle)
-        {
-            Color = parentStyle.Color ?? Color;
-            BackgroundColor = parentStyle.BackgroundColor ?? BackgroundColor;
-            FontFamily = parentStyle.FontFamily ?? FontFamily;
-            Size = parentStyle.Size ?? Size;
-            LineHeight = parentStyle.LineHeight ?? LineHeight;
-            FontWeight = parentStyle.FontWeight ?? FontWeight;
-            FontPosition = parentStyle.FontPosition ?? FontPosition;
-            IsItalic = parentStyle.IsItalic ?? IsItalic;
-            HasStrikethrough = parentStyle.HasStrikethrough ?? HasStrikethrough;
-            HasUnderline = parentStyle.HasUnderline ?? HasUnderline;
-            WrapAnywhere = parentStyle.WrapAnywhere ?? WrapAnywhere;
-        }
-        
-        internal TextStyle Clone()
-        {
-            var clone = (TextStyle)MemberwiseClone();
-            clone.HasGlobalStyleApplied = false;
-            return clone;
-        }
+        public static TextStyle Default { get; } = new();
     }
 }

+ 248 - 0
QuestPDF/Infrastructure/TextStyleManager.cs

@@ -0,0 +1,248 @@
+using System;
+using System.Collections.Concurrent;
+using QuestPDF.Fluent;
+
+namespace QuestPDF.Infrastructure
+{
+    internal enum TextStyleProperty
+    {
+        Color,
+        BackgroundColor,
+        FontFamily,
+        Size,
+        LineHeight,
+        FontWeight,
+        FontPosition,
+        IsItalic,
+        HasStrikethrough,
+        HasUnderline,
+        WrapAnywhere,
+        Fallback
+    }
+    
+    internal static class TextStyleManager
+    {
+        private static readonly ConcurrentDictionary<(TextStyle origin, TextStyleProperty property, object value), TextStyle> TextStyleMutateCache = new();
+        private static readonly ConcurrentDictionary<(TextStyle origin, TextStyle parent), TextStyle> TextStyleApplyGlobalCache = new();
+        private static readonly ConcurrentDictionary<(TextStyle origin, TextStyle parent), TextStyle> TextStyleOverrideCache = new();
+
+        public static TextStyle Mutate(this TextStyle origin, TextStyleProperty property, object value)
+        {
+            var cacheKey = (origin, property, value);
+            return TextStyleMutateCache.GetOrAdd(cacheKey, x => MutateStyle(x.origin, x.property, x.value));
+        }
+
+        private static TextStyle MutateStyle(TextStyle origin, TextStyleProperty property, object? value, bool overrideValue = true)
+        {
+            if (overrideValue && value == null)
+                return origin;
+            
+            if (property == TextStyleProperty.Color)
+            {
+                if (!overrideValue && origin.Color != null)
+                    return origin;
+                
+                var castedValue = (string?)value;
+
+                if (origin.Color == castedValue)
+                    return origin;
+
+                return origin with { Color = castedValue };
+            }
+            
+            if (property == TextStyleProperty.BackgroundColor)
+            {
+                if (!overrideValue && origin.BackgroundColor != null)
+                    return origin;
+                
+                var castedValue = (string?)value;
+                
+                if (origin.BackgroundColor == castedValue)
+                    return origin;
+
+                return origin with { BackgroundColor = castedValue };
+            }
+            
+            if (property == TextStyleProperty.FontFamily)
+            {
+                if (!overrideValue && origin.FontFamily != null)
+                    return origin;
+                
+                var castedValue = (string?)value;
+                
+                if (origin.FontFamily == castedValue)
+                    return origin;
+
+                return origin with { FontFamily = castedValue };
+            }
+            
+            if (property == TextStyleProperty.Size)
+            {
+                if (!overrideValue && origin.Size != null)
+                    return origin;
+                
+                var castedValue = (float?)value;
+                
+                if (origin.Size == castedValue)
+                    return origin;
+
+                return origin with { Size = castedValue };
+            }
+            
+            if (property == TextStyleProperty.LineHeight)
+            {
+                if (!overrideValue && origin.LineHeight != null)
+                    return origin;
+                
+                var castedValue = (float?)value;
+                
+                if (origin.LineHeight == castedValue)
+                    return origin;
+
+                return origin with { LineHeight = castedValue };
+            }
+            
+            if (property == TextStyleProperty.FontWeight)
+            {
+                if (!overrideValue && origin.FontWeight != null)
+                    return origin;
+                
+                var castedValue = (FontWeight?)value;
+                
+                if (origin.FontWeight == castedValue)
+                    return origin;
+
+                return origin with { FontWeight = castedValue };
+            }
+            
+            if (property == TextStyleProperty.FontPosition)
+            {
+                if (!overrideValue && origin.FontPosition != null)
+                    return origin;
+                
+                var castedValue = (FontPosition?)value;
+                
+                if (origin.FontPosition == castedValue)
+                    return origin;
+
+                return origin with { FontPosition = castedValue };
+            }
+            
+            if (property == TextStyleProperty.IsItalic)
+            {
+                if (!overrideValue && origin.IsItalic != null)
+                    return origin;
+                
+                var castedValue = (bool?)value;
+                
+                if (origin.IsItalic == castedValue)
+                    return origin;
+
+                return origin with { IsItalic = castedValue };
+            }
+            
+            if (property == TextStyleProperty.HasStrikethrough)
+            {
+                if (!overrideValue && origin.HasStrikethrough != null)
+                    return origin;
+                
+                var castedValue = (bool?)value;
+                
+                if (origin.HasStrikethrough == castedValue)
+                    return origin;
+
+                return origin with { HasStrikethrough = castedValue };
+            }
+            
+            if (property == TextStyleProperty.HasUnderline)
+            {
+                if (!overrideValue && origin.HasUnderline != null)
+                    return origin;
+                
+                var castedValue = (bool?)value;
+                
+                if (origin.HasUnderline == castedValue)
+                    return origin;
+
+                return origin with { HasUnderline = castedValue };
+            }
+            
+            if (property == TextStyleProperty.WrapAnywhere)
+            {
+                if (!overrideValue && origin.WrapAnywhere != null)
+                    return origin;
+
+                var castedValue = (bool?)value;
+                
+                if (origin.WrapAnywhere == castedValue)
+                    return origin;
+                
+                return origin with { WrapAnywhere = castedValue };
+            }
+            
+            if (property == TextStyleProperty.Fallback)
+            {
+                if (!overrideValue && origin.Fallback != null)
+                    return origin;
+
+                var castedValue = (TextStyle?)value;
+                
+                if (origin.Fallback == castedValue)
+                    return origin;
+                
+                return origin with { Fallback = castedValue };
+            }
+
+            throw new ArgumentOutOfRangeException(nameof(property), property, "Expected to mutate the TextStyle object. Provided property type is not supported.");
+        }
+        
+        internal static TextStyle ApplyGlobalStyle(this TextStyle style, TextStyle parent)
+        {
+            var cacheKey = (style, parent);
+            return TextStyleApplyGlobalCache.GetOrAdd(cacheKey, key => ApplyStyle(key.origin, key.parent, overrideStyle: false).ApplyFontFallback());
+        }
+        
+        private static TextStyle ApplyFontFallback(this TextStyle style)
+        {
+            var targetFallbackStyle = style
+                ?.Fallback
+                ?.ApplyStyle(style, overrideStyle: false, applyFallback: false)
+                ?.ApplyFontFallback();
+            
+            return MutateStyle(style, TextStyleProperty.Fallback, targetFallbackStyle);
+        }
+        
+        internal static TextStyle OverrideStyle(this TextStyle style, TextStyle parent)
+        {
+            var cacheKey = (style, parent);
+            
+            return TextStyleOverrideCache.GetOrAdd(cacheKey, key =>
+            {
+                var result = ApplyStyle(key.origin, key.parent);
+                return MutateStyle(result, TextStyleProperty.Fallback, key.parent.Fallback);
+            });
+        }
+        
+        private static TextStyle ApplyStyle(this TextStyle style, TextStyle parent, bool overrideStyle = true, bool applyFallback = true)
+        {
+            var result = style;
+
+            result = MutateStyle(result, TextStyleProperty.Color, parent.Color, overrideStyle);
+            result = MutateStyle(result, TextStyleProperty.BackgroundColor, parent.BackgroundColor, overrideStyle);
+            result = MutateStyle(result, TextStyleProperty.FontFamily, parent.FontFamily, overrideStyle);
+            result = MutateStyle(result, TextStyleProperty.Size, parent.Size, overrideStyle);
+            result = MutateStyle(result, TextStyleProperty.LineHeight, parent.LineHeight, overrideStyle);
+            result = MutateStyle(result, TextStyleProperty.FontWeight, parent.FontWeight, overrideStyle);
+            result = MutateStyle(result, TextStyleProperty.FontPosition, parent.FontPosition, overrideStyle);
+            result = MutateStyle(result, TextStyleProperty.IsItalic, parent.IsItalic, overrideStyle);
+            result = MutateStyle(result, TextStyleProperty.HasStrikethrough, parent.HasStrikethrough, overrideStyle);
+            result = MutateStyle(result, TextStyleProperty.HasUnderline, parent.HasUnderline, overrideStyle);
+            result = MutateStyle(result, TextStyleProperty.WrapAnywhere, parent.WrapAnywhere, overrideStyle);
+            
+            if (applyFallback)
+                result = MutateStyle(result, TextStyleProperty.Fallback, parent.Fallback, overrideStyle);
+
+            return result;
+        }
+    }
+}

+ 1 - 1
QuestPDF/Previewer/PreviewerService.cs

@@ -19,7 +19,7 @@ namespace QuestPDF.Previewer
         public  event Action? OnPreviewerStopped;
 
         private const int RequiredPreviewerVersionMajor = 2022;
-        private const int RequiredPreviewerVersionMinor = 8;
+        private const int RequiredPreviewerVersionMinor = 9;
         
         public PreviewerService(int port)
         {

+ 1 - 1
QuestPDF/QuestPDF.csproj

@@ -3,7 +3,7 @@
         <Authors>MarcinZiabek</Authors>
         <Company>CodeFlint</Company>
         <PackageId>QuestPDF</PackageId>
-        <Version>2022.8.2</Version>
+        <Version>2022.9.0-alpha1</Version>
         <PackageDescription>QuestPDF is an open-source, modern and battle-tested library that can help you with generating PDF documents by offering friendly, discoverable and predictable C# fluent API.</PackageDescription>
         <PackageReleaseNotes>$([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/Resources/ReleaseNotes.txt"))</PackageReleaseNotes>
         <LangVersion>9</LangVersion>

+ 6 - 20
QuestPDF/Resources/ReleaseNotes.txt

@@ -1,20 +1,6 @@
-2022.8.0:
-
-- Improved library performance,
-- Breaking change: changed default font from Calibri to an open-source Lato,
-- Default font files are included with the nuget package, making it safe to deploy on any environment,
-- Default font files are significantly smaller, so output document files should be smaller too (up to 20x reduction in size),
-- When requested font is not available on the runtime environment, library provides list of available fonts,
-- Fixed a rare layout overflow exception with the Inlined element,
-- Fixed a memory leak connected to the HarfBuzz library.
-
-
-2022.8.1:
-- Fixed: default text style does not always work
-- Fixed: page breaking rendering does not work in very specific corner cases
-- Stability improvements for text wrapping
-- Updated stability of rendering elements in negative space
-- Optimization for the Column element: do not measure child when available height is negative
-
-2022.8.2
-- Fixed: the Column element incorrectly renders zero-height elements.
+2022.9.0
+- Implemented font-fallback algorithm,
+- Introduced new Settings API,
+- Significantly reduced memory allocation cost for TextStyle objects,
+- Implemented optional checking if all font glyphs are available,
+- Minor text-rendering optimizations.

+ 2 - 2
QuestPDF/Settings.cs

@@ -3,7 +3,7 @@
     public static class Settings
     {
         /// <summary>
-        /// This value represents the maximum lenght of the document that the library produces.
+        /// This value represents the maximum length of the document that the library produces.
         /// This is useful when layout constraints are too strong, e.g. one element does not fit in another.
         /// In such cases, the library would produce document of infinite length, consuming all available resources.
         /// To break the algorithm and save the environment, the library breaks the rendering process after reaching specified length of document.
@@ -35,6 +35,6 @@
         /// However, it provides hints that used fonts are not sufficient to produce correct results.
         /// </summary>
         /// <remarks>By default, this flag is enabled only when the debugger IS attached.</remarks>
-        public static bool CheckIfAllTextGlyphsAreAvailableInSpecifiedFont { get; set; } = System.Diagnostics.Debugger.IsAttached;
+        public static bool CheckIfAllTextGlyphsAreAvailable { get; set; } = System.Diagnostics.Debugger.IsAttached;
     }
 }