Technický článek

Past codepage FPC poškozuje XMP metadata PDF/A v Delphi

PDFium Delphi Component skládá XMP packet pro výstup PDF/A spojováním UTF-8 fragmentů do AnsiString a na Free Pascalu 3.2.2 tento packet potichu přestal být platným UTF-8 ve chvíli, kdy titul dokumentu obsahoval ne-ASCII znak. ISO 19005-1 6.7.2 vyžaduje, aby metadata stream byl validní UTF-8, takže soubor neprošel validací. Verze 3.103.1 opravuje samotný encoder v StringToUtf8. Zajímavý není patch, ale to, že jediný nezměněný řádek zdroje vytvářel správné bajty pod Delphi, správné bajty v aplikaci Lazarus LCL a poškozené bajty v běžném console programu Free Pascalu zkompilovaném ze stejné unity. Než to začne dávat smysl, musí do sebe zapadnout tři samostatná chování stringů Free Pascalu a každé je samo o sobě obhajitelné

Proč stejný metadata kód emituje jiné bajty v Delphi a FPC

Protože string není v obou kompilátorech stejný typ. FPC 3.2.2 v režimu {$MODE Delphi} kompiluje string jako AnsiString označený DefaultSystemCodePage, zatímco Delphi ho kompiluje jako UnicodeString. Každé metadata pole v TPdfASaveOptions je deklarované jako string, takže Title, Author, Subject, Keywords, Creator a Producer nesou na jednom kompilátoru UTF-16 code units a na druhém jednobajtové znaky plus označení codepage. Stejný record, stejné pole, jiný payload. Hodnoty přicházejí z dokumentu jako UTF-16. TPdf.GetTitle a sourozenci vracejí WString, což je na FPC WideString a na Delphi string, a SaveAsPdfAToStream doplní každé prázdné option pole z Info dictionary před vložením markerů. Toto přiřazení je na Free Pascalu narrowing conversion a RTL ho provede přes codepage cílového stringu. V LCL programu už LazUTF8 nastavil DefaultSystemCodePage na CP_UTF8, takže narrowing vytvoří UTF-8 a všechno downstream náhodou funguje. V čistém console programu stejné narrowing dopadne na ANSI codepage a StringToUtf8 potom tyto octety zkopíruje beze změny, protože předpokládá, že už jsou UTF-8. Tento tvar sdílí šest save bridges: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream a SaveAsPdfVTToStream, každý s vlastním option recordem

Metadata pipeline PDFium Delphi Component, kde TPdf.GetTitle vrací na obou kompilátorech UTF-16 WString, string v Delphi zůstává UnicodeString, zatímco Free Pascal zužuje přes DefaultSystemCodePage, takže LCL program vytvoří platné UTF-8, zatímco běžný console build složí titulek XMP do neplatných jednobajtových hodnot
Jeden nezměněný řádek zdroje emituje různé bajty XMP, protože string znamená na Delphi UnicodeString a na Free Pascalu AnsiString označený DefaultSystemCodePage, a jen console prostředí zúží metadata do neplatného UTF-8
// PDFium.pas: accessors dokumentu jsou vždy UTF-16
//   WString = WideString na FPC, = string (UnicodeString) na Delphi
function TPdf.GetTitle: WString;

// FPdfPdfa.pas: record save options nese metadata jako `string`
TPdfASaveOptions = record
  Conformance: TPdfAConformance;
  IccProfileData: TBytes;
  Title: string;      // UnicodeString na Delphi
                      // AnsiString + DefaultSystemCodePage na 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 doplňuje prázdná pole z Info dictionary.
// Narrowing je nyní zapsaný explicitně místo ponechání implicitním:
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

Směrování všech šesti bridge přes jediný helper WStringToStr nemění chování RTL, ale staví konverzi tam, kde ji čtenář vidí, a odstranilo 92 warningů o implicitních konverzích, které maskovaly právě tento druh problému. Je to zrcadlový obraz korupce na straně Delphi popsané v poznámkách o pastích cross-compilerů Delphi a FPC v buildech PDFium, kde concatenation v Delphi zničí vysoký bajt, který Free Pascal zachová

Tři chování Free Pascalu, která porazí očividnou opravu

Očividná oprava je zavolat UTF8Encode a skončit. Na FPC 3.2.2 v režimu Delphi to selže třikrát a každé selhání je tiché

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // Past 1: v režimu Delphi je proměnná UTF8String obyčejný AnsiString,
  // takže assignment převede octety rovnou zpět do host codepage
  U := UTF8Encode(W);

  // Past 2: S už je AnsiString, takže UTF8Encode neudělá vůbec nic
  R := UTF8Encode(S);                 // žádný decode, žádný encode, žádná chyba
  R := UTF8Encode(UnicodeString(S));  // tento skutečně encoduje

  // Past 3: concatenation sjednotí každý operand na codepage cíle
  // a RawByteString jako cíl není výjimkou
  Xmp := Xmp + R;
end;

První past znamená, že encoded výsledek musí zůstat v AnsiString nebo RawByteString, v němž vznikl. Pošlete ho cestou ven přes dočasný UTF8String a vykonanou práci jste obrátili. Druhá past se skrývá nejdéle, protože UTF8Encode(S) se zkompiluje, běží, vrátí hodnotu správné délky a když je argument už AnsiString, neprovede žádnou konverzi; teprve rozšíření na UnicodeString předem způsobí, že volání něco dekóduje. Třetí past vysvětluje, proč i správný encoder může vyrobit rozbitý dokument: BuildXmpBytes hromadí packet do lokálního Xmp: AnsiString a Free Pascal při concatenation převede každý operand na codepage cílové proměnné, takže vícebajtové sekvence cestou dovnitř znovu složí do jednobajtových ANSI hodnot

Tři tiché pasti Free Pascalu, které zmaří naivní opravu přes UTF8Encode v XMP encoderu PDFium Delphi Component: dočasná proměnná UTF8String znovu překóduje octety v režimu Delphi, argument AnsiString udělá z volání no-op a concatenation sjednotí každý operand na codepage cíle
Každá past selže tiše na FPC 3.2.2 v režimu Delphi, proto správný encoder může stále složit vícebajtové sekvence UTF-8 na jednobajtové ANSI hodnoty, zatímco concatenation skládá XMP packet do lokálního AnsiString

Co skutečně zaručuje SetCodePage s False

SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) string přeznačí, aniž se dotkne jediného bajtu. Třetí parametr je Convert; předání False znamená „předpokládej, že payload už je v cílové codepage a pouze změň štítek“. Je to záměrná lež o obsahu: octety jsou ve skutečnosti UTF-8, ale označení host codepage zabrání tomu, aby je concatenation z třetí pasti převáděla. Do XMP bufferu se připojí jako raw bytes a vyjdou na druhé straně beze změny

Oprava StringToUtf8 pro PDFium Delphi Component na Free Pascalu, kde UTF8Encode běží po rozšíření na UnicodeString a SetCodePage s Convert False přeznačí buffer bez přesunu jediného bajtu, takže octety UTF-8 přežijí concatenation do ANSI označeného XMP bufferu místo složení do neplatných jednobajtových hodnot
Předání False v parametru Convert je záměrná lež o obsahu: octety jsou ve skutečnosti UTF-8, ale označit je jako host codepage je přesně to, co zabrání RTL převést je na cestě do packetu
function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi: UTF8Encode už vrací octety označené CP_UTF8 a
  // concatenation do AnsiString je zachová
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC: nejdříve rozšiř, jinak je UTF8Encode nad AnsiString no-op
  Result := UTF8Encode(UnicodeString(S));
  // Přeznač bez transcodingu, aby octety přežily concatenation do
  // ANSI-tagovaných bufferů, které skládají XMP packety a PDF string objekty
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

Buďte přesní v hranici. Retag je pouze FPC a není obecným požehnáním pro míchání označených a neoznačených stringů. Funguje zde, protože downstream existuje přesně jeden consumer pattern: připojit k AnsiString a zapsat buffer jako bajty. Cokoli, co by retagovanou hodnotu zkusilo interpretovat jako text v host codepage, by četlo mojibake, a správně. Opačný směr se řeší zrcadlově a na obou kompilátorech stejně: příchozí buffer označte CP_UTF8 přes SetCodePage(..., False) a potom zavolejte UTF8ToString

Proč stejnou past nesly regresní testy

Protože test, který sestaví očekávané bajty ze source literálu, testuje kompilátor, nikoli knihovnu. Konstanta jako #$C3#$A9 zapsaná v Pascal source souboru nese compile-time codepage tohoto souboru a při předání do parametru AnsiString ji RTL znovu zakóduje, což je přesně konverze, kterou testujete. Očekávání se musí sestavit za běhu bajt po bajtu a porovnat bajt po bajtu, protože = nad dvěma AnsiString hodnotami s různými tagy před porovnáním codepage sjednotí a vrátí veselý 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;                    // narrowing, které testujeme
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // 'Caf' + U+00E9 jako UTF-8, sestavené za běhu, aby se literal nepřekódoval
  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;

Harness samotný je LCL program, takže DefaultSystemCodePage je CP_UTF8 a chyba je neviditelná, dokud ji test nepřepne. SetMultiByteConversionCodePage(1252) uvnitř try..finally na dobu jediného testu napodobí prostředí běžného console programu. End-to-end kontrola jde dál a assertuje oba směry: XMP packet vytvořený vložením markeru musí obsahovat $43 $61 $66 $C3 $A9 a nesmí obsahovat $43 $61 $66 $E9, takže budoucí regrese vracející se k raw single-byte outputu selže hlasitě místo vytvoření souboru, který v hex dumpu jen vypadá plausibilně. Pokud pracujete s nelatinskými metadaty, stejná disciplína rozšiřování řídí případy v článku o emoji a CJK textu, které rozbíjejí WideChar handling v Delphi

Kde ještě narrowing dopadá

XMP je viditelná oběť, ale stejnému problému byla vystavena každá další bridge mezi TBytes a string v téže codebase. Ve v3.103.1 se opravily další dvě: Utf8BytesToString a StringToUtf8Bytes v FPdfProduction, které round-trippují packet datasetů XFA přes string, aby MergePdfXfaDatasets mohl nahradit bound values, a BytesToUtf8 v FPdfTrustedList, který po odstranění BOM dekóduje XML evropského trusted listu. Obě nyní připraví buffer v RawByteString, označí ho CP_UTF8 bez konverze a dekódují přes UTF8ToString. Jeden modul byl už imunní a důvod stojí za převzetí. XFDF writer deklaruje vlastní textový typ XFDFString, který se pod FPC řeší na WideString a pod Delphi na UnicodeString, takže jeho encoder nikdy nevidí codepage-tagovaný AnsiString. To je strukturální oprava: držte text v UTF-16 typu až do přesného okamžiku serializace a jedinou úzkou funkci nechte vlastnit převod na bajty. Každý bug z této rodiny vznikl tím, že uprostřed pipeline, která byla na jednom konci UTF-16 a na druhém octety, sedělo pole typu string

Co kontrolovat ve vlastním PDF kódu pro dva kompilátory

Pokud dodáváte Object Pascal běžící na obou kompilátorech a zapisující metadata do PDF vyhovujícího standardu, čtyři kontroly najdou většinu tohoto druhu problému dřív než validator

  • Grepujte UTF8Encode s argumentem string. Na FPC je toto volání no-op a jde o řádek s nejvyšší výtěžností pro audit
  • Každou proměnnou UTF8String v režimu Delphi považujte za podezřelou. Je to tam obyčejný AnsiString a přiřazení zakódovaných bajtů do ní je znovu transcoduje
  • Spusťte alespoň jednu regresi pod SetMultiByteConversionCodePage s jednobajtovou codepage. LCL test harness běží na CP_UTF8 a běžný console program nikdy nereprodukuje
  • Očekávané byte vectors sestavujte za běhu a porovnávejte octet po octetu. Source literály i = procházejí codepage reconciliation a chybu, kterou hledáte, skryjí

Nic z toho není exotická trivia Free Pascalu. Je to běžná cena jazyka, který vedle UTF-16 ponechal byte-oriented string typ, a oba kompilátory rozumně, ale odlišně zvolily, co má string znamenat. Praktický důsledek pro PDF práci je úzký a ostrý: metadata, která vypadají dobře v IDE, mohou dorazit do XMP packetu jako neplatné UTF-8 a ISO 19005-1 6.7.2 nezajímá, který kompilátor je tam vložil. Pokud stavíte archivační pipeline, encoding layer si zaslouží stejnou pozornost jako zbytek workflow archivní shody PDF/A. PDFium Delphi Component dodává tyto konverze jako součást knihovny, takže SaveAsPdfA a jeho pět standardních sourozenců emituje konformní UTF-8 metadata v Delphi, Lazarusu i čistých buildech Free Pascalu bez konfigurace codepage na straně volajícího. Úplnou dokumentaci API a aktuální release najdete na produktové stránce PDFium Delphi Component