PDFium Delphi Component는 PDF/A output용 XMP packet을 UTF-8 fragment를 AnsiString에 concatenate해 조립하며 Free Pascal 3.2.2에서는 document title에 non-ASCII character가 들어가는 순간 그 packet이 조용히 valid UTF-8이 아니게 되었습니다. ISO 19005-1 6.7.2는 metadata stream이 valid UTF-8이어야 한다고 요구하므로 file은 validation에 실패했습니다. Version 3.103.1은 StringToUtf8에서 encoder 자체를 수정합니다. 흥미로운 부분은 patch가 아닙니다. 변경하지 않은 한 source line이 Delphi에서는 올바른 byte를 만들고 Lazarus LCL application에서도 올바른 byte를 만들지만 동일한 unit에서 compile한 plain Free Pascal console program에서는 corrupt byte를 만들었다는 점입니다. 이를 이해하려면 서로 다른 세 가지 Free Pascal string behavior가 맞물려야 하며 각각은 단독으로는 모두 방어 가능한 동작입니다
같은 metadata code가 Delphi와 FPC에서 서로 다른 byte를 내보내는 이유
두 compiler에서 string이 같은 type이 아니기 때문입니다. {$MODE Delphi}의 FPC 3.2.2는 string을 DefaultSystemCodePage로 tag된 AnsiString으로 compile하지만 Delphi는 UnicodeString으로 compile합니다. TPdfASaveOptions의 모든 metadata field는 string으로 선언되어 있으므로 Title, Author, Subject, Keywords, Creator, Producer가 한 compiler에서는 UTF-16 code unit을, 다른 compiler에서는 codepage label이 붙은 single-byte character를 담습니다. 같은 record와 같은 field지만 payload는 다릅니다. value 자체는 document에서 UTF-16으로 들어옵니다. TPdf.GetTitle과 sibling은 WString을 반환하며 FPC에서는 WideString, Delphi에서는 string이고 SaveAsPdfAToStream은 marker를 주입하기 전에 빈 option field를 Info dictionary에서 채웁니다. 그 assignment는 Free Pascal에서 narrowing conversion이며 RTL은 target string codepage를 통해 수행합니다. LCL program에서는 LazUTF8이 이미 DefaultSystemCodePage를 CP_UTF8로 설정하므로 narrowing이 UTF-8을 만들고 이후 단계가 우연히 모두 올바릅니다. plain console program에서는 같은 narrowing이 ANSI codepage에 놓이고 StringToUtf8은 그 octet이 이미 UTF-8이라고 가정해 그대로 복사합니다. 이 shape를 공유하는 save bridge는 여섯 개입니다. SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream, SaveAsPdfVTToStream이며 각각 자체 option record를 가집니다
// PDFium.pas: document accessor는 항상 UTF-16입니다
// WString = FPC에서는 WideString, Delphi에서는 string (UnicodeString)
function TPdf.GetTitle: WString;
// FPdfPdfa.pas: save option record는 metadata를 `string`으로 전달합니다
TPdfASaveOptions = record
Conformance: TPdfAConformance;
IccProfileData: TBytes;
Title: string; // Delphi에서는 UnicodeString
// FPC에서는 AnsiString + DefaultSystemCodePage
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는 Info dictionary에서 빈 field를 backfill합니다
// narrowing은 이제 implicit로 두지 않고 명시합니다
if Eff.Title = '' then
Eff.Title := WStringToStr(GetTitle);
여섯 bridge를 하나의 WStringToStr helper로 통과시키는 것은 RTL 동작을 바꾸지 않지만 conversion이 보이는 위치로 옮기며 정확히 이 종류의 문제를 가리던 92개의 implicit-conversion warning을 없앴습니다. 이것은 PDFium build에서 Delphi와 FPC cross-compiler pitfall에 적은 Delphi-side corruption의 거울상입니다. 그 경우에는 Delphi의 concatenation이 high byte를 없애고 Free Pascal은 보존합니다
명백한 수정안을 무너뜨리는 Free Pascal behavior 세 가지
명백한 수정은 UTF8Encode를 호출하고 끝내는 것입니다. 그러나 Delphi mode의 FPC 3.2.2에서는 이것이 세 번 실패하며 모두 조용합니다
var
W: UnicodeString;
S: string;
U: UTF8String;
R: RawByteString;
Xmp: AnsiString;
begin
// Trap 1: mode Delphi에서 UTF8String *variable*은 plain AnsiString이므로
// assignment가 octet을 host codepage로 곧바로 다시 transcode합니다
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은 모든 operand를 destination codepage로 통일하며
// RawByteString destination도 예외가 아닙니다
Xmp := Xmp + R;
end;
Trap one은 encoded result가 만들어진 AnsiString 또는 RawByteString 안에 그대로 남아 있어야 한다는 뜻입니다. 나가는 길에 UTF8String temporary를 거치면 작업을 되돌린 것입니다. Trap two가 가장 오래 숨어 있는 이유는 UTF8Encode(S)가 compile되고 실행되며 올바른 length의 value를 반환하지만 argument가 이미 AnsiString이면 어떤 conversion도 수행하지 않기 때문입니다. 먼저 UnicodeString으로 widening해야 call이 실제로 무엇인가를 decode합니다. Trap three는 올바른 encoder도 broken document를 만들 수 있는 이유입니다. BuildXmpBytes는 local Xmp: AnsiString에 packet을 누적하고 Free Pascal은 concatenation의 모든 operand를 destination variable의 codepage로 convert해 multi-byte sequence를 들어오는 길에 다시 single ANSI byte로 접습니다
SetCodePage를 False로 호출할 때 실제로 보장되는 것
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False)는 byte를 건드리지 않고 string을 relabel합니다. 세 번째 parameter는 Convert이며 False는 "payload가 이미 target codepage에 있다고 가정하고 tag만 바꾼다"는 뜻입니다. 이것은 content에 대한 거짓말을 일부러 하는 것입니다. octet은 실제로 UTF-8이지만 host codepage로 tag하는 것이 trap three의 concatenation이 이를 변환하지 못하게 막습니다. 그러면 XMP buffer에 raw byte로 합쳐지고 반대편에서도 변경 없이 나옵니다
function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
// Delphi: UTF8Encode는 이미 CP_UTF8-tagged octet을 내고
// AnsiString으로의 concatenation이 이를 유지합니다
Result := AnsiString(UTF8Encode(S));
{$ELSE}
// FPC: 먼저 widen해야 합니다. AnsiString argument에서 UTF8Encode는 no-op입니다
Result := UTF8Encode(UnicodeString(S));
// transcode 없이 relabel하여 XMP packet과 PDF string object를 조립하는
// ANSI-tagged buffer로의 concatenation에서도 octet이 살아 있게 합니다
if Length(Result) > 0 then
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;
경계도 분명히 해야 합니다. retag는 FPC-only이며 tagged와 untagged string을 섞어도 된다는 일반적인 허가가 아닙니다. downstream consumer가 정확히 하나뿐일 때 작동합니다. AnsiString에 append한 뒤 buffer를 byte로 write하는 패턴입니다. retag된 value를 host codepage의 text로 해석하려고 하면 mojibake를 읽게 되며 그렇게 되는 것이 올바릅니다. 반대 방향은 두 compiler에서 동일하게 처리합니다. incoming buffer를 SetCodePage(..., False)로 CP_UTF8 tag한 뒤 UTF8ToString을 호출합니다
regression test에도 같은 trap이 들어간 이유
source literal에서 expected byte를 만드는 test는 library가 아니라 compiler를 테스트하기 때문입니다. Pascal source file에 #$C3#$A9처럼 적은 constant는 그 file의 compile-time codepage를 가지고 있으며 AnsiString parameter에 전달되면 RTL이 이를 re-encode합니다. test 중인 conversion을 그대로 다시 수행하는 셈입니다. expectation은 runtime에 byte by byte로 조립하고 byte by byte로 비교해야 합니다. codepage tag가 다른 두 AnsiString value에 =를 사용하면 비교 전에 codepage를 reconcile해 유쾌한 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; // test 중인 narrowing
Encoded := StringToUtf8(Narrowed);
finally
SetMultiByteConversionCodePage(Saved);
end;
// 'Caf' + U+00E9 as UTF-8, literal이 re-encode되지 않도록 runtime에 구성
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이고 test가 이를 바꿀 때까지 bug가 보이지 않습니다. SetMultiByteConversionCodePage(1252)를 try..finally 안에서 실행하면 한 test 동안 plain console environment가 재현됩니다. end-to-end check는 양방향으로 더 나아갑니다. marker injection이 만든 XMP packet에 $43 $61 $66 $C3 $A9가 들어 있고 $43 $61 $66 $E9는 들어 있지 않은지 assert합니다. 그러므로 raw single-byte output으로 돌아가는 future regression은 hex dump에서 그럴듯해 보이는 file을 만드는 대신 크게 실패합니다. non-Latin metadata를 다룬다면 같은 widening discipline이 Delphi에서 WideChar 처리를 깨뜨리는 emoji와 CJK text에도 적용됩니다
narrowing이 도달하는 다른 위치
XMP가 눈에 보이는 피해자지만 같은 codebase의 모든 TBytes 대 string bridge도 같은 노출을 가지고 있었습니다. v3.103.1에서 두 곳을 더 수정했습니다. FPdfProduction의 Utf8BytesToString과 StringToUtf8Bytes는 XFA datasets packet을 string으로 왕복해 MergePdfXfaDatasets가 bound value를 substitute하도록 하며 FPdfTrustedList의 BytesToUtf8는 byte-order mark를 제거한 뒤 European trusted-list XML을 decode합니다. 둘 다 이제 buffer를 RawByteString에 staging하고 conversion 없이 CP_UTF8으로 tag한 뒤 UTF8ToString으로 decode합니다. 이미 immune한 module도 하나 있었고 이유를 복사할 가치가 있습니다. XFDF writer는 자체 text type을 XFDFString으로 선언하며 FPC에서는 WideString, Delphi에서는 UnicodeString으로 resolve되므로 encoder가 codepage-tagged AnsiString을 전혀 보지 않습니다. 이것이 structural fix입니다. 정확히 serialization하는 지점까지 text를 UTF-16 type으로 유지하고 하나의 narrow function이 byte로의 conversion을 소유하게 하세요. 이 family의 모든 bug는 한쪽 끝은 UTF-16이고 다른 쪽 끝은 octet인 pipeline 중간에 string field가 놓여서 발생했습니다
dual-compiler PDF code에서 확인할 것
두 compiler에서 실행되고 standards-conformant PDF에 metadata를 쓰는 Object Pascal을 배포한다면 네 가지 check만으로도 validator보다 먼저 이 종류의 대부분을 찾을 수 있습니다
stringargument와 함께 쓰인UTF8Encode를 grep하세요. FPC에서 이 call은 no-op이며 audit yield가 가장 높은 한 줄입니다- Delphi mode에서 모든
UTF8Stringvariable을 의심하세요. 그곳에서는 plainAnsiString이고 encoded byte를 대입하면 다시 transcode됩니다 - single-byte codepage와 함께
SetMultiByteConversionCodePage아래에서 적어도 한 regression을 실행하세요. LCL test harness는CP_UTF8에서 실행되므로 plain console program을 재현하지 못합니다 - expected byte vector는 runtime에 만들고 octet 단위로 비교하세요. source literal과
=모두 codepage reconciliation을 거쳐 찾고 있는 defect를 숨깁니다
이것은 이국적인 Free Pascal trivia가 아닙니다. byte-oriented string type과 UTF-16 type을 함께 유지한 language가 치르는 보통 비용이며 두 compiler는 string이 무엇을 의미해야 하는지에 대해 합리적이지만 서로 다른 선택을 했습니다. PDF 작업에서 practical consequence는 좁고 뚜렷합니다. IDE에서 잘 읽히는 metadata가 XMP packet에 invalid UTF-8로 도착할 수 있고 ISO 19005-1 6.7.2는 어느 compiler가 그것을 넣었는지 관심이 없습니다. archival pipeline을 만든다면 encoding layer는 주변의 PDF/A archival compliance workflow만큼 주의해야 합니다. PDFium Delphi Component는 이 conversion을 library의 일부로 제공하므로 SaveAsPdfA와 다섯 standards sibling이 Delphi, Lazarus와 plain Free Pascal build에서 caller의 codepage configuration 없이 conformant UTF-8 metadata를 emit합니다. 전체 API documentation과 current release는 PDFium Delphi Component 제품 페이지에서 확인할 수 있습니다