مقاله فنی

دام‌های codepage در FPC، XMP PDF/A را در Delphi خراب می‌کنند

PDFium Delphi Component، packet مربوط به XMP را برای output از نوع PDF/A با concatenate کردن fragmentهای UTF-8 داخل یک AnsiString می‌سازد و در Free Pascal 3.2.2، همان لحظه‌ای که title document یک کاراکتر غیر ASCII داشت، آن packet بی‌سروصدا دیگر UTF-8 معتبر نبود. ISO 19005-1 6.7.2 معتبر بودن UTF-8 در metadata stream را لازم می‌داند و file validation را fail می‌کند. نسخه 3.103.1 خود encoder را در StringToUtf8 اصلاح می‌کند. بخش جالب patch نیست؛ این است که یک source line بدون تغییر، زیر Delphi byteهای درست، در application مربوط به Lazarus LCL byteهای درست و در یک console program ساده Free Pascal که از همان unit کامپایل شده byteهای خراب تولید می‌کرد. پیش از اینکه این رفتار قابل فهم شود، سه رفتار جداگانه Free Pascal باید کنار هم قرار بگیرند و هر سه به‌تنهایی قابل دفاع‌اند

چرا همان metadata code در Delphi و FPC byteهای متفاوت تولید می‌کند؟

چون string در دو compiler یک type نیست. FPC 3.2.2 در {$MODE Delphi}، string را به AnsiStringای compile می‌کند که با DefaultSystemCodePage tag شده است، در حالی که Delphi آن را به UnicodeString compile می‌کند. هر metadata field در TPdfASaveOptions به‌صورت string declare شده است، پس Title، Author، Subject، Keywords، Creator و Producer در یک compiler، UTF-16 code unit و در دیگری characterهای تک‌بایتی به‌اضافه label مربوط به codepage حمل می‌کنند. record یکسان است اما payload متفاوت. valueها از document به‌صورت UTF-16 می‌آیند. TPdf.GetTitle و siblingهای آن WString برمی‌گردانند که در FPC برابر WideString و در Delphi برابر string یعنی UnicodeString است و SaveAsPdfAToStream هر option field خالی را پیش از inject کردن markerها از Info dictionary پر می‌کند. آن assignment در Free Pascal یک narrowing conversion است و RTL آن را از طریق codepage مربوط به target string انجام می‌دهد. در application از نوع LCL، کتابخانه LazUTF8 قبلاً DefaultSystemCodePage را روی CP_UTF8 تنظیم کرده است، پس narrowing به UTF-8 تبدیل می‌شود و همه چیز downstream اتفاقاً درست می‌ماند. در console program ساده، همان narrowing روی ANSI codepage می‌نشیند و سپس StringToUtf8 آن octetها را بدون تغییر copy می‌کند، چون فرض کرده از قبل UTF-8 هستند. شش save bridge همین شکل را دارند: SaveAsPdfAToStream، SaveAsPdfUaToStream، SaveAsPdfEToStream، SaveAsPdfXToStream، SaveAsPdfRToStream و SaveAsPdfVTToStream که هرکدام record option مخصوص خود را دارند

// PDFium.pas: accessorهای document همیشه UTF-16 هستند
//   WString در FPC برابر WideString و در Delphi برابر string یعنی UnicodeString است
function TPdf.GetTitle: WString;

// FPdfPdfa.pas: record مربوط به save option 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 fieldهای خالی را از Info dictionary پر می‌کند
// narrowing حالا صریح شده و implicit باقی نمانده است:
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

عبور دادن هر شش bridge از یک helper به نام WStringToStr کاری را که RTL انجام می‌دهد تغییر نمی‌دهد، اما conversion را جایی قرار می‌دهد که reader آن را ببیند و 92 warning مربوط به implicit conversion را که دقیقاً همین class از problem را پنهان کرده بودند پاک می‌کند. این تصویر آینه‌ای corruption سمت Delphi است که در یادداشت‌های pitfallهای cross-compiler دلفی و FPC در buildهای PDFium توضیح داده شده، جایی که concatenate در Delphi یک high byte را از بین می‌برد که Free Pascal حفظش می‌کند

سه رفتار Free Pascal که fix بدیهی را شکست می‌دهند

fix بدیهی این است که UTF8Encode را call کنید و تمام. این کار در FPC 3.2.2 با mode Delphi سه بار پشت سر هم fail می‌شود و هر سه failure خاموش هستند

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // دام 1: در mode Delphi یک variable از نوع UTF8String در واقع AnsiString ساده است،
  // پس assignment، octetها را مستقیم به codepage میزبان برمی‌گرداند
  U := UTF8Encode(W);

  // دام 2: S از قبل AnsiString است، پس UTF8Encode اصلاً کاری انجام نمی‌دهد
  R := UTF8Encode(S);                 // بدون decode، بدون encode و بدون error
  R := UTF8Encode(UnicodeString(S));  // این مورد واقعاً encode می‌کند

  // دام 3: concatenate همه operandها را با codepage مقصد یکسان می‌کند
  // و مقصدی از نوع RawByteString هم استثنا نیست
  Xmp := Xmp + R;
end;

دام اول یعنی result encode‌شده باید در همان AnsiString یا RawByteStringی که در آن تولید شده باقی بماند. آن را در مسیر خروج از یک temporary از نوع UTF8String عبور دهید و کاری را که انجام داده بودید undo کرده‌اید. دام دوم طولانی‌تر پنهان می‌ماند، چون UTF8Encode(S) compile و اجرا می‌شود، valueای با length درست برمی‌گرداند و وقتی argument آن از قبل AnsiString باشد هیچ conversionی انجام نمی‌دهد؛ فقط widening اولیه به UnicodeString باعث می‌شود call چیزی را decode کند. دام سوم دلیل آن است که encoder درست هم می‌تواند document خراب تولید کند: BuildXmpBytes packet را در localی به نام Xmp: AnsiString جمع می‌کند و Free Pascal هر operand مربوط به concatenate را به codepage variable مقصد تبدیل می‌کند و sequenceهای چندبایتی را هنگام ورود دوباره به byteهای تک‌بایتی ANSI fold می‌کند

SetCodePage با False دقیقاً چه چیزی را تضمین می‌کند؟

SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) بدون دست زدن به byte، label مربوط به string را عوض می‌کند. parameter سوم Convert است؛ دادن False یعنی «فرض کن payload از قبل در target codepage است و فقط tag را عوض کن». این درباره content دروغی است که عمداً گفته می‌شود: octetها واقعاً UTF-8 هستند اما tag کردن آن‌ها با codepage میزبان همان چیزی است که مانع conversion آن‌ها توسط concatenate در دام سوم می‌شود. byteهای خام به XMP buffer می‌پیوندند و از سمت دیگر بدون تغییر بیرون می‌آیند

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi: UTF8Encode از قبل octetهای tag‌شده با CP_UTF8 می‌دهد و
  // concatenate داخل AnsiString آن‌ها را نگه می‌دارد
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC: ابتدا widen کن وگرنه UTF8Encode روی argument از نوع AnsiString no-op است
  Result := UTF8Encode(UnicodeString(S));
  // بدون transcoding دوباره tag کن تا octetها هنگام concatenate در
  // bufferهای ANSI-tagged سازنده packetهای XMP و objectهای PDF زنده بمانند
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

مرز را روشن نگه دارید. retag کردن فقط مخصوص FPC است و مجوز عمومی برای mix کردن stringهای tag‌شده و untagged نیست. این روش اینجا کار می‌کند چون دقیقاً یک consumer pattern در downstream وجود دارد: append به یک AnsiString و سپس write کردن buffer به‌صورت byte. هر چیزی که بخواهد value retag‌شده را به‌عنوان text روی codepage میزبان تفسیر کند، mojibake می‌خواند و درست هم همین است. جهت معکوس از مسیر دیگری مدیریت می‌شود و در هر دو compiler یکسان است: buffer ورودی را با SetCodePage(..., False) روی CP_UTF8 tag کنید و سپس UTF8ToString را call کنید

چرا regression testها نیز همین دام را داشتند؟

چون testی که byteهای مورد انتظار خود را از source literal می‌سازد، compiler را test می‌کند نه library را. constantی مانند #$C3#$A9 در source file پاسکال، codepage زمان compile همان file را حمل می‌کند و وقتی به parameterی از نوع AnsiString داده شود، RTL دوباره آن را encode می‌کند؛ دقیقاً همان conversionی که زیر test است. expectation باید در runtime، byte به byte ساخته شود و byte به byte مقایسه شود، چون = روی دو value از نوع AnsiString با tagهای متفاوت پیش از مقایسه 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;                    // narrowing زیر test
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // 'Caf' + U+00E9 به‌صورت UTF-8، در runtime ساخته شده تا literal دوباره 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 یک application از نوع LCL است، پس DefaultSystemCodePage برابر CP_UTF8 است و bug تا زمانی پنهان می‌ماند که test آن را تغییر دهد. SetMultiByteConversionCodePage(1252) داخل try..finally محیط console ساده را برای مدت یک test بازتولید می‌کند. check end-to-end هر دو جهت را نیز assert می‌کند: packet مربوط به XMP که از marker injection تولید شده باید $43 $61 $66 $C3 $A9 را داشته باشد و نباید $43 $61 $66 $E9 را داشته باشد؛ بنابراین regression آینده‌ای که به output خام تک‌بایتی برگردد با صدای بلند fail می‌شود، نه اینکه فقط در hex dump قابل‌باور به نظر برسد. اگر با metadata غیرلاتین کار می‌کنید، همین discipline مربوط به widening، caseهای emoji و CJK که WideChar handling در Delphi را می‌شکنند نیز کنترل می‌کند

این narrowing جای دیگری کجا رخ می‌دهد؟

XMP قربانی قابل مشاهده است، اما هر bridge از TBytes به string در همان codebase همین exposure را داشت. دو مورد دیگر نیز در v3.103.1 اصلاح شدند: Utf8BytesToString و StringToUtf8Bytes در FPdfProduction که packet مربوط به XFA datasets را از مسیر string round-trip می‌کنند تا MergePdfXfaDatasets بتواند valueهای bound را substitute کند و BytesToUtf8 در FPdfTrustedList که XML مربوط به trusted-list اروپایی را پس از strip کردن byte-order mark decode می‌کند. هر دو حالا buffer را در یک RawByteString stage می‌کنند، آن را بدون conversion روی CP_UTF8 tag می‌کنند و با UTF8ToString decode می‌کنند. یک module از قبل immune بود و دلیل آن ارزش copy کردن دارد. writer مربوط به XFDF type متنی خودش را با نام XFDFString declare می‌کند که در FPC به WideString و در Delphi به UnicodeString resolve می‌شود؛ بنابراین encoder آن هرگز AnsiString دارای codepage tag‌شده را نمی‌بیند. fix ساختاری همین است: text را تا نقطه دقیق serialization در typeی از جنس UTF-16 نگه دارید و بگذارید یک function باریک مالک conversion به byte باشد. هر bug در این خانواده از جایی آمد که یک field از نوع string وسط pipelineای نشسته بود که در یک سر UTF-16 و در سر دیگر octet بود

در code دو-compiler PDF خودتان چه چیزهایی را check کنید؟

اگر Object Pascalی ship می‌کنید که روی هر دو compiler اجرا می‌شود و metadata را در PDF منطبق با standard می‌نویسد، چهار check بیشتر caseهای این class را پیش از validator پیدا می‌کنند

  • برای UTF8Encode همراه argument از نوع string grep کنید. در FPC این call no-op است و پربازده‌ترین line برای audit است
  • هر variable از نوع UTF8String را در mode Delphi مشکوک بدانید. آنجا AnsiString ساده است و assignment byteهای encode‌شده به آن، آن‌ها را دوباره transcode می‌کند
  • دست‌کم یک regression را زیر SetMultiByteConversionCodePage با codepage تک‌بایتی اجرا کنید. LCL test harness با CP_UTF8 اجرا می‌شود و هرگز یک console program ساده را reproduce نمی‌کند
  • expected byte vectorها را در runtime بسازید و آن‌ها را octet به octet مقایسه کنید. source literal و = هر دو از reconciliation مربوط به codepage عبور می‌کنند و defectی را که دنبال می‌کنید پنهان می‌کنند

هیچ‌کدام از این‌ها trivia عجیب Free Pascal نیست. این هزینه معمول languageای است که یک type string byte-oriented را کنار type UTF-16 زنده نگه داشته و دو compiler درباره معنای string انتخاب‌های معقول اما متفاوتی کرده‌اند. پیامد عملی برای PDF محدود اما sharp است: metadataای که در IDE شما خوب خوانده می‌شود ممکن است با UTF-8 نامعتبر به XMP packet برسد و ISO 19005-1 6.7.2 اهمیتی نمی‌دهد کدام compiler آن را آنجا گذاشته است. اگر archival pipeline می‌سازید، لایه encoding به همان اندازه بخش‌های دیگر workflow سازگاری PDF/A برای archival که اطرافش قرار دارد توجه می‌خواهد. PDFium Delphi Component این conversionها را به‌عنوان بخشی از library ارائه می‌کند، پس SaveAsPdfA و پنج sibling استاندارد آن metadata سازگار UTF-8 را در Delphi، Lazarus و buildهای ساده Free Pascal بدون configuration مربوط به codepage از سمت caller تولید می‌کنند. مستندات کامل API و release فعلی در صفحه محصول PDFium Delphi Component قرار دارد