diff --git a/QuestPDF.Examples/TextExamples.cs b/QuestPDF.Examples/TextExamples.cs index c68252a..905bcb8 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,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: 😊😅🥳👍❤😍👌"); + }); + }); + }); + } } } \ 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..79caeba --- /dev/null +++ b/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 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 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) + { + 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; + } + } + } + } +} \ No newline at end of file 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/Fluent/TextSpanDescriptorExtensions.cs b/QuestPDF/Fluent/TextSpanDescriptorExtensions.cs index 5bc12e8..23cda24 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.MutateTextStyle(x => x.FontColor(value)); diff --git a/QuestPDF/Fluent/TextStyleExtensions.cs b/QuestPDF/Fluent/TextStyleExtensions.cs index e573982..723cec5 100644 --- a/QuestPDF/Fluent/TextStyleExtensions.cs +++ b/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 handler) + { + return style.Fallback(handler(TextStyle.Default)); + } + #endregion } } \ No newline at end of file diff --git a/QuestPDF/Infrastructure/TextStyle.cs b/QuestPDF/Infrastructure/TextStyle.cs index 2a49d9a..6f498ad 100644 --- a/QuestPDF/Infrastructure/TextStyle.cs +++ b/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();