Parcourir la source

Merge branch 'font-fallback' into 2022.9

# Conflicts:
#	QuestPDF/Elements/Text/Items/TextBlockSpan.cs
#	QuestPDF/Fluent/TextStyleExtensions.cs
#	QuestPDF/Infrastructure/TextStyle.cs
MarcinZiabek il y a 3 ans
Parent
commit
4650c2a4ea

+ 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: 😊😅🥳👍❤😍👌");
+                        });
+                    });
+                });
+        }
     }
 }

+ 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)
         {
             

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

@@ -0,0 +1,147 @@
+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;
+                }
+                
+                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)
+        {
+            foreach (var textBlockItem in textBlockItems)
+            {
+                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)
+                    {
+                        yield return new TextBlockSpan
+                        {
+                            Text = textRun.Content,
+                            Style = textRun.Style
+                        };
+                    }
+                }
+                else
+                {
+                    yield return textBlockItem;
+                }
+            }
+        }
+    }
+}

+ 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)

+ 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.MutateTextStyle(x => x.FontColor(value));

+ 16 - 2
QuestPDF/Fluent/TextStyleExtensions.cs

@@ -4,8 +4,6 @@ using QuestPDF.Infrastructure;
 
 namespace QuestPDF.Fluent
 {
-    
-    
     public static class TextStyleExtensions
     {
         [Obsolete("This element has been renamed since version 2022.3. Please use the FontColor method.")]
@@ -131,6 +129,7 @@ namespace QuestPDF.Fluent
         #endregion
 
         #region Position
+        
         public static TextStyle NormalPosition(this TextStyle style)
         {
             return style.Position(FontPosition.Normal);
@@ -150,6 +149,21 @@ namespace QuestPDF.Fluent
         {
             return style.Mutate(TextStyleProperty.FontPosition, fontPosition);
         }
+        
+        #endregion
+
+        #region Fallback
+        
+        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));
+        }
+
         #endregion
     }
 }

+ 4 - 1
QuestPDF/Infrastructure/TextStyle.cs

@@ -17,6 +17,8 @@ namespace QuestPDF.Infrastructure
         internal bool? HasUnderline { get; set; }
         internal bool? WrapAnywhere { get; set; }
 
+        internal TextStyle? Fallback { get; set; }
+
         internal static TextStyle LibraryDefault { get; } = new()
         {
             Color = Colors.Black,
@@ -29,7 +31,8 @@ namespace QuestPDF.Infrastructure
             IsItalic = false,
             HasStrikethrough = false,
             HasUnderline = false,
-            WrapAnywhere = false
+            WrapAnywhere = false,
+            Fallback = null
         };
 
         public static TextStyle Default { get; } = new();