Teknisk artikel

FPC-codepagefælder ødelægger PDF/A XMP-metadata i Delphi

PDFium Delphi Component samler XMP-pakken til PDF/A-output ved at sammenkæde UTF-8-fragmenter i en AnsiString, og på Free Pascal 3.2.2 holdt denne pakke lydløst op med at være gyldig UTF-8, så snart en dokumenttitel indeholdt et non-ASCII-tegn. ISO 19005-1 6.7.2 kræver, at metadatastreamen er gyldig UTF-8, så filen fejlede valideringen. Version 3.103.1 retter selve encoderen i StringToUtf8. Det interessante er ikke patchen. Det er, at én uændret kildelinje producerede korrekte bytes under Delphi, korrekte bytes i en Lazarus LCL-applikation og korrupte bytes i et almindeligt Free Pascal-consoleprogram kompileret fra den identiske unit. Tre separate Free Pascal-stringadfærdsmønstre skal falde på plads, før det giver mening, og hvert af dem kan forsvares isoleret

Hvorfor udsender den samme metadatakode forskellige bytes på Delphi og FPC?

Fordi string ikke er den samme type på de to compilere. FPC 3.2.2 i {$MODE Delphi} kompilerer string til en AnsiString tagget med DefaultSystemCodePage, mens Delphi kompilerer den til UnicodeString. Hvert metadatafelt i TPdfASaveOptions er deklareret som string, så Title, Author, Subject, Keywords, Creator og Producer bærer UTF-16-code units på den ene compiler og enkeltbyte-tegn plus en codepage-label på den anden. Samme record, anden payload. Værdierne kommer selv fra dokumentet som UTF-16. TPdf.GetTitle og søskende returnerer WString, som er WideString på FPC og string på Delphi, og SaveAsPdfAToStream udfylder ethvert blankt optionsfelt fra Info-dictionaryen, før markers indsættes. Denne assignment er en narrowing-konvertering på Free Pascal, og RTL udfører den gennem målstringens codepage. I et LCL-program har LazUTF8 allerede sat DefaultSystemCodePage til CP_UTF8, så narrowing producerer UTF-8, og alt downstream er tilfældigvis korrekt. I et almindeligt consoleprogram lander den samme narrowing på ANSI-codepagen, og StringToUtf8 kopierede derefter disse oktetter videre uændret, fordi den antog, at de allerede var UTF-8. Seks save-bridges har denne form: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream og SaveAsPdfVTToStream, hver med sin egen option-record

// PDFium.pas: dokumentaccessorerne er altid UTF-16
//   WString = WideString på FPC, = string (UnicodeString) på Delphi
function TPdf.GetTitle: WString;

// FPdfPdfa.pas: save-option-recorden bærer metadata som `string`
TPdfASaveOptions = record
  Conformance: TPdfAConformance;
  IccProfileData: TBytes;
  Title: string;      // UnicodeString på Delphi
                      // AnsiString + DefaultSystemCodePage på 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 udfylder blanke felter fra Info-dictionaryen.
// Narrowing er nu skrevet eksplicit i stedet for at være implicit:
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

At route alle seks bridges gennem én WStringToStr-helper ændrer ikke, hvad RTL gør, men placerer konverteringen, hvor en læser kan se den, og det fjernede 92 implicitte konverteringsadvarsler, der havde skjult netop denne type problem. Det er spejlbilledet af den Delphi-side-korruption, der beskrives i vores noter om Delphi- og FPC-cross-compiler-fælder i PDFium-builds, hvor en sammenkædning på Delphi ødelægger en high byte, som Free Pascal bevarer

Tre Free Pascal-adfærdsmønstre, der besejrer den oplagte rettelse

Den oplagte rettelse er at kalde UTF8Encode og være færdig. Det fejler tre gange på FPC 3.2.2 i mode Delphi, og hver fejl er lydløs

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // Fælde 1: i mode Delphi er en UTF8String-*variabel* en almindelig AnsiString,
  // så assignmenten transkoderer oktetterne direkte tilbage til host-codepagen
  U := UTF8Encode(W);

  // Fælde 2: S er allerede en AnsiString, så UTF8Encode gør slet ingenting
  R := UTF8Encode(S);                 // ingen decode, ingen encode, ingen fejl
  R := UTF8Encode(UnicodeString(S));  // denne encoder faktisk

  // Fælde 3: sammenkædning samler alle operander til destinationens codepage,
  // og en RawByteString-destination er ingen undtagelse
  Xmp := Xmp + R;
end;

Fælde ét betyder, at det kodede resultat skal blive i den AnsiString eller RawByteString, det blev produceret i. Send det gennem en midlertidig UTF8String på vej ud, og du har gjort arbejdet om. Fælde to er den, der skjuler sig længst, fordi UTF8Encode(S) kompilerer, kører, returnerer en værdi med den rigtige længde og slet ikke udfører en konvertering, når argumentet allerede er en AnsiString; først når der udvides til UnicodeString, afkoder kaldet noget. Fælde tre er grunden til, at en korrekt encoder stadig kan producere et ødelagt dokument: BuildXmpBytes samler pakken i en lokal Xmp: AnsiString, og Free Pascal konverterer enhver operand i en sammenkædning til destinationsvariablens codepage, så multibyte-sekvenserne foldes ned til enkeltbyte-ANSI-bytes på vej ind

Hvad garanterer SetCodePage med False faktisk?

SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) ommærker strengen uden at røre en byte. Den tredje parameter er Convert; at sende False betyder "antag, at payloaden allerede er i målcodepagen, og skift kun tagget". Det er en løgn om indholdet, fortalt med vilje: Oktetterne er reelt UTF-8, men at tagge dem som host-codepagen er det, der forhindrer sammenkædningen i fælde tre i at konvertere dem. De slutter sig til XMP-bufferen som rå bytes og kommer uændrede ud på den anden side

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi: UTF8Encode giver allerede CP_UTF8-taggede oktetter, og
  // sammenkædning ind i en AnsiString bevarer dem
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC: udvid først, ellers er UTF8Encode no-op på et AnsiString-argument
  Result := UTF8Encode(UnicodeString(S));
  // Ommærk uden transkodning, så oktetterne overlever sammenkædning ind i
  // de ANSI-taggede buffere, der samler XMP-pakker og PDF-stringobjekter
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

Vær tydelig om grænsen. Retagget er FPC-only og er ikke en generel tilladelse til at blande taggede og ikke-taggede strings. Det virker her, fordi præcis ét consumer-mønster findes downstream: append til en AnsiString og skriv derefter bufferen ud som bytes. Alt, der forsøgte at fortolke den retaggede værdi som tekst på host-codepagen, ville læse mojibake, og korrekt nok. Den omvendte retning håndteres på den anden måde og er identisk på begge compilere: Tag den indgående buffer CP_UTF8 med SetCodePage(..., False), og kald derefter UTF8ToString

Hvorfor bar regressionstestene den samme fælde?

Fordi en test, der bygger sine forventede bytes fra en kildeliteral, tester compileren og ikke biblioteket. En konstant som #$C3#$A9 skrevet i en Pascal-kildefil bærer filens compile-time-codepage, og når den sendes til en AnsiString-parameter, rekoder RTL den, hvilket præcis er den konvertering, der testes. Forventningen skal samles ved runtime, byte for byte, og sammenlignes byte for byte, fordi = på to AnsiString-værdier med forskellige tags harmoniserer codepagen, før de sammenlignes, og returnerer en munter falsk negativ

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;                    // den narrowing, der testes
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // 'Caf' + U+00E9 som UTF-8, bygget ved runtime så ingen literal kan rekodes
  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;

Harnesset selv er et LCL-program, så DefaultSystemCodePage er CP_UTF8, og fejlen er usynlig, indtil testen skifter den. SetMultiByteConversionCodePage(1252) inde i en try..finally genskaber det almindelige consolemiljø under én test. End-to-end-kontrollen går længere og hævder begge retninger: XMP-pakken, der produceres af marker-injektion, skal indeholde $43 $61 $66 $C3 $A9 og må ikke indeholde $43 $61 $66 $E9, så en fremtidig regression, der vender tilbage til rå single-byte-output, fejler tydeligt i stedet for at producere en fil, der blot ser plausibel ud i et hex-dump. Hvis du arbejder med non-Latin-metadata, styrer den samme widening-disciplin tilfældene i emoji- og CJK-tekst, der ødelægger WideChar-håndtering i Delphi

Hvor lander narrowing ellers?

XMP er det synlige offer, men enhver TBytes-til-string-bro i samme kodebase havde den samme eksponering. To mere blev rettet i v3.103.1: Utf8BytesToString og StringToUtf8Bytes i FPdfProduction, som round-tripper XFA-datasættets packet gennem en string, så MergePdfXfaDatasets kan substituere bundne værdier, og BytesToUtf8 i FPdfTrustedList, som afkoder europæisk trusted-list-XML efter at have fjernet byte-order mark. Begge samler nu bufferen i en RawByteString, tagger den CP_UTF8 uden at konvertere og afkoder med UTF8ToString. Ét modul var allerede immunt, og grunden er værd at kopiere. XFDF-writeren deklarerer sin egen teksttype som XFDFString, der opløses til WideString under FPC og UnicodeString under Delphi, så dens encoder ser aldrig en codepage-tagget AnsiString. Det er den strukturelle rettelse: Hold tekst i en UTF-16-type indtil det præcise serialiseringspunkt, og lad én smal funktion eje konverteringen til bytes. Alle fejl i denne familie kom fra et string-felt midt i en pipeline, der ellers var UTF-16 i den ene ende og oktetter i den anden

Hvad skal du kontrollere i din egen dual-compiler PDF-kode?

Hvis du leverer Object Pascal, der kører på begge compilere og skriver metadata ind i en standardkonform PDF, finder fire kontroller de fleste af denne type fejl, før en validator gør det

  • Grep efter UTF8Encode med et string-argument. På FPC er det kald et no-op, og det er den enkeltlinje, der giver størst udbytte at auditere
  • Behandl enhver UTF8String-variabel som mistænkelig i mode Delphi. Den er en almindelig AnsiString dér, og assignment af kodede bytes til den transkoderer dem tilbage
  • Kør mindst én regression under SetMultiByteConversionCodePage med en single-byte-codepage. Et LCL-testharness kører på CP_UTF8 og genskaber aldrig et almindeligt consoleprogram
  • Byg forventede bytevektorer ved runtime og sammenlign dem oktet for oktet. Kildeliterals og = går begge gennem codepage-harmonisering og vil skjule den defekt, du leder efter

Intet af dette er eksotisk Free Pascal-trivia. Det er den almindelige pris for et sprog, der lod en byteorienteret stringtype leve ved siden af en UTF-16-type, og de to compilere traf rimelige, men forskellige valg om, hvad string skulle betyde. Den praktiske konsekvens for PDF-arbejde er snæver og skarp: Metadata, der ser korrekt ud i din IDE, kan nå XMP-pakken som ugyldig UTF-8, og ISO 19005-1 6.7.2 er ligeglad med, hvilken compiler der lagde den der. Hvis du bygger en arkiveringspipeline, fortjener encodinglaget lige så meget opmærksomhed som resten af den PDF/A-compliance-workflow til arkivering, der omgiver det. PDFium Delphi Component leverer disse konverteringer som en del af biblioteket, så SaveAsPdfA og dets fem standardsøskende udsender konform UTF-8-metadata på Delphi, Lazarus og almindelige Free Pascal-builds uden codepage-konfiguration fra kalderen. Fuld API-dokumentation og den aktuelle release findes på produktsiden for PDFium Delphi Component