PDFium Delphi Component збирає XMP packet для PDF/A output, конкатенуючи 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 не проходив validation. Version 3.103.1 виправляє сам encoder, у StringToUtf8. Цікава частина не patch. Цікава частина в тому, що один незмінений source line давав правильні bytes під Delphi, правильні bytes у Lazarus LCL application і corrupted bytes у plain Free Pascal console program, скомпільованій з identical unit. Щоб це пояснити, мають зійтися три окремі Free Pascal string behaviors, і кожна сама по собі defensible
Чому той самий metadata code видає різні bytes у Delphi та FPC?
Тому що string — не той самий type у двох compilers. FPC 3.2.2 у {$MODE Delphi} компілює string як AnsiString, tagged значенням DefaultSystemCodePage, тоді як Delphi компілює його як UnicodeString. Кожне metadata field у TPdfASaveOptions оголошене як string, тому Title, Author, Subject, Keywords, Creator та Producer на одному compiler несуть UTF-16 code units, а на іншому — single-byte characters плюс codepage label. Той самий record, те саме field, інший payload. Самі values приходять із document як UTF-16. TPdf.GetTitle і його siblings повертають WString, що є WideString у FPC і string у Delphi, а SaveAsPdfAToStream заповнює будь-яке blank option field з Info dictionary перед injection markers. У Free Pascal це assignment є narrowing conversion, і 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 accessors завжди 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);
Routing усіх six bridges через один helper WStringToStr не змінює того, що робить RTL, але переносить conversion туди, де його видно reader-у, і прибирає 92 implicit-conversion warnings, які маскували саме такий class problem. Це mirror image від Delphi-side corruption, описаної в наших нотатках про Delphi та FPC cross-compiler pitfalls у PDFium builds, де concatenation у Delphi руйнує high byte, який Free Pascal зберігає
Три Free Pascal behaviors, які долають очевидний fix
Очевидний fix — викликати UTF8Encode і завершити. У 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); // без decode, encode і error
R := UTF8Encode(UnicodeString(S)); // цей виклик справді кодує
// Trap 3: concatenation уніфікує кожен 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 усе одно може створити broken document: BuildXmpBytes накопичує packet у local Xmp: AnsiString, а Free Pascal перетворює кожен operand concatenation до codepage destination variable, folding multi-byte sequences назад у single ANSI bytes на вході
Що насправді гарантує SetCodePage з False?
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) relabel-ить string, не торкаючись жодного byte. Third parameter — Convert; передати False означає "припустити, що payload уже в target codepage і лише змінити tag". Це intentional lie про content: octets насправді UTF-8, але tagging їх як host codepage зупиняє concatenation у trap three від conversion. Вони приєднуються до 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 і не є загальним дозволом змішувати tagged та untagged strings. Тут він працює, бо downstream існує рівно один consumer pattern: append до AnsiString, потім write buffer як bytes. Будь-що, що спробує interpret retagged value як text у host codepage, прочитає mojibake, і це буде правильно. Reverse direction працює навпаки і однаково в обох compilers: 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, який ми тестуємо. Expectation потрібно зібрати at runtime, byte by byte, і порівняти byte by byte, бо = на двох AnsiString values із різними tags узгоджує codepages перед compare і повертає cheerfully 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, що тестується
Encoded := StringToUtf8(Narrowed);
finally
SetMultiByteConversionCodePage(Saved);
end;
// 'Caf' + U+00E9 як UTF-8, зібрано runtime, щоб жоден 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 на час одного test. End-to-end check іде далі та assert-ить обидва directions: XMP packet, створений marker injection, має містити $43 $61 $66 $C3 $A9 і не містити $43 $61 $66 $E9, тому майбутня regression, яка повернеться до raw single-byte output, провалиться голосно, а не створить file, який лише виглядає plausible у hex dump. Якщо ви працюєте з non-Latin metadata, та сама widening discipline керує випадками в emoji та CJK text, які ламають WideChar handling у Delphi
Де ще виникає narrowing
XMP — видима casualty, але будь-який TBytes to string bridge у тому самому codebase мав таку саму exposure. Ще два виправили у v3.103.1: Utf8BytesToString і StringToUtf8Bytes у FPdfProduction, які round-trip-ять XFA datasets packet через string, щоб MergePdfXfaDatasets міг substitute bound values, і BytesToUtf8 у FPdfTrustedList, який decodes European trusted-list XML після stripping byte-order mark. Обидва тепер stage-ять buffer у RawByteString, tag-ять його як CP_UTF8 без conversion і decode-ять через UTF8ToString. Один module уже був immune, і причину варто скопіювати. XFDF writer оголошує власний text type як XFDFString, який під FPC розв’язується у WideString, а під Delphi — у UnicodeString, тому його encoder ніколи не бачить codepage-tagged AnsiString. Це structural fix: тримайте text у UTF-16 type до exact point serialization і доручіть одній narrow function володіти conversion у bytes. Кожен bug у цій family виник через string field посеред pipeline, який на одному кінці був UTF-16, а на іншому — octets
Що перевірити у власному dual-compiler PDF code
Якщо ви ship-ите Object Pascal, який працює на обох compilers і записує metadata у standards-conformant PDF, чотири checks знаходять більшість таких problems раніше за validator
- Grep-айте
UTF8Encodeзstringargument. У FPC цей call є no-op, і це single highest-yield line для audit - Вважайте кожну
UTF8Stringvariable підозрілою в mode Delphi. Там це plainAnsiString, і assignment encoded bytes у неї transcode-ить їх назад - Запускайте хоча б одну regression під
SetMultiByteConversionCodePageіз single-byte codepage. LCL test harness працює вCP_UTF8і ніколи не відтворить plain console program - Будуйте expected byte vectors runtime і порівнюйте їх octet by octet. Source literals і
=обидва проходять codepage reconciliation і приховають defect, який ви шукаєте
Нічого exotic у цій Free Pascal trivia немає. Це звичайна cost language, яка зберегла byte-oriented string type поруч із UTF-16, а два compilers зробили розумний, але різний вибір щодо того, що має означати string. Практичний наслідок для PDF вузький і sharp: 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 documentation та current release доступні на product page PDFium Delphi Component