Repository navigation
perf: load PDFs off the UI thread and detect CSV encoding from a sample #2044
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 ?? | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔎 Supported by static analysis
Preserve non-ASCII values after an ASCII-only sample.
|
||
| 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); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<PdfDocumentWrapper> _loadTask; | ||
|
|
||
| /// <summary>How long Prepare() waits for the document to get the exact window size.</summary> | ||
| 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); | ||
| } | ||
|
Comment on lines
+107
to
+144
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔎 Supported by static analysis
Route deferred PDF load failures through When Pass the host failure callback into the deferred PDF path and invoke it with the captured exception. Do not rethrow from the later dispatcher callback, because it runs outside the host’s
|
||
|
|
||
| /// <summary> | ||
| /// Shows the loaded document, or the password prompt. Returns any other load error. | ||
| /// </summary> | ||
| private Exception OnDocumentLoaded(Task<PdfDocumentWrapper> 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; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dispose the file stream if detection fails.
If
DetectFromStreamor the position reset throws, execution never reaches theStreamReaderdeclaration. The file stream then remains open until finalization. Declare the stream withusingat creation so the exception path closes it;StreamReaderdisposes its supplied stream only after the reader exists. (learn.microsoft.com)