Driving a preview

Getting to the file, then driving three different previews: QLPreviewPanel on macOS, GNOME Sushi over D-Bus, QuickLook’s named pipe on Windows, and the EPUB conversion.

Three systems, three mechanisms, one behaviour: press the key, the preview appears; press it again, the preview goes away.

Getting to the file

Before any of that, the attachment has to become a path — and under download as needed it may not be a file on this machine at all. That setting is the default for group libraries and a common choice for personal ones, and under it most of a library is a database row until something asks for the file. A preview is such an asking.

Zotero offers two accessors, and the difference between them is easy to read the wrong way round. getFilePath() assembles the path the attachment should have. getFilePathAsync() assembles it and checks the file is on disk, answering false when it is not.

Reaching for the checking one first looks like the careful choice. It is the trap: “there is no such attachment” and “the file has not been fetched yet” come back as the same false, and the fetch that ought to follow sits behind a branch nothing can reach. Nothing was previewable that had not already been opened in Zotero once — silently, because a preview that never starts says nothing. Zotero’s own viewAttachment takes the synchronous accessor, and this plugin now does the same:

  1. getFilePath() for the path it should have.
  2. IOUtils.exists here, where the answer can be acted on.
  3. Zotero.Sync.Runner.downloadFile when the file is absent, the attachment is a stored one, and the library syncs files at all. A linked file that has gone missing has no copy on a server to fetch.
  4. getFilePathAsync() afterwards — now that the file should be there, the checking accessor is the one that answers both whether it arrived and where it went.

A book or a scan is megabytes over the network, so the fetch shows a progress window. Without one the plugin would look like it had ignored the keystroke, which is exactly what a missing file looked like for as long as the fetch was unreachable.

macOS — QLPreviewPanel

The plugin launches a small bundled helper, qlpreview, which drives QLPreviewPanel — the very panel the Finder shows. The subprocess handle is retained, so pressing Space again, or Escape, closes the preview.

Why not qlmanage

/usr/bin/qlmanage -p looks like the obvious answer and is not. It is a debugging tool rather than the Finder’s Quick Look, and it cannot show everything the Finder can. On a video it dies outright:

NSInternalInconsistencyException
'Setting highlightedTimeRanges is not supported in your app.'
AVKitCore  -[AVPlayerItem setHighlightedTimeRanges:]
Movie      (/System/Library/QuickLook/Movie.qlgenerator)

Apple’s own movie generator calls an AVKit method that AVKit refuses inside qlmanage. Driving QLPreviewPanel instead renders whatever the Finder renders, third-party Quick Look extensions included. qlmanage is kept only as a fallback for the case where the helper cannot be deployed.

Linux — GNOME Sushi over D-Bus

dbus-send --session --print-reply --reply-timeout=10000 \
  --dest=org.gnome.NautilusPreviewer \
  /org/gnome/NautilusPreviewer org.gnome.NautilusPreviewer.ShowFile \
  string:file:///path int32:0 boolean:true
Warning–print-reply is not there for its output

Leaving it off is not a small difference. Without it dbus-send writes the message and exits within milliseconds. Sushi is started by D-Bus on demand and takes about a second to come up, and dbus-daemon discards a message queued for an activation as soon as the connection that sent it goes away. The service then starts, is never told what to show, and quits again on its twelve-second idle timeout.

Nothing appears and nothing is logged. The failure is completely silent, which is what makes it worth a warning box. Waiting for the reply keeps the connection open until the call has been delivered.

The reply is also the only sign the plugin gets that the preview service answered at all, so the exit status is checked and a refusal — Sushi not installed, most likely — is named in the log rather than swallowed.

The call returns as soon as the window has been handed its file, rather than staying alive for as long as the preview, so there is no process to hold and kill. ShowFile’s last argument closes the preview when the same file is already showing, which gives Space the same toggle it has on macOS. Sushi shows one file at a time.

Windows — QuickLook’s named pipe

QuickLook listens on a named pipe, QuickLook.App.Pipe.<user SID>. One UTF-8 line through it —

QuickLook.App.PipeMessages.Toggle|C:\path\file.pdf|

— shows the preview, the same line again dismisses it, and a different path switches to that file. So Space has its toggle here too, again with no process to hold.

The line is written straight from the plugin through js-ctypes: a handful of Win32 calls, no helper binary, no subprocess, with the SID read from the process token. Should Gecko ever drop ctypes, a PowerShell one-liner does the same write; it reaches the shell as -EncodedCommand, so no execution policy and no command-line quoting apply to it.

An earlier attempt at this integration (QuickLook #584, 2019) foundered on exactly that SID, and its era’s conclusion — that the Store build is sandboxed away from the pipe — was repeated for years. Measured today, neither holds: the Store package declares runFullTrust and answers the pipe like the desktop build.

CautionNever probe the pipe

QuickLook’s pipe reader takes a line per connection and dereferences whatever it got. A client that connects and sends nothing — a Test-Path on the pipe path, any is-it-there probe — kills the listener, silently, until QuickLook is restarted.

The plugin therefore never probes. Every connection it makes carries one complete line, and “QuickLook is not running” is learned from the connect failing, which is when it is named in the log.

EPUB, everywhere

No system preview renders an EPUB, so this is the one format the plugin transforms rather than hands over. Everything else — MOBI, DJVU, whatever a preview extension claims — goes to the panel untouched.

The book is read out of its archive, through two readers the platform already provides. Gecko’s nsIZipReader opens the container; Zotero’s own EPUB.mjs — the module it uses for full-text indexing — joins the manifest to the spine and yields the sections in reading order, already parsed. Each chapter’s <body> is appended into a single output document.

NoteWhy so little of this is ours

Until 1.2.0 the plugin spawned /usr/bin/unzip, or tar.exe on Windows, and spread the book across a temp directory — a subprocess and its timeout for something the platform does in-process, plus a directory to clean up. What Zotero already maintains is now used instead. What it does not offer is a converter: its EPUB reader is epub.js, which renders live in a Zotero window, and a preview panel runs no scripts at all. So the assembly stays ours.

The book’s own stylesheets, linked and inline, are carried in as text so the original layout survives, with a Palatino base font, an 80 px margin and a 720 px book-width column applied on top. Images, fonts and inline SVGs — named in markup and in CSS — are written out into an asset/ directory beside the page, once each however often the book names them, and referred to relatively from there.

NoteWhy not embedded in the page

They were, briefly, as data: URLs. That made the page self-contained, but base64 costs a third more than the file it encodes and the whole book has to be held as one string while it is built — which called for a size limit, and a limit means a preview that shows part of a book. A preview of part of a book is not a preview of that book. Written out beside the page there is nothing to limit: the volume can be any size, and the store removes the entry whole when its time comes.

Parsing and assembly go through DOMParser and run on the document tree, so scripts and inline event handlers are removed as nodes rather than matched as text. A converted book is kept and reused until its file changes — see the store.

Annotations

Zotero keeps annotations in its database rather than in the file, for an EPUB as for a PDF. The PDF path asks Zotero to bake them in — PDFWorker.export writes a copy with the marks drawn on the page, and that copy is what the panel shows. There is no such export for an EPUB. Zotero’s reader draws them live from an EPUB CFI, and the engine that resolves a CFI lives inside the reader’s own bundle, reachable only from the reader.

So the CFI is read here, and written for one thing only — the link each tile of a page overview carries back into the reader. cfi.js parses it and resolves it against the spine document, which is exactly the tree the CFI was written against and exactly what getSectionDocuments() hands over. That is why the marking happens per section, before the page is assembled: afterwards the tree the identifier describes no longer exists.

The package path names the spine item — /6 the <spine> among the package’s children, /14 the seventh <itemref> — and Zotero’s own annotationSortIndex begins with that same number, two independent statements of one fact. The rest of the path walks the document to a text node, and the offsets are the extent. They are drawn as Zotero draws them, not as it seemed reasonable to draw them. Its reader styles an annotation in flowing text as

type === "underline" ? { textDecorationColor: color }
                     : { backgroundColor: color + "80" }

— so a highlight carries its colour at half opacity, the same alpha the PDF canvas fills a highlight with, and an underline is a real underline in the annotation’s colour, 2 px thick at 2 px offset from the reader’s own stylesheet. A solid fill was what this did before: a purple or blue passage became hard to read, and it looked unlike the document it came from. A range crossing element boundaries becomes one wrapper per text node, which is what a browser does with a selection.

The colour itself is whatever the annotation carries. Zotero offers eight, but the field holds a string and plugins put their own palettes in it — a library checked here had 232 of 500 annotations in colours Zotero never offers, from zotQDA. All of them are six-digit hex, which is what Zotero’s own reader requires too, since it appends the alpha as two more digits exactly as this does. A three-digit form is written out first, or the alpha would land inside the colour; anything else is passed on untouched and without an alpha, because an unexpected notation should cost the right opacity at worst, never the right colour.

Noteepub.js, not the paper

A strict reading of EPUB CFI 1.1 interleaves elements and character data in one numbering, so that odd steps address the text between elements. epub.js counts text children among text children — and epub.js is what wrote every CFI this will ever read. Matching the writer is what makes the answer right.

NoteNothing is dropped

A note carries no text to place, a comment belongs to no particular word, and a highlight whose wording the markup broke differently cannot be found. All of them appear in a list behind a button in the corner, in reading order, with their colour and comment — and one that could not be placed is shown as such, rather than quietly missing.

The quotation in an entry is marked as the passage is marked, which is what Zotero does in its own annotation list: the same span with the same style, annotation-text beside a menu button and a hint. A swatch alone says which colour was used; this says what was done with it.

The menu opens closed, and each entry folds: the quotation is cut to two lines by the stylesheet — measured in Quick Look, which renders the clamp — and opening one shows the whole of it, the comment, and a link to the passage. One copy of the text either way, cut where it is shown rather than where it is built, so nothing is lost and nothing duplicated. A flex row puts the swatch beside the first line, which takes the native disclosure marker away, so one is drawn instead — steadier in any case across three engines that each draw their own differently.

An entry whose passage is in the page links into it: the first piece of the marked range carries an anchor, and the entry points at it. First in reading order, not first wrapped — wrapping runs backwards, because splitting a text node moves every offset after it, and a reader following the link should arrive at the beginning of the passage rather than its end.

The table of contents

What is read is the navigation the book itself carries — EPUB 3 keeps it in a navigation document (the manifest item with properties="nav"), EPUB 2 in the NCX file the spine names in its toc attribute. Both are read, the newer first. Titles and nesting are therefore the publisher’s; ones derived from headings would be guesses.

NoteWhy

and no script

macOS renders previews with JavaScript disabled and refuses ordinary navigation, which appears to rule out a contents box that folds. It does not: <details>/<summary> opens natively, without a line of script, and anchors within the same page are followed.

Both were measured by clicking them in a real preview — the fold works, the jump works. That is why the contents are built this way and not another.

The same element carries the floating menu: the <summary> is fixed to a corner as a button, and opening it lays the list over the text instead of pushing the book down. No script is involved in that either — <details> holds the state itself, which is the only reason such a menu can exist in a panel that runs none. Below 420 px the menus go back into the flow, where they cover nothing.

Each chapter is given an anchor of its own during assembly (zotlook-ch0, zotlook-ch1, …). It is needed because the navigation often points at the file alone and the book’s own ids may be missing. Where an entry does point inside a chapter, that is where it jumps — but only if the id really survives into the assembled document; sanitising removes scripts and event attributes and leaves ids alone.

A book with no usable navigation gets no box. The preview of a document without chapters should look like the document, not like a feature that failed.

Because the plugin cannot reliably tell whether an EPUB preview extension is installed, the conversion is a setting rather than a guess.