Browse Source

Implemented font-fallback as required configuration

MarcinZiabek 3 years ago
parent
commit
5390fc3f1b

+ 6 - 3
QuestPDF.Examples/TextExamples.cs

@@ -625,7 +625,7 @@ namespace QuestPDF.Examples
         {
             RenderingTest
                 .Create()
-                .ProducePdf()
+                .ProduceImages()
                 .ShowResults()
                 .RenderDocument(container =>
                 {
@@ -633,6 +633,9 @@ namespace QuestPDF.Examples
                     {
                         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);
 
@@ -640,11 +643,11 @@ namespace QuestPDF.Examples
                         {
                             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();

+ 82 - 19
QuestPDF/Elements/Text/FontFallback.cs

@@ -1,4 +1,6 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
+using System.Linq;
 using QuestPDF.Drawing;
 using QuestPDF.Drawing.Exceptions;
 using QuestPDF.Elements.Text.Items;
@@ -16,45 +18,99 @@ namespace QuestPDF.Elements.Text
             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 partStartIndex = 0;
-            var partTextStyle = 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 font = partTextStyle.ToFont();
-                var typeface = font.Typeface;
+                var newFallbackOption = MatchFallbackOption(fallbackOptions, codepoint);
 
-                if (font.ContainsGlyph(codepoint))
+                if (newFallbackOption == spanFallbackOption)
                     continue;
-                
-                var fallbackTypeface = FontManager.MatchCharacter(typeface.FamilyName, typeface.FontWeight, typeface.FontWidth, typeface.FontSlant, null, codepoint);
-
-                if (fallbackTypeface == null)
-                    throw new DocumentDrawingException($"Could not find an appropriate font fallback for text: '{text}'");
 
                 yield return new TextRun
                 {
-                    Content = text.Substring(partStartIndex, i - partStartIndex),
-                    Style = partTextStyle
+                    Content = text.Substring(spanStartIndex, i - spanStartIndex),
+                    Style = spanFallbackOption.Style
                 };
 
-                partStartIndex = i;
-                partTextStyle = textStyle.FontFamily(fallbackTypeface.FamilyName).Weight((FontWeight)fallbackTypeface.FontWeight);
+                spanStartIndex = i;
+                spanFallbackOption = newFallbackOption;
             }
             
-            if (partStartIndex > text.Length)
+            if (spanStartIndex > text.Length)
                 yield break;
             
             yield return new TextRun
             {
-                Content = text.Substring(partStartIndex, text.Length - partStartIndex),
-                Style = partTextStyle
+                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;
+                }
+                
+                var character = char.ConvertFromUtf32(codepoint);
+                var unicode = $"U-{codepoint:X4}";
+
+
+                var proposedFonts = FindFontsContainingGlyph(codepoint);
+                var proposedFontsFormatted = proposedFonts.Any() ? string.Join(", ", proposedFonts) : "no fonts available";
+                
+                throw 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)
@@ -63,6 +119,13 @@ namespace QuestPDF.Elements.Text
             {
                 if (textBlockItem is TextBlockSpan textBlockSpan)
                 {
+                    // perform font-fallback operation only when any fallback is available
+                    if (textBlockSpan.Style.Fallback == null)
+                    {
+                        yield return textBlockSpan;
+                        continue;
+                    }
+                    
                     var textRuns = textBlockSpan.Text.SplitWithFontFallback(textBlockSpan.Style);
 
                     foreach (var textRun in textRuns)

+ 11 - 0
QuestPDF/Fluent/TextSpanDescriptorExtensions.cs

@@ -15,6 +15,17 @@ namespace QuestPDF.Fluent
             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;

+ 10 - 0
QuestPDF/Fluent/TextStyleExtensions.cs

@@ -14,6 +14,16 @@ namespace QuestPDF.Fluent
             return style;
         }
         
+        public static TextStyle Fallback(this TextStyle style, TextStyle? value = null)
+        {
+            return style.Mutate(x => x.Fallback = value);
+        }
+        
+        public static TextStyle Fallback(this TextStyle style, Func<TextStyle, TextStyle> handler)
+        {
+            return style.Fallback(handler(TextStyle.Default));
+        }
+        
         [Obsolete("This element has been renamed since version 2022.3. Please use the FontColor method.")]
         public static TextStyle Color(this TextStyle style, string value)
         {

+ 20 - 2
QuestPDF/Infrastructure/TextStyle.cs

@@ -1,4 +1,5 @@
 using System;
+using HarfBuzzSharp;
 using QuestPDF.Helpers;
 
 namespace QuestPDF.Infrastructure
@@ -19,6 +20,8 @@ namespace QuestPDF.Infrastructure
         internal bool? HasUnderline { get; set; }
         internal bool? WrapAnywhere { get; set; }
 
+        internal TextStyle? Fallback { get; set; }
+        
         // TODO: without cache, this may be an expensive operation
         internal object PaintKey => (FontFamily, Size, FontWeight, FontPosition, IsItalic, Color);
         internal object FontMetricsKey => (FontFamily, Size, FontWeight, IsItalic);
@@ -35,7 +38,8 @@ 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
@@ -48,9 +52,18 @@ namespace QuestPDF.Infrastructure
             
             HasGlobalStyleApplied = true;
             ApplyParentStyle(globalStyle);
+
+            if (Fallback != null)
+                ApplyFallbackStyle(this);
+        }
+
+        internal void ApplyFallbackStyle(TextStyle parentStyle)
+        {
+            ApplyParentStyle(parentStyle, false);
+            Fallback?.ApplyFallbackStyle(this);
         }
         
-        internal void ApplyParentStyle(TextStyle parentStyle)
+        internal void ApplyParentStyle(TextStyle parentStyle, bool mapFallback = true)
         {
             Color ??= parentStyle.Color;
             BackgroundColor ??= parentStyle.BackgroundColor;
@@ -63,6 +76,9 @@ namespace QuestPDF.Infrastructure
             HasStrikethrough ??= parentStyle.HasStrikethrough;
             HasUnderline ??= parentStyle.HasUnderline;
             WrapAnywhere ??= parentStyle.WrapAnywhere;
+            
+            if (mapFallback)
+                Fallback ??= parentStyle.Fallback?.Clone();
         }
 
         internal void OverrideStyle(TextStyle parentStyle)
@@ -78,12 +94,14 @@ namespace QuestPDF.Infrastructure
             HasStrikethrough = parentStyle.HasStrikethrough ?? HasStrikethrough;
             HasUnderline = parentStyle.HasUnderline ?? HasUnderline;
             WrapAnywhere = parentStyle.WrapAnywhere ?? WrapAnywhere;
+            Fallback = parentStyle.Fallback?.Clone() ?? Fallback;
         }
         
         internal TextStyle Clone()
         {
             var clone = (TextStyle)MemberwiseClone();
             clone.HasGlobalStyleApplied = false;
+            clone.Fallback = Fallback?.Clone();
             return clone;
         }
     }