Technischer Artikel

PDF-Viewer für Lazarus und Free Pascal mit PDFium

Delphi und Lazarus kompilieren dasselbe Object Pascal, und genau diese oberflächliche Ähnlichkeit macht die Portierung eines Viewers zwischen ihnen trügerisch. Die beiden Toolchains weichen an drei Stellen ab, die für PDF-Arbeit zählen: Der native string-Typ ist in Delphi UTF-16 und in einer LCL-Anwendung UTF-8; VCL und LCL sind verschiedene visuelle Frameworks mit eigenen Steuerelementen, Dialogen und Formular-Streaming-Formaten; und ein Delphi-Binary zielt auf Windows, während ein FPC-Binary auch auf Linux oder macOS zusteuern kann. Keiner dieser Unterschiede zeigt sich zur Compile-Zeit. Ein Viewer auf Basis der PDFium-Komponente, die VCL- und LCL-Editionen aus einem einzigen Quellbaum liefert, kompiliert nach einer Handvoll Unit-Namen-Tauschen und ein paar {$IFDEF FPC}-Blöcken sauber unter Lazarus. Die Fehler kommen später, wenn echte Daten und ein echter Einsatz die Annahmen bloßlegen, die der Delphi-Build stillschweigend getroffen hatte

Vier dieser Annahmen verantworten den größten Teil der verlorenen Zeit: die Textkodierung an der UI-Grenze, die Versuchung, zwei Kopien des Formulars zu pflegen, die Art, wie sich ein natives Engine-Binary zur Laufzeit auflöst, und der Moment, in dem der Sprachausgabe die Plattform ausgeht, sobald SAPI fehlt. Jede davon ist günstig zu handhaben, wenn man weiß, dass sie kommt, und teuer zu jagen, wenn nicht

Gleiches Pascal, andere String-Inhalte

Delphis nativer string ist seit 2009 UTF-16. Lazarus und Free Pascal stehen in LCL-Anwendungen standardmäßig auf UTF-8. Die textseitigen APIs der Komponente sprechen UTF-16 über den Typ WString, den der FPC-Build auf WideString aliasiert, sodass jede Grenze, an der Text zwischen Ihrer LCL-UI und der PDF-Engine wechselt, ein Konvertierungspunkt ist

Die Konvertierungen passieren in einfachen Zuweisungen automatisch, und der meiste Code muss nie an sie denken. Zwei Gewohnheiten halten die Kodierungs-Bugs draußen. Reichen Sie Text direkt durch, ohne Manipulation auf Byte-Ebene: Code, der einen Suchbegriff per Byte-Offset zerschneidet, funktioniert in Delphi, wo ein Char eine UTF-16-Einheit ist, und korrumpiert Multi-Byte-UTF-8 in der LCL. Und testen Sie vom ersten Lauf an mit Nicht-ASCII-Daten. Ein deutscher Dateiname, ein kyrillischer Suchbegriff, ein akzentuierter Autorenname in den Dokumentmetadaten: Rein-ASCII-Testdaten verstecken jeden Kodierungsfehler, weil ASCII der eine Bereich ist, in dem UTF-8 und UTF-16 Zeichen für Zeichen übereinstimmen. Der Bug ist die ganze Zeit real; ASCII hält ihn nur unsichtbar, bis ein Kunde in München eine Datei öffnet, die Sie nie probiert haben

Diagramm der UTF-16- und UTF-8-String-Konvertierungsgrenzen zwischen einer LCL-Viewer-UI und der PDFium-Komponente in Lazarus
Die Text-APIs der Komponente sprechen UTF-16 über WString, daher trifft eine LCL-UI mit UTF-8-Zeichenketten an jeder Grenze auf einen Konvertierungspunkt, und Byte-Offset-Slicing oder ASCII-only-Testdaten sind der Ort, an dem sich die Kodierungs-Bugs verstecken

Ein bedingter Block, kein Fork pro IDE

Nach dem ersten Dutzend IFDEFs fühlt sich die Codebasis an wie zwei Projekte in einem Repository, und ein Fork pro IDE sieht verlockend aus. Er ist der falsche Griff. Die echten Unterschiede fallen in einen gemeinsamen Deklarationsblock zusammen, und ein Fork verdoppelt ab dann die Kosten jeder Fehlerbehebung. Halten Sie die bedingte Schicht so klein:

{$IFDEF FPC}
uses
  LCLType, Forms, Graphics, Controls;

type
  WString = WideString;   // Komponenten-Text-APIs sind UTF-16
  TBytes  = array of Byte;
{$ELSE}
uses
  Winapi.Windows, Vcl.Forms, Vcl.Graphics, Vcl.Controls;
{$ENDIF}

Alles unterhalb dieses Blocks kompiliert in beiden IDEs identisch. Dokumentenhandhabung, Seitennavigation, Rendering-Aufrufe: TPdf und TPdfView bieten in der VCL- und der LCL-Edition dieselbe Oberfläche, sodass der Großteil des Viewers nie eine Compiler-Bedingung sieht. Das so zu halten ist eher strukturelle Disziplin als ein cleverer Trick. Geteilte PDF-Logik lebt in Units, die keine framework-spezifischen Dialoge oder Panels hereinziehen. Die Handvoll Dinge, die sich wirklich unterscheiden, etwa Druckdialoge und Dateiauswahldialoge mit ihren Plattformkonventionen, verstecken sich hinter einer dünnen Schnittstelle, die pro Framework einmal implementiert wird. Der IFDEF-Block wird zum einzigen Ort, an dem künftige Plattform-Divergenz landen darf, statt Compiler-Direktiven über vierzig Units zu verteilen

Bauen Sie das Formular im Code, nicht in zwei Designern

Formular-Streaming ist die Stelle, an der Dual-IDE-Projekte still verrotten. Eine .dfm und eine .lfm, die behaupten, dasselbe Formular zu beschreiben, driften Eigenschaft für Eigenschaft auseinander, bis sich die beiden Builds aus Gründen unterschiedlich verhalten, die niemand diffen kann, weil die beiden Dateien nicht einmal im selben Format vorliegen. Den Viewer zur Laufzeit zu konstruieren umgeht das ganze Problem. Es gibt eine Konstruktor-Sequenz, in der Versionsverwaltung als ganz normaler Code, und sie liest sich auf beiden Plattformen gleich:

procedure TViewerForm.FormCreate(Sender: TObject);
begin
  Pdf := TPdf.Create(Self);

  PdfView := TPdfView.Create(Self);
  PdfView.Parent := Self;
  PdfView.Align := alClient;
  PdfView.Pdf := Pdf;
  PdfView.FitMode := pfmFitWidth;

  if ParamCount > 0 then
  begin
    Pdf.FileName := ParamStr(1);
    Pdf.Active := True;   // öffnet das Dokument; PageCount ist danach gültig
  end;
end;

Die genaue Reihenfolge dieser Zuweisungen ist weniger wichtig als die eine Zeile, die die eigentliche Arbeit erledigt. PdfView.Pdf := Pdf bindet das visuelle Steuerelement an die Dokumentkomponente, und von diesem Punkt an reagieren Seitennavigation über PageNumber und Fit-Verhalten über FitMode unter VCL und LCL identisch. Eine Framework-übergreifende Eigenheit lohnt zu kennen, bevor ein Benutzer sie als Bug meldet: Ein von Hand zugewiesenes Zoom snappt FitMode in beiden Frameworks auf pfmNone zurück. Wenn Ihre Toolbar „Auf Breite einpassen“ als klebende Einstellung behandelt, müssen Sie den Fit-Modus nach jedem programmatischen Zoom neu zuweisen, sonst hört die Einstellung auf zu kleben, sobald Code die Zoomstufe berührt

Die Binärdatei, vor der die IDE nie gewarnt hat

Die Komponente umschließt die PDFium-Engine, die als native Plattform-Binärdatei ausgeliefert wird, und diese Binärdatei ist die Quelle fast jedes Berichts der Art „läuft in der IDE, scheitert vom installierten Shortcut“. Drei Regeln verantworten die meisten davon. Die Bitigkeit muss exakt passen. Ein 32-Bit-Executable kann keine 64-Bit-PDFium-Bibliothek laden, und die Meldung, die das Betriebssystem zurückgibt („Modul nicht gefunden“ auf manchen Windows-Versionen), führt aktiv in die Irre, weil die Datei genau dort neben dem Executable liegt. Lösen Sie den Bibliothekspfad relativ zum Executable auf, niemals relativ zum Arbeitsverzeichnis; ein IDE-Start und ein Shell-Start unterscheiden sich genau in diesem Punkt, weshalb sich der Bug während der Entwicklung versteckt. Und fangen Sie ein fehlgeschlagenes Laden ab, bevor das erste Dokument geöffnet wird, und melden Sie es mit ausgeschriebenem erwartetem Pfad und Architektur. Ein Support-Ticket, das liest „PDFium-64-Bit-Binärdatei fehlt unter <path>“, ist in Minuten geschlossen. Eines, das liest „Viewer stürzt beim Start ab“, wird zu einer Woche Hin und Her

Versionieren Sie die Engine-Binärdatei gleich mit neben dem Executable. PDFium bewegt sich schnell, und ein Installer, der die Anwendung aktualisiert, aber eine veraltete Bibliothek auf der Platte lässt, erzeugt Abstürze, die niemand in Ihrem Büro reproduzieren kann, aus dem einfachen Grund, dass jede Maschine in Ihrem Büro zufällig das zusammenpassende Paar hält. Behandeln Sie die Bibliothek als Teil des Build-Artefakts, mit demselben Installer, demselben Versionsstempel und demselben Rollback-Pfad wie das Executable, das sie lädt

Diagramm der drei PDFium-Laderegeln für native Binärdateien einer Lazarus- oder Delphi-PDF-Viewer-Programmdatei
Drei Regeln decken die meisten Berichte im Stil „läuft in der IDE, scheitert installiert“ ab: Die ausführbare Datei und die PDFium-Bibliothek müssen dieselbe Bitness teilen, der Bibliothekspfad löst sich von der ausführbaren Datei auf, nicht vom Arbeitsverzeichnis, und ein fehlgeschlagenes Laden wird abgefangen, mit dem erwarteten Pfad ausgeschrieben

Komponenten in der Lazarus-IDE registrieren

Die Laufzeit-Konstruktion braucht überhaupt keine Design-Time-Registrierung, was die sauberste Konfiguration für einen Viewer ist, der seine eigene UI im Code baut. Wenn Sie die Komponenten doch für Design-Time-Arbeiten auf der Lazarus-Palette haben wollen, installieren Sie das Paket und lassen Sie seine eigene Registrierungs-Unit, PDFiumLazReg in Lib/FPC/PDFiumLaz.lpk, das erledigen. Diese Unit ist absichtlich als Design-Time markiert: Sie referenziert IDE-Schnittstellen für Property-Editoren, die niemals in Ihr ausgeliefertes Executable linken dürfen

Machen Sie das falsch, ist das Symptom eine Anwendung, die unerklärlich von IDE-Paketen abhängt, was sich als Deployment-Fehler auf der ersten Kundenmaschine zeigt, auf der nie Lazarus installiert war

Sprachausgabe und Screenreader außerhalb von Windows

Sprachausgabe ist das eine Feature, an dem die Cross-Platform-Geschichte bricht, und sie bricht am Betriebssystem, nicht an der Komponente. SAPI, das übliche TTS-Backend unter Windows, existiert nur unter Windows. Ein Lazarus-Build, der weiterhin auf Windows zielt, behält die volle SAPI-Ausgabe und dasselbe NVDA-kompatible Verhalten wie das Delphi-Original, verliert also eine Windows-auf-Windows-Portierung hier nichts, und ein NVDA-Benutzer kann die beiden Builds nicht unterscheiden

Ein Linux- oder macOS-Ziel ist eine andere Sache. Es gibt kein SAPI aufzurufen, sodass die Audio-Ausgabe an einen nativen Sprachdienst neu verdrahtet werden muss, während die Lese-APIs darüber unangetastet bleiben. Diese Aufteilung ist das Argument dafür, Sprachausgabe vom ersten Commit an hinter eine Schnittstelle zu stellen: Die Lesereihenfolge-Analyse und der Wort-Verfolgungs-Cursor sind plattformneutral und tragen unverändert hinüber, und nur die dünne Schicht, die tatsächlich Ton erzeugt, muss sich pro Plattform ändern. Der Artikel zum barrierefreien Reader behandelt diese Lese-Maschinerie in der Tiefe

Eine Paritäts-Checkliste, bevor Sie die Portierung fertig nennen

Der folgende Durchgang hat echte Regressionen gefunden, aufgelistet ungefähr in der Reihenfolge, in der Fehler auftauchen. Öffnen Sie ein Dokument, dessen Pfad Nicht-ASCII-Zeichen enthält. Suchen Sie einen Begriff mit Nicht-ASCII-Zeichen und bestätigen Sie, dass die Treffer dort hervorgehoben werden, wo sie hingehören. Üben Sie Mausrad-Scrollen, Ziehen-Auswählen und Tastatur-Seitennavigation auf jedem Widget-Set aus, das Sie ausliefern, denn Fokusbehandlung und Radverhalten sind die widget-set-abhängigsten Ecken der LCL. Prüfen Sie das Rendering bei 100 %, 150 % und 200 % Anzeige-Skalierung. Starten Sie zuletzt den installierten Build, nicht den IDE-Build, auf einer Maschine, auf der nie die IDE war, denn das ist der einzige Test, der die Binär-Auflösung ehrlich prüft. Alles andere kann durchgehen, während genau dieser still scheitert

Die Rendering-Leistung trägt zwischen den beiden Editionen unverändert hinüber, sodass der Caching-Ansatz aus dem Artikel zu Render-Cache und Zoom-Leistung für den LCL-Viewer genau so gilt wie für den VCL-Viewer

Nichts davon macht die LCL-Edition zu einer geringeren. Die Kernoberfläche ist auf beiden Seiten identisch: TPdf, TPdfView, Rendering, Formulare, Textextraktion und die Barrierefreiheits-APIs verhalten sich gleich, egal welche IDE sie kompiliert hat. Jeder Unterschied, den es zu verfolgen lohnt, ist plattformgebunden, nicht editionsgebunden. SAPI-Sprachausgabe ist Windows-only, Dialoge folgen den Konventionen des jeweiligen Frameworks, und die Binärdatei muss zur Architektur passen, in die sie geladen wird. Bekommen Sie die Kodierungsgrenzen, das Laufzeit-Formular und die Binär-Auflösung richtig, ist der Rest der Portierung die mechanische Arbeit, die der Compiler bereits für Sie erledigt hat

PDFium-Component-Diagramm der Sprachschnittstellen-Aufteilung, die TTS-Ausgabe hinter eine plattformweise Engine stellt in einem Lazarus-PDF-Viewer
Leserichtungsanalyse und der Wortverfolgungs-Cursor bleiben plattformneutral, während eine schmale Sprachschnittstelle sich zu SAPI auf Windows und zu nativen Sprachdiensten auf Linux und macOS auflöst

Die hier beschriebenen VCL- und LCL-Editionen werden zusammen als PDFium Component ausgeliefert, mit Quellcode und identischen öffentlichen APIs für Delphi, C++Builder und Lazarus/FPC