The contact sheet
Every page of a PDF as a JPEG thumbnail, in a scrollable HTML grid that the preview panel can show.
Rendering
Pages are rendered in ChromeWorkers against the pdf.js build Zotero already ships — nothing is downloaded and nothing has to be compiled.
- Four workers at most, fewer when the document is large enough that four copies of it are not worth the memory, and never more than there are cores.
- Pages are dealt round-robin, not in blocks: a document’s heavy pages sit together — plates, scanned inserts — and a block split would hand one worker all of them.
- Thumbnails are written as files beside the HTML and referenced with
loading="lazy", so the document stays small and images are decoded only as they scroll into view. Inlining a 300-page book as base64 produced a 111 MB document that Quick Look could not display at all; the same book is now about 7 seconds and 38 KB of HTML. - Pages are rendered from the crop box with their
/Rotateentry applied, so landscape and rotated scans come out the right way round.
Generation runs under a 60-second deadline and shows a progress window while it works.
The renderer that was dropped
There was a second renderer: a bundled Swift binary using PDFKit. It was about four times faster — 0.12 s against 0.42 s over 30 pages, measured on the same document at the same width. It was removed anyway.
It only ever ran on macOS, so its annotation drawing was a second implementation that had to be kept looking like the first. Reaching it meant a subprocess, a JSON manifest and a pipe to read — machinery that produced two silent failures in one afternoon and existed for nothing else.
Poppler’s pdftoppm was measured as an alternative and turned out only a third faster per thread than pdf.js, while being impossible to bundle. MuPDF was slower than both.
| renderer | 30 pages, one thread |
|---|---|
| Swift / PDFKit | 0.12 s (across ten cores) |
Poppler pdftoppm |
0.56 s |
| pdf.js in a worker | 0.72 s |
MuPDF mutool |
1.99 s |
The gap was concurrency, not the engine. What remains renders identically on every platform, and identically to Zotero’s own reader, being the same engine.
Getting annotations onto the thumbnails
This took some finding.
Zotero’s build of pdf.js keeps a list of annotation subtypes it will paint onto a canvas, and Highlight is not on it — nor are Zotero’s own area and ink annotations, which it excludes by name. Its reader draws them in an HTML layer instead. No rendering intent and no annotation mode changes that.
The data still comes through getAnnotations, so the renderer draws them itself: quads under multiply for highlights, so the text stays readable through them; a rule for underline and strikeout; an outline for areas; polylines for ink.
Making a click open the reader
Each thumbnail is wrapped in a link to zotero://open-pdf/…?page=N.
Quick Look renders previews with JavaScript disabled and refuses ordinary navigation — measured by serving a probe page over a local HTTP server and watching which requests arrived. The same measurement confirmed that previews do load files sitting next to the HTML, which is what both the contact sheet and the EPUB preview rely on.
A new-window request is a different path through WebKit, and Quick Look does hand those to the system handler. That is what target="_blank" is for, and it was established by clicking all four combinations in a real preview:
| link | in Quick Look |
|---|---|
zotero: with target="_blank" |
opens the reader at that page; the preview closes |
zotero: without it |
a beep, nothing else |
http: with target="_blank" |
opens the browser |
QuickLook for Windows behaves the same way — measured in its viewer too, a click opens Zotero’s reader at that page.
Its WebKit view really does follow links, and it cannot load a zotero: URL. The load fails, and a failed load is how Sushi’s renderer reports that it cannot show the file at all — so a click replaced the whole sheet with “Unable to display”.
With target="_blank", WebKit raises create instead of navigating, Sushi has no handler for it, and the click does nothing while the sheet stays up. The attribute was added for exactly this, and turned out to be what makes the links work on macOS.
Nothing is lost where the links do work: zotero://open-pdf is a noContent handler that performs an action rather than returning a page, so it does not care which context loads it. The links are styled without underline or colour, so one sheet serves every route.
Layout
The grid adapts to the page count: a document with fewer pages than the configured column count gets fewer columns, and the renderer raises the per-thumbnail resolution to match, so short documents fill the width sharply instead of being blown up.
That sizing rule lives in sheet.js, apart from the rendering, so the grid, the labels and the links can be changed and tested without a PDF anywhere in sight.
Two corner menus sit over the grid: the contents and the annotations. The outline comes out of the PDF in the render worker, the only place that has the document open — pdf.js reads it and resolves each destination to a page, named destinations included — and travels back with the first worker’s opened message. The annotations are read from Zotero’s database, where the stored position of each carries its page index. Both menus are <details> and CSS, shared with the EPUB preview through zotLookSheet.MENU_CSS, because a preview panel runs no scripts. An entry is a fragment link to the page’s tile; a :target rule frames the tile, and a scroll-margin-top written as an expression of the window’s width puts it in the middle of the window, since the tile’s height on screen is not known when the sheet is written.
A fragment link is a navigation, though, and in Zotero’s own window that is one navigation too many. The sheet is a file: document in a browser that may switch process for it, and the click comes back out through the window’s browser shim as a file for something to open: what appears is a dialog asking which application handles file links. The system previews do not ask, so it took the window to show it. The sheet therefore carries one small script that answers such a click itself — scroll to the tile, and put on as a class the outline :target would have drawn. It is an addition and not a requirement: where scripts do not run the anchors are ordinary anchors and jump as they always did, and only same-page fragments are taken. A page tile’s link leads to the reader and stays a real navigation.
What is kept, and how it goes away again
The sheet is not the only thing here that is made from an attachment and costs seconds to make; the EPUB preview and the annotated copy of a PDF are too. All three go through one store, which is what keeps the answers to is this still right and when does it go away in a single place.
An entry is a directory holding whatever that kind of artefact consists of — a sheet with its thumbnails, an assembled EPUB page — plus a .key file naming what it was made from: plugin version, path, size and modification time of the source, and for the sheet also the annotation state, the column count and the page limit. A directory rather than files side by side is what lets the clearing remove an entry without knowing what kind it is.
The key file is written last, and only on success. An aborted run therefore leaves no key behind, and a half-built entry cannot be mistaken for a finished one. On every hit it is rewritten: its modification time thus means “last used” rather than “built”, which is the distinction the age-based clearing lives on.
Kept entries sit in zotLook-cache under the local profile directory. They first lived beside the working directory, which put them inside Zotero’s temp directory — and Zotero registers a shutdown blocker that deletes that whole directory at every exit, so no kept entry ever survived a restart and the settings pane truthfully reported 0 B. What is not kept — the annotated copy — goes into the working directory itself and is swept away with it.
The clearing runs at startup rather than on a timer. A directory listing costs nothing, and a clearing that happened mid-session would make the same preview appear at once one moment and not the next.