Browse Source

Paragraph spacing and first line indentation (#891)

Marcin Ziąbek 1 year ago
parent
commit
9b36197837

+ 2 - 8
Source/QuestPDF.Examples/TextBenchmark.cs

@@ -32,7 +32,7 @@ namespace QuestPDF.Examples
         {
             var chapters = GetBookChapters().ToList();
   
-            var results = PerformTest(128).ToList();
+            var results = PerformTest(32).ToList();
  
             Console.WriteLine($"Min: {results.Min():F}");
             Console.WriteLine($"Max: {results.Max():F}");
@@ -170,13 +170,7 @@ namespace QuestPDF.Examples
                 container.Column(column =>
                 {
                     SectionTitle(column, title);
-  
-                    column.Item().Text(text =>
-                    {
-                        text.ParagraphSpacing(5);
-                        text.Span(content).Style(normalStyle);
-                    });
-                    
+                    column.Item().Text(content).ParagraphFirstLineIndentation(16).ParagraphSpacing(8).Justify();
                     column.Item().PageBreak();
                 });
             }

+ 1 - 0
Source/QuestPDF/Elements/Text/Items/TextBlockElement.cs

@@ -9,6 +9,7 @@ namespace QuestPDF.Elements.Text.Items
         public Element Element { get; set; } = Empty.Instance;
         public Size ElementSize { get; set; } = Size.Zero;
         public TextInjectedElementAlignment Alignment { get; set; } = TextInjectedElementAlignment.AboveBaseline;
+        public int ParagraphBlockIndex { get; set; }
 
         public void ConfigureElement(IPageContext pageContext, ICanvas canvas)
         {

+ 13 - 0
Source/QuestPDF/Elements/Text/Items/TextBlockParagraphSpacing.cs

@@ -0,0 +1,13 @@
+namespace QuestPDF.Elements.Text.Items;
+
+internal class TextBlockParagraphSpacing : ITextBlockItem
+{
+    public float Width { get; }
+    public float Height { get; }
+
+    public TextBlockParagraphSpacing(float width, float height)
+    {
+        Width = width;
+        Height = height;
+    }
+}

+ 108 - 9
Source/QuestPDF/Elements/Text/TextBlock.cs

@@ -16,14 +16,20 @@ namespace QuestPDF.Elements.Text
         public ContentDirection ContentDirection { get; set; }
         
         public TextHorizontalAlignment? Alignment { get; set; }
+        
         public int? LineClamp { get; set; }
-        public string LineClampEllipsis { get; set; }
+         public string LineClampEllipsis { get; set; }
+
+        public float ParagraphSpacing { get; set; }
+        public float ParagraphFirstLineIndentation { get; set; }
+
         public List<ITextBlockItem> Items { get; set; } = new();
 
         private SkParagraph Paragraph { get; set; }
         
         private bool RebuildParagraphForEveryPage { get; set; }
         private bool AreParagraphMetricsValid { get; set; }
+        private bool AreParagraphItemsTransformedWithSpacingAndIndentation { get; set; }
         
         private SkSize[] LineMetrics { get; set; }
         private float WidthForLineMetricsCalculation { get; set; }
@@ -152,14 +158,11 @@ namespace QuestPDF.Elements.Text
 
             void DrawInjectedElements()
             {
-                var elementItems = Items.OfType<TextBlockElement>().ToArray();
-                
-                for (var placeholderIndex = 0; placeholderIndex < PlaceholderPositions.Length; placeholderIndex++)
+                foreach (var textBlockElement in Items.OfType<TextBlockElement>())
                 {
-                    var placeholder = PlaceholderPositions[placeholderIndex];
-                    var associatedElement = elementItems[placeholderIndex];
+                    var placeholder = PlaceholderPositions[textBlockElement.ParagraphBlockIndex];
                     
-                    associatedElement.ConfigureElement(PageContext, Canvas);
+                    textBlockElement.ConfigureElement(PageContext, Canvas);
 
                     var offset = new Position(placeholder.Left, placeholder.Top);
                     
@@ -167,7 +170,7 @@ namespace QuestPDF.Elements.Text
                         continue;
                     
                     Canvas.Translate(offset);
-                    associatedElement.Element.Draw(new Size(placeholder.Width, placeholder.Height));
+                    textBlockElement.Element.Draw(new Size(placeholder.Width, placeholder.Height));
                     Canvas.Translate(offset.Reverse());
                 }
             }
@@ -223,6 +226,12 @@ namespace QuestPDF.Elements.Text
         {
             if (Paragraph != null && !RebuildParagraphForEveryPage)
                 return;
+
+            if (!AreParagraphItemsTransformedWithSpacingAndIndentation)
+            {
+                Items = ApplyParagraphSpacingToTextBlockItems().ToList();
+                AreParagraphItemsTransformedWithSpacingAndIndentation = true;
+            }
             
             RebuildParagraphForEveryPage = Items.Any(x => x is TextBlockPageNumber);
             BuildParagraph();
@@ -292,6 +301,7 @@ namespace QuestPDF.Elements.Text
             SkParagraph CreateParagraph(SkParagraphBuilder builder)
             {
                 var currentTextIndex = 0;
+                var currentBlockIndex = 0;
             
                 foreach (var textBlockItem in Items)
                 {
@@ -315,6 +325,7 @@ namespace QuestPDF.Elements.Text
                     {
                         textBlockElement.ConfigureElement(PageContext, Canvas);
                         textBlockElement.UpdateElementSize();
+                        textBlockElement.ParagraphBlockIndex = currentBlockIndex;
                     
                         builder.AddPlaceholder(new SkPlaceholderStyle
                         {
@@ -326,13 +337,101 @@ namespace QuestPDF.Elements.Text
                         });
 
                         currentTextIndex++;
+                        currentBlockIndex++;
+                    }
+                    else if (textBlockItem is TextBlockParagraphSpacing spacing)
+                    {
+                        builder.AddPlaceholder(new SkPlaceholderStyle
+                        {
+                            Width = spacing.Width,
+                            Height = spacing.Height,
+                            Alignment = SkPlaceholderStyle.PlaceholderAlignment.Bottom,
+                            Baseline = SkPlaceholderStyle.PlaceholderBaseline.Alphabetic,
+                            BaselineOffset = 0
+                        });
+
+                        currentTextIndex++;
+                        currentBlockIndex++;
                     }
                 }
 
                 return builder.CreateParagraph();
             }
         }
-        
+
+        private IEnumerable<ITextBlockItem> ApplyParagraphSpacingToTextBlockItems()
+        {
+            if (ParagraphSpacing < Size.Epsilon && ParagraphFirstLineIndentation < Size.Epsilon)
+                return Items;
+            
+            var result = new List<ITextBlockItem>();
+            AddParagraphFirstLineIndentation();
+            
+            foreach (var textBlockItem in Items)
+            {
+                if (textBlockItem is not TextBlockSpan textBlockSpan)
+                {
+                    result.Add(textBlockItem);
+                    continue;
+                }
+                
+                var textFragments = textBlockSpan.Text.Split('\n');
+                    
+                foreach (var textFragment in textFragments)
+                {
+                    AddClonedTextBlockSpanWithTextFragment(textBlockSpan, textFragment);
+                        
+                    if (textFragment == textFragments.Last())
+                        continue;
+
+                    AddParagraphSpacing();
+                    AddParagraphFirstLineIndentation();
+                }
+            }
+
+            return result;
+
+            void AddClonedTextBlockSpanWithTextFragment(TextBlockSpan originalSpan, string textFragment)
+            {
+                TextBlockSpan newItem;
+                        
+                if (originalSpan is TextBlockSectionLink textBlockSectionLink)
+                    newItem = new TextBlockSectionLink { SectionName = textBlockSectionLink.SectionName };
+            
+                else if (originalSpan is TextBlockHyperlink textBlockHyperlink)
+                    newItem = new TextBlockHyperlink { Url = textBlockHyperlink.Url };
+            
+                else if (originalSpan is TextBlockPageNumber textBlockPageNumber)
+                    newItem = textBlockPageNumber;
+
+                else
+                    newItem = new TextBlockSpan();
+
+                newItem.Text = textFragment;
+                newItem.Style = originalSpan.Style;
+                
+                result.Add(newItem);
+            }
+            
+            void AddParagraphSpacing()
+            {
+                if (ParagraphSpacing <= Size.Epsilon)
+                    return;
+                
+                result.Add(new TextBlockSpan() { Text = "\n ", Style = TextStyle.ParagraphSpacing }); // space ensures proper line spacing
+                result.Add(new TextBlockParagraphSpacing(0, ParagraphSpacing));
+            }
+            
+            void AddParagraphFirstLineIndentation()
+            {
+                if (ParagraphFirstLineIndentation <= Size.Epsilon)
+                    return;
+                
+                result.Add(new TextBlockSpan() { Text = "\n", Style = TextStyle.ParagraphSpacing });
+                result.Add(new TextBlockParagraphSpacing(ParagraphFirstLineIndentation, 0));
+            }
+        }
+
         private void CalculateParagraphMetrics(Size availableSpace)
         {
             // SkParagraph seems to require a bigger space buffer to calculate metrics correctly

+ 33 - 1
Source/QuestPDF/Fluent/TextExtensions.cs

@@ -117,6 +117,26 @@ namespace QuestPDF.Fluent
             TextBlock.LineClampEllipsis = ellipsis;
             return this;
         }
+        
+        /// <include file='../Resources/Documentation.xml' path='documentation/doc[@for="text.paragraph.spacing"]/*' />
+        public TextBlockDescriptor ParagraphSpacing(float value, Unit unit = Unit.Point)
+        {
+            if (value < 0)
+                throw new ArgumentException("Paragraph spacing must be greater or equal to zero", nameof(value));
+            
+            TextBlock.ParagraphSpacing = value.ToPoints(unit);
+            return this;
+        }
+
+        /// <include file='../Resources/Documentation.xml' path='documentation/doc[@for="text.paragraph.firstLineIndentation"]/*' />
+        public TextBlockDescriptor ParagraphFirstLineIndentation(float value, Unit unit = Unit.Point)
+        {
+            if (value < 0)
+                throw new ArgumentException("Paragraph indentation must be greater or equal to zero", nameof(value));
+            
+            TextBlock.ParagraphFirstLineIndentation = value.ToPoints(unit);
+            return this;
+        }
     }
     
     public class TextDescriptor
@@ -187,10 +207,22 @@ namespace QuestPDF.Fluent
             TextBlock.LineClampEllipsis = ellipsis;
         }
         
-        [Obsolete("This method is not supported since the 2024.3 version. Please split your text into separate paragraphs, combine using the Column element that also provides the Spacing capability.")]
+        /// <include file='../Resources/Documentation.xml' path='documentation/doc[@for="text.paragraph.spacing"]/*' />
         public void ParagraphSpacing(float value, Unit unit = Unit.Point)
         {
+            if (value < 0)
+                throw new ArgumentException("Paragraph spacing must be greater or equal to zero", nameof(value));
+            
+            TextBlock.ParagraphSpacing = value.ToPoints(unit);
+        }
+
+        /// <include file='../Resources/Documentation.xml' path='documentation/doc[@for="text.paragraph.firstLineIndentation"]/*' />
+        public void ParagraphFirstLineIndentation(float value, Unit unit = Unit.Point)
+        {
+            if (value < 0)
+                throw new ArgumentException("Paragraph indentation must be greater or equal to zero", nameof(value));
             
+            TextBlock.ParagraphFirstLineIndentation = value.ToPoints(unit);
         }
 
         [Obsolete("This element has been renamed since version 2022.3. Please use the overload that returns a TextSpanDescriptor object which allows to specify text style.")]

+ 7 - 0
Source/QuestPDF/Infrastructure/TextStyle.cs

@@ -57,6 +57,13 @@ namespace QuestPDF.Infrastructure
             DecorationThickness = 2f,
             Direction = TextDirection.Auto
         };
+        
+        internal static TextStyle ParagraphSpacing { get; } = LibraryDefault with
+        {
+            Id = 2,
+            Size = 0,
+            LineHeight = 1
+        };
 
         private SkTextStyle? SkTextStyleCache;
         

+ 2 - 1
Source/QuestPDF/Infrastructure/TextStyleManager.cs

@@ -71,7 +71,8 @@ namespace QuestPDF.Infrastructure
         private static readonly List<TextStyle> TextStyles = new()
         {
             TextStyle.Default,
-            TextStyle.LibraryDefault
+            TextStyle.LibraryDefault,
+            TextStyle.ParagraphSpacing
         };
 
         private static readonly ConcurrentDictionary<(int originId, TextStyleProperty property, object value), TextStyle> TextStyleMutateCache = new();

+ 12 - 0
Source/QuestPDF/Resources/Documentation.xml

@@ -279,6 +279,18 @@
             <para>A value of 0 maintains the original spacing. When the value is positive, the text is more spread out. When it is negative, the text is more condensed.</para>
         </param>
     </doc>
+
+    <doc for="text.paragraph.spacing">
+        <summary>
+            Adjusts the gap between successive paragraphs (separated by line breaks).
+        </summary>
+    </doc>
+
+    <doc for="text.paragraph.firstLineIndentation">
+        <summary>
+            Adjusts the indentation of the first line in each paragraph.
+        </summary>
+    </doc>
     
     <!-- TEXT EFFECTS -->