PDFium Delphi Component สร้าง XMP packet สำหรับ PDF/A output ด้วยการต่อ UTF-8 fragment เข้า AnsiString และบน Free Pascal 3.2.2 packet นี้จะหยุดเป็น UTF-8 ที่ถูกต้องอย่างเงียบ ๆ ทันทีเมื่อ document title มี non-ASCII character ISO 19005-1 6.7.2 บังคับให้ metadata stream เป็น UTF-8 ที่ถูกต้อง ไฟล์จึงไม่ผ่าน validation version 3.103.1 แก้ที่ encoder เองใน StringToUtf8 ส่วนที่น่าสนใจไม่ใช่ patch แต่คือ source line เดิมที่ไม่เปลี่ยนเลยกลับสร้าง byte ถูกต้องบน Delphi, ถูกต้องใน Lazarus LCL application และเสียใน plain Free Pascal console program ที่ compile จาก unit เดียวกัน พฤติกรรม string ของ Free Pascal สามอย่างต้องเรียงต่อกันจึงจะอธิบายได้ และแต่ละอย่างก็สมเหตุสมผลในตัวเอง
ทำไม metadata code เดียวกันจึง emit byte ต่างกันบน Delphi กับ FPC
เพราะ string ไม่ใช่ type เดียวกันบน compiler สองตัว FPC 3.2.2 ใน {$MODE Delphi} compile string เป็น AnsiString ที่ติด tag DefaultSystemCodePage ส่วน Delphi compile มันเป็น UnicodeString field metadata ทุกตัวใน TPdfASaveOptions ประกาศเป็น string ดังนั้น Title, Author, Subject, Keywords, Creator และ Producer จึงเก็บ UTF-16 code unit บน compiler หนึ่ง แต่เก็บ single-byte character พร้อม codepage label บนอีก compiler record เดียว field เดียว payload ต่างกัน ค่าเองมาจาก document ในรูป UTF-16 TPdf.GetTitle และ sibling method คืน WString ซึ่งเป็น WideString บน FPC และเป็น string บน Delphi และ SaveAsPdfAToStream จะเติม option field ที่ว่างจาก Info dictionary ก่อน inject marker assignment นี้เป็น narrowing conversion บน Free Pascal และ RTL จะทำผ่าน codepage ของ target string ใน LCL program LazUTF8 ตั้ง DefaultSystemCodePage เป็น CP_UTF8 ไว้แล้ว narrowing จึงสร้าง UTF-8 และทุกอย่างหลังจากนั้นบังเอิญถูกต้อง แต่ใน plain console program narrowing เดียวกันจะลงที่ ANSI codepage แล้ว StringToUtf8 copy octet เหล่านั้นต่อโดยไม่เปลี่ยน เพราะคิดว่ามันเป็น UTF-8 อยู่แล้ว save bridge หกตัวมีรูปแบบเดียวกัน ได้แก่ 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 เติม field ที่ว่างจาก Info dictionary
// ตอนนี้ระบุ narrowing ชัดเจนแทนการปล่อยให้ implicit:
if Eff.Title = '' then
Eff.Title := WStringToStr(GetTitle);
การ route bridge ทั้งหกผ่าน helper WStringToStr เดียวไม่ได้เปลี่ยนสิ่งที่ RTL ทำ แต่ทำให้ conversion อยู่ในจุดที่คนอ่านเห็นได้ และล้าง implicit-conversion warning 92 รายการที่เคยบังปัญหาประเภทเดียวกันไว้ เรื่องนี้เป็นภาพกลับด้านของ corruption ฝั่ง Delphi ที่อธิบายในบันทึกเรื่อง กับดัก cross-compiler ของ Delphi และ FPC ใน PDFium build ซึ่ง concatenation บน Delphi ทำลาย high byte ที่ Free Pascal รักษาไว้
พฤติกรรม Free Pascal สามอย่างที่ทำให้ obvious fix ไม่ได้ผล
obvious fix คือเรียก UTF8Encode แล้วจบ แต่มัน fail ถึงสามครั้งบน FPC 3.2.2 ใน mode Delphi และแต่ละครั้งเงียบทั้งหมด
var
W: UnicodeString;
S: string;
U: UTF8String;
R: RawByteString;
Xmp: AnsiString;
begin
// Trap 1: ใน mode Delphi UTF8String *variable* เป็น AnsiString ธรรมดา
// assignment จึง transcode octet กลับไปยัง host codepage ตรง ๆ
U := UTF8Encode(W);
// Trap 2: S เป็น AnsiString อยู่แล้ว UTF8Encode จึงไม่ทำอะไรเลย
R := UTF8Encode(S); // ไม่ decode ไม่ encode ไม่มี error
R := UTF8Encode(UnicodeString(S)); // แบบนี้จึง encode จริง
// Trap 3: concatenation unify operand ทุกตัวเข้ากับ destination codepage
// และ destination ที่เป็น RawByteString ก็ไม่ใช่ข้อยกเว้น
Xmp := Xmp + R;
end;
Trap แรกหมายความว่า encoded result ต้องอยู่ใน AnsiString หรือ RawByteString ที่มันถูกสร้างขึ้นมา หากส่งผ่าน temporary ที่เป็น UTF8String ระหว่างทางออก คุณจะ undo งานที่ทำไว้ Trap สองซ่อนอยู่นานที่สุด เพราะ UTF8Encode(S) compile ได้ run ได้คืนค่าที่มีความยาวถูกต้องและไม่ทำ conversion ใด ๆ เมื่อ argument เป็น AnsiString อยู่แล้ว ต้อง widen เป็น UnicodeString ก่อนจึงทำให้ call decode ได้ Trap สามคือเหตุผลที่ encoder ที่ถูกต้องยังสร้าง document เสียได้ BuildXmpBytes สะสม packet ใน local Xmp: AnsiString และ Free Pascal convert operand ทุกตัวของ concatenation ให้เป็น codepage ของ destination variable ทำให้ multi-byte sequence ถูกพับกลับเป็น single ANSI byte ตอนต่อเข้าไป
SetCodePage ที่มี False รับประกันอะไรจริง
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) แค่เปลี่ยน label ของ string โดยไม่แตะ byte parameter ตัวที่สามคือ Convert การส่ง False หมายถึง "ถือว่า payload อยู่ใน target codepage แล้วและเปลี่ยน tag อย่างเดียว" นี่เป็นการโกหกเกี่ยวกับเนื้อหาโดยตั้งใจ octet เป็น UTF-8 จริง แต่การ tag ว่าเป็น host codepage คือสิ่งที่หยุด concatenation ใน trap สามไม่ให้ convert พวกมันเมื่อเข้า XMP buffer มันจะถูกต่อเป็น raw byte และออกอีกด้านมาเหมือนเดิม
function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
// Delphi: UTF8Encode คืน octet ที่ tag เป็น CP_UTF8 อยู่แล้ว และ
// concatenation เข้า AnsiString จะรักษาไว้
Result := AnsiString(UTF8Encode(S));
{$ELSE}
// FPC: ต้อง widen ก่อน ไม่เช่นนั้น UTF8Encode บน AnsiString จะ no-op
Result := UTF8Encode(UnicodeString(S));
// relabel โดยไม่ transcode เพื่อให้ octet อยู่รอดเมื่อ concat เข้า
// buffer ที่ tag เป็น ANSI ซึ่งใช้ประกอบ XMP packet และ PDF string object
if Length(Result) > 0 then
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;
ต้องเข้าใจขอบเขต retag นี้ใช้เฉพาะ FPC และไม่ใช่ใบอนุญาตทั่วไปให้ผสม tagged กับ untagged string มันใช้ได้ที่นี่เพราะ downstream มี consumer pattern เดียวพอดี คือ append เข้า AnsiString แล้วเขียน buffer ออกเป็น byte สิ่งใดก็ตามที่พยายาม interpret ค่า retagged ในฐานะ text บน host codepage จะอ่าน mojibake และมันก็ถูกต้องแล้ว ทิศทางย้อนกลับจัดการอีกแบบและเหมือนกันบนทั้งสอง compiler คือ tag incoming buffer เป็น CP_UTF8 ด้วย SetCodePage(..., False) แล้วเรียก UTF8ToString
ทำไม regression test จึงมี trap เดียวกัน
เพราะ test ที่สร้าง expected byte จาก source literal กำลัง test compiler ไม่ใช่ library constant อย่าง #$C3#$A9 ใน Pascal source file จะมี compile-time codepage ของไฟล์ และเมื่อส่งเข้า AnsiString parameter RTL จะ re-encode มัน ซึ่งก็คือ conversion ที่กำลัง test พอดี expectation จึงต้องประกอบตอน runtime ทีละ byte และเปรียบเทียบทีละ byte เพราะ = ระหว่าง AnsiString สองค่าที่มี tag ต่างกันจะ reconcile codepage ก่อนเปรียบเทียบ แล้วคืน 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 สร้างตอน 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 ไปไกลกว่านั้นและ assert สองทิศทางด้วย XMP packet ที่ marker injection ผลิตต้องมี $43 $61 $66 $C3 $A9 และต้องไม่มี $43 $61 $66 $E9 ดังนั้น regression ที่ย้อนกลับไปสร้าง raw single-byte output จะ fail ดัง ๆ แทนการได้ไฟล์ที่ดูสมเหตุสมผลใน hex dump หากคุณทำงานกับ metadata ที่ไม่ใช่ Latin หลักการ widen เดียวกันใช้กับกรณีใน emoji และ CJK text ที่ทำลาย WideChar handling ใน Delphi
narrowing ไปโผล่ที่ไหนอีก
XMP เป็น casualty ที่มองเห็นได้ แต่ TBytes-to-string bridge ใด ๆ ใน codebase เดียวกันก็มีความเสี่ยงแบบเดียวกัน อีกสองจุดถูกแก้ใน v3.103.1 ได้แก่ Utf8BytesToString และ StringToUtf8Bytes ใน FPdfProduction ซึ่ง round-trip XFA datasets packet ผ่าน string เพื่อให้ MergePdfXfaDatasets substitute bound value และ BytesToUtf8 ใน FPdfTrustedList ซึ่ง decode European trusted-list XML หลัง strip byte-order mark ทั้งสองจุดตอนนี้ stage buffer ใน RawByteString tag เป็น CP_UTF8 โดยไม่ convert แล้ว decode ด้วย UTF8ToString มี module หนึ่งที่ immune อยู่แล้ว และเหตุผลควรถูกนำไปใช้ซ้ำ XFDF writer ประกาศ text type ของตัวเองชื่อ XFDFString ซึ่ง resolve เป็น WideString บน FPC และ UnicodeString บน Delphi ดังนั้น encoder ไม่เคยเห็น AnsiString ที่ติด codepage tag นี่คือ structural fix เก็บ text ใน UTF-16 type จนถึงจุด serialize ที่แน่นอน แล้วให้ narrow function เดียวเป็นเจ้าของ conversion ไปเป็น byte bug ทุกตัวในตระกูลนี้เกิดจาก string field อยู่กลาง pipeline ที่ปลายด้านหนึ่งเป็น UTF-16 และอีกด้านหนึ่งเป็น octet
สิ่งที่ควรตรวจใน dual-compiler PDF code ของคุณ
หากคุณส่ง Object Pascal ที่รันบน compiler สองตัวและเขียน metadata ลง PDF ที่ต้อง conform กับ standard check สี่ข้อนี้จะจับ bug กลุ่มนี้ได้มากที่สุดก่อน validator
- grep หา
UTF8Encodeที่รับ argument เป็นstringบน FPC call นี้เป็น no-op และเป็นบรรทัดที่คุ้มค่าที่สุดในการ audit - มอง variable ที่เป็น
UTF8Stringทุกตัวใน mode Delphi เป็น suspect ที่นั่นมันคือAnsiStringธรรมดา และการ assign encoded byte เข้าไปจะ transcode กลับ - รัน regression อย่างน้อยหนึ่งครั้งภายใต้
SetMultiByteConversionCodePageด้วย single-byte codepage LCL test harness รันที่CP_UTF8และจะ reproduce plain console program ไม่ได้ - สร้าง expected byte vector ตอน runtime แล้วเทียบทีละ octet source literal กับ
=ต่างก็ผ่าน codepage reconciliation และจะซ่อน defect ที่คุณกำลังตามหา
เรื่องทั้งหมดนี้ไม่ใช่ Free Pascal trivia ที่พิสดาร มันเป็นต้นทุนปกติของภาษาที่รักษา byte-oriented string type ไว้ข้าง UTF-16 และ compiler สองตัวก็เลือกอย่างสมเหตุสมผลแต่ต่างกันว่า string ควรหมายถึงอะไร ผลในทางปฏิบัติสำหรับ PDF งานจึงแคบแต่ชัด 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 มี conversion เหล่านี้อยู่ในไลบรารี ดังนั้น SaveAsPdfA และ sibling สำหรับ standard อีกห้าตัวจะ emit metadata UTF-8 ที่ conform บน Delphi, Lazarus และ plain Free Pascal โดยไม่ต้องให้ caller configure codepage เอกสาร API ฉบับเต็มและ release ปัจจุบันอยู่ที่ หน้า product ของ PDFium Delphi Component