From 0468dd5f02cd2f6abe8e5daf108a4759f464b6b9 Mon Sep 17 00:00:00 2001 From: MarcinZiabek Date: Thu, 8 Sep 2022 13:23:59 +0200 Subject: [PATCH 1/2] Font-fallback implementation --- QuestPDF.Examples/TextExamples.cs | 36 ++++++++ .../Exceptions/DocumentDrawingException.cs | 5 ++ QuestPDF/Elements/Text/FontFallback.cs | 84 +++++++++++++++++++ QuestPDF/Elements/Text/Items/TextBlockSpan.cs | 2 +- QuestPDF/Elements/Text/TextBlock.cs | 12 +++ QuestPDF/Infrastructure/TextStyle.cs | 11 +-- 6 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 QuestPDF/Elements/Text/FontFallback.cs diff --git a/QuestPDF.Examples/TextExamples.cs b/QuestPDF.Examples/TextExamples.cs index c68252a..8acda8b 100644 --- a/QuestPDF.Examples/TextExamples.cs +++ b/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,40 @@ namespace QuestPDF.Examples .FontSize(20); }); } + + [Test] + public void FontFallback() + { + RenderingTest + .Create() + .ProducePdf() + .ShowResults() + .RenderDocument(container => + { + container.Page(page => + { + page.Margin(50); + page.PageColor(Colors.White); + + 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: 😊😅🥳👍❤😍👌"); + }); + }); + }); + } } } \ No newline at end of file diff --git a/QuestPDF/Drawing/Exceptions/DocumentDrawingException.cs b/QuestPDF/Drawing/Exceptions/DocumentDrawingException.cs index a650d90..01a63bc 100644 --- a/QuestPDF/Drawing/Exceptions/DocumentDrawingException.cs +++ b/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) { diff --git a/QuestPDF/Elements/Text/FontFallback.cs b/QuestPDF/Elements/Text/FontFallback.cs new file mode 100644 index 0000000..387d794 --- /dev/null +++ b/QuestPDF/Elements/Text/FontFallback.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +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; } + } + + private static SKFontManager FontManager => SKFontManager.Default; + + public static IEnumerable SplitWithFontFallback(this string text, TextStyle textStyle) + { + var partStartIndex = 0; + var partTextStyle = textStyle; + + 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; + + if (font.ContainsGlyph(codepoint)) + 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 + }; + + partStartIndex = i; + partTextStyle = textStyle.FontFamily(fallbackTypeface.FamilyName).Weight((FontWeight)fallbackTypeface.FontWeight); + } + + if (partStartIndex > text.Length) + yield break; + + yield return new TextRun + { + Content = text.Substring(partStartIndex, text.Length - partStartIndex), + Style = partTextStyle + }; + } + + public static IEnumerable ApplyFontFallback(this ICollection textBlockItems) + { + foreach (var textBlockItem in textBlockItems) + { + if (textBlockItem is TextBlockSpan textBlockSpan) + { + 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; + } + } + } + } +} \ No newline at end of file diff --git a/QuestPDF/Elements/Text/Items/TextBlockSpan.cs b/QuestPDF/Elements/Text/Items/TextBlockSpan.cs index 033931e..701e60a 100644 --- a/QuestPDF/Elements/Text/Items/TextBlockSpan.cs +++ b/QuestPDF/Elements/Text/Items/TextBlockSpan.cs @@ -15,7 +15,7 @@ namespace QuestPDF.Elements.Text.Items { public string Text { get; set; } public TextStyle Style { get; set; } = new(); - public TextShapingResult? TextShapingResult { get; set; } + private TextShapingResult? TextShapingResult { get; set; } private Dictionary<(int startIndex, float availableWidth), TextMeasurementResult?> MeasureCache = new (); protected virtual bool EnableTextCache => true; diff --git a/QuestPDF/Elements/Text/TextBlock.cs b/QuestPDF/Elements/Text/TextBlock.cs index 6420a7d..c14f8cc 100644 --- a/QuestPDF/Elements/Text/TextBlock.cs +++ b/QuestPDF/Elements/Text/TextBlock.cs @@ -18,8 +18,11 @@ namespace QuestPDF.Elements.Text private Queue 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) diff --git a/QuestPDF/Infrastructure/TextStyle.cs b/QuestPDF/Infrastructure/TextStyle.cs index 8eb91f9..cea2719 100644 --- a/QuestPDF/Infrastructure/TextStyle.cs +++ b/QuestPDF/Infrastructure/TextStyle.cs @@ -19,12 +19,10 @@ 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; } + // 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); - // 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 { Color = Colors.Black, @@ -49,10 +47,7 @@ namespace QuestPDF.Infrastructure return; HasGlobalStyleApplied = true; - ApplyParentStyle(globalStyle); - PaintKey ??= (FontFamily, Size, FontWeight, FontPosition, IsItalic, Color); - FontMetricsKey ??= (FontFamily, Size, FontWeight, IsItalic); } internal void ApplyParentStyle(TextStyle parentStyle) From 5390fc3f1bc9d704bc16672302f59f9447d10b0c Mon Sep 17 00:00:00 2001 From: MarcinZiabek Date: Sat, 10 Sep 2022 23:27:27 +0200 Subject: [PATCH 2/2] Implemented font-fallback as required configuration --- QuestPDF.Examples/TextExamples.cs | 9 +- QuestPDF/Elements/Text/FontFallback.cs | 101 ++++++++++++++---- .../Fluent/TextSpanDescriptorExtensions.cs | 11 ++ QuestPDF/Fluent/TextStyleExtensions.cs | 10 ++ QuestPDF/Infrastructure/TextStyle.cs | 22 +++- 5 files changed, 129 insertions(+), 24 deletions(-) diff --git a/QuestPDF.Examples/TextExamples.cs b/QuestPDF.Examples/TextExamples.cs index 8acda8b..905bcb8 100644 --- a/QuestPDF.Examples/TextExamples.cs +++ b/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(); diff --git a/QuestPDF/Elements/Text/FontFallback.cs b/QuestPDF/Elements/Text/FontFallback.cs index 387d794..79caeba 100644 --- a/QuestPDF/Elements/Text/FontFallback.cs +++ b/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 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 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 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 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 ApplyFontFallback(this ICollection 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) diff --git a/QuestPDF/Fluent/TextSpanDescriptorExtensions.cs b/QuestPDF/Fluent/TextSpanDescriptorExtensions.cs index 41af136..3c8e7dc 100644 --- a/QuestPDF/Fluent/TextSpanDescriptorExtensions.cs +++ b/QuestPDF/Fluent/TextSpanDescriptorExtensions.cs @@ -15,6 +15,17 @@ namespace QuestPDF.Fluent return descriptor; } + public static T Fallback(this T descriptor, TextStyle? value = null) where T : TextSpanDescriptor + { + descriptor.TextStyle.Fallback = value; + return descriptor; + } + + public static T Fallback(this T descriptor, Func handler) where T : TextSpanDescriptor + { + return descriptor.Fallback(handler(TextStyle.Default)); + } + public static T FontColor(this T descriptor, string value) where T : TextSpanDescriptor { descriptor.TextStyle.Color = value; diff --git a/QuestPDF/Fluent/TextStyleExtensions.cs b/QuestPDF/Fluent/TextStyleExtensions.cs index 7823908..6c7396b 100644 --- a/QuestPDF/Fluent/TextStyleExtensions.cs +++ b/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 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) { diff --git a/QuestPDF/Infrastructure/TextStyle.cs b/QuestPDF/Infrastructure/TextStyle.cs index cea2719..1bb500e 100644 --- a/QuestPDF/Infrastructure/TextStyle.cs +++ b/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; } }