Eine Vorschau ansteuern

Zur Datei kommen und drei Vorschauen ansteuern: QLPreviewPanel unter macOS, GNOME Sushi über D-Bus, QuickLooks Named Pipe unter Windows, und die EPUB-Wandlung.

Drei Systeme, drei Mechanismen, ein Verhalten: Taste drücken, Vorschau erscheint; nochmals drücken, Vorschau verschwindet.

Zur Datei kommen

Vorher muss aus dem Anhang erst ein Pfad werden — und bei Dateien bei Bedarf laden liegt womöglich gar keine Datei auf diesem Rechner. Diese Einstellung ist für Gruppenbibliotheken die Vorgabe und für eigene eine verbreitete Wahl; unter ihr ist der größte Teil einer Bibliothek ein Datenbankeintrag, solange niemand nach der Datei fragt. Eine Vorschau ist eine solche Frage.

Zotero bietet zwei Zugriffe, und ihr Unterschied lässt sich leicht falsch herum lesen. getFilePath() setzt den Pfad zusammen, den der Anhang haben sollte. getFilePathAsync() setzt ihn zusammen und prüft, ob die Datei auf der Platte liegt — fehlt sie, lautet die Antwort false.

Zum prüfenden Zugriff zu greifen sieht nach der sorgfältigen Wahl aus. Genau darin liegt die Falle: „diesen Anhang gibt es nicht” und „die Datei ist noch nicht geholt” kommen als dasselbe false zurück, und das Nachladen, das nun folgen müsste, liegt hinter einem Zweig, den nichts mehr erreicht. Nichts war mehr in der Vorschau zu sehen, was nicht schon einmal in Zotero geöffnet worden war — lautlos, denn eine Vorschau, die gar nicht erst anläuft, sagt nichts. Zoteros eigenes viewAttachment nimmt den synchronen Zugriff, und dieses Plugin tut es ihm nach:

  1. getFilePath() für den Pfad, den die Datei haben sollte.
  2. IOUtils.exists hier, wo sich auf die Antwort reagieren lässt.
  3. Zotero.Sync.Runner.downloadFile, wenn die Datei fehlt, der Anhang ein gespeicherter ist und die Bibliothek Dateien überhaupt synchronisiert. Für eine verschwundene verknüpfte Datei gibt es keine Kopie auf einem Server.
  4. getFilePathAsync() danach — jetzt, da die Datei da sein sollte, ist der prüfende Zugriff derjenige, der beides beantwortet: ob sie angekommen ist und wohin.

Ein Buch oder ein Scan sind Megabytes über die Leitung, deshalb zeigt das Nachladen ein Fortschrittsfenster. Ohne eines sähe es so aus, als habe das Plugin den Tastendruck übergangen — und genau so sah eine fehlende Datei aus, solange das Nachladen unerreichbar war.

macOS — QLPreviewPanel

Das Plugin startet einen kleinen mitgelieferten Helfer, qlpreview, der QLPreviewPanel ansteuert — genau die Ansicht, die auch der Finder zeigt. Der Prozess wird festgehalten, damit ein zweites Drücken der Leertaste oder Escape die Vorschau schließt.

Warum nicht qlmanage

/usr/bin/qlmanage -p sieht nach der naheliegenden Antwort aus und ist es nicht. Es ist ein Werkzeug zur Fehlersuche und nicht die Quick-Look-Ansicht des Finders, und es kann nicht alles zeigen, was der Finder zeigt. An einem Video stirbt es sofort:

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

Apples eigener Film-Generator ruft eine AVKit-Methode auf, die AVKit innerhalb von qlmanage verweigert. QLPreviewPanel anzusteuern zeigt dagegen alles, was der Finder zeigt, Quick-Look-Erweiterungen Dritter eingeschlossen. qlmanage bleibt nur als Rückfall für den Fall, dass der Helfer nicht bereitgestellt werden kann.

Linux — GNOME Sushi über D-Bus

dbus-send --session --print-reply --reply-timeout=10000 \
  --dest=org.gnome.NautilusPreviewer \
  /org/gnome/NautilusPreviewer org.gnome.NautilusPreviewer.ShowFile \
  string:file:///pfad int32:0 boolean:true
Warnung–print-reply steht nicht wegen seiner Ausgabe da

Es wegzulassen ist kein kleiner Unterschied. Ohne das Argument schreibt dbus-send die Nachricht und endet binnen Millisekunden. Sushi wird von D-Bus bei Bedarf gestartet und braucht etwa eine Sekunde, und dbus-daemon verwirft eine für eine Aktivierung eingereihte Nachricht, sobald die absendende Verbindung verschwindet. Der Dienst startet dann, erfährt nie, was er zeigen soll, und beendet sich nach zwölf Sekunden Leerlauf wieder.

Nichts erscheint, und nichts wird protokolliert. Der Fehlschlag ist vollkommen lautlos — was ihm diesen Kasten einträgt. Auf die Antwort zu warten hält die Verbindung offen, bis der Aufruf zugestellt ist.

Die Antwort ist zugleich das einzige Zeichen, das das Plugin überhaupt bekommt, dass der Vorschaudienst geantwortet hat. Der Rückgabewert wird deshalb geprüft, und eine Absage — meist: Sushi ist nicht installiert — wird im Protokoll benannt, statt verschluckt zu werden.

Der Aufruf kehrt zurück, sobald dem Fenster seine Datei übergeben ist, und nicht erst mit dem Ende der Vorschau. Es gibt also keinen Prozess festzuhalten und zu beenden. Das letzte Argument von ShowFile schließt die Vorschau, wenn dieselbe Datei bereits gezeigt wird — das gibt der Leertaste denselben Umschalter wie unter macOS. Sushi zeigt eine Datei zur Zeit.

Windows — die Named Pipe von QuickLook

QuickLook horcht auf einer Named Pipe, QuickLook.App.Pipe.<Benutzer-SID>. Eine UTF-8-Zeile hindurch —

QuickLook.App.PipeMessages.Toggle|C:\pfad\datei.pdf|

— zeigt die Vorschau, dieselbe Zeile nochmals schließt sie, und ein anderer Pfad wechselt zu jener Datei. Auch hier hat die Leertaste ihren Umschalter, wieder ohne einen Prozess festzuhalten.

Die Zeile wird unmittelbar aus dem Plugin über js-ctypes geschrieben: eine Handvoll Win32-Aufrufe, kein Helfer-Binary, kein Unterprozess, mit der SID aus dem Prozess-Token. Sollte Gecko ctypes je fallenlassen, tut ein PowerShell-Einzeiler dasselbe; er erreicht die Shell als -EncodedCommand, es greifen also weder Ausführungsrichtlinie noch Kommandozeilen-Maskierung.

Ein früherer Anlauf zu dieser Anbindung (QuickLook #584, 2019) scheiterte an eben jener SID, und der Schluss seiner Zeit — die Store-Fassung sei von der Pipe abgeschottet — wurde jahrelang wiederholt. Heute gemessen gilt keines von beidem: Das Store-Paket erklärt runFullTrust und antwortet auf der Pipe wie die Desktop-Fassung.

VorsichtDie Pipe niemals abtasten

QuickLooks Pipe-Leser nimmt eine Zeile je Verbindung und dereferenziert, was immer er bekommen hat. Ein Client, der sich verbindet und nichts sendet — ein Test-Path auf den Pipe-Pfad, irgendeine Ist-da-was-Prüfung — tötet den Lauscher, lautlos, bis QuickLook neu gestartet wird.

Das Plugin tastet deshalb nie ab. Jede Verbindung, die es aufbaut, trägt eine vollständige Zeile, und „QuickLook läuft nicht” wird daraus gelernt, dass der Verbindungsaufbau scheitert — dann wird es im Protokoll benannt.

EPUB, überall

Keine Systemvorschau stellt ein EPUB dar. Es ist damit das einzige Format, das das Plugin wandelt statt weiterzureichen. Alles andere — MOBI, DJVU, was immer eine Vorschau-Erweiterung beansprucht — geht unverändert an die Ansicht.

Das Buch wird aus seinem Archiv heraus gelesen, über zwei Leser, die die Plattform ohnehin mitbringt. Geckos nsIZipReader öffnet den Container; Zoteros eigenes EPUB.mjs — das Modul, mit dem es seinen Volltext indiziert — verbindet Manifest und Spine und liefert die Abschnitte in Lesereihenfolge, bereits geparst. Der <body> jedes Kapitels wird an ein einziges Ausgabedokument angehängt.

HinweisWarum davon so wenig von uns stammt

Bis 1.2.0 startete das Plugin /usr/bin/unzip, unter Windows tar.exe, und verteilte das Buch über ein temporäres Verzeichnis — ein Unterprozess samt Zeitüberschreitung für etwas, das die Plattform im eigenen Prozess erledigt, dazu ein Verzeichnis zum Aufräumen. Was Zotero ohnehin pflegt, wird jetzt benutzt. Was es nicht anbietet, ist ein Wandler: Sein EPUB-Betrachter ist epub.js, der zur Laufzeit in einem Zotero-Fenster rendert, und eine Systemvorschau führt überhaupt kein Skript aus. Der Zusammenbau bleibt deshalb unserer.

Die buchspezifischen Stylesheets, verlinkte wie eingebettete, werden als Text übernommen, damit der ursprüngliche Aufbau erhalten bleibt; darüber liegen eine Palatino-Grundschrift, 80 px Rand und eine 720 px breite Buchspalte. Bilder, Schriften und eingebettete SVGs — im Markup und im CSS genannt — werden in ein Verzeichnis asset/ neben der Seite geschrieben, jede Datei einmal, so oft das Buch sie auch nennt, und von dort relativ angesprochen.

HinweisWarum nicht in die Seite eingebettet

Kurzzeitig waren sie es, als data:-Adressen. Die Seite stand damit für sich allein — aber base64 kostet ein Drittel mehr als die Datei, die es kodiert, und das ganze Buch muss beim Erzeugen als eine Zeichenkette gehalten werden. Das verlangte nach einer Obergrenze, und eine Obergrenze heißt: eine Vorschau, die einen Teil des Buches zeigt. Eine Vorschau eines Teils ist keine Vorschau des Buches. Daneben geschrieben gibt es nichts zu begrenzen: Der Band darf beliebig groß sein, und der Speicher entfernt den Eintrag als Ganzes, wenn seine Zeit gekommen ist.

Parsen und Zusammenbau laufen über DOMParser auf dem Dokumentbaum. Skripte und eingebettete Ereignisbehandler werden dadurch als Knoten entfernt statt als Text gesucht. Ein gewandeltes Buch wird behalten und wiederverwendet, bis sich seine Datei ändert — siehe den Speicher.

Annotationen

Zotero bewahrt Annotationen in seiner Datenbank auf und nicht in der Datei — beim EPUB wie beim PDF. Für PDFs lässt zotLook sie von Zotero einarbeiten: PDFWorker.export schreibt eine Kopie mit den Markierungen auf der Seite, und diese Kopie wird gezeigt. Für EPUB gibt es keinen solchen Export. Zoteros Reader zeichnet sie zur Laufzeit aus einer EPUB-CFI, und die Maschinerie dafür steckt im Bündel des Readers und ist nur von dort erreichbar.

Deshalb wird die CFI hier gelesen — und geschrieben nur für eines: den Link, den jede Kachel einer Seitenübersicht zurück in den Reader trägt. cfi.js zerlegt sie und löst sie gegen das Spine-Dokument auf, also genau gegen den Baum, gegen den sie geschrieben wurde und den getSectionDocuments() liefert. Darum wird je Abschnitt markiert, bevor die Seite zusammengesetzt wird: danach gibt es den Baum, den der Bezeichner beschreibt, nicht mehr.

Der Paketpfad benennt das Spine-Element — /6 den <spine> unter den Kindern des Pakets, /14 das siebte <itemref> —, und Zoteros eigener annotationSortIndex beginnt mit derselben Zahl: zwei unabhängige Angaben desselben Sachverhalts. Der übrige Pfad läuft durch das Dokument bis zu einem Textknoten, die Versätze sind die Ausdehnung. Gezeichnet wird, wie Zotero zeichnet, und nicht, wie es plausibel schien. Sein Reader gestaltet eine Annotation in fließendem Text so:

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

Eine Hervorhebung trägt ihre Farbe also bei halber Deckkraft — derselbe Alphawert, mit dem auch die PDF-Fläche gefüllt wird —, und eine Unterstreichung ist eine echte Unterstreichung in der Annotationsfarbe, 2 px stark bei 2 px Abstand, aus dem Stylesheet des Readers. Vorher war es eine volle Füllung: eine violette oder blaue Stelle wurde schwer lesbar und sah dem Dokument unähnlich, aus dem sie stammte. Ein Bereich über Elementgrenzen hinweg wird zu je einer Umhüllung pro Textknoten — so, wie ein Browser es mit einer Auswahl hält.

Die Farbe selbst ist die, die die Annotation trägt. Zotero bietet acht an, aber das Feld hält eine Zeichenkette, und Plugins legen eigene Paletten hinein — in einer hier geprüften Bibliothek trugen 232 von 500 Annotationen Farben, die Zotero nie anbietet, aus zotQDA. Alle sind sechsstellige Hex-Werte, was auch Zoteros eigener Reader voraussetzt: Er hängt den Alphawert als zwei weitere Ziffern an, genau wie hier. Eine dreistellige Kurzform wird zuvor ausgeschrieben, sonst landete das Alpha in der Farbe selbst; alles andere wird unverändert und ohne Alpha durchgereicht, denn eine unerwartete Schreibweise soll höchstens die richtige Deckkraft kosten, niemals die richtige Farbe.

Hinweisepub.js, nicht das Papier

Streng gelesen verschränkt EPUB CFI 1.1 Elemente und Zeichendaten in einer Nummerierung, sodass ungerade Schritte den Text zwischen Elementen adressieren. epub.js zählt Textkinder unter Textkindern — und epub.js hat jede CFI geschrieben, die hier je zu lesen sein wird. Sich nach dem Schreiber zu richten ist das, was die Antwort richtig macht.

HinweisNichts geht verloren

Eine Notiz trägt keinen Text, den man verorten könnte, ein Kommentar gehört zu keinem einzelnen Wort, und eine Hervorhebung, deren Wortlaut das Markup anders umbrochen hat, lässt sich nicht finden. Sie alle stehen in einer Liste hinter einem Knopf in der Ecke, in Lesereihenfolge, mit Farbe und Kommentar — und was sich nicht verorten ließ, wird als solches gekennzeichnet, statt einfach zu fehlen.

Das Zitat eines Eintrags ist so ausgezeichnet wie die Stelle selbst — und ebenso hält es Zotero in seiner eigenen Annotationsliste: dieselbe Auszeichnung mit demselben Stil, annotation-text neben Menüknopf und Hinweiszeile. Ein Farbfleck allein nennt die Farbe; dies nennt, was mit ihr getan wurde.

Das Menü öffnet geschlossen, und jeder Eintrag ist gefaltet: Das Zitat wird vom Stylesheet auf zwei Zeilen gekürzt — in Quick Look nachgemessen, das die Begrenzung darstellt — und beim Aufklappen erscheinen der ganze Text, der Kommentar und ein Verweis auf die Stelle. In beiden Fällen dieselbe eine Kopie des Textes, gekürzt dort, wo er gezeigt wird, und nicht dort, wo er entsteht; so geht nichts verloren und nichts wird verdoppelt. Eine Flex-Zeile stellt den Farbfleck neben die erste Zeile, was das mitgelieferte Aufklapp-Dreieck verschwinden lässt — also wird eines selbst gezeichnet, was über drei Maschinen, die alle ihr eigenes zeichnen, ohnehin verlässlicher ist.

Ein Eintrag, dessen Stelle in der Seite steht, verweist dorthin: Das erste Stück des markierten Bereichs trägt einen Anker, der Eintrag zeigt darauf. Das erste in Leserichtung, nicht das zuerst umschlossene — umschlossen wird rückwärts, weil das Teilen eines Textknotens jeden Versatz danach verschiebt, und wer dem Verweis folgt, soll am Anfang der Stelle ankommen und nicht an ihrem Ende.

Das Inhaltsverzeichnis

Gelesen wird die Navigation, die das Buch selbst führt — EPUB 3 im Navigationsdokument (das Manifest-Element mit properties="nav"), EPUB 2 in der NCX-Datei, auf die die Spine mit ihrem toc-Attribut zeigt. Beide Formate werden gelesen, das neuere zuerst. Titel und Verschachtelung stammen damit vom Verlag; aus Überschriften abgeleitete wären schlechter geraten.

HinweisWarum

und keine Skripte

Die Systemvorschau von macOS stellt Seiten mit abgeschaltetem JavaScript dar und verweigert gewöhnliche Navigation. Ein zuklappbares Verzeichnis scheint damit unmöglich — ist es aber nicht: <details>/<summary> klappt nativ auf, ohne ein einziges Skript, und Sprungmarken innerhalb derselben Seite werden ausgeführt.

Beides wurde nachgemessen, indem in einer echten Vorschau geklickt wurde: Das Aufklappen funktioniert, der Sprung funktioniert. Deshalb ist das Verzeichnis so gebaut und nicht anders.

Dasselbe Element trägt auch das schwebende Menü: Das <summary> steht als Knopf fest in einer Ecke, und beim Öffnen legt sich die Liste über den Text, statt das Buch nach unten zu schieben. Auch daran ist kein Skript beteiligt — <details> hält den Zustand selbst, und nur deshalb kann es ein solches Menü in einer Ansicht überhaupt geben, die keines ausführt. Unter 420 px rücken die Menüs zurück in den Textfluss, wo sie nichts verdecken.

Jedes Kapitel bekommt beim Zusammenbau einen eigenen Anker (zotlook-ch0, zotlook-ch1, …). Das ist nötig, weil die Navigation oft nur auf die Datei zeigt und die bucheigenen ids fehlen können. Zeigt ein Eintrag dagegen auf eine Stelle innerhalb eines Kapitels, wird dorthin gesprungen — aber nur, wenn diese id im zusammengesetzten Dokument tatsächlich vorhanden ist; das Bereinigen entfernt Skripte und Ereignis-Attribute, ids bleiben also erhalten.

Bringt ein Buch keine brauchbare Navigation mit, entsteht kein Kasten. Die Vorschau eines Dokuments ohne Kapitel soll wie das Dokument aussehen und nicht wie eine Funktion, die versagt hat.

Weil das Plugin nicht zuverlässig feststellen kann, ob eine EPUB-Vorschau-Erweiterung installiert ist, ist die Wandlung eine Einstellung statt einer Vermutung.