Техническа статия

FPC codepage traps за PDF/A XMP metadata в Delphi

PDFium Delphi Component сглобява XMP packet-а за PDF/A output чрез concatenating UTF-8 fragments в AnsiString, а при Free Pascal 3.2.2 този packet тихо престана да бъде valid UTF-8 в момента, в който document title носеше non-ASCII character. ISO 19005-1 6.7.2 изисква metadata stream-ът да е valid UTF-8 и затова file-ът fail-ваше validation. Version 3.103.1 поправя самия encoder, в StringToUtf8. Интересното не е patch-ът. Интересното е, че един непроменен source line произвеждаше correct bytes под Delphi, correct bytes в Lazarus LCL application и corrupt bytes в plain Free Pascal console program, компилиран от идентичния unit. Три отделни Free Pascal string behaviors трябва да се подредят, за да стане това разбираемо, и всеки от тях сам по себе си е защитим

Защо един и същ metadata code emit-ва различни bytes под Delphi и FPC?

Защото string не е един и същ type в двата compiler-а. FPC 3.2.2 в {$MODE Delphi} компилира string като AnsiString, маркиран с DefaultSystemCodePage, докато Delphi го компилира като UnicodeString. Всеки metadata field в TPdfASaveOptions е деклариран като string, така че Title, Author, Subject, Keywords, Creator и Producer носят UTF-16 code units в единия compiler и single-byte characters плюс codepage label в другия. Същият record, същият field, различен payload. Самите values идват от document-а като UTF-16. TPdf.GetTitle и siblings-ите му връщат WString, което е WideString във FPC и string (UnicodeString) в Delphi, а SaveAsPdfAToStream попълва всеки blank option field от Info dictionary преди да inject-не markers. Това assignment е narrowing conversion във Free Pascal и RTL-ът го извършва през target string codepage. В LCL program LazUTF8 вече е задал DefaultSystemCodePage на CP_UTF8, така че narrowing-ът произвежда UTF-8 и всичко downstream случайно се оказва правилно. В plain console program същият narrowing попада в ANSI codepage и StringToUtf8 след това копира octets-те непроменени, защото приема, че вече са UTF-8. Шест save bridges имат тази shape: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream и SaveAsPdfVTToStream, всеки със собствен option record

// PDFium.pas: document accessor-ите винаги са UTF-16
//   WString = WideString във FPC, = string (UnicodeString) в Delphi
function TPdf.GetTitle: WString;

// FPdfPdfa.pas: save option record-ът носи metadata като `string`
TPdfASaveOptions = record
  Conformance: TPdfAConformance;
  IccProfileData: TBytes;
  Title: string;      // UnicodeString в Delphi
                      // AnsiString + DefaultSystemCodePage във 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 backfill-ва blank fields от Info dictionary
// Narrowing-ът вече е изписан явно, вместо да остава implicit:
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

Насочването на всичките шест bridges през един WStringToStr helper не променя какво прави RTL, но поставя conversion-а там, където reader може да го види, и изчисти 92 implicit-conversion warnings, които са прикривали точно този клас problem. Това е mirror image на Delphi-side corruption, описан в notes за Delphi и FPC cross-compiler pitfalls в PDFium builds, където concatenation на Delphi унищожава high byte, който Free Pascal запазва

Три Free Pascal behaviors, които побеждават очевидния fix

Очевидният fix е да извикате UTF8Encode и да приключите. Това fail-ва три пъти под FPC 3.2.2 в mode Delphi, като всеки failure е silent

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // Trap 1: в mode Delphi UTF8String *variable* е plain AnsiString,
  // така че assignment transcode-ва octets-те обратно към host codepage
  U := UTF8Encode(W);

  // Trap 2: S вече е AnsiString, така че UTF8Encode изобщо не прави нищо
  R := UTF8Encode(S);                 // no decode, no encode, no error
  R := UTF8Encode(UnicodeString(S));  // този наистина encode-ва

  // Trap 3: concatenation unify-ва всеки operand към destination codepage,
  // а RawByteString destination не е изключение
  Xmp := Xmp + R;
end;

Trap one означава, че encoded result трябва да остане в AnsiString или RawByteString, в който е произведен. Прекарайте го през UTF8String temporary по пътя навън и сте отменили свършената работа. Trap two е този, който се крие най-дълго, защото UTF8Encode(S) се компилира, изпълнява, връща value с правилната length и не извършва никаква conversion, когато argument-ът вече е AnsiString; само първото widening до UnicodeString кара call-а да decode-не нещо. Trap three е причината correct encoder все пак да produce-не broken document: BuildXmpBytes натрупва packet-а в local Xmp: AnsiString, а Free Pascal convert-ва всеки operand на concatenation към codepage-а на destination variable, като свива multi-byte sequences обратно до single ANSI bytes при вкарването

Какво всъщност гарантира SetCodePage с False?

SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) relabel-ва string-а, без да докосва byte. Третият parameter е Convert; подаването на False означава „приеми, че payload-ът вече е в target codepage и само смени tag-а“. Това е лъжа за content-а, казана нарочно: octets-те действително са UTF-8, но маркирането им като host codepage спира concatenation-а в trap three да ги convert-ва. Те се join-ват към XMP buffer-а като raw bytes и излизат от другата страна непроменени

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi: UTF8Encode вече връща CP_UTF8-tagged octets и
  // concatenation в AnsiString ги запазва
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC: първо widen-нете, иначе UTF8Encode е no-op върху AnsiString argument
  Result := UTF8Encode(UnicodeString(S));
  // Relabel без transcoding, така че octets-те да оцелеят при concatenation
  // в ANSI-tagged buffers, които сглобяват XMP packets и PDF string objects
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

Бъдете ясни за boundary-то. Retag е FPC-only и не е general blessing за смесване на tagged и untagged strings. Работи тук, защото надолу има точно един consumer pattern: append към AnsiString, после write на buffer-а като bytes. Всичко, което се опита да interpret-не retag-натата value като text в host codepage, ще прочете mojibake и правилно ще го направи. Reverse direction се обработва по другия начин и е еднаква в двата compiler-а: tag-нете incoming buffer с CP_UTF8 чрез SetCodePage(..., False), после извикайте UTF8ToString

Защо regression tests носеха същия trap?

Защото test, който изгражда expected bytes от source literal, тества compiler-а, а не library-то. Constant като #$C3#$A9, записан в Pascal source file, носи compile-time codepage-а на този file и когато се подаде на AnsiString parameter, RTL го re-encode-ва, което е точно conversion-ът под test. Expected value трябва да бъде сглобена at run time, byte по byte, и сравнявана byte по byte, защото = върху две AnsiString values с различни tags reconcile-ва codepages преди compare и връща cheerful 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-ът под test
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // 'Caf' + U+00E9 като UTF-8, изграден at run time, така че literal да не се re-encode-ва
  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-ът сам по себе си е LCL program, така че DefaultSystemCodePage е CP_UTF8 и bug-ът е невидим, докато test-ът не го смени. SetMultiByteConversionCodePage(1252) вътре в try..finally пресъздава plain console environment-а за duration-а на един test. End-to-end check-ът отива по-далеч и assert-ва и двете посоки: XMP packet-ът, произведен чрез marker injection, трябва да съдържа $43 $61 $66 $C3 $A9 и да не съдържа $43 $61 $66 $E9, така че бъдеща regression, която се върне към raw single-byte output, да fail-не шумно, вместо да produce-не file, който само изглежда правдоподобен в hex dump. Ако работите с non-Latin metadata, същата widening discipline управлява случаите в emoji и CJK text, които чупят WideChar handling в Delphi

Къде другаде попада narrowing-ът

XMP е видимата casualty, но всеки TBytes към string bridge в същия codebase е имал същата exposure. Още два бяха поправени във v3.103.1: Utf8BytesToString и StringToUtf8Bytes в FPdfProduction, които round-trip-ват XFA datasets packet през string, за да може MergePdfXfaDatasets да substitute-не bound values, и BytesToUtf8 във FPdfTrustedList, който decode-ва European trusted-list XML след strip на byte-order mark. И двата вече stage-ват buffer-а в RawByteString, tag-ват го CP_UTF8 без conversion и го decode-ват с UTF8ToString. Един module вече е бил immune и причината си струва да се копира. XFDF writer-ът декларира собствен text type XFDFString, който resolve-ва до WideString под FPC и UnicodeString под Delphi, така че encoder-ът му никога не вижда codepage-tagged AnsiString. Това е structural fix-ът: пазете text в UTF-16 type до точния point of serialization и оставете една narrow function да притежава conversion-а до bytes. Всеки bug в това семейство е идвал от string field, застанал по средата на pipeline, който иначе е UTF-16 в единия край и octets в другия

Какво да проверите в собствения си dual-compiler PDF code

Ако доставяте Object Pascal, който върви и на двата compiler-а и записва metadata в standards-conformant PDF, четири checks хващат повечето проблеми от този клас, преди validator-ът да ги хване

  • Grep-нете за UTF8Encode с string argument. Във FPC този call е no-op и това е line-ът с най-висока стойност за audit
  • Третирайте всяка UTF8String variable като suspect в mode Delphi. Там тя е plain AnsiString и assigning на encoded bytes към нея ги transcode-ва обратно
  • Пуснете поне една regression под SetMultiByteConversionCodePage с single-byte codepage. LCL test harness върви при CP_UTF8 и никога няма да reproduce-не plain console program
  • Изграждайте expected byte vectors at run time и ги сравнявайте octet по octet. Source literals и = и двете минават през codepage reconciliation и ще скрият defect-а, който търсите

Нищо от това не е exotic Free Pascal trivia. Това е обикновената цена на език, който е запазил byte-oriented string type наред с UTF-16 type, а двата compiler-а са направили разумен, но различен избор какво да означава string. Практическата последица за PDF work е тясна и остра: metadata, която изглежда правилна в IDE-то, може да стигне до XMP packet-а като invalid UTF-8, а ISO 19005-1 6.7.2 не се интересува кой compiler я е поставил там. Ако изграждате archival pipeline, encoding layer-ът заслужава толкова внимание, колкото и останалата PDF/A archival compliance workflow, която го обгражда. PDFium Delphi Component доставя тези conversions като част от library-то, така че SaveAsPdfA и петте му standards siblings emit-ват conformant UTF-8 metadata на Delphi, Lazarus и plain Free Pascal builds без codepage configuration от caller-а. Пълната API документация и текущият release са на продуктовата страница на PDFium Delphi Component