Browse Source

Feat: enhanced layout issue debugging experience (initial implementation)

Marcin Ziąbek 1 year ago
parent
commit
63c87b3fb6

+ 16 - 0
Source/QuestPDF/CompatibilitySuppressions.xml

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- https://learn.microsoft.com/en-us/dotnet/fundamentals/package-validation/diagnostic-ids -->
+<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
+  <Suppression>
+    <DiagnosticId>CP0002</DiagnosticId>
+    <Target>M:QuestPDF.Fluent.ElementExtensions.Element(QuestPDF.Infrastructure.IContainer,System.Action{QuestPDF.Infrastructure.IContainer},System.String,System.String,System.Int32)</Target>
+    <Left>lib/netstandard2.0/QuestPDF.dll</Left>
+    <Right>lib/net6.0/QuestPDF.dll</Right>
+  </Suppression>
+  <Suppression>
+    <DiagnosticId>CP0002</DiagnosticId>
+    <Target>M:QuestPDF.Fluent.ElementExtensions.Element(QuestPDF.Infrastructure.IContainer,System.Func{QuestPDF.Infrastructure.IContainer,QuestPDF.Infrastructure.IContainer},System.String,System.String,System.Int32)</Target>
+    <Left>lib/netstandard2.0/QuestPDF.dll</Left>
+    <Right>lib/net6.0/QuestPDF.dll</Right>
+  </Suppression>
+</Suppressions>

+ 2 - 0
Source/QuestPDF/Drawing/DocumentContainer.cs

@@ -21,6 +21,8 @@ namespace QuestPDF.Drawing
             {
                 if (Pages.Count == 0)
                     return;
+
+                container = container.DebugPointer(DebugPointerType.LayoutStructure, "Document");
                 
                 if (Pages.Count == 1)
                 {

+ 23 - 0
Source/QuestPDF/Drawing/DocumentGenerator.cs

@@ -243,6 +243,29 @@ namespace QuestPDF.Drawing
             
             void ThrowLayoutException()
             {
+                content.RemoveExistingProxies();
+                content.ApplyLayoutOverflowDetection();
+                content.Measure(Size.Max);
+                
+                var overflowState = content.ExtractElementsOfType<OverflowDebuggingProxy>().FirstOrDefault();
+                overflowState.CaptureOriginalValues();
+                overflowState.ApplyLayoutOverflowVisualization();
+
+                var rootCause = overflowState.FindLayoutOverflowVisualization();
+                
+                var stack = rootCause
+                    .ExtractAncestors()
+                    .Select(x => x.Value)
+                    .Reverse()
+                    .FormatAncestors();
+
+                var inside = rootCause
+                    .ExtractAncestors()
+                    .First(x => x.Value.Child is SourceCodePointer)
+                    .Children
+                    .First()
+                    .FormatLayoutSubtree();
+                
                 var newLine = "\n";
                 var newParagraph = newLine + newLine;
                     

+ 174 - 20
Source/QuestPDF/Drawing/Proxy/Helpers.cs

@@ -1,3 +1,6 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
 using System.Linq;
 using System.Text;
 using QuestPDF.Elements;
@@ -54,24 +57,27 @@ internal static class Helpers
         
         void Traverse(TreeNode<OverflowDebuggingProxy> element)
         {
+            if (element.Value.MeasurementSize is null)
+                return;
+            
             // before assessing the element,
             // reset layout state by measuring the element with original space
             // in case when parent has altered the layout state with different overflow test
-            element.Value.Measure(element.Value.MeasurementSize);
+            element.Value.Measure(element.Value.MeasurementSize.Value);
             
             // element was not part of the current layout measurement,
             // it could not impact the process
-            if (element.Value.SpacePlanType is null)
+            if (element.Value.SpacePlan is null)
                 return;
             
             // element renders fully,
             // it could not impact the process
-            if (element.Value.SpacePlanType is SpacePlanType.FullRender)
+            if (element.Value.SpacePlan?.Type is SpacePlanType.FullRender)
                 return;
 
             // when element is partially rendering, it likely has no issues,
             // however, in certain cases, it may contain a child that is a root cause
-            if (element.Value.SpacePlanType is SpacePlanType.PartialRender)
+            if (element.Value.SpacePlan?.Type is SpacePlanType.PartialRender)
             {
                 foreach (var child in element.Children)
                     Traverse(child);
@@ -85,7 +91,7 @@ internal static class Helpers
             // strategy
             // element does not contain any wrapping elements, no obvious root causes,
             // if it renders fully with extended space, it is a layout root cause
-            if (element.Children.All(x => x.Value.SpacePlanType is not SpacePlanType.Wrap) && MeasureElementWithExtendedSpace() is SpacePlanType.FullRender)
+            if (element.Children.All(x => x.Value.SpacePlan?.Type is not SpacePlanType.Wrap) && MeasureElementWithExtendedSpace() is SpacePlanType.FullRender)
             {
                 // so apply the layout overflow proxy
                 element.Value.CreateProxy(x => new LayoutOverflowVisualization { Child = x });
@@ -95,12 +101,12 @@ internal static class Helpers
             // every time a measurement is made, the layout state is mutated
             // the previous strategy could modify the layout state
             // reset layout state by measuring the element with original space
-            element.Value.Measure(element.Value.MeasurementSize); 
+            element.Value.Measure(element.Value.MeasurementSize.Value); 
             
             // strategy:
             // element contains wrapping children, they are likely the root cause,
             // traverse them and attempt to fix them
-            foreach (var child in element.Children.Where(x => x.Value.SpacePlanType is SpacePlanType.Wrap))
+            foreach (var child in element.Children.Where(x => x.Value.SpacePlan?.Type is SpacePlanType.Wrap))
                 Traverse(child);
                 
             // check if fixing wrapping children helped
@@ -108,12 +114,12 @@ internal static class Helpers
                 return;
 
             // reset layout state by measuring the element with original space
-            element.Value.Measure(element.Value.MeasurementSize); // reset state
+            element.Value.Measure(element.Value.MeasurementSize.Value); // reset state
             
             // strategy:
             // element has layout issues but no obvious/trivial root causes
             // possibly the problem is in nested children of partial rendering children
-            foreach (var child in element.Children.Where(x => x.Value.SpacePlanType is SpacePlanType.PartialRender))
+            foreach (var child in element.Children.Where(x => x.Value.SpacePlan?.Type is SpacePlanType.PartialRender))
                 Traverse(child);
                 
             // check if fixing partial children helped
@@ -126,7 +132,7 @@ internal static class Helpers
 
             SpacePlanType MeasureElementWithExtendedSpace()
             {
-                return element.Value.TryMeasureWithOverflow(element.Value.MeasurementSize).Type;
+                return element.Value.TryMeasureWithOverflow(element.Value.MeasurementSize.Value).Type;
             }
         }
     }
@@ -139,31 +145,179 @@ internal static class Helpers
         });
     }
 
-    public static string HierarchyToString(this Element root)
+    public static void CaptureOriginalValues(this TreeNode<OverflowDebuggingProxy> parent)
+    {
+        parent.Value.CaptureOriginalValues();
+            
+        foreach (var child in parent.Children)
+            CaptureOriginalValues(child);
+    }
+
+    public static TreeNode<OverflowDebuggingProxy>? FindLayoutOverflowVisualization(this TreeNode<OverflowDebuggingProxy> element)
+    {
+        TreeNode<OverflowDebuggingProxy> result = null;
+        Traverse(element);
+        return result;
+        
+        void Traverse(TreeNode<OverflowDebuggingProxy> currentElement)
+        {
+            if (currentElement.Value.Child is LayoutOverflowVisualization)
+            {
+                result = currentElement;
+                return;
+            }
+
+            foreach (var child in currentElement.Children)
+                Traverse(child);
+        }
+    }
+    
+    public static ICollection<TreeNode<OverflowDebuggingProxy>> ExtractAncestors(this TreeNode<OverflowDebuggingProxy> element)
+    {
+        var parent = element;
+        var result = new List<TreeNode<OverflowDebuggingProxy>>();
+        
+        while (parent is not null)
+        {
+            result.Add(parent);
+            parent = parent.Parent;
+        }
+
+        return result;
+    }
+    
+    public static string FormatAncestors(this IEnumerable<OverflowDebuggingProxy> ancestors)
     {
-        var indentationCache = Enumerable.Range(0, 128).Select(x => new string(' ', x)).ToArray();
+        var result = new StringBuilder();
+        
+        foreach (var ancestor in ancestors)
+            Format(ancestor);
+        
+        return result.ToString();
+
+        void Format(OverflowDebuggingProxy node)
+        {
+            if (node.Child is DebugPointer debugPointer)
+            {
+                result.AppendLine($"-> {debugPointer.Label}");
+            }
+            else if (node.Child is SourceCodePointer sourceCodePointer)
+            {
+                result.AppendLine($"-> In method:   {sourceCodePointer.HandlerName}");
+                result.AppendLine($"   Called from: {sourceCodePointer.ParentName}");
+                result.AppendLine($"   Source path: {sourceCodePointer.SourceFilePath}");
+                result.AppendLine($"   Line number: {sourceCodePointer.SourceLineNumber}");
+            }
+            else
+            {
+                return;
+            }
+            
+            result.AppendLine();
+        }
+    }
+    
+    public static string FormatLayoutSubtree(this TreeNode<OverflowDebuggingProxy> root)
+    {
+        var indentationCache = Enumerable.Range(0, 128).Select(x => x * 3).Select(x => new string(' ', x)).ToArray();
         
         var indentationLevel = 0;
         var result = new StringBuilder();
         
         Traverse(root);
-
-        return result.ToString();
         
-        void Traverse(Element parent)
+        return result.ToString();
+
+        void Traverse(TreeNode<OverflowDebuggingProxy> parent)
         {
-            var elementName = (parent as DebugPointer)?.Target ?? parent.GetType().Name;
+            var proxy = parent.Value;
+
+            if (proxy.Child is Container)
+            {
+                Traverse(parent.Children.First());
+                return;
+            }
+            
+            if (proxy.OriginalMeasurementSize is null || proxy.OriginalSpacePlan is null)
+                return;
+            
+            var indent = indentationCache[indentationLevel];
+            
+            foreach (var content in Format(proxy))
+                result.AppendLine($"{indent} {content}");
             
             result.AppendLine();
-            result.Append(indentationCache[indentationLevel]);
-            result.Append(elementName);
-
+            result.AppendLine();
+            
             indentationLevel++;
             
-            foreach (var child in parent.GetChildren())
+            foreach (var child in parent.Children)
                 Traverse(child);
 
             indentationLevel--;
         }
+
+        static IEnumerable<string> Format(OverflowDebuggingProxy proxy)
+        {
+            var child = proxy.Child;
+            
+            if (child is LayoutOverflowVisualization layoutOverflowVisualization)
+                child = layoutOverflowVisualization.Child;
+            
+            yield return $"{SpacePlanDot()} {child.GetType().Name}";
+            
+            yield return new string('=', child.GetType().Name.Length + 4);
+            
+            yield return $"Available Space: {proxy.OriginalMeasurementSize}";
+            yield return $"Space Plan: {proxy.OriginalSpacePlan}";
+            
+            yield return new string('-', child.GetType().Name.Length + 4);
+            
+            foreach (var configuration in GetElementConfiguration(child))
+                yield return $"{configuration}";
+            
+            string SpacePlanDot()
+            {
+                return proxy.OriginalSpacePlan.Value.Type switch
+                {
+                    SpacePlanType.Wrap => "🔴",
+                    SpacePlanType.PartialRender => "🟡",
+                    SpacePlanType.FullRender => "🟢",
+                    _ => "-"
+                };
+            }
+        }
+        
+        static IEnumerable<string> GetElementConfiguration(IElement element)
+        {
+            if (element is DebugPointer)
+                return Enumerable.Empty<string>();
+                
+            return element
+                .GetType()
+                .GetProperties()
+                .Select(x => new
+                {
+                    Property = x.Name.PrettifyName(),
+                    Value = x.GetValue(element)
+                })
+                .Where(x => !(x.Value is IElement))
+                .Where(x => x.Value is string || !(x.Value is IEnumerable))
+                .Where(x => !(x.Value is TextStyle))
+                .Select(x => $"{x.Property}: {FormatValue(x.Value)}");
+
+            string FormatValue(object value)
+            {
+                const int maxLength = 100;
+                    
+                var text = value?.ToString() ?? "-";
+
+                if (text.Length < maxLength)
+                    return text;
+
+                return text.Substring(0, maxLength) + "...";
+                //return text.AsSpan(0, maxLength).ToString() + "...";
+            }
+        }
     }
 }

+ 12 - 3
Source/QuestPDF/Drawing/Proxy/OverflowDebuggingProxy.cs

@@ -4,8 +4,11 @@ namespace QuestPDF.Drawing.Proxy;
 
 internal class OverflowDebuggingProxy : ElementProxy
 {
-    public Size MeasurementSize { get; private set; }
-    public SpacePlanType? SpacePlanType { get; private set; }
+    public Size? OriginalMeasurementSize { get; private set; }
+    public Size? MeasurementSize { get; private set; }
+    
+    public SpacePlan? OriginalSpacePlan { get; private set; }
+    public SpacePlan? SpacePlan { get; private set; }
 
     public OverflowDebuggingProxy(Element child)
     {
@@ -17,8 +20,14 @@ internal class OverflowDebuggingProxy : ElementProxy
         var spacePlan = Child.Measure(availableSpace);
 
         MeasurementSize = availableSpace;
-        SpacePlanType = spacePlan.Type;
+        SpacePlan = spacePlan;
         
         return spacePlan;
     }
+
+    internal void CaptureOriginalValues()
+    {
+        OriginalMeasurementSize = MeasurementSize;
+        OriginalSpacePlan = SpacePlan;
+    }
 }

+ 5 - 1
Source/QuestPDF/Drawing/Proxy/TreeTraversal.cs

@@ -7,6 +7,7 @@ namespace QuestPDF.Drawing.Proxy;
 internal class TreeNode<T>
 {
     public T Value { get; }
+    public TreeNode<T>? Parent { get; set; }
     public ICollection<TreeNode<T>> Children { get; } = new List<TreeNode<T>>();
     
     public TreeNode(T Value)
@@ -22,9 +23,12 @@ internal static class TreeTraversal
         if (element is T proxy)
         {
             var result = new TreeNode<T>(proxy);
-                
+
             foreach (var treeNode in proxy.GetChildren().SelectMany(ExtractElementsOfType<T>))
+            {
                 result.Children.Add(treeNode);
+                treeNode.Parent = result;
+            }
                 
             yield return result;
         }

+ 9 - 2
Source/QuestPDF/Elements/DebugPointer.cs

@@ -1,8 +1,15 @@
 namespace QuestPDF.Elements
 {
+    internal enum DebugPointerType
+    {
+        LayoutStructure,
+        Component,
+        UserDefined
+    }
+    
     internal sealed class DebugPointer : Container
     {
-        public string Target { get; set; }
-        public bool Highlight { get; set; }
+        public DebugPointerType Type { get; set; }
+        public string Label { get; set; }
     }
 }

+ 7 - 7
Source/QuestPDF/Elements/Page.cs

@@ -37,7 +37,7 @@ namespace QuestPDF.Elements
                 {
                     layers
                         .Layer()
-                        .DebugPointer("Page background layer")
+                        .DebugPointer(DebugPointerType.LayoutStructure, "Page background layer")
                         .Repeat()
                         .Element(Background);
                     
@@ -58,25 +58,25 @@ namespace QuestPDF.Elements
                         {
                             decoration
                                 .Before()
-                                .DebugPointer("Page header")
+                                .DebugPointer(DebugPointerType.LayoutStructure, "Page header")
                                 .Element(Header);
 
                             decoration
                                 .Content()
-                                .Element(x => IsClose(MinSize.Width, MaxSize.Width) ? x.ExtendHorizontal() : x)
-                                .Element(x => IsClose(MinSize.Height, MaxSize.Height) ? x.ExtendVertical() : x)
-                                .DebugPointer("Page content")
+                                .NonTrackingElement(x => IsClose(MinSize.Width, MaxSize.Width) ? x.ExtendHorizontal() : x)
+                                .NonTrackingElement(x => IsClose(MinSize.Height, MaxSize.Height) ? x.ExtendVertical() : x)
+                                .DebugPointer(DebugPointerType.LayoutStructure, "Page content")
                                 .Element(Content);
 
                             decoration
                                 .After()
-                                .DebugPointer("Page footer")
+                                .DebugPointer(DebugPointerType.LayoutStructure, "Page footer")
                                 .Element(Footer);
                         });
                     
                     layers
                         .Layer()
-                        .DebugPointer("Page foreground layer")
+                        .DebugPointer(DebugPointerType.LayoutStructure, "Page foreground layer")
                         .Repeat()
                         .Element(Foreground);
                 });

+ 11 - 0
Source/QuestPDF/Elements/SourceCodePointer.cs

@@ -0,0 +1,11 @@
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.Elements;
+
+internal class SourceCodePointer : ContainerElement
+{
+    public string HandlerName { get; set; }
+    public string ParentName { get; set; }
+    public string SourceFilePath { get; set; }
+    public int SourceLineNumber { get; set; }
+}

+ 6 - 4
Source/QuestPDF/Fluent/ComponentExtentions.cs

@@ -1,6 +1,7 @@
 using System;
 using System.Linq.Expressions;
 using QuestPDF.Drawing.Exceptions;
+using QuestPDF.Elements;
 using QuestPDF.Helpers;
 using QuestPDF.Infrastructure;
 
@@ -59,10 +60,11 @@ namespace QuestPDF.Fluent
             var descriptor = new ComponentDescriptor<T>(component);
             handler?.Invoke(descriptor);
 
-            if (System.Diagnostics.Debugger.IsAttached)
-                element = element.DebugPointer(component.GetType().Name, highlight: false);
-
-            component.Compose(element.Container());
+            var componentContainer = element
+                .Container()
+                .DebugPointer(DebugPointerType.Component, component.GetType().Name);
+            
+            component.Compose(componentContainer);
         }
         
         static void Component<T>(this IContainer element, Action<ComponentDescriptor<T>>? handler = null) where T : IComponent, new()

+ 5 - 5
Source/QuestPDF/Fluent/DebugExtensions.cs

@@ -38,17 +38,17 @@ namespace QuestPDF.Fluent
         /// This debug element does not appear in the final PDF output.
         /// </remarks>
         /// <param name="elementTraceText">Text visible somewhere in the "element trace" content identifying given document fragment.</param>
-        public static IContainer DebugPointer(this IContainer parent, string elementTraceText)
+        public static IContainer DebugPointer(this IContainer parent, string label)
         {
-            return parent.DebugPointer(elementTraceText, true);
+            return parent.DebugPointer(DebugPointerType.UserDefined, label);
         }
         
-        internal static IContainer DebugPointer(this IContainer parent, string elementTraceText, bool highlight)
+        internal static IContainer DebugPointer(this IContainer parent, DebugPointerType type, string label)
         {
             return parent.Element(new DebugPointer
             {
-                Target = elementTraceText,
-                Highlight = highlight
+                Type = type,
+                Label = label
             });
         }
     }

+ 56 - 6
Source/QuestPDF/Fluent/ElementExtensions.cs

@@ -1,4 +1,6 @@
 using System;
+using System.Net.Mime;
+using System.Runtime.CompilerServices;
 using QuestPDF.Drawing.Exceptions;
 using QuestPDF.Elements;
 using QuestPDF.Helpers;
@@ -46,9 +48,31 @@ namespace QuestPDF.Fluent
         /// <para>Extracting implementation of certain layout structures into separate methods, allows you to accurately describe their purpose and reuse them code in various parts of the application.</para>
         /// </remarks>
         /// <param name="handler">A delegate that takes the current container and populates it with content.</param>
-        public static void Element(this IContainer parent, Action<IContainer> handler)
+        public static void Element(
+            this IContainer parent, 
+            Action<IContainer> handler, 
+#if NETCOREAPP3_0_OR_GREATER
+            [CallerArgumentExpression("handler")] string handlerName = null,
+#endif
+            [CallerMemberName] string parentName = "",
+            [CallerFilePath] string sourceFilePath = "",
+            [CallerLineNumber] int sourceLineNumber = 0)
         {
-            handler(parent.Container());
+#if !NETCOREAPP3_0_OR_GREATER
+            const string handlerName = "Unknown function";
+#endif
+            
+            var handlerContainer = parent
+                .Container()
+                .Element(new SourceCodePointer
+                {
+                    HandlerName = handlerName,
+                    ParentName = parentName,
+                    SourceFilePath = sourceFilePath,
+                    SourceLineNumber = sourceLineNumber
+                });
+            
+            handler(handlerContainer);
         }
         
         /// <summary>
@@ -61,9 +85,35 @@ namespace QuestPDF.Fluent
         /// </remarks>
         /// <param name="handler">A method that accepts the current container, optionally populates it with content, and returns a subsequent container to continue the Fluent API chain.</param>
         /// <returns>The container returned by the <paramref name="handler"/> method.</returns>
-        public static IContainer Element(this IContainer parent, Func<IContainer, IContainer> handler)
+        public static IContainer Element(
+            this IContainer parent, 
+            Func<IContainer, IContainer> handler,
+#if NETCOREAPP3_0_OR_GREATER
+            [CallerArgumentExpression("handler")] string handlerName = null,
+#endif
+            [CallerMemberName] string parentName = "",
+            [CallerFilePath] string sourceFilePath = "",
+            [CallerLineNumber] int sourceLineNumber = 0)
+        {
+#if !NETCOREAPP3_0_OR_GREATER
+            const string handlerName = "Unknown function";
+#endif
+            
+            var handlerContainer = parent
+                .Element(new SourceCodePointer
+                {
+                    HandlerName = handlerName,
+                    ParentName = parentName,
+                    SourceFilePath = sourceFilePath,
+                    SourceLineNumber = sourceLineNumber
+                });
+            
+            return handler(handlerContainer);
+        }
+        
+        internal static IContainer NonTrackingElement(this IContainer parent, Func<IContainer, IContainer> handler)
         {
-            return handler(parent.Container()).Container();
+            return handler(parent.Container());
         }
         
         /// <summary>
@@ -306,7 +356,7 @@ namespace QuestPDF.Fluent
         }
         
         /// <summary>
-        /// Applies a default text style to all nested <see cref="TextExtensions.Text">Text</see> elements.
+        /// Applies a default text style to all nested <see cref="MediaTypeNames.Text">Text</see> elements.
         /// <a href="https://www.questpdf.com/api-reference/default-text-style.html">Learn more</a>
         /// </summary>
         /// <remarks>
@@ -322,7 +372,7 @@ namespace QuestPDF.Fluent
         }
         
         /// <summary>
-        /// Applies a default text style to all nested <see cref="TextExtensions.Text">Text</see> elements.
+        /// Applies a default text style to all nested <see cref="MediaTypeNames.Text">Text</see> elements.
         /// <a href="https://www.questpdf.com/api-reference/default-text-style.html">Learn more</a>
         /// </summary>
         /// <remarks>