瀏覽代碼

Merge remote-tracking branch 'origin/previewer'

MarcinZiabek 3 年之前
父節點
當前提交
7b4923b45d

+ 131 - 0
.editorconfig

@@ -0,0 +1,131 @@
+# Rules in this file were initially inferred by Visual Studio IntelliCode from the D:\GithubRepos\QuestPDF codebase based on best match to current usage at 16.03.2022
+# You can modify the rules from these initially generated values to suit your own policies
+# You can learn more about editorconfig here: https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-code-style-settings-reference
+[*.cs]
+
+
+#Core editorconfig formatting - indentation
+
+#use soft tabs (spaces) for indentation
+indent_style = space
+
+#Formatting - new line options
+
+#place catch statements on a new line
+csharp_new_line_before_catch = true
+#place else statements on a new line
+csharp_new_line_before_else = true
+#require members of anonymous types to be on separate lines
+csharp_new_line_before_members_in_anonymous_types = true
+#require members of object intializers to be on separate lines
+csharp_new_line_before_members_in_object_initializers = true
+#require braces to be on a new line for object_collection_array_initializers, methods, anonymous_types, control_blocks, types, and lambdas (also known as "Allman" style)
+csharp_new_line_before_open_brace =all
+
+#Formatting - organize using options
+
+#sort System.* using directives alphabetically, and place them before other usings
+dotnet_sort_system_directives_first = true
+
+#Formatting - spacing options
+
+#require NO space between a cast and the value
+csharp_space_after_cast = false
+#require a space before the colon for bases or interfaces in a type declaration
+csharp_space_after_colon_in_inheritance_clause = true
+#require a space after a keyword in a control flow statement such as a for loop
+csharp_space_after_keywords_in_control_flow_statements = true
+#require a space before the colon for bases or interfaces in a type declaration
+csharp_space_before_colon_in_inheritance_clause = true
+#remove space within empty argument list parentheses
+csharp_space_between_method_call_empty_parameter_list_parentheses = false
+#remove space between method call name and opening parenthesis
+csharp_space_between_method_call_name_and_opening_parenthesis = false
+#do not place space characters after the opening parenthesis and before the closing parenthesis of a method call
+csharp_space_between_method_call_parameter_list_parentheses = false
+#remove space within empty parameter list parentheses for a method declaration
+csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
+#place a space character after the opening parenthesis and before the closing parenthesis of a method declaration parameter list.
+csharp_space_between_method_declaration_parameter_list_parentheses = false
+
+#Formatting - wrapping options
+
+#leave code block on single line
+csharp_preserve_single_line_blocks = true
+
+#Style - Code block preferences
+
+#prefer no curly braces if allowed
+csharp_prefer_braces = false:suggestion
+
+#Style - expression bodied member options
+
+#prefer block bodies for constructors
+csharp_style_expression_bodied_constructors = false:suggestion
+#prefer block bodies for methods
+csharp_style_expression_bodied_methods = false:suggestion
+#prefer expression-bodied members for properties
+csharp_style_expression_bodied_properties = true:suggestion
+
+#Style - expression level options
+
+#prefer out variables to be declared inline in the argument list of a method call when possible
+csharp_style_inlined_variable_declaration = true:suggestion
+#prefer the language keyword for member access expressions, instead of the type name, for types that have a keyword to represent them
+dotnet_style_predefined_type_for_member_access = true:suggestion
+
+#Style - Expression-level  preferences
+
+#prefer objects to be initialized using object initializers when possible
+dotnet_style_object_initializer = true:suggestion
+#prefer inferred anonymous type member names
+dotnet_style_prefer_inferred_anonymous_type_member_names = false:suggestion
+#prefer inferred tuple element names
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+
+#Style - implicit and explicit types
+
+#prefer var over explicit type in all cases, unless overridden by another code style rule
+csharp_style_var_elsewhere = true:suggestion
+#prefer var is used to declare variables with built-in system types such as int
+csharp_style_var_for_built_in_types = true:suggestion
+#prefer var when the type is already mentioned on the right-hand side of a declaration expression
+csharp_style_var_when_type_is_apparent = true:suggestion
+
+#Style - language keyword and framework type options
+
+#prefer the language keyword for local variables, method parameters, and class members, instead of the type name, for types that have a keyword to represent them
+dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
+
+#Style - Miscellaneous preferences
+
+#prefer local functions over anonymous functions
+csharp_style_pattern_local_over_anonymous_function = true:suggestion
+
+#Style - modifier options
+
+#prefer accessibility modifiers to be declared except for public interface members. This will currently not differ from always and will act as future proofing for if C# adds default interface methods.
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion
+
+#Style - Modifier preferences
+
+#when this rule is set to a list of modifiers, prefer the specified ordering.
+csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent
+
+#Style - Pattern matching
+
+#prefer pattern matching instead of is expression with type casts
+csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+
+#Style - qualification options
+
+#prefer fields not to be prefaced with this. or Me. in Visual Basic
+dotnet_style_qualification_for_field = false:suggestion
+#prefer methods not to be prefaced with this. or Me. in Visual Basic
+dotnet_style_qualification_for_method = false:suggestion
+#prefer properties not to be prefaced with this. or Me. in Visual Basic
+dotnet_style_qualification_for_property = false:suggestion
+
+[*.{cs,vb}]
+tab_width=4
+indent_size=4

+ 80 - 0
QuestPDF.Previewer.Examples/Program.cs

@@ -0,0 +1,80 @@
+using Avalonia.Media;
+using QuestPDF.Fluent;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+using QuestPDF.Previewer;
+using QuestPDF.ReportSample;
+using QuestPDF.ReportSample.Layouts;
+using Colors = QuestPDF.Helpers.Colors;
+
+var model = DataSource.GetReport();
+var report = new StandardReport(model);
+report.ShowInPreviewer();
+
+return;
+
+Document
+    .Create(container =>
+    {
+        container.Page(page =>
+        {
+            page.Size(PageSizes.A4);
+            page.Margin(2, Unit.Centimetre);
+            page.PageColor(Colors.White);
+            page.DefaultTextStyle(x => x.FontSize(20));
+
+            page.Header()
+                .Text("Hot Reload!")
+                .SemiBold().FontSize(36).FontColor(Colors.Blue.Darken2);
+
+            page.Content()
+                .PaddingVertical(1, Unit.Centimetre)
+                .Column(x =>
+                {
+                    x.Spacing(20);
+
+                    x.Item().Table(t =>
+                    {
+                        t.ColumnsDefinition(c =>
+                        {
+                            c.RelativeColumn();
+                            c.RelativeColumn(3);
+                        });
+
+                        t.Cell().Border(1).Background(Colors.Grey.Lighten3).Padding(5).Text("Visual Studio");
+                        t.Cell().Border(1).Padding(5).Text("Start in debug mode with 'Hot Reload on Save' enabled.");
+                        t.Cell().Border(1).Background(Colors.Grey.Lighten3).Padding(5).Text("Command line");
+                        t.Cell().Border(1).Padding(5).Text("Run 'dotnet watch'.");
+                    });
+
+                    x.Item().Text("Modify this line and the preview should show your changes instantly.");
+                });
+
+            page.Footer()
+                .AlignCenter()
+                .Text(x =>
+                {
+                    x.Span("Page ");
+                    x.CurrentPageNumber();
+                });
+        });
+        
+        container.Page(page =>
+        {
+            page.Size(PageSizes.A4);
+            page.Margin(2, Unit.Centimetre);
+            page.PageColor(Colors.Red.Medium);
+            page.DefaultTextStyle(x => x.FontSize(20));
+
+            page.Content()
+                .PaddingVertical(1, Unit.Centimetre)
+                .Column(x =>
+                {
+                    x.Spacing(20);
+
+                    foreach (var i in Enumerable.Range(0, 10))
+                        x.Item().Background(Colors.Grey.Lighten2).Height(80);
+                });
+        });
+    })
+    .ShowInPreviewer();

+ 16 - 0
QuestPDF.Previewer.Examples/QuestPDF.Previewer.Examples.csproj

@@ -0,0 +1,16 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>WinExe</OutputType>
+    <TargetFramework>net6.0</TargetFramework>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\QuestPDF.Previewer\QuestPDF.Previewer.csproj" />
+    <ProjectReference Include="..\QuestPDF.ReportSample\QuestPDF.ReportSample.csproj" />
+    <ProjectReference Include="..\QuestPDF\QuestPDF.csproj" />
+  </ItemGroup>
+
+</Project>

+ 50 - 0
QuestPDF.Previewer/DocumentPreviewerExtensions.cs

@@ -0,0 +1,50 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.ReactiveUI;
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.Previewer
+{
+    public static class DocumentPreviewerExtensions
+    {
+        /// <summary>
+        /// Opens document in the QuestPDF previewer tool.
+        /// Improves development speed by supporting hot reloading.
+        /// Shows document preview and refreshes it after each code change.
+        /// </summary>
+        /// <remarks>
+        /// Intended for development only. Do not use in production environment.
+        /// </remarks>
+        public static void ShowInPreviewer(this IDocument document)
+        {
+            ArgumentNullException.ThrowIfNull(document);
+
+            // currently there is no way to utilize a previously run Avalonia app.
+            // so we need to check if the previewer was already run and show the window again.
+            if(Application.Current?.ApplicationLifetime is ClassicDesktopStyleApplicationLifetime desktop)
+            {
+                desktop.MainWindow = new PreviewerWindow()
+                {
+                    DataContext = new PreviewerWindowViewModel()
+                    {
+                        Document = document,
+                    }
+                };
+                
+                desktop.MainWindow.Show();
+                desktop.Start(Array.Empty<string>());
+                
+                return;
+            }
+
+            AppBuilder
+                .Configure(() => new PreviewerApp()
+                {
+                    Document = document,
+                })
+                .UsePlatformDetect()
+                .UseReactiveUI()
+                .StartWithClassicDesktopLifetime(Array.Empty<string>());
+        }
+    }
+}

+ 63 - 0
QuestPDF.Previewer/DocumentRenderer.cs

@@ -0,0 +1,63 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using Avalonia.Threading;
+using QuestPDF.Drawing;
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.Previewer
+{
+    internal class DocumentRenderer : INotifyPropertyChanged
+    {
+        public IDocument? Document { get; private set; }
+
+        public event PropertyChangedEventHandler? PropertyChanged;
+
+        private ObservableCollection<PreviewPage> _pages = new();
+        public ObservableCollection<PreviewPage> Pages
+        {
+            get => _pages;
+            set
+            {
+                if (_pages != value)
+                {
+                    _pages = value;
+                    PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Pages)));
+                }
+            }
+        }
+
+        public void UpdateDocument(IDocument? document)
+        {
+            Document = document;
+            
+            if (document == null) 
+                return;
+            
+            try
+            {
+                RenderDocument(document);
+            }
+            catch (Exception exception)
+            {
+                var exceptionDocument = new ExceptionDocument(exception);
+                RenderDocument(exceptionDocument);
+            }
+        }
+
+        private void RenderDocument(IDocument document)
+        {
+            var canvas = new PreviewerCanvas();
+
+            DocumentGenerator.RenderDocument(canvas, document);
+            
+            foreach (var pages in Pages)
+                pages?.Picture?.Dispose();
+            
+            Dispatcher.UIThread.Post(() =>
+            {
+                Pages.Clear();
+                Pages = new ObservableCollection<PreviewPage>(canvas.Pictures);
+            });
+        }
+    }
+}

+ 55 - 0
QuestPDF.Previewer/ExceptionDocument.cs

@@ -0,0 +1,55 @@
+using QuestPDF.Drawing;
+using QuestPDF.Fluent;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.Previewer;
+
+public class ExceptionDocument : IDocument
+{
+    private Exception Exception { get; }
+    
+    public ExceptionDocument(Exception exception)
+    {
+        Exception = exception;
+    }
+    
+    public DocumentMetadata GetMetadata()
+    {
+        return DocumentMetadata.Default;
+    }
+
+    public void Compose(IDocumentContainer document)
+    {
+        document.Page(page =>
+        {
+            page.Size(PageSizes.A4);
+            page.Margin(1, Unit.Inch);
+            page.PageColor(Colors.Red.Lighten4);
+            page.DefaultTextStyle(x => x.FontSize(16));
+
+            page.Header()
+                .BorderBottom(2)
+                .BorderColor(Colors.Red.Medium)
+                .PaddingBottom(5)
+                .Text("Ooops! Something went wrong...").FontSize(28).FontColor(Colors.Red.Medium).Bold();
+
+            page.Content().PaddingVertical(20).Column(column =>
+            {
+                var currentException = Exception;
+
+                while (currentException != null)
+                {
+                    column.Item().Text(currentException.GetType().Name).FontSize(20).SemiBold();
+                    column.Item().Text(currentException.Message).FontSize(14);
+                    column.Item().PaddingTop(10).Text(currentException.StackTrace).FontSize(10).Light();
+
+                    currentException = currentException.InnerException;
+
+                    if (currentException != null)
+                        column.Item().PaddingVertical(15).LineHorizontal(2).LineColor(Colors.Red.Medium);
+                }
+            });
+        });
+    }
+}

+ 17 - 0
QuestPDF.Previewer/HotReloadManager.cs

@@ -0,0 +1,17 @@
+[assembly: System.Reflection.Metadata.MetadataUpdateHandler(typeof(QuestPDF.Previewer.HotReloadManager))]
+
+namespace QuestPDF.Previewer
+{
+    /// <summary>
+    /// Helper for subscribing to hot reload notifications.
+    /// </summary>
+    internal static class HotReloadManager
+    {
+        public static event EventHandler? UpdateApplicationRequested;
+
+        public static void UpdateApplication(Type[]? _)
+        {
+            UpdateApplicationRequested?.Invoke(null, EventArgs.Empty);
+        }
+    }
+}

二進制
QuestPDF.Previewer/Images/Logo.png


+ 205 - 0
QuestPDF.Previewer/InteractiveCanvas.cs

@@ -0,0 +1,205 @@
+using System.Diagnostics;
+using Avalonia;
+using Avalonia.Platform;
+using Avalonia.Rendering.SceneGraph;
+using Avalonia.Skia;
+using SkiaSharp;
+
+namespace QuestPDF.Previewer;
+
+class InteractiveCanvas : ICustomDrawOperation
+{
+    public Rect Bounds { get; set; }
+    public ICollection<PreviewPage> Pages { get; set; }
+
+    private float Width => (float)Bounds.Width;
+    private float Height => (float)Bounds.Height;
+
+    public float Scale { get; private set; } = 1;
+    public float TranslateX { get; set; }
+    public float TranslateY { get; set; }
+
+    private const float MinScale = 0.1f;
+    private const float MaxScale = 10f;
+
+    private const float PageSpacing = 25f;
+    private const float SafeZone = 25f;
+
+    public float TotalPagesHeight => Pages.Sum(x => x.Size.Height) + (Pages.Count - 1) * PageSpacing;
+    public float TotalHeight => TotalPagesHeight + SafeZone * 2 / Scale;
+    public float MaxWidth => Pages.Any() ? Pages.Max(x => x.Size.Width) : 0;
+    
+    public float MaxTranslateY => TotalHeight - Height / Scale;
+
+    public float ScrollPercentY
+    {
+        get
+        {
+            return TranslateY / MaxTranslateY;
+        }
+        set
+        {
+            TranslateY = value * MaxTranslateY;
+        }
+    }
+
+    public float ScrollViewportSizeY
+    {
+        get
+        {
+            var viewPortSize = Height / Scale / TotalHeight;
+            return Math.Clamp(viewPortSize, 0, 1);
+        }
+    }
+
+    #region transformations
+    
+    private void LimitScale()
+    {
+        Scale = Math.Max(Scale, MinScale);
+        Scale = Math.Min(Scale, MaxScale);
+    }
+    
+    private void LimitTranslate()
+    {
+        if (TotalPagesHeight > Height / Scale)
+        {
+            TranslateY = Math.Min(TranslateY, MaxTranslateY);
+            TranslateY = Math.Max(TranslateY, 0);
+        }
+        else
+        {
+            TranslateY = (TotalPagesHeight - Height / Scale) / 2;
+        }
+
+        if (Width / Scale < MaxWidth)
+        {
+            var maxTranslateX = (Width / 2 - SafeZone) / Scale - MaxWidth / 2;
+
+            TranslateX = Math.Min(TranslateX, -maxTranslateX);
+            TranslateX = Math.Max(TranslateX, maxTranslateX);
+        }
+        else
+        {
+            TranslateX = 0;
+        }
+    }
+
+    public void TranslateWithCurrentScale(float x, float y)
+    {
+        TranslateX += x / Scale;
+        TranslateY += y / Scale;
+
+        LimitTranslate();
+    }
+    
+    public void ZoomToPoint(float x, float y, float factor)
+    {
+        var oldScale = Scale;
+        Scale *= factor;
+                
+        LimitScale();
+                
+        factor = Scale / oldScale;
+        
+        TranslateX -= x / (oldScale * factor) - x / oldScale;
+        TranslateY -= y / (oldScale * factor) - y / oldScale;
+
+        LimitTranslate();
+    }
+    
+    #endregion
+    
+    #region rendering
+    
+    public void Render(IDrawingContextImpl context)
+    {
+        if (Pages.Count <= 0)
+            return;
+
+        LimitScale();
+        LimitTranslate();
+        
+        var canvas = (context as ISkiaDrawingContextImpl)?.SkCanvas;
+        
+        if (canvas == null)
+            throw new InvalidOperationException($"Context needs to be ISkiaDrawingContextImpl but got {nameof(context)}");
+
+        var originalMatrix = canvas.TotalMatrix;
+
+        canvas.Translate(Width / 2, 0);
+        
+        canvas.Scale(Scale);
+        canvas.Translate(TranslateX, -TranslateY + SafeZone / Scale);
+        
+        foreach (var page in Pages)
+        {
+            canvas.Translate(-page.Size.Width / 2f, 0);
+            DrawBlankPage(canvas, page.Size);
+            canvas.DrawPicture(page.Picture);
+            canvas.Translate(page.Size.Width / 2f, page.Size.Height + PageSpacing);
+        }
+
+        canvas.SetMatrix(originalMatrix);
+        DrawInnerGradient(canvas);
+    }
+    
+    public void Dispose() { }
+    public bool Equals(ICustomDrawOperation? other) => false;
+    public bool HitTest(Point p) => true;
+
+    #endregion
+    
+    #region blank page
+
+    private static SKPaint BlankPagePaint = new SKPaint
+    {
+        Color = SKColors.White
+    };
+    
+    private static SKPaint BlankPageShadowPaint = new SKPaint
+    {
+        ImageFilter = SKImageFilter.CreateBlendMode(
+            SKBlendMode.Overlay, 
+            SKImageFilter.CreateDropShadowOnly(0, 6, 6, 6, SKColors.Black.WithAlpha(64)),
+            SKImageFilter.CreateDropShadowOnly(0, 10, 14, 14, SKColors.Black.WithAlpha(32)))
+    };
+    
+    private void DrawBlankPage(SKCanvas canvas, QuestPDF.Infrastructure.Size size)
+    {
+        canvas.DrawRect(0, 0, size.Width, size.Height, BlankPageShadowPaint);
+        canvas.DrawRect(0, 0, size.Width, size.Height, BlankPagePaint);
+    }
+    
+    #endregion
+
+    #region inner viewport gradient
+
+    private const int InnerGradientSize = (int)SafeZone;
+    private static readonly SKColor InnerGradientColor = SKColor.Parse("#666");
+    
+    private void DrawInnerGradient(SKCanvas canvas)
+    {
+        // gamma correction
+        var colors = Enumerable
+            .Range(0, InnerGradientSize)
+            .Select(x => 1f - x / (float) InnerGradientSize)
+            .Select(x => Math.Pow(x, 2f))
+            .Select(x => (byte)(x * 255))
+            .Select(x => InnerGradientColor.WithAlpha(x))
+            .ToArray();
+        
+        using var fogPaint = new SKPaint
+        {
+            Shader = SKShader.CreateLinearGradient(
+                new SKPoint(0, 0),
+                new SKPoint(0, InnerGradientSize), 
+                colors,
+                SKShaderTileMode.Clamp)
+        };
+        
+        canvas.DrawRect(0, 0, Width, InnerGradientSize, fogPaint);
+    }
+
+    #endregion
+}

+ 7 - 0
QuestPDF.Previewer/PreviewerApp.axaml

@@ -0,0 +1,7 @@
+<Application x:Class="QuestPDF.Previewer.PreviewerApp"
+             xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
+	<Application.Styles>
+		<FluentTheme Mode="Dark" />
+	</Application.Styles>
+</Application>

+ 33 - 0
QuestPDF.Previewer/PreviewerApp.axaml.cs

@@ -0,0 +1,33 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.Previewer
+{
+    internal class PreviewerApp : Application
+    {
+        public IDocument? Document { get; init; }
+
+        public override void Initialize()
+        {
+            AvaloniaXamlLoader.Load(this);
+        }
+
+        public override void OnFrameworkInitializationCompleted()
+        {
+            if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+            {
+                desktop.MainWindow = new PreviewerWindow()
+                {
+                    DataContext = new PreviewerWindowViewModel()
+                    {
+                        Document = Document,
+                    }
+                };
+            }
+            
+            base.OnFrameworkInitializationCompleted();
+        }
+    }
+}

+ 42 - 0
QuestPDF.Previewer/PreviewerCanvas.cs

@@ -0,0 +1,42 @@
+using QuestPDF.Drawing;
+using QuestPDF.Infrastructure;
+using SkiaSharp;
+
+namespace QuestPDF.Previewer
+{
+    record PreviewPage(SKPicture Picture, Size Size);
+    
+    sealed class PreviewerCanvas : SkiaCanvasBase, IRenderingCanvas
+    {
+        private SKPictureRecorder? PictureRecorder { get; set; }
+        private Size? CurrentPageSize { get; set; }
+        
+        public ICollection<PreviewPage> Pictures { get; } = new List<PreviewPage>();
+        
+        public override void BeginDocument()
+        {
+            Pictures.Clear();
+        }
+
+        public override void BeginPage(Size size)
+        {
+            CurrentPageSize = size;
+            PictureRecorder = new SKPictureRecorder();
+            
+            Canvas = PictureRecorder.BeginRecording(new SKRect(0, 0, size.Width, size.Height));
+        }
+
+        public override void EndPage()
+        {
+            var picture = PictureRecorder?.EndRecording();
+            
+            if (picture != null && CurrentPageSize.HasValue)
+                Pictures.Add(new PreviewPage(picture, CurrentPageSize.Value));
+
+            PictureRecorder?.Dispose();
+            PictureRecorder = null;
+        }
+
+        public override void EndDocument() { }
+    }
+}

+ 120 - 0
QuestPDF.Previewer/PreviewerControl.cs

@@ -0,0 +1,120 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+using ReactiveUI;
+
+namespace QuestPDF.Previewer
+{
+    class PreviewerControl : Control
+    {
+        private InteractiveCanvas InteractiveCanvas { get; set; } = new ();
+        
+        public static readonly StyledProperty<ObservableCollection<PreviewPage>> PagesProperty =
+            AvaloniaProperty.Register<PreviewerControl, ObservableCollection<PreviewPage>>(nameof(Pages));
+        
+        public ObservableCollection<PreviewPage>? Pages
+        {
+            get => GetValue(PagesProperty);
+            set => SetValue(PagesProperty, value);
+        }
+
+        public static readonly StyledProperty<float> CurrentScrollProperty = AvaloniaProperty.Register<PreviewerControl, float>(nameof(CurrentScroll));
+        
+        public float CurrentScroll
+        {
+            get => GetValue(CurrentScrollProperty);
+            set => SetValue(CurrentScrollProperty, value);
+        }
+        
+        public static readonly StyledProperty<float> ScrollViewportSizeProperty = AvaloniaProperty.Register<PreviewerControl, float>(nameof(ScrollViewportSize));
+        
+        public float ScrollViewportSize
+        {
+            get => GetValue(ScrollViewportSizeProperty);
+            set => SetValue(ScrollViewportSizeProperty, value);
+        }
+        
+        public PreviewerControl()
+        {
+            PagesProperty.Changed.Subscribe(x =>
+            {
+                InteractiveCanvas.Pages = x.NewValue.Value;
+                InvalidateVisual();
+            });
+
+            CurrentScrollProperty.Changed.Subscribe(x =>
+            {
+                InteractiveCanvas.ScrollPercentY = x.NewValue.Value;
+                InvalidateVisual();
+            });
+
+            ClipToBounds = true;
+        }
+        
+        protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
+        {
+            base.OnPointerWheelChanged(e);
+
+            if ((e.KeyModifiers & KeyModifiers.Control) != 0)
+            {
+                var scaleFactor = 1 + e.Delta.Y / 10f;
+                var point = new Point(Bounds.Center.X, Bounds.Top) - e.GetPosition(this);
+                
+                InteractiveCanvas.ZoomToPoint((float)point.X, -(float)point.Y, (float)scaleFactor);
+            }
+                
+            if (e.KeyModifiers == KeyModifiers.None)
+            {
+                var translation = (float)e.Delta.Y * 25;
+                InteractiveCanvas.TranslateWithCurrentScale(0, -translation);
+            }
+                
+            InvalidateVisual();
+        }
+
+        private bool IsMousePressed { get; set; }
+        private Vector MousePosition { get; set; }
+        
+        protected override void OnPointerMoved(PointerEventArgs e)
+        {
+            base.OnPointerMoved(e);
+
+            if (IsMousePressed)
+            {
+                var currentPosition = e.GetPosition(this);
+                var translation = currentPosition - MousePosition;
+                InteractiveCanvas.TranslateWithCurrentScale((float)translation.X, -(float)translation.Y);
+                
+                InvalidateVisual();
+            }
+
+            MousePosition = e.GetPosition(this);
+        }
+        
+        protected override void OnPointerPressed(PointerPressedEventArgs e)
+        {
+            base.OnPointerPressed(e);
+            IsMousePressed = true;
+        }
+
+        protected override void OnPointerReleased(PointerReleasedEventArgs e)
+        {
+            base.OnPointerReleased(e);
+            IsMousePressed = false;
+        }
+
+        public override void Render(DrawingContext context)
+        {
+            CurrentScroll = InteractiveCanvas.ScrollPercentY;
+            ScrollViewportSize = InteractiveCanvas.ScrollViewportSizeY;
+    
+            InteractiveCanvas.Bounds = new Rect(0, 0, Bounds.Width, Bounds.Height);
+
+            context.Custom(InteractiveCanvas);
+            base.Render(context);
+        }
+    }
+}

+ 82 - 0
QuestPDF.Previewer/PreviewerWindow.axaml

@@ -0,0 +1,82 @@
+<Window xmlns="https://github.com/avaloniaui"
+							xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+							xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+							xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+							xmlns:previewer="clr-namespace:QuestPDF.Previewer"
+							mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+							x:Class="QuestPDF.Previewer.PreviewerWindow"
+							WindowStartupLocation="CenterScreen"
+							ExtendClientAreaToDecorationsHint="true"
+							ExtendClientAreaTitleBarHeightHint="-1"
+							Background="#666"
+							Icon="/Images/Logo.png"
+							UseLayoutRounding="True"
+							Title="QuestPDF Document Preview">
+	<Panel>
+		<Grid>
+			<Grid.RowDefinitions>
+				<RowDefinition Height="32" />
+				<RowDefinition Height="*" />
+			</Grid.RowDefinitions>
+
+			<Grid.ColumnDefinitions>
+				<ColumnDefinition Width="*" />
+				<ColumnDefinition Width="Auto" />
+			</Grid.ColumnDefinitions>
+			
+			<TextBlock Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2"
+			           VerticalAlignment="Center" HorizontalAlignment="Center" 
+			           TextAlignment="Center" Text="QuestPDF Previewer" FontSize="14" Foreground="#DFFF" FontWeight="Regular" IsHitTestVisible="False" />
+			
+			<previewer:PreviewerControl Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2"
+			                            HorizontalAlignment="Stretch" 
+			                            VerticalAlignment="Stretch"
+			                            CurrentScroll="{Binding CurrentScroll, Mode=TwoWay}"
+			                            ScrollViewportSize="{Binding ScrollViewportSize, Mode=OneWayToSource}"
+			                            Pages="{Binding DocumentRenderer.Pages, Mode=OneWay}" />
+			
+			<StackPanel Grid.Row="1" Grid.Column="0" Orientation="Vertical" VerticalAlignment="Bottom" Spacing="16" Margin="32" ZIndex="100">
+				<Button VerticalAlignment="Bottom" HorizontalAlignment="Left" 
+				        Padding="10" CornerRadius="100" 
+				        Command="{Binding ShowPdfCommand, Mode=OneTime}"
+				        ToolTip.Tip="Generates PDF file and shows it in the default browser. Useful for testing compatibility and interactive links.">
+					<Viewbox Width="24" Height="24">
+						<Canvas Width="24" Height="24">
+							<Path Fill="White" Data="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H13C12.59,21.75 12.2,21.44 11.86,21.1C11.53,20.77 11.25,20.4 11,20H6V4H13V9H18V10.18C18.71,10.34 19.39,10.61 20,11V8L14,2M20.31,18.9C21.64,16.79 21,14 18.91,12.68C16.8,11.35 14,12 12.69,14.08C11.35,16.19 12,18.97 14.09,20.3C15.55,21.23 17.41,21.23 18.88,20.32L22,23.39L23.39,22L20.31,18.9M16.5,19A2.5,2.5 0 0,1 14,16.5A2.5,2.5 0 0,1 16.5,14A2.5,2.5 0 0,1 19,16.5A2.5,2.5 0 0,1 16.5,19Z" />
+						</Canvas>
+					</Viewbox>
+				</Button>
+				
+				<Button VerticalAlignment="Bottom" HorizontalAlignment="Left" 
+				        Padding="10" CornerRadius="100" 
+				        Command="{Binding ShowDocumentationCommand, Mode=OneTime}"
+				        ToolTip.Tip="Opens official QuestPDF documentation">
+					<Viewbox Width="24" Height="24">
+						<Canvas Width="24" Height="24">
+							<Path Fill="White" Data="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z" />
+						</Canvas>
+					</Viewbox>
+				</Button>
+				
+				<Button VerticalAlignment="Bottom" HorizontalAlignment="Left" 
+				        Padding="10" CornerRadius="100" 
+				        Command="{Binding SponsorProjectCommand, Mode=OneTime}"
+				        ToolTip.Tip="Do you like QuestPDF? Please consider sponsoring the project. It really helps!">
+					<Viewbox Width="24" Height="24">
+						<Canvas Width="24" Height="24">
+							<Path Fill="White" Data="M12,21.1L10.5,22.4C3.9,16.5 0.5,13.4 0.5,9.6C0.5,8.4 0.9,7.3 1.5,6.4C1.5,6.6 1.5,6.8 1.5,7C1.5,11.7 5.4,15.2 12,21.1M13.6,17C18.3,12.7 21.5,9.9 21.6,7C21.6,5 20.1,3.5 18.1,3.5C16.5,3.5 15,4.5 14.5,5.9H12.6C12,4.5 10.5,3.5 9,3.5C7,3.5 5.5,5 5.5,7C5.5,9.9 8.6,12.7 13.4,17L13.5,17.1M18,1.5C21.1,1.5 23.5,3.9 23.5,7C23.5,10.7 20.1,13.8 13.5,19.8C6.9,13.9 3.5,10.8 3.5,7C3.5,3.9 5.9,1.5 9,1.5C10.7,1.5 12.4,2.3 13.5,3.6C14.6,2.3 16.3,1.5 18,1.5Z" />
+						</Canvas>
+					</Viewbox>
+				</Button>
+			</StackPanel>
+			
+			<ScrollBar Grid.Row="1" Grid.Column="1"
+			           Orientation="Vertical" 
+			           AllowAutoHide="False" 
+			           Minimum="0" Maximum="1" 
+			           IsVisible="{Binding VerticalScrollbarVisible, Mode=OneWay}"
+			           Value="{Binding CurrentScroll, Mode=TwoWay}" 
+			           ViewportSize="{Binding ScrollViewportSize, Mode=OneWay}"></ScrollBar>
+		</Grid>
+	</Panel>
+</Window>

+ 23 - 0
QuestPDF.Previewer/PreviewerWindow.axaml.cs

@@ -0,0 +1,23 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace QuestPDF.Previewer
+{
+    class PreviewerWindow : Window
+    {
+        public PreviewerWindow()
+        {
+            InitializeComponent();
+        }
+
+        protected override void OnClosed(EventArgs e)
+        {
+            (DataContext as PreviewerWindowViewModel)?.UnregisterHotReloadHandler();
+        }
+
+        private void InitializeComponent()
+        {
+            AvaloniaXamlLoader.Load(this);
+        }
+    }
+}

+ 108 - 0
QuestPDF.Previewer/PreviewerWindowViewModel.cs

@@ -0,0 +1,108 @@
+using QuestPDF.Fluent;
+using System.Diagnostics;
+using ReactiveUI;
+using QuestPDF.Infrastructure;
+using Unit = System.Reactive.Unit;
+using Avalonia.Threading;
+
+namespace QuestPDF.Previewer
+{
+    internal class PreviewerWindowViewModel : ReactiveObject
+    {
+        public DocumentRenderer DocumentRenderer { get; } = new();
+
+        private IDocument? _document;
+        public IDocument? Document
+        {
+            get => _document;
+            set
+            {
+                this.RaiseAndSetIfChanged(ref _document, value);
+                UpdateDocument(value);
+            }
+        }
+
+        private float _currentScroll;
+        public float CurrentScroll
+        {
+            get => _currentScroll;
+            set => this.RaiseAndSetIfChanged(ref _currentScroll, value);
+        }
+
+        private float _scrollViewportSize;
+        public float ScrollViewportSize
+        {
+            get => _scrollViewportSize;
+            set
+            {
+                this.RaiseAndSetIfChanged(ref _scrollViewportSize, value);
+                VerticalScrollbarVisible = value < 1;
+            }
+        }
+
+        private bool _verticalScrollbarVisible;
+        public bool VerticalScrollbarVisible
+        {
+            get => _verticalScrollbarVisible;
+            private set => Dispatcher.UIThread.Post(() => this.RaiseAndSetIfChanged(ref _verticalScrollbarVisible, value));
+        }
+
+        public ReactiveCommand<Unit, Unit> ShowPdfCommand { get; }
+        public ReactiveCommand<Unit, Unit> ShowDocumentationCommand { get; }
+        public ReactiveCommand<Unit, Unit> SponsorProjectCommand { get; }
+
+        public PreviewerWindowViewModel()
+        {
+            HotReloadManager.UpdateApplicationRequested += InvalidateDocument;
+            
+            ShowPdfCommand = ReactiveCommand.Create(ShowPdf);
+            ShowDocumentationCommand = ReactiveCommand.Create(() => OpenLink("https://www.questpdf.com/documentation/api-reference.html"));
+            SponsorProjectCommand = ReactiveCommand.Create(() => OpenLink("https://github.com/sponsors/QuestPDF"));
+        }
+
+        public void UnregisterHotReloadHandler()
+        {
+            HotReloadManager.UpdateApplicationRequested -= InvalidateDocument;
+        }
+
+        private void InvalidateDocument(object? sender, EventArgs e)
+        {
+            UpdateDocument(Document);
+        }
+
+        private Task UpdateDocument(IDocument? document)
+        {
+            return Task.Run(() => DocumentRenderer.UpdateDocument(document));
+        }
+
+        private void ShowPdf()
+        {
+            var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.pdf");
+
+            try
+            {
+                Document?.GeneratePdf(filePath);
+            }
+            catch (Exception exception)
+            {
+                new ExceptionDocument(exception).GeneratePdf(filePath);
+            }
+
+            OpenLink(filePath);
+        }
+        
+        private void OpenLink(string path)
+        {
+            var openBrowserProcess = new Process
+            {
+                StartInfo = new()
+                {
+                    UseShellExecute = true,
+                    FileName = path
+                }
+            };
+
+            openBrowserProcess.Start();
+        }
+    }
+}

+ 27 - 0
QuestPDF.Previewer/QuestPDF.Previewer.csproj

@@ -0,0 +1,27 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>net6.0</TargetFramework>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <AvaloniaResource Include="Images\Logo.png" />
+  </ItemGroup>
+
+	<ItemGroup>
+		<PackageReference Include="Avalonia" Version="0.10.10" />
+		<PackageReference Include="Avalonia.Desktop" Version="0.10.10" />
+		<PackageReference Include="Avalonia.Diagnostics" Version="0.10.10" />
+		<PackageReference Include="Avalonia.Markup.Xaml.Loader" Version="0.10.10" />
+		<PackageReference Include="Avalonia.ReactiveUI" Version="0.10.10" />
+		<PackageReference Include="ReactiveUI" Version="17.1.50" />
+		<PackageReference Include="System.Reactive" Version="5.0.0" />
+	</ItemGroup>
+
+	<ItemGroup>
+	  <ProjectReference Include="..\QuestPDF\QuestPDF.csproj" />
+	</ItemGroup>
+
+</Project>

+ 2 - 1
QuestPDF.ReportSample/Layouts/PhotoTemplate.cs

@@ -1,3 +1,4 @@
+using System;
 using QuestPDF.Fluent;
 using QuestPDF.Fluent;
 using QuestPDF.Helpers;
 using QuestPDF.Helpers;
 using QuestPDF.Infrastructure;
 using QuestPDF.Infrastructure;
@@ -47,7 +48,7 @@ namespace QuestPDF.ReportSample.Layouts
             container.Border(0.75f).BorderColor(Colors.Grey.Medium).Grid(grid =>
             container.Border(0.75f).BorderColor(Colors.Grey.Medium).Grid(grid =>
             {
             {
                 grid.Columns(6);
                 grid.Columns(6);
-                
+
                 grid.Item().LabelCell().Text("Date");
                 grid.Item().LabelCell().Text("Date");
                 grid.Item(2).ValueCell().Text(Model.Date?.ToString("g") ?? string.Empty);
                 grid.Item(2).ValueCell().Text(Model.Date?.ToString("g") ?? string.Empty);
                 grid.Item().LabelCell().Text("Location");
                 grid.Item().LabelCell().Text("Location");

+ 17 - 0
QuestPDF.sln

@@ -8,6 +8,15 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestPDF.UnitTests", "Quest
 EndProject
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestPDF.Examples", "QuestPDF.Examples\QuestPDF.Examples.csproj", "{8BD0A2B4-2DC1-47BA-9724-C158320D9CAE}"
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestPDF.Examples", "QuestPDF.Examples\QuestPDF.Examples.csproj", "{8BD0A2B4-2DC1-47BA-9724-C158320D9CAE}"
 EndProject
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestPDF.Previewer", "QuestPDF.Previewer\QuestPDF.Previewer.csproj", "{D63255E7-7043-4AD4-A4C9-7ACFEDBE8225}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestPDF.Previewer.Examples", "QuestPDF.Previewer.Examples\QuestPDF.Previewer.Examples.csproj", "{CA413A39-038F-4A9F-B56F-0E5413B6B158}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Configuration", "Configuration", "{73123649-216A-47B4-BFB2-DADF13FBE75D}"
+	ProjectSection(SolutionItems) = preProject
+		.editorconfig = .editorconfig
+	EndProjectSection
+EndProject
 Global
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
 		Debug|Any CPU = Debug|Any CPU
@@ -30,5 +39,13 @@ Global
 		{8BD0A2B4-2DC1-47BA-9724-C158320D9CAE}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{8BD0A2B4-2DC1-47BA-9724-C158320D9CAE}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{8BD0A2B4-2DC1-47BA-9724-C158320D9CAE}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{8BD0A2B4-2DC1-47BA-9724-C158320D9CAE}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{8BD0A2B4-2DC1-47BA-9724-C158320D9CAE}.Release|Any CPU.Build.0 = Release|Any CPU
 		{8BD0A2B4-2DC1-47BA-9724-C158320D9CAE}.Release|Any CPU.Build.0 = Release|Any CPU
+		{D63255E7-7043-4AD4-A4C9-7ACFEDBE8225}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{D63255E7-7043-4AD4-A4C9-7ACFEDBE8225}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{D63255E7-7043-4AD4-A4C9-7ACFEDBE8225}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{D63255E7-7043-4AD4-A4C9-7ACFEDBE8225}.Release|Any CPU.Build.0 = Release|Any CPU
+		{CA413A39-038F-4A9F-B56F-0E5413B6B158}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{CA413A39-038F-4A9F-B56F-0E5413B6B158}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{CA413A39-038F-4A9F-B56F-0E5413B6B158}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{CA413A39-038F-4A9F-B56F-0E5413B6B158}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	EndGlobalSection
 EndGlobal
 EndGlobal

+ 1 - 0
QuestPDF/Assembly.cs

@@ -1,6 +1,7 @@
 using System.Runtime.CompilerServices;
 using System.Runtime.CompilerServices;
 
 
 [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
 [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
+[assembly: InternalsVisibleTo("QuestPDF.Previewer")]
 [assembly: InternalsVisibleTo("QuestPDF.UnitTests")]
 [assembly: InternalsVisibleTo("QuestPDF.UnitTests")]
 [assembly: InternalsVisibleTo("QuestPDF.Examples")]
 [assembly: InternalsVisibleTo("QuestPDF.Examples")]
 [assembly: InternalsVisibleTo("QuestPDF.ReportSample")]
 [assembly: InternalsVisibleTo("QuestPDF.ReportSample")]

+ 1 - 1
QuestPDF/Drawing/DocumentGenerator.cs

@@ -51,7 +51,7 @@ namespace QuestPDF.Drawing
             return canvas.Images;
             return canvas.Images;
         }
         }
 
 
-        private static void RenderDocument<TCanvas>(TCanvas canvas, IDocument document)
+        internal static void RenderDocument<TCanvas>(TCanvas canvas, IDocument document)
             where TCanvas : ICanvas, IRenderingCanvas
             where TCanvas : ICanvas, IRenderingCanvas
         {
         {
             var container = new DocumentContainer();
             var container = new DocumentContainer();

+ 4 - 2
QuestPDF/Drawing/FontManager.cs

@@ -52,7 +52,8 @@ namespace QuestPDF.Drawing
             {
             {
                 return new SKPaint
                 return new SKPaint
                 {
                 {
-                    Color = SKColor.Parse(color)
+                    Color = SKColor.Parse(color),
+                    IsAntialias = true
                 };
                 };
             }
             }
         }
         }
@@ -67,7 +68,8 @@ namespace QuestPDF.Drawing
                 {
                 {
                     Color = SKColor.Parse(style.Color),
                     Color = SKColor.Parse(style.Color),
                     Typeface = GetTypeface(style),
                     Typeface = GetTypeface(style),
-                    TextSize = style.Size ?? 12
+                    TextSize = style.Size ?? 12,
+                    IsAntialias = true,
                 };
                 };
             }
             }