Technisch artikel

FPC-codepagevallen breken PDF/A-XMP-metadata in Delphi

De PDFium Delphi Component stelt het XMP-pakket voor PDF/A-output samen door UTF-8-fragmenten aan elkaar te plakken in een AnsiString, en op Free Pascal 3.2.2 hield dat pakket stilletjes op geldige UTF-8 te zijn zodra een documenttitel een niet-ASCII-teken bevatte. ISO 19005-1 6.7.2 vereist dat de metadatastream geldige UTF-8 is, dus het bestand faalde bij validatie. Versie 3.103.1 repareert de encoder zelf, in StringToUtf8. Het interessante deel is niet de patch. Het is dat één ongewijzigde bronregel correcte bytes produceerde onder Delphi, correcte bytes in een Lazarus LCL-applicatie en corrupte bytes in een plain Free Pascal-consoleprogramma dat vanuit dezelfde unit was gecompileerd. Drie afzonderlijke Free Pascal-stringgedragingen moeten op elkaar aansluiten voordat dit logisch wordt, en elk ervan is op zichzelf verdedigbaar

Waarom geeft dezelfde metadatacode verschillende bytes uit op Delphi en FPC?

Omdat string op de twee compilers niet hetzelfde type is. FPC 3.2.2 compileert string in {$MODE Delphi} naar een AnsiString met de tag DefaultSystemCodePage, terwijl Delphi hem naar UnicodeString compileert. Elk metadatafield in TPdfASaveOptions is als string gedeclareerd, dus Title, Author, Subject, Keywords, Creator en Producer dragen op de ene compiler UTF-16-code-units en op de andere single-byte-tekens plus een codepagelabel. Zelfde record, zelfde veld, andere payload. De waarden zelf komen als UTF-16 uit het document. TPdf.GetTitle en zijn siblings retourneren WString, wat op FPC WideString en op Delphi string is, en SaveAsPdfAToStream vult elke lege option field uit het Info-dictionary voordat markers worden geïnjecteerd. Die assignment is op Free Pascal een narrowing conversion en de RTL voert die uit via de doelstringcodepage. In een LCL-programma heeft LazUTF8 DefaultSystemCodePage al op CP_UTF8 gezet, dus de narrowing levert UTF-8 op en alles downstream klopt toevallig. In een plain consoleprogramma landt dezelfde narrowing op de ANSI-codepage, en StringToUtf8 kopieerde die octetten vervolgens ongewijzigd omdat hij aannam dat ze al UTF-8 waren. Zes save bridges hebben deze vorm: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream en SaveAsPdfVTToStream, elk met een eigen option record

// PDFium.pas: de documentaccessors zijn altijd UTF-16
//   WString = WideString op FPC, = string (UnicodeString) op Delphi
function TPdf.GetTitle: WString;

// FPdfPdfa.pas: het save-option-record draagt metadata als `string`
TPdfASaveOptions = record
  Conformance: TPdfAConformance;
  IccProfileData: TBytes;
  Title: string;      // UnicodeString op Delphi
                      // AnsiString + DefaultSystemCodePage op 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 vult lege velden aan vanuit het Info-dictionary
// De narrowing wordt nu expliciet geschreven in plaats van impliciet gelaten:
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

Alle zes bridges via één WStringToStr-helper routeren verandert niet wat de RTL doet, maar zet de conversie op een plek waar een lezer hem kan zien, en het ruimde 92 warnings over impliciete conversie op die precies deze probleemklasse hadden gemaskeerd. Dit is het spiegelbeeld van de Delphi-side corruptie die wordt beschreven in onze notities over cross-compiler-valkuilen van Delphi en FPC in PDFium-builds, waar een concatenatie op Delphi een high byte vernietigt die Free Pascal behoudt

Drie Free Pascal-gedragingen die de voor de hand liggende fix verslaan

De voor de hand liggende fix is UTF8Encode aanroepen en klaar. Dat faalt drie keer op FPC 3.2.2 in mode Delphi, en elke fout is stil

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // Valkuil 1: in mode Delphi is een UTF8String-*variabele* een gewone AnsiString,
  // dus de assignment transcodeert de octetten rechtstreeks terug naar de hostcodepage
  U := UTF8Encode(W);

  // Valkuil 2: S is al een AnsiString, dus UTF8Encode doet helemaal niets
  R := UTF8Encode(S);                 // geen decode, geen encode, geen fout
  R := UTF8Encode(UnicodeString(S));  // deze codeert echt

  // Valkuil 3: concatenatie maakt elk operand gelijk aan de doelcodepage,
  // en een RawByteString-doel is geen uitzondering
  Xmp := Xmp + R;
end;

Valkuil één betekent dat het gecodeerde resultaat moet blijven in de AnsiString of RawByteString waarin het is geproduceerd. Stuur het onderweg naar buiten door een tijdelijke UTF8String en je hebt het werk weer ongedaan gemaakt. Valkuil twee is degene die het langst verborgen blijft, omdat UTF8Encode(S) compileert, draait, een waarde van de juiste lengte retourneert en helemaal geen conversie uitvoert wanneer het argument al een AnsiString is; alleen eerst verbreden naar UnicodeString laat de call iets decoderen. Valkuil drie verklaart waarom een correcte encoder toch een kapot document kan produceren: BuildXmpBytes verzamelt het pakket in een lokale Xmp: AnsiString, en Free Pascal converteert elke operand van een concatenatie naar de codepage van de doelvariabele, waarbij de multibyte-sequenties onderweg terugvallen naar single-byte ANSI-bytes

Wat garandeert SetCodePage met False precies?

SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) voorziet de string van een nieuw label zonder een byte aan te raken. De derde parameter is Convert; False betekent "neem aan dat de payload al in de doelcodepage staat en wijzig alleen de tag". Dat is een leugen over de inhoud, bewust verteld: de octetten zijn werkelijk UTF-8, maar ze als hostcodepage taggen voorkomt dat de concatenatie in valkuil drie ze converteert. Ze komen als raw bytes in de XMP-buffer terecht en komen er aan de andere kant ongewijzigd weer uit

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi: UTF8Encode levert al CP_UTF8-getagde octetten op en
  // concatenatie in een AnsiString houdt ze intact
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC: eerst verbreden, anders is UTF8Encode een no-op op een AnsiString-argument
  Result := UTF8Encode(UnicodeString(S));
  // Label opnieuw zonder transcodering, zodat de octetten een concatenatie
  // overleven in de ANSI-getagde buffers die XMP-pakketten en PDF-stringobjecten samenstellen
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

Wees helder over de grens. De retag is FPC-only en geen algemene toestemming om getagde en ongetagde strings te mengen. Hij werkt hier omdat downstream precies één consumerpatroon bestaat: toevoegen aan een AnsiString en de buffer daarna als bytes wegschrijven. Alles wat de retagged value als tekst in de hostcodepage probeerde te interpreteren zou mojibake lezen, en terecht. De omgekeerde richting wordt andersom afgehandeld en is op beide compilers identiek: tag de binnenkomende buffer met CP_UTF8 via SetCodePage(..., False) en roep daarna UTF8ToString aan

Waarom droegen de regressietests dezelfde valkuil?

Omdat een test die zijn verwachte bytes uit een bronliteral opbouwt de compiler test en niet de library. Een constante zoals #$C3#$A9 in een Pascal-bronbestand draagt de compile-time-codepage van dat bestand, en wanneer die aan een AnsiString-parameter wordt doorgegeven, encodeert de RTL hem opnieuw, precies de conversie die je test. De verwachting moet runtime, byte voor byte, worden samengesteld en byte voor byte worden vergeleken, want = op twee AnsiString-waarden met verschillende tags verzoent de codepages vóór het vergelijken en retourneert een vrolijke 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;                    // de narrowing die wordt getest
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // 'Caf' + U+00E9 als UTF-8, runtime opgebouwd zodat geen literal opnieuw kan worden gecodeerd
  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;

De harness zelf is een LCL-programma, dus DefaultSystemCodePage is CP_UTF8 en de bug is onzichtbaar totdat de test hem omschakelt. SetMultiByteConversionCodePage(1252) binnen een try..finally bootst de plain-consoleomgeving na voor de duur van één test. De end-to-end-check gaat verder en assert beide richtingen: het XMP-pakket dat door markerinjectie wordt geproduceerd moet $43 $61 $66 $C3 $A9 bevatten en mag niet $43 $61 $66 $E9 bevatten, zodat een toekomstige regressie die terugvalt naar raw single-byte-output luid faalt in plaats van een bestand te produceren dat alleen in een hex dump plausibel lijkt. Als je met niet-Latijnse metadata werkt, beheerst dezelfde discipline van eerst verbreden de gevallen in emoji- en CJK-tekst die WideChar-afhandeling in Delphi breken

Waar komt de narrowing nog meer terecht?

XMP is het zichtbare slachtoffer, maar elke TBytes-naar-string-bridge in dezelfde codebase stond aan dezelfde blootstelling bloot. In v3.103.1 zijn er nog twee gecorrigeerd: Utf8BytesToString en StringToUtf8Bytes in FPdfProduction, die de XFA-datasets-packet via een string round-trippen zodat MergePdfXfaDatasets gebonden waarden kan substitueren, en BytesToUtf8 in FPdfTrustedList, die Europese trusted-list-XML decodeert nadat de byte-order-mark is verwijderd. Beide zetten de buffer nu in een RawByteString, taggen hem zonder conversie als CP_UTF8 en decoderen met UTF8ToString. Eén module was al immuun en de reden is het kopiëren waard. De XFDF-writer declareert een eigen teksttype als XFDFString, dat onder FPC naar WideString en onder Delphi naar UnicodeString resolveert, zodat zijn encoder nooit een codepage-getagde AnsiString ziet. Dat is de structurele fix: houd tekst in een UTF-16-type tot het exacte serialisatiepunt en laat één smalle functie de conversie naar bytes bezitten. Elke bug in deze familie kwam doordat een string-veld midden in een pipeline zat die aan de ene kant verder UTF-16 en aan de andere kant octetten was

Wat moet je controleren in je eigen PDF-code voor twee compilers?

Als je Object Pascal uitlevert dat op beide compilers draait en metadata naar een standards-conforme PDF schrijft, vinden vier checks het grootste deel van deze klasse voordat een validator dat doet

  • Zoek naar UTF8Encode met een string-argument. Op FPC is die call een no-op en dit is de regel met de hoogste opbrengst om te auditen
  • Behandel elke UTF8String-variabele in mode Delphi als verdacht. Het is daar een gewone AnsiString en encoded bytes eraan toewijzen transcodeert ze terug
  • Voer minstens één regressie uit onder SetMultiByteConversionCodePage met een single-byte-codepage. Een LCL-testharness draait op CP_UTF8 en zal een plain-consoleprogramma nooit reproduceren
  • Bouw verwachte bytevectors runtime en vergelijk ze octet voor octet. Zowel sourceliterals als = lopen via codepageresolutie en verbergen het defect waar je naar zoekt

Niets hiervan is exotische Free Pascal-trivia. Het is de gewone prijs van een taal die een bytegeoriënteerd stringtype naast een UTF-16-type in leven hield, waarbij de twee compilers redelijke maar verschillende keuzes maakten over wat string moet betekenen. Het praktische gevolg voor PDF-werk is smal en scherp: metadata die in je IDE goed leest, kan als ongeldige UTF-8 het XMP-pakket bereiken, en ISO 19005-1 6.7.2 maakt het niet uit welke compiler hem daar heeft neergezet. Als je een archiveringspipeline bouwt, verdient de encodinglaag evenveel aandacht als de rest van de PDF/A-workflow voor archiefconformiteit eromheen. De PDFium Delphi Component levert deze conversies als onderdeel van de library, zodat SaveAsPdfA en zijn vijf standardsiblings conforme UTF-8-metadata uitgeven op Delphi, Lazarus en plain Free Pascal builds zonder codepageconfiguratie van de caller. Volledige API-documentatie en de actuele release staan op de PDFium Delphi Component-productpagina