WIP Implemented virtualization of pages.
Render pages into seperate SKPictures
This commit is contained in:
@@ -1,22 +1,55 @@
|
||||
using QuestPDF.Drawing;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using Avalonia.Threading;
|
||||
using QuestPDF.Drawing;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace QuestPDF.Previewer
|
||||
{
|
||||
internal class DocumentRenderer
|
||||
public record RenderedPageInfo(SKPicture Picture, Size Size);
|
||||
|
||||
internal class DocumentRenderer : INotifyPropertyChanged
|
||||
{
|
||||
public float PageSpacing { get; set; }
|
||||
public Size Bounds { get; private set; }
|
||||
public IDocument? Document { get; private set; }
|
||||
public SKPicture? Picture { get; private set; }
|
||||
public Exception? RenderException { get; private set; }
|
||||
public bool IsRendering { get; private set; }
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private ObservableCollection<RenderedPageInfo> _pages = new();
|
||||
public ObservableCollection<RenderedPageInfo> Pages
|
||||
{
|
||||
get => _pages;
|
||||
set
|
||||
{
|
||||
if (_pages != value)
|
||||
{
|
||||
_pages = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Pages)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool _isRendering;
|
||||
public bool IsRendering
|
||||
{
|
||||
get => _isRendering;
|
||||
private set
|
||||
{
|
||||
if (_isRendering != value)
|
||||
{
|
||||
_isRendering = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsRendering)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateDocument(IDocument? document)
|
||||
{
|
||||
Document = document;
|
||||
RenderException = null;
|
||||
if (document != null)
|
||||
{
|
||||
try
|
||||
@@ -26,9 +59,7 @@ namespace QuestPDF.Previewer
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RenderException = ex;
|
||||
Picture?.Dispose();
|
||||
Picture = null;
|
||||
RenderDocument(CreateExceptionDocument(ex));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -39,23 +70,59 @@ namespace QuestPDF.Previewer
|
||||
|
||||
private void RenderDocument(IDocument document)
|
||||
{
|
||||
Picture?.Dispose();
|
||||
var canvas = new PreviewerCanvas();
|
||||
|
||||
var canvas = new PreviewerCanvas()
|
||||
{
|
||||
PageSpacing = PageSpacing,
|
||||
};
|
||||
|
||||
using var recorder = new SKPictureRecorder();
|
||||
DocumentGenerator.RenderDocument(canvas, new SizeTrackingCanvas(), document, s =>
|
||||
{
|
||||
var width = s.PageSizes.Max(p => p.Width);
|
||||
var height = s.PageSizes.Sum(p => p.Height) + ((s.PageSizes.Count - 1) * PageSpacing);
|
||||
Bounds = new Size(width, height);
|
||||
canvas.Canvas = recorder.BeginRecording(new SKRect(0, 0, width, height));
|
||||
canvas.MaxPageWidth = width;
|
||||
});
|
||||
Picture = recorder.EndRecording();
|
||||
|
||||
foreach (var pages in Pages)
|
||||
pages?.Picture?.Dispose();
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
Pages.Clear();
|
||||
Pages = new ObservableCollection<RenderedPageInfo>(canvas.Pictures);
|
||||
});
|
||||
}
|
||||
|
||||
private static IDocument CreateExceptionDocument(Exception exception)
|
||||
{
|
||||
return Fluent.Document.Create(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(exception.GetType().Name).FontSize(20).SemiBold();
|
||||
column.Item().Text(exception.Message).FontSize(14);
|
||||
column.Item().PaddingTop(10).Text(exception.StackTrace).FontSize(10).Light();
|
||||
|
||||
currentException = currentException.InnerException;
|
||||
|
||||
if (currentException != null)
|
||||
column.Item().PaddingVertical(15).LineHorizontal(2).LineColor(Colors.Red.Medium);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,24 +7,11 @@ namespace QuestPDF.Previewer
|
||||
/// </summary>
|
||||
internal static class HotReloadManager
|
||||
{
|
||||
private static readonly List<Action> _actions = new();
|
||||
|
||||
public static void Register(Action action)
|
||||
{
|
||||
_actions.Add(action);
|
||||
}
|
||||
|
||||
public static void Unregister(Action action)
|
||||
{
|
||||
_actions.Remove(action);
|
||||
}
|
||||
|
||||
public static void ClearCache(Type[]? _) { }
|
||||
public static event EventHandler? UpdateApplicationRequested;
|
||||
|
||||
public static void UpdateApplication(Type[]? _)
|
||||
{
|
||||
foreach (var action in _actions)
|
||||
action();
|
||||
UpdateApplicationRequested?.Invoke(null, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,45 +6,38 @@ namespace QuestPDF.Previewer
|
||||
{
|
||||
internal sealed class PreviewerCanvas : SkiaCanvasBase, IRenderingCanvas
|
||||
{
|
||||
private bool _isFirstPage;
|
||||
private Size? _lastPageSize;
|
||||
private float? _distanceToCenter;
|
||||
private SKPictureRecorder? _currentRecorder;
|
||||
|
||||
public float PageSpacing { get; set; }
|
||||
public float MaxPageWidth { get; set; }
|
||||
private readonly List<RenderedPageInfo> _pictures = new();
|
||||
public IReadOnlyList<RenderedPageInfo> Pictures => _pictures;
|
||||
|
||||
private Size? _currentSize;
|
||||
|
||||
public override void BeginDocument()
|
||||
{
|
||||
_isFirstPage = true;
|
||||
_pictures.Clear();
|
||||
}
|
||||
|
||||
public override void BeginPage(Size size)
|
||||
{
|
||||
_lastPageSize = size;
|
||||
|
||||
if (!_isFirstPage)
|
||||
Canvas.Translate(0, PageSpacing);
|
||||
|
||||
if (MaxPageWidth > 0)
|
||||
{
|
||||
_distanceToCenter = (MaxPageWidth - size.Width) / 2;
|
||||
Canvas.Translate(_distanceToCenter.Value, 0);
|
||||
}
|
||||
_currentSize = size;
|
||||
_currentRecorder = new SKPictureRecorder();
|
||||
Canvas = _currentRecorder.BeginRecording(new SKRect(0, 0, size.Width, size.Height));
|
||||
|
||||
using var paint = new SKPaint() { Color = SKColors.White };
|
||||
Canvas.DrawRect(0, 0, size.Width, size.Height, paint);
|
||||
}
|
||||
|
||||
_isFirstPage = false;
|
||||
public override void EndPage()
|
||||
{
|
||||
var picture = _currentRecorder?.EndRecording();
|
||||
if (picture != null && _currentSize.HasValue)
|
||||
_pictures.Add(new RenderedPageInfo(picture, _currentSize.Value));
|
||||
|
||||
_currentRecorder?.Dispose();
|
||||
_currentRecorder = null;
|
||||
}
|
||||
|
||||
public override void EndDocument() { }
|
||||
public override void EndPage()
|
||||
{
|
||||
if (_lastPageSize.HasValue)
|
||||
Canvas.Translate(0, _lastPageSize.Value.Height);
|
||||
|
||||
if (_distanceToCenter.HasValue)
|
||||
Canvas.Translate(-_distanceToCenter.Value, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace QuestPDF.Previewer
|
||||
{
|
||||
internal class PreviewerControl : Control
|
||||
{
|
||||
public static readonly StyledProperty<IDocument?> DocumentProperty =
|
||||
AvaloniaProperty.Register<PreviewerControl, IDocument?>(nameof(Document));
|
||||
|
||||
public IDocument? Document
|
||||
{
|
||||
get => GetValue(DocumentProperty);
|
||||
set => SetValue(DocumentProperty, value);
|
||||
}
|
||||
|
||||
public static readonly StyledProperty<bool> IsGeneratingDocumentProperty =
|
||||
AvaloniaProperty.Register<PreviewerControl, bool>(nameof(IsGeneratingDocument));
|
||||
|
||||
public bool IsGeneratingDocument
|
||||
{
|
||||
get => GetValue(IsGeneratingDocumentProperty);
|
||||
set => SetValue(IsGeneratingDocumentProperty, value);
|
||||
}
|
||||
|
||||
public static readonly StyledProperty<double> PageSpacingProperty =
|
||||
AvaloniaProperty.Register<PreviewerControl, double>(nameof(PageSpacing), 20);
|
||||
|
||||
public double PageSpacing
|
||||
{
|
||||
get => GetValue(PageSpacingProperty);
|
||||
set => SetValue(PageSpacingProperty, value);
|
||||
}
|
||||
|
||||
private readonly DocumentRenderer _renderer = new();
|
||||
|
||||
public PreviewerControl()
|
||||
{
|
||||
ClipToBounds = true;
|
||||
|
||||
_renderer.PageSpacing = (float)PageSpacing;
|
||||
DocumentProperty.Changed.Subscribe(_ => _renderer.UpdateDocument(Document));
|
||||
PageSpacingProperty.Changed.Subscribe(f => _renderer.PageSpacing = (float)f.NewValue.Value);
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
IsGeneratingDocument = _renderer.IsRendering;
|
||||
if (_renderer.IsRendering)
|
||||
return;
|
||||
|
||||
if (_renderer.RenderException != null)
|
||||
{
|
||||
context.DrawRectangle(Brushes.Transparent, null, new Rect(0, 0, Bounds.Width, Bounds.Height));
|
||||
DrawException(context, _renderer.RenderException);
|
||||
return;
|
||||
}
|
||||
|
||||
Width = _renderer.Bounds.Width;
|
||||
Height = _renderer.Bounds.Height;
|
||||
|
||||
if (_renderer.Picture == null)
|
||||
return;
|
||||
|
||||
context.Custom(new SkCustomDrawOperation(
|
||||
new Rect(0, 0, Bounds.Width, Bounds.Height),
|
||||
c => c.DrawPicture(_renderer.Picture)));
|
||||
}
|
||||
|
||||
internal void InvalidateDocument()
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
_renderer.UpdateDocument(Document);
|
||||
InvalidateVisual();
|
||||
});
|
||||
}
|
||||
|
||||
private void DrawException(DrawingContext context, Exception ex)
|
||||
{
|
||||
var parentBounds = Parent?.Bounds ?? Bounds;
|
||||
|
||||
var exceptionMsg = string.Join("\n", ex.GetType(), ex.Message);
|
||||
|
||||
var fmtText = new FormattedText($"Exception occured:\n{exceptionMsg}",
|
||||
Typeface.Default, 25, TextAlignment.Left, TextWrapping.Wrap,
|
||||
new Avalonia.Size(parentBounds.Width / 2, parentBounds.Height / 2));
|
||||
|
||||
var center = new Point((parentBounds.Width - fmtText.Bounds.Width) / 2, (parentBounds.Height - fmtText.Bounds.Height) / 2);
|
||||
context.DrawText(Brushes.Black, center, fmtText);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Reactive.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace QuestPDF.Previewer
|
||||
{
|
||||
internal class PreviewerPageControl : Control
|
||||
{
|
||||
public static readonly StyledProperty<RenderedPageInfo?> PageProperty =
|
||||
AvaloniaProperty.Register<PreviewerPageControl, RenderedPageInfo?>(nameof(Page));
|
||||
|
||||
public RenderedPageInfo? Page
|
||||
{
|
||||
get => GetValue(PageProperty);
|
||||
set => SetValue(PageProperty, value);
|
||||
}
|
||||
|
||||
public PreviewerPageControl()
|
||||
{
|
||||
PageProperty.Changed.Take(1).Subscribe(p =>
|
||||
{
|
||||
var size = p.NewValue.Value?.Size ?? Infrastructure.Size.Zero;
|
||||
Width = size.Width;
|
||||
Height = size.Height;
|
||||
});
|
||||
|
||||
ClipToBounds = true;
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
base.Render(context);
|
||||
|
||||
//context.DrawRectangle(Brushes.Red, null, new Rect(0, 0, Bounds.Width, Bounds.Height));
|
||||
|
||||
var picture = Page?.Picture;
|
||||
if (picture != null)
|
||||
context.Custom(new SkCustomDrawOperation(
|
||||
new Rect(0, 0, Bounds.Width, Bounds.Height),
|
||||
c => c.DrawPicture(picture)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
Icon="/Images/Logo.png"
|
||||
UseLayoutRounding="True"
|
||||
Background="{x:Null}"
|
||||
x:Name="Window"
|
||||
Title="QuestPDF Document Preview">
|
||||
<FluentWindow.Styles>
|
||||
<Style Selector="TitleBar:fullscreen">
|
||||
@@ -26,16 +27,33 @@
|
||||
<Grid Margin="10,40,10,0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="40"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<DockPanel LastChildFill="False" VerticalAlignment="Center" Margin="20,0" Width="{Binding #PreviewerSurface.Width, Mode=OneWay}">
|
||||
<DockPanel LastChildFill="False" VerticalAlignment="Center" Margin="20,0">
|
||||
<TextBlock VerticalAlignment="Center" Text="QuestPDF Document Preview" DockPanel.Dock="Left" FontWeight="SemiBold" />
|
||||
<Button x:Name="GeneratePdf" Content="Generate PDF" DockPanel.Dock="Right" />
|
||||
</DockPanel>
|
||||
|
||||
<ScrollViewer Grid.Row="1" VerticalAlignment="Top">
|
||||
<previewer:PreviewerControl x:Name="PreviewerSurface" Margin="25" />
|
||||
|
||||
<ProgressBar IsVisible="{Binding #Window.DocumentRenderer.IsRendering}"
|
||||
Margin="20,0"
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
Height="5"
|
||||
IsIndeterminate="True"/>
|
||||
|
||||
<ScrollViewer Grid.Row="2" VerticalAlignment="Top">
|
||||
<ItemsRepeater HorizontalAlignment="Center" Margin="0,10" Grid.Row="1" VerticalAlignment="Top" Items="{Binding #Window.DocumentRenderer.Pages, Mode=OneWay}">
|
||||
<ItemsRepeater.Layout>
|
||||
<UniformGridLayout MaximumRowsOrColumns="1" Orientation="Horizontal" MinRowSpacing="25" MinColumnSpacing="25" />
|
||||
</ItemsRepeater.Layout>
|
||||
<ItemsRepeater.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<previewer:PreviewerPageControl Page="{Binding}" />
|
||||
</DataTemplate>
|
||||
</ItemsRepeater.ItemTemplate>
|
||||
</ItemsRepeater>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Panel>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace QuestPDF.Previewer
|
||||
{
|
||||
internal class PreviewerWindow : FluentWindow
|
||||
{
|
||||
private readonly PreviewerControl _previewHost;
|
||||
public DocumentRenderer DocumentRenderer { get; } = new();
|
||||
|
||||
public static readonly StyledProperty<IDocument?> DocumentProperty =
|
||||
AvaloniaProperty.Register<PreviewerWindow, IDocument?>(nameof(Document));
|
||||
@@ -22,13 +23,11 @@ namespace QuestPDF.Previewer
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_previewHost = this.FindControl<PreviewerControl>("PreviewerSurface");
|
||||
|
||||
this.FindControl<Button>("GeneratePdf")
|
||||
.Click += (_, _) => _ = PreviewerUtils.SavePdfWithDialog(Document, this);
|
||||
|
||||
DocumentProperty.Changed.Subscribe(v => _previewHost.Document = v.NewValue.Value);
|
||||
HotReloadManager.Register(InvalidatePreview);
|
||||
DocumentProperty.Changed.Subscribe(v => Task.Run(() => DocumentRenderer.UpdateDocument(v.NewValue.Value)));
|
||||
HotReloadManager.UpdateApplicationRequested += (_, _) => InvalidatePreview();
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
@@ -38,7 +37,11 @@ namespace QuestPDF.Previewer
|
||||
|
||||
private void InvalidatePreview()
|
||||
{
|
||||
_previewHost.InvalidateDocument();
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var document = Document;
|
||||
_ = Task.Run(() => DocumentRenderer.UpdateDocument(document));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user