From 43279f1df3a7d4c379fe2bae4445af41f6100f38 Mon Sep 17 00:00:00 2001 From: GA Date: Sun, 25 Oct 2026 15:34:42 +0100 Subject: [PATCH] perf: load PDFs off the UI thread and detect CSV encoding from a sample PDFViewer: Prepare() parsed the whole document on the UI thread just to size the window, then View() read and parsed it again there. A 1.5 GB PDF blocked QuickLook for 7 s, during which switching to another file was impossible. The document is now loaded once on a background task. Prepare() waits up to 200 ms for it, so typical files still get their exact page size, and otherwise starts with an A4 page. The in-memory copy is done in chunks and cancelled from Cleanup(), so moving to another file stops reading at once. Load errors keep falling back to another viewer when the document is ready by View(). LoadPdf(path) now also closes its file stream. CsvViewer: CharsetDetector.DetectFromFile() read the whole file on the UI thread, although only the first 10000 rows are shown: 380 ms for a 119 MB CSV with a warm cache, delaying any switch to the next file. Detect the encoding from a 1 MB sample instead. Co-Authored-By: Claude Haiku 5.5 --- .../CsvViewerPanel.xaml.cs | 9 +- .../PdfDocumentWrapper.cs | 31 ++- .../PdfViewerControl.xaml.cs | 39 ++-- .../QuickLook.Plugin.PDFViewer/Plugin.cs | 185 ++++++++++++------ 4 files changed, 187 insertions(+), 77 deletions(-) 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; }