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сstringargument. Във FPC този call е no-op и това е line-ът с най-висока стойност за audit - Третирайте всяка
UTF8Stringvariable като suspect в mode Delphi. Там тя е plainAnsiStringи 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