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)