diff --git a/QuestPDF.Examples/TextBenchmark.cs b/QuestPDF.Examples/TextBenchmark.cs index a5d1fb9..053fbaf 100644 --- a/QuestPDF.Examples/TextBenchmark.cs +++ b/QuestPDF.Examples/TextBenchmark.cs @@ -111,7 +111,7 @@ namespace QuestPDF.Examples { page.Margin(50); - page.Content().Column(column => + page.Content().PaddingVertical(10).Column(column => { column.Item().Element(Title); column.Item().PageBreak(); 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.Previewer/QuestPDF.Previewer.csproj b/QuestPDF.Previewer/QuestPDF.Previewer.csproj index 64b7719..196a94f 100644 --- a/QuestPDF.Previewer/QuestPDF.Previewer.csproj +++ b/QuestPDF.Previewer/QuestPDF.Previewer.csproj @@ -4,7 +4,7 @@ MarcinZiabek CodeFlint QuestPDF.Previewer - 2022.8.0 + 2022.9.0 true questpdf-previewer QuestPDF is an open-source, modern and battle-tested library that can help you with generating PDF documents by offering friendly, discoverable and predictable C# fluent API. diff --git a/QuestPDF/Drawing/DocumentGenerator.cs b/QuestPDF/Drawing/DocumentGenerator.cs index c24e285..b64baac 100644 --- a/QuestPDF/Drawing/DocumentGenerator.cs +++ b/QuestPDF/Drawing/DocumentGenerator.cs @@ -172,7 +172,7 @@ namespace QuestPDF.Drawing { if (textBlockItem is TextBlockSpan textSpan) { - textSpan.Style.ApplyGlobalStyle(documentDefaultTextStyle); + textSpan.Style = textSpan.Style.ApplyGlobalStyle(documentDefaultTextStyle); } else if (textBlockItem is TextBlockElement textElement) { @@ -184,18 +184,13 @@ namespace QuestPDF.Drawing } if (content is DynamicHost dynamicHost) - dynamicHost.TextStyle.ApplyGlobalStyle(documentDefaultTextStyle); - - var targetTextStyle = documentDefaultTextStyle; + dynamicHost.TextStyle = dynamicHost.TextStyle.ApplyGlobalStyle(documentDefaultTextStyle); if (content is DefaultTextStyle defaultTextStyleElement) - { - defaultTextStyleElement.TextStyle.ApplyParentStyle(documentDefaultTextStyle); - targetTextStyle = defaultTextStyleElement.TextStyle; - } - + documentDefaultTextStyle = defaultTextStyleElement.TextStyle.ApplyGlobalStyle(documentDefaultTextStyle); + foreach (var child in content.GetChildren()) - ApplyDefaultTextStyle(child, targetTextStyle); + ApplyDefaultTextStyle(child, documentDefaultTextStyle); } } } \ 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/Drawing/FontManager.cs b/QuestPDF/Drawing/FontManager.cs index 280cca8..0f90b7f 100644 --- a/QuestPDF/Drawing/FontManager.cs +++ b/QuestPDF/Drawing/FontManager.cs @@ -14,13 +14,13 @@ namespace QuestPDF.Drawing { public static class FontManager { - private static ConcurrentDictionary StyleSets = new(); - private static ConcurrentDictionary FontMetrics = new(); - private static ConcurrentDictionary FontPaints = new(); - private static ConcurrentDictionary ColorPaints = new(); - private static ConcurrentDictionary ShaperFonts = new(); - private static ConcurrentDictionary Fonts = new(); - private static ConcurrentDictionary TextShapers = new(); + private static readonly ConcurrentDictionary StyleSets = new(); + private static readonly ConcurrentDictionary FontMetrics = new(); + private static readonly ConcurrentDictionary FontPaints = new(); + private static readonly ConcurrentDictionary ColorPaints = new(); + private static readonly ConcurrentDictionary ShaperFonts = new(); + private static readonly ConcurrentDictionary Fonts = new(); + private static readonly ConcurrentDictionary TextShapers = new(); static FontManager() { @@ -110,7 +110,7 @@ namespace QuestPDF.Drawing internal static SKPaint ToPaint(this TextStyle style) { - return FontPaints.GetOrAdd(style.PaintKey, key => Convert(style)); + return FontPaints.GetOrAdd(style, Convert); static SKPaint Convert(TextStyle style) { @@ -172,14 +172,14 @@ namespace QuestPDF.Drawing internal static SKFontMetrics ToFontMetrics(this TextStyle style) { - return FontMetrics.GetOrAdd(style.FontMetricsKey, key => style.NormalPosition().ToPaint().FontMetrics); + return FontMetrics.GetOrAdd(style, key => key.NormalPosition().ToPaint().FontMetrics); } internal static Font ToShaperFont(this TextStyle style) { - return ShaperFonts.GetOrAdd(style.PaintKey, _ => + return ShaperFonts.GetOrAdd(style, key => { - var typeface = style.ToPaint().Typeface; + var typeface = key.ToPaint().Typeface; using var harfBuzzBlob = typeface.OpenStream(out var ttcIndex).ToHarfBuzzBlob(); @@ -200,12 +200,12 @@ namespace QuestPDF.Drawing internal static TextShaper ToTextShaper(this TextStyle style) { - return TextShapers.GetOrAdd(style.PaintKey, _ => new TextShaper(style)); + return TextShapers.GetOrAdd(style, key => new TextShaper(key)); } internal static SKFont ToFont(this TextStyle style) { - return Fonts.GetOrAdd(style.PaintKey, _ => style.ToPaint().ToFont()); + return Fonts.GetOrAdd(style, key => key.ToPaint().ToFont()); } } } \ No newline at end of file diff --git a/QuestPDF/Drawing/TextMeasurement.cs b/QuestPDF/Drawing/TextMeasurement.cs deleted file mode 100644 index 36d5dd6..0000000 --- a/QuestPDF/Drawing/TextMeasurement.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace QuestPDF.Drawing -{ - internal struct TextMeasurement - { - public int LineIndex { get; set; } - public float FragmentWidth { get; set; } - } -} \ No newline at end of file diff --git a/QuestPDF/Drawing/TextShaper.cs b/QuestPDF/Drawing/TextShaper.cs index 23ac13f..509482c 100644 --- a/QuestPDF/Drawing/TextShaper.cs +++ b/QuestPDF/Drawing/TextShaper.cs @@ -55,9 +55,6 @@ namespace QuestPDF.Drawing xOffset += glyphPositions[i].XAdvance * scaleX; yOffset += glyphPositions[i].YAdvance * scaleY; } - - if (Settings.CheckIfAllTextGlyphsAreAvailableInSpecifiedFont) - CheckIfAllGlyphsAreAvailable(glyphs, text); return new TextShapingResult(glyphs); } @@ -78,20 +75,6 @@ namespace QuestPDF.Drawing else throw new NotSupportedException("TextEncoding of type GlyphId is not supported."); } - - void CheckIfAllGlyphsAreAvailable(ShapedGlyph[] glyphs, string originalText) - { - var containsMissingGlyphs = glyphs.Any(x => x.Codepoint == default); - - if (!containsMissingGlyphs) - return; - - throw new ArgumentException( - $"Detected missing font glyphs while rendering text. " + - $"This means that the document contains text with characters not present in the assigned font. " + - $"Such characters are replaced by placeholders, usually visible as empty rectangles. " + - $"Font family used: {TextStyle.FontFamily}. Issue detected in text: '{originalText}'"); - } } internal struct ShapedGlyph diff --git a/QuestPDF/Elements/Dynamic.cs b/QuestPDF/Elements/Dynamic.cs index ce8ba74..482704a 100644 --- a/QuestPDF/Elements/Dynamic.cs +++ b/QuestPDF/Elements/Dynamic.cs @@ -11,7 +11,7 @@ namespace QuestPDF.Elements private DynamicComponentProxy Child { get; } private object InitialComponentState { get; set; } - internal TextStyle TextStyle { get; } = new(); + internal TextStyle TextStyle { get; set; } = TextStyle.Default; public DynamicHost(DynamicComponentProxy child) { diff --git a/QuestPDF/Elements/Text/FontFallback.cs b/QuestPDF/Elements/Text/FontFallback.cs new file mode 100644 index 0000000..5eabe9c --- /dev/null +++ b/QuestPDF/Elements/Text/FontFallback.cs @@ -0,0 +1,150 @@ +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; + } + + throw CreateNotMatchingFontException(codepoint); + } + + static Exception CreateNotMatchingFontException(int codepoint) + { + var character = char.ConvertFromUtf32(codepoint); + var unicode = $"U-{codepoint:X4}"; + + var proposedFonts = FindFontsContainingGlyph(codepoint); + var proposedFontsFormatted = proposedFonts.Any() ? string.Join(", ", proposedFonts) : "no fonts available"; + + return 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 and not TextBlockPageNumber) + { + if (!Settings.CheckIfAllTextGlyphsAreAvailable && 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/Items/TextBlockSpan.cs b/QuestPDF/Elements/Text/Items/TextBlockSpan.cs index 033931e..4dfad0f 100644 --- a/QuestPDF/Elements/Text/Items/TextBlockSpan.cs +++ b/QuestPDF/Elements/Text/Items/TextBlockSpan.cs @@ -14,7 +14,7 @@ namespace QuestPDF.Elements.Text.Items internal class TextBlockSpan : ITextBlockItem { public string Text { get; set; } - public TextStyle Style { get; set; } = new(); + public TextStyle Style { get; set; } = TextStyle.Default; public TextShapingResult? TextShapingResult { get; set; } private Dictionary<(int startIndex, float availableWidth), TextMeasurementResult?> MeasureCache = new (); 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/TextExtensions.cs b/QuestPDF/Fluent/TextExtensions.cs index 80416a0..fba270d 100644 --- a/QuestPDF/Fluent/TextExtensions.cs +++ b/QuestPDF/Fluent/TextExtensions.cs @@ -12,11 +12,18 @@ namespace QuestPDF.Fluent { public class TextSpanDescriptor { - internal TextStyle TextStyle { get; } + internal TextStyle TextStyle = TextStyle.Default; + internal Action AssignTextStyle { get; } - internal TextSpanDescriptor(TextStyle textStyle) + internal TextSpanDescriptor(Action assignTextStyle) { - TextStyle = textStyle; + AssignTextStyle = assignTextStyle; + } + + internal void MutateTextStyle(Func handler) + { + TextStyle = handler(TextStyle); + AssignTextStyle(TextStyle); } } @@ -24,16 +31,17 @@ namespace QuestPDF.Fluent public class TextPageNumberDescriptor : TextSpanDescriptor { - internal PageNumberFormatter FormatFunction { get; private set; } = x => x?.ToString() ?? string.Empty; - - internal TextPageNumberDescriptor(TextStyle textStyle) : base(textStyle) + internal Action AssignFormatFunction { get; } + + internal TextPageNumberDescriptor(Action assignTextStyle, Action assignFormatFunction) : base(assignTextStyle) { - + AssignFormatFunction = assignFormatFunction; + AssignFormatFunction(x => x?.ToString()); } public TextPageNumberDescriptor Format(PageNumberFormatter formatter) { - FormatFunction = formatter ?? FormatFunction; + AssignFormatFunction(formatter); return this; } } @@ -41,7 +49,7 @@ namespace QuestPDF.Fluent public class TextDescriptor { private ICollection TextBlocks { get; } = new List(); - private TextStyle DefaultStyle { get; set; } = TextStyle.Default; + private TextStyle? DefaultStyle { get; set; } internal HorizontalAlignment Alignment { get; set; } = HorizontalAlignment.Left; private float Spacing { get; set; } = 0f; @@ -91,19 +99,15 @@ namespace QuestPDF.Fluent public TextSpanDescriptor Span(string? text) { - var style = DefaultStyle.Clone(); - var descriptor = new TextSpanDescriptor(style); - if (text == null) - return descriptor; + return new TextSpanDescriptor(_ => { }); var items = text .Replace("\r", string.Empty) .Split(new[] { '\n' }, StringSplitOptions.None) .Select(x => new TextBlockSpan { - Text = x, - Style = style + Text = x }) .ToList(); @@ -118,7 +122,7 @@ namespace QuestPDF.Fluent .ToList() .ForEach(TextBlocks.Add); - return descriptor; + return new TextSpanDescriptor(x => items.ForEach(y => y.Style = x)); } public TextSpanDescriptor Line(string? text) @@ -134,16 +138,10 @@ namespace QuestPDF.Fluent private TextPageNumberDescriptor PageNumber(Func pageNumber) { - var style = DefaultStyle.Clone(); - var descriptor = new TextPageNumberDescriptor(style); + var textBlockItem = new TextBlockPageNumber(); + AddItemToLastTextBlock(textBlockItem); - AddItemToLastTextBlock(new TextBlockPageNumber - { - Source = context => descriptor.FormatFunction(pageNumber(context)), - Style = style - }); - - return descriptor; + return new TextPageNumberDescriptor(x => textBlockItem.Style = x, x => textBlockItem.Source = context => x(pageNumber(context))); } public TextPageNumberDescriptor CurrentPageNumber() @@ -187,20 +185,17 @@ namespace QuestPDF.Fluent if (IsNullOrEmpty(sectionName)) throw new ArgumentException("Section name cannot be null or empty", nameof(sectionName)); - var style = DefaultStyle.Clone(); - var descriptor = new TextSpanDescriptor(style); - if (IsNullOrEmpty(text)) - return descriptor; - - AddItemToLastTextBlock(new TextBlockSectionLink + return new TextSpanDescriptor(_ => { }); + + var textBlockItem = new TextBlockSectionLink { - Style = style, Text = text, SectionName = sectionName - }); + }; - return descriptor; + AddItemToLastTextBlock(textBlockItem); + return new TextSpanDescriptor(x => textBlockItem.Style = x); } [Obsolete("This element has been renamed since version 2022.3. Please use the SectionLink method.")] @@ -214,20 +209,17 @@ namespace QuestPDF.Fluent if (IsNullOrEmpty(url)) throw new ArgumentException("Url cannot be null or empty", nameof(url)); - var style = DefaultStyle.Clone(); - var descriptor = new TextSpanDescriptor(style); - if (IsNullOrEmpty(text)) - return descriptor; + return new TextSpanDescriptor(_ => { }); - AddItemToLastTextBlock(new TextBlockHyperlink + var textBlockItem = new TextBlockHyperlink { - Style = style, Text = text, Url = url - }); + }; - return descriptor; + AddItemToLastTextBlock(textBlockItem); + return new TextSpanDescriptor(x => textBlockItem.Style = x); } [Obsolete("This element has been renamed since version 2022.3. Please use the Hyperlink method.")] @@ -251,7 +243,9 @@ namespace QuestPDF.Fluent internal void Compose(IContainer container) { TextBlocks.ToList().ForEach(x => x.Alignment = Alignment); - container = container.DefaultTextStyle(DefaultStyle); + + if (DefaultStyle != null) + container = container.DefaultTextStyle(DefaultStyle); if (TextBlocks.Count == 1) { diff --git a/QuestPDF/Fluent/TextSpanDescriptorExtensions.cs b/QuestPDF/Fluent/TextSpanDescriptorExtensions.cs index 41af136..23cda24 100644 --- a/QuestPDF/Fluent/TextSpanDescriptorExtensions.cs +++ b/QuestPDF/Fluent/TextSpanDescriptorExtensions.cs @@ -11,120 +11,135 @@ namespace QuestPDF.Fluent if (style == null) return descriptor; - descriptor.TextStyle.OverrideStyle(style); + descriptor.MutateTextStyle(x => x.OverrideStyle(style)); 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; + descriptor.MutateTextStyle(x => x.FontColor(value)); return descriptor; } public static T BackgroundColor(this T descriptor, string value) where T : TextSpanDescriptor { - descriptor.TextStyle.BackgroundColor = value; + descriptor.MutateTextStyle(x => x.BackgroundColor(value)); return descriptor; } public static T FontFamily(this T descriptor, string value) where T : TextSpanDescriptor { - descriptor.TextStyle.FontFamily = value; + descriptor.MutateTextStyle(x => x.FontFamily(value)); return descriptor; } public static T FontSize(this T descriptor, float value) where T : TextSpanDescriptor { - descriptor.TextStyle.Size = value; + descriptor.MutateTextStyle(x => x.FontSize(value)); return descriptor; } public static T LineHeight(this T descriptor, float value) where T : TextSpanDescriptor { - descriptor.TextStyle.LineHeight = value; + descriptor.MutateTextStyle(x => x.LineHeight(value)); return descriptor; } public static T Italic(this T descriptor, bool value = true) where T : TextSpanDescriptor { - descriptor.TextStyle.IsItalic = value; + descriptor.MutateTextStyle(x => x.Italic(value)); return descriptor; } public static T Strikethrough(this T descriptor, bool value = true) where T : TextSpanDescriptor { - descriptor.TextStyle.HasStrikethrough = value; + descriptor.MutateTextStyle(x => x.Strikethrough(value)); return descriptor; } public static T Underline(this T descriptor, bool value = true) where T : TextSpanDescriptor { - descriptor.TextStyle.HasUnderline = value; + descriptor.MutateTextStyle(x => x.Underline(value)); return descriptor; } public static T WrapAnywhere(this T descriptor, bool value = true) where T : TextSpanDescriptor { - descriptor.TextStyle.WrapAnywhere = value; + descriptor.MutateTextStyle(x => x.WrapAnywhere(value)); return descriptor; } #region Weight - public static T Weight(this T descriptor, FontWeight weight) where T : TextSpanDescriptor - { - descriptor.TextStyle.FontWeight = weight; - return descriptor; - } - public static T Thin(this T descriptor) where T : TextSpanDescriptor { - return descriptor.Weight(FontWeight.Thin); + descriptor.MutateTextStyle(x => x.Thin()); + return descriptor; } public static T ExtraLight(this T descriptor) where T : TextSpanDescriptor { - return descriptor.Weight(FontWeight.ExtraLight); + descriptor.MutateTextStyle(x => x.ExtraLight()); + return descriptor; } public static T Light(this T descriptor) where T : TextSpanDescriptor { - return descriptor.Weight(FontWeight.Light); + descriptor.MutateTextStyle(x => x.Light()); + return descriptor; } public static T NormalWeight(this T descriptor) where T : TextSpanDescriptor { - return descriptor.Weight(FontWeight.Normal); + descriptor.MutateTextStyle(x => x.NormalWeight()); + return descriptor; } public static T Medium(this T descriptor) where T : TextSpanDescriptor { - return descriptor.Weight(FontWeight.Medium); + descriptor.MutateTextStyle(x => x.Medium()); + return descriptor; } public static T SemiBold(this T descriptor) where T : TextSpanDescriptor { - return descriptor.Weight(FontWeight.SemiBold); + descriptor.MutateTextStyle(x => x.SemiBold()); + return descriptor; } public static T Bold(this T descriptor) where T : TextSpanDescriptor { - return descriptor.Weight(FontWeight.Bold); + descriptor.MutateTextStyle(x => x.Bold()); + return descriptor; } public static T ExtraBold(this T descriptor) where T : TextSpanDescriptor { - return descriptor.Weight(FontWeight.ExtraBold); + descriptor.MutateTextStyle(x => x.ExtraBold()); + return descriptor; } public static T Black(this T descriptor) where T : TextSpanDescriptor { - return descriptor.Weight(FontWeight.Black); + descriptor.MutateTextStyle(x => x.Black()); + return descriptor; } public static T ExtraBlack(this T descriptor) where T : TextSpanDescriptor { - return descriptor.Weight(FontWeight.ExtraBlack); + descriptor.MutateTextStyle(x => x.ExtraBlack()); + return descriptor; } #endregion @@ -132,24 +147,22 @@ namespace QuestPDF.Fluent #region Position public static T NormalPosition(this T descriptor) where T : TextSpanDescriptor { - return descriptor.Position(FontPosition.Normal); + descriptor.MutateTextStyle(x => x.NormalPosition()); + return descriptor; } public static T Subscript(this T descriptor) where T : TextSpanDescriptor { - return descriptor.Position(FontPosition.Subscript); + descriptor.MutateTextStyle(x => x.Subscript()); + return descriptor; } public static T Superscript(this T descriptor) where T : TextSpanDescriptor { - return descriptor.Position(FontPosition.Superscript); - } - - private static T Position(this T descriptor, FontPosition fontPosition) where T : TextSpanDescriptor - { - descriptor.TextStyle.FontPosition = fontPosition; + descriptor.MutateTextStyle(x => x.Superscript()); return descriptor; } + #endregion } } \ No newline at end of file diff --git a/QuestPDF/Fluent/TextStyleExtensions.cs b/QuestPDF/Fluent/TextStyleExtensions.cs index 7823908..6119f6d 100644 --- a/QuestPDF/Fluent/TextStyleExtensions.cs +++ b/QuestPDF/Fluent/TextStyleExtensions.cs @@ -6,14 +6,6 @@ namespace QuestPDF.Fluent { public static class TextStyleExtensions { - private static TextStyle Mutate(this TextStyle style, Action handler) - { - style = style.Clone(); - - handler(style); - return style; - } - [Obsolete("This element has been renamed since version 2022.3. Please use the FontColor method.")] public static TextStyle Color(this TextStyle style, string value) { @@ -22,12 +14,12 @@ namespace QuestPDF.Fluent public static TextStyle FontColor(this TextStyle style, string value) { - return style.Mutate(x => x.Color = value); + return style.Mutate(TextStyleProperty.Color, value); } public static TextStyle BackgroundColor(this TextStyle style, string value) { - return style.Mutate(x => x.BackgroundColor = value); + return style.Mutate(TextStyleProperty.BackgroundColor, value); } [Obsolete("This element has been renamed since version 2022.3. Please use the FontFamily method.")] @@ -38,7 +30,7 @@ namespace QuestPDF.Fluent public static TextStyle FontFamily(this TextStyle style, string value) { - return style.Mutate(x => x.FontFamily = value); + return style.Mutate(TextStyleProperty.FontFamily, value); } [Obsolete("This element has been renamed since version 2022.3. Please use the FontSize method.")] @@ -49,39 +41,39 @@ namespace QuestPDF.Fluent public static TextStyle FontSize(this TextStyle style, float value) { - return style.Mutate(x => x.Size = value); + return style.Mutate(TextStyleProperty.Size, value); } public static TextStyle LineHeight(this TextStyle style, float value) { - return style.Mutate(x => x.LineHeight = value); + return style.Mutate(TextStyleProperty.LineHeight, value); } public static TextStyle Italic(this TextStyle style, bool value = true) { - return style.Mutate(x => x.IsItalic = value); + return style.Mutate(TextStyleProperty.IsItalic, value); } public static TextStyle Strikethrough(this TextStyle style, bool value = true) { - return style.Mutate(x => x.HasStrikethrough = value); + return style.Mutate(TextStyleProperty.HasStrikethrough, value); } public static TextStyle Underline(this TextStyle style, bool value = true) { - return style.Mutate(x => x.HasUnderline = value); + return style.Mutate(TextStyleProperty.HasUnderline, value); } public static TextStyle WrapAnywhere(this TextStyle style, bool value = true) { - return style.Mutate(x => x.WrapAnywhere = value); + return style.Mutate(TextStyleProperty.WrapAnywhere, value); } #region Weight public static TextStyle Weight(this TextStyle style, FontWeight weight) { - return style.Mutate(x => x.FontWeight = weight); + return style.Mutate(TextStyleProperty.FontWeight, weight); } public static TextStyle Thin(this TextStyle style) @@ -137,6 +129,7 @@ namespace QuestPDF.Fluent #endregion #region Position + public static TextStyle NormalPosition(this TextStyle style) { return style.Position(FontPosition.Normal); @@ -154,11 +147,23 @@ namespace QuestPDF.Fluent private static TextStyle Position(this TextStyle style, FontPosition fontPosition) { - if (style.FontPosition == fontPosition) - return style; - - return style.Mutate(t => t.FontPosition = fontPosition); + return style.Mutate(TextStyleProperty.FontPosition, fontPosition); } + + #endregion + + #region Fallback + + public static TextStyle Fallback(this TextStyle style, TextStyle? value = null) + { + return style.Mutate(TextStyleProperty.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 8eb91f9..6f498ad 100644 --- a/QuestPDF/Infrastructure/TextStyle.cs +++ b/QuestPDF/Infrastructure/TextStyle.cs @@ -3,10 +3,8 @@ using QuestPDF.Helpers; namespace QuestPDF.Infrastructure { - public class TextStyle + public record TextStyle { - internal bool HasGlobalStyleApplied { get; private set; } - internal string? Color { get; set; } internal string? BackgroundColor { get; set; } internal string? FontFamily { get; set; } @@ -19,13 +17,9 @@ 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; } - - // 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 + internal TextStyle? Fallback { get; set; } + + internal static TextStyle LibraryDefault { get; } = new() { Color = Colors.Black, BackgroundColor = Colors.Transparent, @@ -37,59 +31,10 @@ 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 - public static TextStyle Default => new TextStyle(); - - internal void ApplyGlobalStyle(TextStyle globalStyle) - { - if (HasGlobalStyleApplied) - return; - - HasGlobalStyleApplied = true; - - ApplyParentStyle(globalStyle); - PaintKey ??= (FontFamily, Size, FontWeight, FontPosition, IsItalic, Color); - FontMetricsKey ??= (FontFamily, Size, FontWeight, IsItalic); - } - - internal void ApplyParentStyle(TextStyle parentStyle) - { - Color ??= parentStyle.Color; - BackgroundColor ??= parentStyle.BackgroundColor; - FontFamily ??= parentStyle.FontFamily; - Size ??= parentStyle.Size; - LineHeight ??= parentStyle.LineHeight; - FontWeight ??= parentStyle.FontWeight; - FontPosition ??= parentStyle.FontPosition; - IsItalic ??= parentStyle.IsItalic; - HasStrikethrough ??= parentStyle.HasStrikethrough; - HasUnderline ??= parentStyle.HasUnderline; - WrapAnywhere ??= parentStyle.WrapAnywhere; - } - - internal void OverrideStyle(TextStyle parentStyle) - { - Color = parentStyle.Color ?? Color; - BackgroundColor = parentStyle.BackgroundColor ?? BackgroundColor; - FontFamily = parentStyle.FontFamily ?? FontFamily; - Size = parentStyle.Size ?? Size; - LineHeight = parentStyle.LineHeight ?? LineHeight; - FontWeight = parentStyle.FontWeight ?? FontWeight; - FontPosition = parentStyle.FontPosition ?? FontPosition; - IsItalic = parentStyle.IsItalic ?? IsItalic; - HasStrikethrough = parentStyle.HasStrikethrough ?? HasStrikethrough; - HasUnderline = parentStyle.HasUnderline ?? HasUnderline; - WrapAnywhere = parentStyle.WrapAnywhere ?? WrapAnywhere; - } - - internal TextStyle Clone() - { - var clone = (TextStyle)MemberwiseClone(); - clone.HasGlobalStyleApplied = false; - return clone; - } + public static TextStyle Default { get; } = new(); } } \ No newline at end of file diff --git a/QuestPDF/Infrastructure/TextStyleManager.cs b/QuestPDF/Infrastructure/TextStyleManager.cs new file mode 100644 index 0000000..031c56b --- /dev/null +++ b/QuestPDF/Infrastructure/TextStyleManager.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Concurrent; +using QuestPDF.Fluent; + +namespace QuestPDF.Infrastructure +{ + internal enum TextStyleProperty + { + Color, + BackgroundColor, + FontFamily, + Size, + LineHeight, + FontWeight, + FontPosition, + IsItalic, + HasStrikethrough, + HasUnderline, + WrapAnywhere, + Fallback + } + + internal static class TextStyleManager + { + private static readonly ConcurrentDictionary<(TextStyle origin, TextStyleProperty property, object value), TextStyle> TextStyleMutateCache = new(); + private static readonly ConcurrentDictionary<(TextStyle origin, TextStyle parent), TextStyle> TextStyleApplyGlobalCache = new(); + private static readonly ConcurrentDictionary<(TextStyle origin, TextStyle parent), TextStyle> TextStyleOverrideCache = new(); + + public static TextStyle Mutate(this TextStyle origin, TextStyleProperty property, object value) + { + var cacheKey = (origin, property, value); + return TextStyleMutateCache.GetOrAdd(cacheKey, x => MutateStyle(x.origin, x.property, x.value)); + } + + private static TextStyle MutateStyle(TextStyle origin, TextStyleProperty property, object? value, bool overrideValue = true) + { + if (overrideValue && value == null) + return origin; + + if (property == TextStyleProperty.Color) + { + if (!overrideValue && origin.Color != null) + return origin; + + var castedValue = (string?)value; + + if (origin.Color == castedValue) + return origin; + + return origin with { Color = castedValue }; + } + + if (property == TextStyleProperty.BackgroundColor) + { + if (!overrideValue && origin.BackgroundColor != null) + return origin; + + var castedValue = (string?)value; + + if (origin.BackgroundColor == castedValue) + return origin; + + return origin with { BackgroundColor = castedValue }; + } + + if (property == TextStyleProperty.FontFamily) + { + if (!overrideValue && origin.FontFamily != null) + return origin; + + var castedValue = (string?)value; + + if (origin.FontFamily == castedValue) + return origin; + + return origin with { FontFamily = castedValue }; + } + + if (property == TextStyleProperty.Size) + { + if (!overrideValue && origin.Size != null) + return origin; + + var castedValue = (float?)value; + + if (origin.Size == castedValue) + return origin; + + return origin with { Size = castedValue }; + } + + if (property == TextStyleProperty.LineHeight) + { + if (!overrideValue && origin.LineHeight != null) + return origin; + + var castedValue = (float?)value; + + if (origin.LineHeight == castedValue) + return origin; + + return origin with { LineHeight = castedValue }; + } + + if (property == TextStyleProperty.FontWeight) + { + if (!overrideValue && origin.FontWeight != null) + return origin; + + var castedValue = (FontWeight?)value; + + if (origin.FontWeight == castedValue) + return origin; + + return origin with { FontWeight = castedValue }; + } + + if (property == TextStyleProperty.FontPosition) + { + if (!overrideValue && origin.FontPosition != null) + return origin; + + var castedValue = (FontPosition?)value; + + if (origin.FontPosition == castedValue) + return origin; + + return origin with { FontPosition = castedValue }; + } + + if (property == TextStyleProperty.IsItalic) + { + if (!overrideValue && origin.IsItalic != null) + return origin; + + var castedValue = (bool?)value; + + if (origin.IsItalic == castedValue) + return origin; + + return origin with { IsItalic = castedValue }; + } + + if (property == TextStyleProperty.HasStrikethrough) + { + if (!overrideValue && origin.HasStrikethrough != null) + return origin; + + var castedValue = (bool?)value; + + if (origin.HasStrikethrough == castedValue) + return origin; + + return origin with { HasStrikethrough = castedValue }; + } + + if (property == TextStyleProperty.HasUnderline) + { + if (!overrideValue && origin.HasUnderline != null) + return origin; + + var castedValue = (bool?)value; + + if (origin.HasUnderline == castedValue) + return origin; + + return origin with { HasUnderline = castedValue }; + } + + if (property == TextStyleProperty.WrapAnywhere) + { + if (!overrideValue && origin.WrapAnywhere != null) + return origin; + + var castedValue = (bool?)value; + + if (origin.WrapAnywhere == castedValue) + return origin; + + return origin with { WrapAnywhere = castedValue }; + } + + if (property == TextStyleProperty.Fallback) + { + if (!overrideValue && origin.Fallback != null) + return origin; + + var castedValue = (TextStyle?)value; + + if (origin.Fallback == castedValue) + return origin; + + return origin with { Fallback = castedValue }; + } + + throw new ArgumentOutOfRangeException(nameof(property), property, "Expected to mutate the TextStyle object. Provided property type is not supported."); + } + + internal static TextStyle ApplyGlobalStyle(this TextStyle style, TextStyle parent) + { + var cacheKey = (style, parent); + return TextStyleApplyGlobalCache.GetOrAdd(cacheKey, key => ApplyStyle(key.origin, key.parent, overrideStyle: false).ApplyFontFallback()); + } + + private static TextStyle ApplyFontFallback(this TextStyle style) + { + var targetFallbackStyle = style + ?.Fallback + ?.ApplyStyle(style, overrideStyle: false, applyFallback: false) + ?.ApplyFontFallback(); + + return MutateStyle(style, TextStyleProperty.Fallback, targetFallbackStyle); + } + + internal static TextStyle OverrideStyle(this TextStyle style, TextStyle parent) + { + var cacheKey = (style, parent); + + return TextStyleOverrideCache.GetOrAdd(cacheKey, key => + { + var result = ApplyStyle(key.origin, key.parent); + return MutateStyle(result, TextStyleProperty.Fallback, key.parent.Fallback); + }); + } + + private static TextStyle ApplyStyle(this TextStyle style, TextStyle parent, bool overrideStyle = true, bool applyFallback = true) + { + var result = style; + + result = MutateStyle(result, TextStyleProperty.Color, parent.Color, overrideStyle); + result = MutateStyle(result, TextStyleProperty.BackgroundColor, parent.BackgroundColor, overrideStyle); + result = MutateStyle(result, TextStyleProperty.FontFamily, parent.FontFamily, overrideStyle); + result = MutateStyle(result, TextStyleProperty.Size, parent.Size, overrideStyle); + result = MutateStyle(result, TextStyleProperty.LineHeight, parent.LineHeight, overrideStyle); + result = MutateStyle(result, TextStyleProperty.FontWeight, parent.FontWeight, overrideStyle); + result = MutateStyle(result, TextStyleProperty.FontPosition, parent.FontPosition, overrideStyle); + result = MutateStyle(result, TextStyleProperty.IsItalic, parent.IsItalic, overrideStyle); + result = MutateStyle(result, TextStyleProperty.HasStrikethrough, parent.HasStrikethrough, overrideStyle); + result = MutateStyle(result, TextStyleProperty.HasUnderline, parent.HasUnderline, overrideStyle); + result = MutateStyle(result, TextStyleProperty.WrapAnywhere, parent.WrapAnywhere, overrideStyle); + + if (applyFallback) + result = MutateStyle(result, TextStyleProperty.Fallback, parent.Fallback, overrideStyle); + + return result; + } + } +} \ No newline at end of file diff --git a/QuestPDF/Previewer/PreviewerService.cs b/QuestPDF/Previewer/PreviewerService.cs index f9d9c7b..2925a7c 100644 --- a/QuestPDF/Previewer/PreviewerService.cs +++ b/QuestPDF/Previewer/PreviewerService.cs @@ -19,7 +19,7 @@ namespace QuestPDF.Previewer public event Action? OnPreviewerStopped; private const int RequiredPreviewerVersionMajor = 2022; - private const int RequiredPreviewerVersionMinor = 8; + private const int RequiredPreviewerVersionMinor = 9; public PreviewerService(int port) { diff --git a/QuestPDF/QuestPDF.csproj b/QuestPDF/QuestPDF.csproj index 44a8cda..2acb10a 100644 --- a/QuestPDF/QuestPDF.csproj +++ b/QuestPDF/QuestPDF.csproj @@ -3,7 +3,7 @@ MarcinZiabek CodeFlint QuestPDF - 2022.8.2 + 2022.9.0-alpha1 QuestPDF is an open-source, modern and battle-tested library that can help you with generating PDF documents by offering friendly, discoverable and predictable C# fluent API. $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/Resources/ReleaseNotes.txt")) 9 diff --git a/QuestPDF/Resources/ReleaseNotes.txt b/QuestPDF/Resources/ReleaseNotes.txt index 8ed583b..44c7fc7 100644 --- a/QuestPDF/Resources/ReleaseNotes.txt +++ b/QuestPDF/Resources/ReleaseNotes.txt @@ -1,20 +1,6 @@ -2022.8.0: - -- Improved library performance, -- Breaking change: changed default font from Calibri to an open-source Lato, -- Default font files are included with the nuget package, making it safe to deploy on any environment, -- Default font files are significantly smaller, so output document files should be smaller too (up to 20x reduction in size), -- When requested font is not available on the runtime environment, library provides list of available fonts, -- Fixed a rare layout overflow exception with the Inlined element, -- Fixed a memory leak connected to the HarfBuzz library. - - -2022.8.1: -- Fixed: default text style does not always work -- Fixed: page breaking rendering does not work in very specific corner cases -- Stability improvements for text wrapping -- Updated stability of rendering elements in negative space -- Optimization for the Column element: do not measure child when available height is negative - -2022.8.2 -- Fixed: the Column element incorrectly renders zero-height elements. +2022.9.0 +- Implemented font-fallback algorithm, +- Introduced new Settings API, +- Significantly reduced memory allocation cost for TextStyle objects, +- Implemented optional checking if all font glyphs are available, +- Minor text-rendering optimizations. diff --git a/QuestPDF/Settings.cs b/QuestPDF/Settings.cs index 10776ee..5314c96 100644 --- a/QuestPDF/Settings.cs +++ b/QuestPDF/Settings.cs @@ -3,7 +3,7 @@ public static class Settings { /// - /// This value represents the maximum lenght of the document that the library produces. + /// This value represents the maximum length of the document that the library produces. /// This is useful when layout constraints are too strong, e.g. one element does not fit in another. /// In such cases, the library would produce document of infinite length, consuming all available resources. /// To break the algorithm and save the environment, the library breaks the rendering process after reaching specified length of document. @@ -35,6 +35,6 @@ /// However, it provides hints that used fonts are not sufficient to produce correct results. /// /// By default, this flag is enabled only when the debugger IS attached. - public static bool CheckIfAllTextGlyphsAreAvailableInSpecifiedFont { get; set; } = System.Diagnostics.Debugger.IsAttached; + public static bool CheckIfAllTextGlyphsAreAvailable { get; set; } = System.Diagnostics.Debugger.IsAttached; } } \ No newline at end of file