Die PDFium Delphi Component setzt das XMP-Paket für PDF/A-Ausgaben zusammen, indem sie UTF-8-Fragmente in einem AnsiString verkettet. Unter Free Pascal 3.2.2 war dieses Paket in dem Moment nicht mehr gültiges UTF-8, in dem ein Dokumenttitel ein Nicht-ASCII-Zeichen enthielt. ISO 19005-1 6.7.2 verlangt, dass der Metadatenstream gültiges UTF-8 enthält, daher scheiterte die Datei bei der Validierung. Version 3.103.1 korrigiert den Encoder selbst, in StringToUtf8. Interessant ist nicht der Patch, sondern dass dieselbe unveränderte Quellzeile unter Delphi korrekte Bytes, in einer Lazarus-LCL-Anwendung korrekte Bytes und in einem aus derselben Unit kompilierten reinen Free-Pascal-Konsolenprogramm beschädigte Bytes erzeugte. Drei getrennte Free-Pascal-Stringverhalten müssen zusammenpassen, bevor das verständlich wird, und jedes ist für sich vertretbar
Warum erzeugt derselbe Metadatencode unter Delphi und FPC unterschiedliche Bytes?
Weil string bei den beiden Compilern nicht derselbe Typ ist. FPC 3.2.2 kompiliert im {$MODE Delphi} das string zu einem AnsiString, der mit DefaultSystemCodePage markiert ist, während Delphi daraus UnicodeString macht. Jedes Metadatenfeld in TPdfASaveOptions ist als string deklariert. Daher tragen Title, Author, Subject, Keywords, Creator und Producer auf dem einen Compiler UTF-16-Codeeinheiten und auf dem anderen Einzelbytezeichen plus Codepage-Tag. Derselbe Record, anderer Payload. Die Werte selbst kommen als UTF-16 aus dem Dokument. TPdf.GetTitle und seine Geschwister liefern WString, auf FPC WideString und auf Delphi string (UnicodeString), und SaveAsPdfAToStream füllt jedes leere Optionsfeld vor dem Injizieren der Marker aus dem Info-Wörterbuch. Diese Verengung ist unter Free Pascal eine Narrowing-Konvertierung, und die RTL führt sie über die Codepage des Zielstrings aus. In einem LCL-Programm hat LazUTF8 DefaultSystemCodePage bereits auf CP_UTF8 gesetzt, daher erzeugt die Verengung UTF-8 und alles danach ist zufällig korrekt. Im reinen Konsolenprogramm landet dieselbe Verengung auf der ANSI-Codepage, und StringToUtf8 kopiert diese Oktette anschließend unverändert, weil es annahm, sie seien bereits UTF-8. Sechs Save-Bridges teilen diese Form: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream und SaveAsPdfVTToStream, jeweils mit eigenem Options-Record
// PDFium.pas: Die Dokumentzugriffe sind immer UTF-16
// WString = WideString auf FPC, = string (UnicodeString) auf Delphi
function TPdf.GetTitle: WString;
// FPdfPdfa.pas: Der Save-Options-Record trägt Metadaten als string
TPdfASaveOptions = record
Conformance: TPdfAConformance;
IccProfileData: TBytes;
Title: string; // UnicodeString auf Delphi
// AnsiString + DefaultSystemCodePage auf FPC
Author: string;
Subject: string;
Keywords: string;
Creator: string;
Producer: string;
CreationDate: string;
ModDate: string;
DocumentId: TBytes;
InstanceId: TBytes;
class function Default: TPdfASaveOptions; static;
end;
// SaveAsPdfAToStream füllt leere Felder aus dem Info-Wörterbuch nach.
// Die Verengung ist jetzt ausdrücklich geschrieben statt implizit:
if Eff.Title = '' then
Eff.Title := WStringToStr(GetTitle);
Alle sechs Bridges über einen einzigen WStringToStr-Helper zu führen, ändert nicht, was die RTL tut, macht die Konvertierung aber für Leser sichtbar und beseitigte 92 Warnungen über implizite Konvertierungen, die genau diese Fehlerklasse verdeckt hatten. Das ist das Spiegelbild der Delphi-seitigen Beschädigung aus den Notizen zu Delphi- und FPC-Cross-Compiler-Fallen in PDFium-Builds, bei der eine Verkettung unter Delphi ein High-Byte zerstört, das Free Pascal beibehält
Drei Free-Pascal-Verhalten, die die offensichtliche Korrektur vereiteln
Die offensichtliche Korrektur lautet, UTF8Encode aufzurufen und fertig zu sein. Unter FPC 3.2.2 im Delphi-Modus scheitert das dreimal und jedes Mal still
var
W: UnicodeString;
S: string;
U: UTF8String;
R: RawByteString;
Xmp: AnsiString;
begin
// Falle 1: Im Delphi-Modus ist eine UTF8String-*Variable* ein einfaches
// AnsiString, daher transkodiert die Zuweisung die Oktette zurück in die Host-Codepage
U := UTF8Encode(W);
// Falle 2: S ist bereits ein AnsiString, daher tut UTF8Encode überhaupt nichts
R := UTF8Encode(S); // kein Decode, kein Encode, kein Fehler
R := UTF8Encode(UnicodeString(S)); // dieses Beispiel kodiert tatsächlich
// Falle 3: Verkettung vereinheitlicht jeden Operanden auf die Zielcodepage,
// und ein RawByteString-Ziel ist keine Ausnahme
Xmp := Xmp + R;
end;
Falle eins bedeutet, dass das kodierte Ergebnis in dem AnsiString oder RawByteString bleiben muss, in dem es erzeugt wurde. Leitet man es auf dem Weg nach draußen über einen temporären UTF8String, macht man die Arbeit rückgängig. Falle zwei bleibt am längsten verborgen, weil UTF8Encode(S) kompiliert, läuft, einen Wert der richtigen Länge zurückgibt und überhaupt keine Konvertierung vornimmt, wenn sein Argument bereits ein AnsiString ist. Erst die vorherige Erweiterung auf UnicodeString lässt den Aufruf etwas dekodieren. Falle drei erklärt, warum ein korrekter Encoder trotzdem ein beschädigtes Dokument erzeugen kann: BuildXmpBytes sammelt das Paket in einem lokalen Xmp: AnsiString, und Free Pascal konvertiert jeden Operanden einer Verkettung auf die Codepage der Zielvariable. Dabei werden die Mehrbytefolgen auf dem Weg hinein wieder zu einzelnen ANSI-Bytes zusammengefaltet
Was garantiert SetCodePage mit False tatsächlich?
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) etikettiert den String neu, ohne ein Byte anzufassen. Der dritte Parameter heißt Convert; False bedeutet: "Nimm an, dass der Payload bereits in der Zielcodepage liegt, und ändere nur das Tag". Das ist eine absichtlich erzählte Lüge über den Inhalt: Die Oktette sind tatsächlich UTF-8, aber das Tag als Host-Codepage verhindert, dass die Verkettung aus Falle drei sie konvertiert. Sie werden als rohe Bytes an den XMP-Puffer angehängt und kommen am anderen Ende unverändert heraus
function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
// Delphi: UTF8Encode liefert bereits mit CP_UTF8 markierte Oktette, und
// die Verkettung in ein AnsiString behält sie
Result := AnsiString(UTF8Encode(S));
{$ELSE}
// FPC: zuerst erweitern, sonst ist UTF8Encode bei einem AnsiString-Argument ein No-op
Result := UTF8Encode(UnicodeString(S));
// Ohne Transkodierung neu etikettieren, damit die Oktette die Verkettung
// in den ANSI-markierten Puffern überleben, die XMP-Pakete und PDF-Stringobjekte bilden
if Length(Result) > 0 then
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;
Die Grenze sollte klar sein. Das Retagging ist FPC-only und kein allgemeiner Freibrief für das Mischen markierter und unmarkierter Strings. Es funktioniert hier, weil downstream genau ein Verbrauchsmuster existiert: an einen AnsiString anhängen und den Puffer anschließend als Bytes ausschreiben. Alles, was den neu etikettierten Wert als Text in der Host-Codepage interpretieren wollte, würde Mojibake lesen, und zwar zu Recht. In der Gegenrichtung wird es andersherum und bei beiden Compilern identisch behandelt: Den eingehenden Puffer mit SetCodePage(..., False) als CP_UTF8 markieren und anschließend UTF8ToString aufrufen
Warum trugen die Regressionstests dieselbe Falle?
Weil ein Test, der erwartete Bytes aus einem Quellliteral aufbaut, den Compiler und nicht die Bibliothek testet. Eine Konstante wie #$C3#$A9 in einer Pascal-Quelldatei trägt die Compile-Time-Codepage dieser Datei, und wenn sie an einen AnsiString-Parameter übergeben wird, kodiert die RTL sie neu – genau die Konvertierung, die getestet werden soll. Die Erwartung muss zur Laufzeit Byte für Byte aufgebaut und Byte für Byte verglichen werden, denn = auf zwei AnsiString-Werten mit verschiedenen Tags gleicht die Codepages vor dem Vergleich an und liefert ein fröhliches False Negative
function BytesPattern(const Values: array of Byte): AnsiString;
var
I: Integer;
begin
SetLength(Result, Length(Values));
for I := 0 to High(Values) do
Result[I + 1] := AnsiChar(Values[I]);
end;
procedure TPdfATests.StringToUtf8_AnsiCodePageString_EncodesUtf8;
var
Saved: Word;
Wide: WideString;
Narrowed: string;
Encoded, ExpectedUtf8: AnsiString;
begin
Saved := DefaultSystemCodePage;
try
SetMultiByteConversionCodePage(1252);
Wide := WideChar($0043) + WideChar($0061) + WideChar($0066) + WideChar($00E9);
Narrowed := Wide; // die zu testende Verengung
Encoded := StringToUtf8(Narrowed);
finally
SetMultiByteConversionCodePage(Saved);
end;
// 'Caf' + U+00E9 als UTF-8, zur Laufzeit erzeugt, damit kein Literal neu kodiert wird
ExpectedUtf8 := BytesPattern([$43, $61, $66, $C3, $A9]);
AssertTrue('StringToUtf8 must emit UTF-8 for a string carrying a non-UTF-8 codepage',
SameOctets(Encoded, ExpectedUtf8));
end;
Der Harness selbst ist ein LCL-Programm, daher ist DefaultSystemCodePage CP_UTF8, und der Fehler bleibt unsichtbar, bis der Test ihn umstellt. SetMultiByteConversionCodePage(1252) in einem try..finally reproduziert für die Dauer eines Tests die Umgebung eines reinen Konsolenprogramms. Die End-to-End-Prüfung geht weiter und prüft beide Richtungen: Das durch Marker-Injektion erzeugte XMP-Paket muss $43 $61 $66 $C3 $A9 enthalten und darf $43 $61 $66 $E9 nicht enthalten. Eine künftige Regression zurück zu roher Einzelbyteausgabe scheitert dadurch laut, statt in einem Hexdump nur plausibel auszusehen. Bei Nicht-Latin-Metadaten gilt dieselbe Erweiterungsdisziplin für die Fälle aus Emoji und CJK-Text, die WideChar-Verarbeitung in Delphi brechen
Wo landet die Verengung sonst noch?
XMP ist das sichtbare Opfer, aber jede Brücke zwischen TBytes und string im selben Codebestand war derselben Gefahr ausgesetzt. Zwei weitere Stellen wurden in v3.103.1 korrigiert: Utf8BytesToString und StringToUtf8Bytes in FPdfProduction, die den XFA-Datasets-Payload über einen String roundtrippen, damit MergePdfXfaDatasets gebundene Werte ersetzen kann, sowie BytesToUtf8 in FPdfTrustedList, das europäische Trusted-List-XML nach dem Entfernen des Byte-Order-Marks dekodiert. Beide verwenden nun zunächst einen RawByteString, markieren ihn ohne Konvertierung als CP_UTF8 und dekodieren mit UTF8ToString. Ein Modul war bereits geschützt, und der Grund ist es wert, kopiert zu werden. Der XFDF-Writer deklariert seinen eigenen Texttyp als XFDFString, der unter FPC zu WideString und unter Delphi zu UnicodeString aufgelöst wird. Sein Encoder sieht daher nie einen codepage-markierten AnsiString. Das ist die strukturelle Lösung: Text bis exakt zur Serialisierung in einem UTF-16-Typ halten und eine einzelne schmale Funktion die Konvertierung in Bytes besitzen lassen. Jeder Fehler dieser Familie entstand, weil mitten in einer Pipeline, die an einem Ende UTF-16 und am anderen Oktette verarbeitet, ein string-Feld saß
Was Sie in eigenem PDF-Code für beide Compiler prüfen sollten
Wenn Sie Object Pascal ausliefern, das auf beiden Compilern läuft und Metadaten in ein standardkonformes PDF schreibt, finden vier Prüfungen den Großteil dieser Fehlerklasse, bevor es ein Validator tut
- Nach
UTF8Encodemit einemstring-Argument suchen. Auf FPC ist dieser Aufruf ein No-op und die ertragreichste einzelne Zeile für ein Audit - Jede
UTF8String-Variable im Delphi-Modus als verdächtig behandeln. Dort ist sie ein einfachesAnsiString, und die Zuweisung kodierter Bytes an sie transkodiert sie zurück - Mindestens eine Regression unter
SetMultiByteConversionCodePagemit einer Einzelbyte-Codepage ausführen. Ein LCL-Test-Harness läuft mitCP_UTF8und reproduziert ein reines Konsolenprogramm nie - Erwartete Bytevektoren zur Laufzeit aufbauen und Oktett für Oktett vergleichen. Quellliterale und
=durchlaufen beide die Codepage-Abstimmung und verbergen den Fehler, den Sie suchen
Das ist keine exotische Free-Pascal-Kuriosität. Es ist der gewöhnliche Preis einer Sprache, die einen byteorientierten Stringtyp neben einem UTF-16-Typ erhalten hat; die beiden Compiler trafen vernünftige, aber unterschiedliche Entscheidungen darüber, was string bedeuten soll. Für PDF-Arbeit ist die Konsequenz eng und scharf: Metadaten, die in der IDE korrekt aussehen, können als ungültiges UTF-8 im XMP-Paket landen, und ISO 19005-1 6.7.2 interessiert nicht, welcher Compiler sie dort abgelegt hat. Wenn Sie eine Archivpipeline bauen, verdient die Kodierungsschicht ebenso viel Aufmerksamkeit wie der übrige PDF/A-Compliance-Workflow. Die PDFium Delphi Component liefert diese Konvertierungen als Teil der Bibliothek aus, sodass SaveAsPdfA und seine fünf Geschwister für Standardskonformität auf Delphi, Lazarus und reinen Free-Pascal-Builds ohne Codepage-Konfiguration des Aufrufers konformes UTF-8 in den Metadaten ausgeben. Die vollständige API-Dokumentation und das aktuelle Release stehen auf der Produktseite der PDFium Delphi Component