diff --git a/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml.cs b/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml.cs index 26e289cf2..1bf514988 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml.cs @@ -69,10 +69,15 @@ public void LoadFile(string path) searchPanel.SetMatchCount(0, 0); searchPanel.Visibility = Visibility.Collapsed; - var encoding = CharsetDetector.DetectFromFile(path).Detected?.Encoding ?? + var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + // Detect from a sample: only the first rows are shown, and scanning a whole large file + // here blocks the UI thread (and switching to the next file) for seconds on slow disks. + var encoding = CharsetDetector.DetectFromStream(stream, 1024 * 1024).Detected?.Encoding ?? Encoding.Default; + stream.Position = 0; - using var sr = new StreamReader(new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite), encoding); + using var sr = new StreamReader(stream, encoding); // Use fixed delimiters for known extensions to avoid mis-detection on small samples. var extension = Path.GetExtension(path); diff --git a/QuickLook.Plugin/QuickLook.Plugin.PDFViewer/PdfDocumentWrapper.cs b/QuickLook.Plugin/QuickLook.Plugin.PDFViewer/PdfDocumentWrapper.cs index e1883d734..51fe9ecfa 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.PDFViewer/PdfDocumentWrapper.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.PDFViewer/PdfDocumentWrapper.cs @@ -17,6 +17,7 @@ using System; using System.IO; +using System.Threading; using PdfiumViewer; namespace QuickLook.Plugin.PDFViewer; @@ -24,11 +25,35 @@ namespace QuickLook.Plugin.PDFViewer; public class PdfDocumentWrapper : IDisposable { public PdfDocumentWrapper(Stream stream, string password = null) + : this(stream, password, CancellationToken.None) { - PdfStream = new MemoryStream((int)stream.Length); - stream.CopyTo(PdfStream); + } + + /// + /// Copies the document into memory in chunks so a pending load can be abandoned + /// (e.g. when the user moves to another file) without reading the rest of the file. + /// + public PdfDocumentWrapper(Stream stream, string password, CancellationToken cancellationToken) + { + PdfStream = new MemoryStream(stream.Length <= int.MaxValue ? (int)stream.Length : 0); + try + { + var buffer = new byte[1024 * 1024]; + int read; + while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + PdfStream.Write(buffer, 0, read); + } - PdfDocument = PdfDocument.Load(PdfStream, password); + cancellationToken.ThrowIfCancellationRequested(); + PdfDocument = PdfDocument.Load(PdfStream, password); + } + catch + { + PdfStream.Dispose(); + throw; + } } public PdfDocument PdfDocument { get; private set; } diff --git a/QuickLook.Plugin/QuickLook.Plugin.PDFViewer/PdfViewerControl.xaml.cs b/QuickLook.Plugin/QuickLook.Plugin.PDFViewer/PdfViewerControl.xaml.cs index f684547a9..7a3a9f755 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.PDFViewer/PdfViewerControl.xaml.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.PDFViewer/PdfViewerControl.xaml.cs @@ -212,31 +212,36 @@ private void UpdatePageViewWhenSelectionChanged(object sender, SelectionChangedE public static Size GetDesiredControlSizeByFirstPage(string path, string password = null) { - Size size; + using var s = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var tempHandle = PdfDocument.Load(s, password); - using (var s = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + return GetDesiredControlSize(tempHandle); + } + + public static Size GetDesiredControlSize(PdfDocument document) + { + var size = new Size(0, 0); + document.PageSizes.Take(5).ForEach(p => { - using (var tempHandle = PdfDocument.Load(s, password)) - { - size = new Size(0, 0); - tempHandle.PageSizes.Take(5).ForEach(p => - { - size.Width = Math.Max(size.Width, p.Width); - size.Height = Math.Max(size.Height, p.Height); - }); - - if (tempHandle.PageCount > 1) - size.Width += /*listThumbnails.ActualWidth*/ 150; - } - } + size.Width = Math.Max(size.Width, p.Width); + size.Height = Math.Max(size.Height, p.Height); + }); + + if (document.PageCount > 1) + size.Width += /*listThumbnails.ActualWidth*/ 150; return new Size(size.Width * 3, size.Height * 3); } public void LoadPdf(string path, string password = null) { - var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - PdfDocumentWrapper = new PdfDocumentWrapper(stream, password); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + LoadPdf(new PdfDocumentWrapper(stream, password)); + } + + public void LoadPdf(PdfDocumentWrapper document) + { + PdfDocumentWrapper = document; _pdfLoaded = true; if (PdfDocumentWrapper.PdfDocument.PageCount < 2) diff --git a/QuickLook.Plugin/QuickLook.Plugin.PDFViewer/Plugin.cs b/QuickLook.Plugin/QuickLook.Plugin.PDFViewer/Plugin.cs index 54733c2d7..d0584ed43 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.PDFViewer/Plugin.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.PDFViewer/Plugin.cs @@ -22,7 +22,10 @@ using System.IO; using System.Reflection; using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; using System.Windows; +using System.Windows.Controls; using System.Windows.Threading; namespace QuickLook.Plugin.PDFViewer; @@ -33,6 +36,11 @@ public sealed class Plugin : IViewer private string _path; private PdfViewerControl _pdfControl; private PasswordControl _passwordControl; + private CancellationTokenSource _loadCts; + private Task _loadTask; + + /// How long Prepare() waits for the document to get the exact window size. + private const int PrepareWaitMilliseconds = 200; public int Priority => -1; @@ -62,14 +70,31 @@ public void Prepare(string path, ContextObject context) _context = context; _path = path; + // Load on a background thread: a large PDF takes seconds to read, and blocking the UI + // thread here would also block switching to the next file until it finishes. + _loadCts = new CancellationTokenSource(); + var token = _loadCts.Token; + _loadTask = Task.Run(() => + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + return new PdfDocumentWrapper(stream, null, token); + }, token); + try { - var desiredSize = PdfViewerControl.GetDesiredControlSizeByFirstPage(path); - context.SetPreferredSizeFit(desiredSize, 0.9); + // Typical documents are ready within this wait, so the window still gets the page size. + if (_loadTask.Wait(PrepareWaitMilliseconds)) + { + context.SetPreferredSizeFit(PdfViewerControl.GetDesiredControlSize(_loadTask.Result.PdfDocument), 0.9); + return; + } + + // Still loading: assume an A4 portrait page + context.SetPreferredSizeFit(new Size(595 * 3, 842 * 3), 0.9); } - catch (PdfException ex) when (ex.Message == "Password required or incorrect password") + catch (AggregateException) { - // Fallback to a size to request the password + // Fallback to a size to request the password, or to show the error (handled in View) context.PreferredSize = new Size { Width = 800, Height = 600 }; } } @@ -79,85 +104,135 @@ public void View(string path, ContextObject context) _pdfControl = new PdfViewerControl(); context.ViewerContent = _pdfControl; - Exception exception = null; + var control = _pdfControl; + var loadTask = _loadTask; - _ = _pdfControl.Dispatcher.BeginInvoke(() => + if (loadTask.IsCompleted) { - try - { - _pdfControl.LoadPdf(path); + // Keep the original behaviour: let the control be loaded first, and rethrow load + // errors so QuickLook falls back to another viewer. + Exception exception = null; + _ = control.Dispatcher.BeginInvoke(() => exception = OnDocumentLoaded(loadTask, control, path, context), + DispatcherPriority.Loaded).Wait(); + + if (exception != null) + ExceptionDispatchInfo.Capture(exception).Throw(); + return; + } - context.Title = $"1 / {_pdfControl.TotalPages}: {Path.GetFileName(path)}"; + loadTask.ContinueWith(_ => control.Dispatcher.BeginInvoke(() => + { + // Superseded by another file meanwhile: Cleanup() releases the document + if (_pdfControl != control) + return; - _pdfControl.CurrentPageChanged += UpdateWindowCaption; + var exception = OnDocumentLoaded(loadTask, control, path, context); + if (exception != null) + { + context.ViewerContent = new TextBlock + { + Text = exception.Message, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(24), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + context.Title = Path.GetFileName(path); context.IsBusy = false; } - catch (PdfException ex) when (ex.Message == "Password required or incorrect password") + }, DispatcherPriority.Loaded), TaskScheduler.Default); + } + + /// + /// Shows the loaded document, or the password prompt. Returns any other load error. + /// + private Exception OnDocumentLoaded(Task loadTask, PdfViewerControl control, string path, ContextObject context) + { + try + { + control.LoadPdf(loadTask.GetAwaiter().GetResult()); + + context.Title = $"1 / {control.TotalPages}: {Path.GetFileName(path)}"; + + control.CurrentPageChanged += UpdateWindowCaption; + context.IsBusy = false; + } + catch (PdfException ex) when (ex.Message == "Password required or incorrect password") + { + // Fallback to request a password + _passwordControl = new PasswordControl(); + _passwordControl.PasswordRequested += (string password) => { - // Fallback to request a password - _passwordControl = new PasswordControl(); - _passwordControl.PasswordRequested += (string password) => + try { - try + var desiredSize = PdfViewerControl.GetDesiredControlSizeByFirstPage(path, password); + context.SetPreferredSizeFit(desiredSize, 0.9); // Actually it is no longer effective here + context.ViewerContent = _pdfControl; + + // Call the viewer window private method using reflection + // QuickLook.ViewerWindow.ResizeAndCentreExistingWindow + if (Window.GetWindow(_pdfControl) is Window window) { - var desiredSize = PdfViewerControl.GetDesiredControlSizeByFirstPage(path, password); - context.SetPreferredSizeFit(desiredSize, 0.9); // Actually it is no longer effective here - context.ViewerContent = _pdfControl; + var ResizeAndCentreExistingWindow = window.GetType().GetMethod("ResizeAndCentreExistingWindow", + BindingFlags.NonPublic | BindingFlags.Instance); - // Call the viewer window private method using reflection - // QuickLook.ViewerWindow.ResizeAndCentreExistingWindow - if (Window.GetWindow(_pdfControl) is Window window) + if (ResizeAndCentreExistingWindow != null) { - var ResizeAndCentreExistingWindow = window.GetType().GetMethod("ResizeAndCentreExistingWindow", - BindingFlags.NonPublic | BindingFlags.Instance); + var newRect = (Rect)ResizeAndCentreExistingWindow.Invoke(window, [context.PreferredSize]); - if (ResizeAndCentreExistingWindow != null) - { - var newRect = (Rect)ResizeAndCentreExistingWindow.Invoke(window, [context.PreferredSize]); - - window.MoveWindow(newRect.Left, newRect.Top, newRect.Width, newRect.Height); - } + window.MoveWindow(newRect.Left, newRect.Top, newRect.Width, newRect.Height); } + } - context.IsBusy = true; - _pdfControl.LoadPdf(path, password); + context.IsBusy = true; + _pdfControl.LoadPdf(path, password); - context.Title = $"1 / {_pdfControl.TotalPages}: {Path.GetFileName(path)}"; + context.Title = $"1 / {_pdfControl.TotalPages}: {Path.GetFileName(path)}"; - _pdfControl.CurrentPageChanged += UpdateWindowCaption; - context.IsBusy = false; - } - catch (PdfException ex) when (ex.Message == "Password required or incorrect password") - { - // This password is not accepted - return false; - } + _pdfControl.CurrentPageChanged += UpdateWindowCaption; + context.IsBusy = false; + } + catch (PdfException ex) when (ex.Message == "Password required or incorrect password") + { + // This password is not accepted + return false; + } - // This password is accepted - return true; - }; + // This password is accepted + return true; + }; - context.ViewerContent = _passwordControl; - context.Title = $"[PASSWORD PROTECTED] {Path.GetFileName(path)}"; - context.IsBusy = false; - } - catch (Exception ex) - { - exception = ex; - } - }, DispatcherPriority.Loaded).Wait(); + context.ViewerContent = _passwordControl; + context.Title = $"[PASSWORD PROTECTED] {Path.GetFileName(path)}"; + context.IsBusy = false; + } + catch (Exception ex) + { + return ex; + } - if (exception != null) - ExceptionDispatchInfo.Capture(exception).Throw(); + return null; } public void Cleanup() { GC.SuppressFinalize(this); + // Stop reading the file if it is still loading + _loadCts?.Cancel(); + + var adopted = _pdfControl?.PdfDocumentWrapper; _pdfControl?.Dispose(); _pdfControl = null; + // Release a document that finished loading but was never shown + _loadTask?.ContinueWith(t => + { + if (t.Status == TaskStatus.RanToCompletion && !ReferenceEquals(t.Result, adopted)) + t.Result.Dispose(); + }, TaskScheduler.Default); + _loadTask = null; + _context = null; }