Bài viết kỹ thuật

Bẫy codepage FPC làm hỏng XMP PDF/A trong Delphi

PDFium Delphi Component dựng XMP packet cho output PDF/A bằng cách nối các fragment UTF-8 vào một AnsiString, còn trên Free Pascal 3.2.2 packet đó âm thầm không còn là UTF-8 hợp lệ ngay khi title document có ký tự non-ASCII. ISO 19005-1 6.7.2 yêu cầu metadata stream là UTF-8 hợp lệ, nên file fail validation. Version 3.103.1 sửa encoder trong StringToUtf8. Điều đáng chú ý không phải patch mà là một source line không đổi lại tạo byte đúng trên Delphi, byte đúng trong ứng dụng Lazarus LCL và byte hỏng trong Free Pascal console program biên dịch từ đúng unit đó. Ba behavior string riêng của Free Pascal phải khớp nhau mới hiểu được, và mỗi behavior đều hợp lý khi đứng riêng

Vì sao cùng metadata code emit byte khác nhau trên Delphi và FPC?

string không phải cùng một type trên hai compiler. FPC 3.2.2 ở {$MODE Delphi} compile string thành AnsiString gắn với DefaultSystemCodePage, còn Delphi compile nó thành UnicodeString. Mọi metadata field trong TPdfASaveOptions đều khai báo là string, nên Title, Author, Subject, Keywords, CreatorProducer mang UTF-16 code unit trên compiler này nhưng mang ký tự single-byte cộng codepage label trên compiler kia. Cùng record, cùng field, payload khác nhau. Giá trị ban đầu đến từ document dưới dạng UTF-16. TPdf.GetTitle và sibling trả về WString, là WideString trên FPC và string (UnicodeString) trên Delphi, còn SaveAsPdfAToStream điền option field còn trống từ Info dictionary trước khi inject marker. Assignment đó là narrowing conversion trên Free Pascal, RTL thực hiện qua target string codepage. Trong LCL program, LazUTF8 đã set DefaultSystemCodePage thành CP_UTF8, nên narrowing tạo UTF-8 và mọi downstream tình cờ đúng. Trong console program thuần, narrowing rơi vào ANSI codepage, rồi StringToUtf8 copy nguyên octet vì giả định chúng đã là UTF-8. Sáu save bridge có shape này: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStreamSaveAsPdfVTToStream, mỗi bridge có option record riêng

// PDFium.pas: document accessor luôn là UTF-16
//   WString = WideString trên FPC, = string (UnicodeString) trên Delphi
function TPdf.GetTitle: WString;

// FPdfPdfa.pas: save option record mang metadata dưới dạng string
TPdfASaveOptions = record
  Conformance: TPdfAConformance;
  IccProfileData: TBytes;
  Title: string;      // UnicodeString trên Delphi
                      // AnsiString + DefaultSystemCodePage trên 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 field trống từ Info dictionary
// Giờ narrowing được viết rõ thay vì để implicit:
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

Route cả sáu bridge qua một helper WStringToStr không thay đổi điều RTL làm, nhưng đưa conversion tới nơi reader nhìn thấy và xóa 92 warning implicit-conversion vốn che đúng lớp vấn đề này. Đây là hình ảnh ngược của corruption phía Delphi được mô tả trong ghi chú về pitfall cross-compiler Delphi và FPC trong build PDFium, nơi phép nối trên Delphi phá high byte mà Free Pascal giữ lại

Ba behavior Free Pascal đánh bại bản sửa hiển nhiên

Bản sửa hiển nhiên là gọi UTF8Encode rồi xong. Trên FPC 3.2.2 ở mode Delphi, nó fail ba lần, và lần nào cũng im lặng

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // Bẫy 1: trong mode Delphi, variable UTF8String là AnsiString thường,
  // assignment sẽ transcode octet ngược về host codepage
  U := UTF8Encode(W);

  // Bẫy 2: S đã là AnsiString, nên UTF8Encode hoàn toàn không làm gì
  R := UTF8Encode(S);                 // không decode, không encode, không lỗi
  R := UTF8Encode(UnicodeString(S));  // dòng này mới thực sự encode

  // Bẫy 3: phép nối unify mọi operand vào destination codepage,
  // destination RawByteString cũng không ngoại lệ
  Xmp := Xmp + R;
end;

Bẫy một có nghĩa encoded result phải ở lại trong AnsiString hoặc RawByteString mà nó được tạo ra. Đưa nó qua temporary UTF8String trên đường đi sẽ undo việc đã làm. Bẫy hai là thứ ẩn lâu nhất, vì UTF8Encode(S) compile, chạy, trả value đúng length nhưng không conversion gì khi argument đã là AnsiString; chỉ widen sang UnicodeString trước mới khiến call decode. Bẫy ba giải thích vì sao encoder đúng vẫn tạo document hỏng: BuildXmpBytes tích lũy packet trong local Xmp: AnsiString, còn Free Pascal convert mọi operand của phép nối sang codepage của destination variable, fold multi-byte sequence trở lại single ANSI byte trên đường vào

SetCodePage với False thực sự bảo đảm điều gì?

SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) relabel string mà không chạm vào byte. Tham số thứ ba là Convert; truyền False nghĩa là "giả định payload đã ở target codepage và chỉ đổi tag". Đây là lời nói dối có chủ ý về content: octet thực sự là UTF-8, nhưng gắn tag chúng là host codepage mới ngăn phép nối trong bẫy ba convert chúng. Chúng đi vào XMP buffer như raw byte và đi ra phía kia không đổi

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi: UTF8Encode đã cho octet có tag CP_UTF8, phép nối
  // vào AnsiString sẽ giữ nguyên chúng
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC: widen trước, nếu không UTF8Encode sẽ no-op với AnsiString argument
  Result := UTF8Encode(UnicodeString(S));
  // Relabel không transcode để octet sống sót khi nối vào các buffer
  // gắn tag ANSI dùng để dựng XMP packet và PDF string object
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

Cần nói rõ boundary. Retag chỉ dành cho FPC và không phải lời chúc lành chung cho việc trộn string có tag với string không tag. Nó hoạt động ở đây vì downstream chỉ có đúng một consumer pattern: append vào AnsiString rồi ghi buffer ra dưới dạng byte. Thứ gì cố interpret value đã retag như text theo host codepage sẽ đọc mojibake, và đọc đúng như vậy. Chiều ngược lại được xử lý theo cách khác nhưng giống nhau trên cả hai compiler: tag buffer đầu vào là CP_UTF8 bằng SetCodePage(..., False), rồi gọi UTF8ToString

Vì sao regression test cũng mang cùng cái bẫy?

Vì test dựng expected byte từ source literal đang test compiler chứ không test library. Constant như #$C3#$A9 trong Pascal source mang compile-time codepage của file đó, và khi truyền vào AnsiString parameter, RTL re-encode nó, đúng conversion đang được test. Expectation phải dựng tại runtime, byte-by-byte và so sánh cũng byte-by-byte, vì = trên hai AnsiString có tag khác nhau sẽ reconcile codepage trước khi compare rồi trả về false negative rất vui vẻ

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 đang được test
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // 'Caf' + U+00E9 dưới dạng UTF-8, dựng runtime để literal không bị 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 tự nó là LCL program, nên DefaultSystemCodePageCP_UTF8 và bug vô hình cho tới khi test đổi nó. SetMultiByteConversionCodePage(1252) trong try..finally tái tạo môi trường plain console trong thời gian một test. End-to-end check đi xa hơn và assert cả hai hướng: XMP packet do marker injection tạo ra phải chứa $43 $61 $66 $C3 $A9không được chứa $43 $61 $66 $E9, để regression quay lại raw single-byte output sẽ fail rõ ràng thay vì tạo file chỉ trông có vẻ hợp lý trong hex dump. Nếu làm metadata non-Latin, cùng discipline widen chi phối các case trong emoji và CJK text phá WideChar handling trong Delphi

Narrowing còn rơi ở đâu?

XMP là casualty nhìn thấy được, nhưng mọi bridge TBytes tới string trong codebase có cùng exposure. Hai bridge khác được sửa trong v3.103.1: Utf8BytesToStringStringToUtf8Bytes trong FPdfProduction, round-trip XFA datasets packet qua string để MergePdfXfaDatasets substitute bound value, cùng BytesToUtf8 trong FPdfTrustedList, decode European trusted-list XML sau khi strip byte-order mark. Cả hai giờ stage buffer trong RawByteString, tag CP_UTF8 không convert rồi decode bằng UTF8ToString. Một module vốn đã immune và lý do đáng copy. XFDF writer tự khai báo text type là XFDFString, resolve thành WideString dưới FPC và UnicodeString dưới Delphi, nên encoder không bao giờ thấy AnsiString có codepage tag. Đó là structural fix: giữ text trong UTF-16 type tới đúng điểm serialize rồi giao conversion sang byte cho một narrow function duy nhất. Mọi bug trong họ này đến từ field string nằm giữa pipeline vốn là UTF-16 ở một đầu và octet ở đầu kia

Cần check gì trong PDF code chạy trên hai compiler của bạn?

Nếu bạn ship Object Pascal chạy trên cả hai compiler và ghi metadata vào PDF conform standards, bốn check này tìm ra phần lớn lớp vấn đề trước khi validator tìm thấy

  • Grep UTF8Encode với argument là string. Trên FPC call đó là no-op, và đây là dòng có yield cao nhất cần audit
  • Coi mọi variable UTF8String trong mode Delphi là đáng ngờ. Ở đó nó là AnsiString thường, assignment encoded byte vào nó sẽ transcode ngược
  • Chạy ít nhất một regression dưới SetMultiByteConversionCodePage với single-byte codepage. LCL test harness chạy ở CP_UTF8 và sẽ không bao giờ tái hiện plain console program
  • Dựng expected byte vector tại runtime rồi so sánh từng octet. Source literal và = đều đi qua codepage reconciliation và sẽ che defect bạn đang săn

Không có gì trong chuyện này là trivia Free Pascal kỳ quặc. Đây là chi phí bình thường của một ngôn ngữ giữ một byte-oriented string type cạnh một UTF-16 type, còn hai compiler đưa ra lựa chọn hợp lý nhưng khác nhau về ý nghĩa của string. Hệ quả thực tế với PDF hẹp nhưng sắc: metadata đọc ổn trong IDE vẫn có thể tới XMP packet dưới dạng UTF-8 không hợp lệ, và ISO 19005-1 6.7.2 không quan tâm compiler nào đã đưa nó vào. Nếu đang xây archival pipeline, encoding layer đáng được chú ý ngang phần còn lại của workflow PDF/A archival compliance bao quanh nó. PDFium Delphi Component ship các conversion này như một phần library, nên SaveAsPdfA cùng năm sibling standards của nó emit metadata UTF-8 conformant trên Delphi, Lazarus và plain Free Pascal mà caller không cần cấu hình codepage. Tài liệu API đầy đủ và release hiện tại có trên trang sản phẩm PDFium Delphi Component