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; } }