Explorar o código

Added http-based previewer

MarcinZiabek %!s(int64=3) %!d(string=hai) anos
pai
achega
b80b3414d8

+ 19 - 7
QuestPDF.Previewer.Examples/Program.cs

@@ -1,4 +1,5 @@
-using Avalonia.Media;
+using System.Net.Http.Headers;
+using Avalonia.Media;
 using QuestPDF.Fluent;
 using QuestPDF.Helpers;
 using QuestPDF.Infrastructure;
@@ -7,11 +8,13 @@ using QuestPDF.ReportSample;
 using QuestPDF.ReportSample.Layouts;
 using Colors = QuestPDF.Helpers.Colors;
 
-var model = DataSource.GetReport();
-var report = new StandardReport(model);
-report.ShowInPreviewer();
+ImagePlaceholder.Solid = true;
 
-return;
+// var model = DataSource.GetReport();
+// var report = new StandardReport(model);
+// report.ShowInPreviewer().Wait();
+//
+// return;
 
 Document
     .Create(container =>
@@ -48,6 +51,16 @@ Document
                     });
 
                     x.Item().Text("Modify this line and the preview should show your changes instantly.");
+                    
+                    // for testing exception handling
+                    // try
+                    // {
+                    //     throw new ArgumentException("This file does not exists... peace.png");
+                    // }
+                    // catch (Exception e)
+                    // {
+                    //     throw new FileNotFoundException("This is the top exception!", e);
+                    // }
                 });
 
             page.Footer()
@@ -63,7 +76,6 @@ Document
         {
             page.Size(PageSizes.A4);
             page.Margin(2, Unit.Centimetre);
-            page.PageColor(Colors.Red.Medium);
             page.DefaultTextStyle(x => x.FontSize(20));
 
             page.Content()
@@ -77,4 +89,4 @@ Document
                 });
         });
     })
-    .ShowInPreviewer();
+    .ShowInPreviewer().Wait();

+ 65 - 0
QuestPDF.Previewer/CommunicationService.cs

@@ -0,0 +1,65 @@
+using System.Text.Json;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using SkiaSharp;
+
+namespace QuestPDF.Previewer;
+
+class CommunicationService
+{
+    public static CommunicationService Instance { get; } = new ();
+    
+    public event Action<ICollection<PreviewPage>> OnDocumentRefreshed;
+
+    private WebApplication? Application { get; set; }
+
+    private readonly JsonSerializerOptions JsonSerializerOptions = new()
+    {
+        PropertyNameCaseInsensitive = true
+    };
+
+    private CommunicationService()
+    {
+        
+    }
+    
+    public Task Start(int port)
+    {
+        var builder = WebApplication.CreateBuilder();
+        builder.Services.AddLogging(x => x.ClearProviders());
+        Application = builder.Build();
+
+        Application.MapGet("ping", () => Results.Ok());
+        Application.MapGet("version", () => Results.Ok(GetType().Assembly.GetName().Version));
+        Application.MapPost("update/preview", HandleUpdatePreview);
+            
+        return Application.RunAsync($"http://localhost:{port}/");
+    }
+
+    public async Task Stop()
+    {
+        await Application.StopAsync();
+        await Application.DisposeAsync();
+    }
+
+    private async Task<IResult> HandleUpdatePreview(HttpRequest request)
+    {
+        var command = JsonSerializer.Deserialize<DocumentSnapshot>(request.Form["command"], JsonSerializerOptions);
+
+        var pages = command
+            .Pages
+            .Select(page =>
+            {
+                using var stream = request.Form.Files[page.Id].OpenReadStream();
+                var picture = SKPicture.Deserialize(stream);
+                        
+                return new PreviewPage(picture, page.Width, page.Height);
+            })
+            .ToList();
+            
+        OnDocumentRefreshed(pages);
+        return Results.Ok();
+    }
+}

+ 23 - 0
QuestPDF.Previewer/Helpers.cs

@@ -0,0 +1,23 @@
+using SkiaSharp;
+
+namespace QuestPDF.Previewer;
+
+class Helpers
+{
+    public static void GeneratePdfFromDocumentSnapshots(string filePath, ICollection<PreviewPage> pages)
+    {
+        using var stream = File.Create(filePath);
+            
+        var document = SKDocument.CreatePdf(stream);
+            
+        foreach (var page in pages)
+        {
+            using var canvas = document.BeginPage(page.Width, page.Height);
+            canvas.DrawPicture(page.Picture);
+            document.EndPage();
+            canvas.Dispose();
+        }
+        
+        document.Close();
+    }
+}

+ 202 - 0
QuestPDF.Previewer/InteractiveCanvas.cs

@@ -0,0 +1,202 @@
+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.Height) + (Pages.Count - 1) * PageSpacing;
+    public float TotalHeight => TotalPagesHeight + SafeZone * 2 / Scale;
+    public float MaxWidth => Pages.Any() ? Pages.Max(x => x.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();
+   
+        TranslateX -= x / Scale - x / oldScale;
+        TranslateY -= y / Scale - 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.Width / 2f, 0);
+            DrawBlankPage(canvas, page.Width, page.Height);
+            canvas.DrawPicture(page.Picture);
+            canvas.Translate(page.Width / 2f, page.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, float width, float height)
+    {
+        canvas.DrawRect(0, 0, width, height, BlankPageShadowPaint);
+        canvas.DrawRect(0, 0, width, 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>

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

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

+ 6 - 0
QuestPDF.Previewer/PreviewerCanvas.cs

@@ -0,0 +1,6 @@
+using SkiaSharp;
+
+namespace QuestPDF.Previewer
+{
+    record PreviewPage(SKPicture Picture, float Width, float Height);
+}

+ 118 - 0
QuestPDF.Previewer/PreviewerControl.cs

@@ -0,0 +1,118 @@
+using System.Collections.ObjectModel;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+
+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);
+        }
+    }
+}

+ 18 - 0
QuestPDF.Previewer/PreviewerRefreshCommand.cs

@@ -0,0 +1,18 @@
+using SkiaSharp;
+
+namespace QuestPDF.Previewer;
+
+internal class DocumentSnapshot
+{
+    public ICollection<PageSnapshot> Pages { get; set; }
+
+    public class PageSnapshot
+    {
+        public string Id { get; set; }
+        
+        public float Width { get; set; }
+        public float Height { get; set; }
+        
+        public SKPicture Picture { get; set; }
+    }
+}

+ 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="/Resources/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 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)
+        {
+            
+        }
+
+        private void InitializeComponent()
+        {
+            AvaloniaXamlLoader.Load(this);
+        }
+    }
+}

+ 89 - 0
QuestPDF.Previewer/PreviewerWindowViewModel.cs

@@ -0,0 +1,89 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using ReactiveUI;
+using Unit = System.Reactive.Unit;
+using Avalonia.Threading;
+
+namespace QuestPDF.Previewer
+{
+    internal class PreviewerWindowViewModel : ReactiveObject
+    {
+        private ObservableCollection<PreviewPage> _pages = new();
+        public ObservableCollection<PreviewPage> Pages
+        {
+            get => _pages;
+            set => this.RaiseAndSetIfChanged(ref _pages, 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()
+        {
+            CommunicationService.Instance.OnDocumentRefreshed += HandleUpdatePreview;
+            
+            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"));
+        }
+
+        private void ShowPdf()
+        {
+            var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.pdf");
+            Helpers.GeneratePdfFromDocumentSnapshots(filePath, Pages);
+
+            OpenLink(filePath);
+        }
+        
+        private void OpenLink(string path)
+        {
+            var openBrowserProcess = new Process
+            {
+                StartInfo = new()
+                {
+                    UseShellExecute = true,
+                    FileName = path
+                }
+            };
+
+            openBrowserProcess.Start();
+        }
+        
+        private void HandleUpdatePreview(ICollection<PreviewPage> pages)
+        {
+            var oldPages = Pages;
+            
+            Pages.Clear();
+            Pages = new ObservableCollection<PreviewPage>(pages);
+            
+            foreach (var page in oldPages)
+                page.Picture.Dispose();
+        }
+    }
+}

+ 38 - 0
QuestPDF.Previewer/Program.cs

@@ -0,0 +1,38 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.ReactiveUI;
+using QuestPDF.Previewer;
+
+var applicationPort = GetCommunicationPort();
+CommunicationService.Instance.Start(applicationPort);
+
+if(Application.Current?.ApplicationLifetime is ClassicDesktopStyleApplicationLifetime desktop)
+{
+    desktop.MainWindow = new PreviewerWindow()
+    {
+        DataContext = new PreviewerWindowViewModel()
+    };
+                
+    desktop.MainWindow.Show();
+    desktop.Start(Array.Empty<string>());
+                
+    return;
+}
+
+AppBuilder
+    .Configure(() => new PreviewerApp())
+    .UsePlatformDetect()    
+    .UseReactiveUI()
+    .StartWithClassicDesktopLifetime(Array.Empty<string>());
+
+static int GetCommunicationPort()
+{
+    const int defaultApplicationPort = 12500;
+    
+    var arguments = Environment.GetCommandLineArgs();
+
+    if (arguments.Length < 2)
+        return defaultApplicationPort;
+
+    return int.TryParse(arguments[1], out var port) ? port : defaultApplicationPort;
+}    

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

@@ -0,0 +1,31 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <OutputType>exe</OutputType>
+        <TargetFramework>net6.0</TargetFramework>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+
+        <PackAsTool>true</PackAsTool>
+        <PackageId>QuestPDF.Previewer</PackageId>
+        <Version>2022.4.0</Version>
+        <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
+        <ToolCommandName>questpdf-previewer</ToolCommandName>
+    </PropertyGroup>
+
+    <ItemGroup>
+        <AvaloniaResource Include="Resources\Logo.png" />
+    </ItemGroup>
+
+    <ItemGroup>
+        <FrameworkReference Include="Microsoft.AspNetCore.App" />
+        <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" />
+        <PackageReference Include="SkiaSharp" Version="2.80.3" />
+    </ItemGroup>
+</Project>

BIN=BIN
QuestPDF.Previewer/Resources/Logo.png


+ 23 - 0
QuestPDF.Previewer/readme.md

@@ -0,0 +1,23 @@
+Install nuget locally (in directory where nupkg file is located)
+
+```
+dotnet tool install --global --add-source . QuestPDF.Previewer --global
+```
+
+Run on default port
+
+```
+questpdf-previewer
+```
+
+Run on custom port
+
+```
+questpdf-previewer 12500
+```
+
+Remove nuget locally 
+
+```
+dotnet tool uninstall QuestPDF.Previewer --global
+```

+ 6 - 6
QuestPDF.sln

@@ -8,8 +8,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestPDF.UnitTests", "Quest
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestPDF.Examples", "QuestPDF.Examples\QuestPDF.Examples.csproj", "{8BD0A2B4-2DC1-47BA-9724-C158320D9CAE}"
 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}"
@@ -17,6 +15,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Configuration", "Configurat
 		.editorconfig = .editorconfig
 	EndProjectSection
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestPDF.Previewer", "QuestPDF.Previewer\QuestPDF.Previewer.csproj", "{B2FF6003-3A45-4A78-A85D-B86C7F01D054}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -39,13 +39,13 @@ Global
 		{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.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
+		{B2FF6003-3A45-4A78-A85D-B86C7F01D054}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{B2FF6003-3A45-4A78-A85D-B86C7F01D054}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{B2FF6003-3A45-4A78-A85D-B86C7F01D054}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{B2FF6003-3A45-4A78-A85D-B86C7F01D054}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 EndGlobal

+ 1 - 1
QuestPDF/Previewer/ExceptionDocument.cs

@@ -81,7 +81,7 @@ namespace QuestPDF.Previewer
                             
                             .Text(text =>
                             {
-                                text.DefaultTextStyle(x => x.FontSize(18));
+                                text.DefaultTextStyle(x => x.FontSize(16));
 
                                 text.Span(currentException.GetType().Name + ": ").Bold();
                                 text.Span(currentException.Message);

+ 1 - 1
QuestPDF/Previewer/PreviewerService.cs

@@ -63,7 +63,7 @@ namespace QuestPDF.Previewer
                     StartInfo = new()
                     {
                         UseShellExecute = false,
-                        FileName = "questpdf-test",
+                        FileName = "questpdf-previewer",
                         CreateNoWindow = true
                     }
                 };