Teknik Makale

PDFiumPas'te FPC RawByteString, Codepage ve XMP

PDFium Delphi Component, PDF/A çıktısı için XMP packet'ını UTF-8 fragment'larını bir AnsiString içinde birleştirerek kurar; Free Pascal 3.2.2'de belge başlığı ASCII dışı bir karakter taşıdığı anda bu packet sessizce geçerli UTF-8 olmaktan çıktı. ISO 19005-1 6.7.2 metadata stream'in geçerli UTF-8 olmasını ister, dolayısıyla dosya validation'dan kalırdı. Sürüm 3.103.1 encoder'ın kendisini, StringToUtf8 içinde düzeltti. İlginç olan patch değil; hiç değişmemiş tek bir source satırının Delphi altında doğru baytlar, Lazarus LCL uygulamasında doğru baytlar ve aynı unit'ten derlenmiş düz Free Pascal console programında bozuk baytlar üretmesidir. Bunun anlaşılması için üç ayrı Free Pascal string davranışının aynı hizaya gelmesi gerekir ve her biri kendi başına savunulabilirdir

Aynı metadata kodu Delphi ve FPC'de neden farklı baytlar üretir?

Çünkü string iki compiler'da aynı type değildir. {$MODE Delphi} içindeki FPC 3.2.2, string'i DefaultSystemCodePage ile etiketlenmiş bir AnsiString olarak derler; Delphi ise onu UnicodeString olarak derler. TPdfASaveOptions içindeki her metadata field'ı string olarak bildirildiği için Title, Author, Subject, Keywords, Creator ve Producer bir compiler'da UTF-16 code unit, diğerinde codepage label taşıyan single-byte character olur. Aynı record, farklı payload. Değerler belgeden UTF-16 olarak gelir. TPdf.GetTitle ve kardeşleri WString döndürür; bu FPC'de WideString, Delphi'de string olur ve SaveAsPdfAToStream her boş option field'ını Info dictionary'den doldurup marker'ları enjekte etmeden önce assignment yapar. Free Pascal'da bu assignment narrowing conversion'dır ve RTL bunu hedef string codepage'i üzerinden yapar. Bir LCL programında LazUTF8 zaten DefaultSystemCodePage'i CP_UTF8 olarak ayarlamıştır; bu yüzden narrowing UTF-8 üretir ve aşağı akıştaki her şey tesadüfen doğru olur. Düz bir console programında aynı narrowing ANSI codepage'e düşer ve StringToUtf8 bunların zaten UTF-8 olduğunu varsaydığı için octet'leri değiştirmeden kopyalar. Altı save bridge bu şekli paylaşır: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream ve SaveAsPdfVTToStream; her birinin kendi option record'ı vardır

// PDFium.pas: document accessor'ları her zaman UTF-16'dır
//   WString = FPC'de WideString, Delphi'de string (UnicodeString)
function TPdf.GetTitle: WString;

// FPdfPdfa.pas: save option record metadata'yı `string` taşır
TPdfASaveOptions = record
  Conformance: TPdfAConformance;
  IccProfileData: TBytes;
  Title: string;      // Delphi'de UnicodeString
                      // FPC'de 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, boş field'ları Info dictionary'den backfill eder
// Narrowing artık örtük bırakılmak yerine açıkça yazılmıştır:
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

Altı bridge'i tek bir WStringToStr helper'ından geçirmek RTL'nin ne yaptığını değiştirmez; ancak conversion'ı okuyucunun görebileceği yere koyar ve tam olarak bu problem sınıfını örten 92 implicit-conversion warning'ini temizler. Bu, PDFium build'lerinde Delphi ve FPC cross-compiler pitfall'ları notlarında anlatılan Delphi tarafı corruption'ın ayna görüntüsüdür; orada Delphi'deki bir concatenation, Free Pascal'ın koruduğu high byte'ı yok eder

Bariz düzeltmeyi bozan üç Free Pascal davranışı

Bariz düzeltme UTF8Encode çağırıp bitirmektir. Bu, mode Delphi'de FPC 3.2.2 üzerinde üç kez başarısız olur ve her biri sessizdir

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // Tuzak 1: mode Delphi'de UTF8String *variable* düz bir AnsiString'dir;
  // assignment octet'leri doğrudan host codepage'e geri transcode eder
  U := UTF8Encode(W);

  // Tuzak 2: S zaten AnsiString'dir; UTF8Encode hiçbir şey yapmaz
  R := UTF8Encode(S);                 // decode yok, encode yok, hata yok
  R := UTF8Encode(UnicodeString(S));  // bu gerçekten encode eder

  // Tuzak 3: concatenation her operand'ı destination codepage'e birleştirir;
  // RawByteString destination da istisna değildir
  Xmp := Xmp + R;
end;

Birinci tuzak, encode edilmiş sonucun üretildiği AnsiString veya RawByteString içinde kalması gerektiği anlamına gelir. Dışarı çıkarken onu UTF8String temporary'sinden geçirirseniz yaptığınız işi geri alırsınız. İkinci tuzak daha uzun süre gizlenir; çünkü UTF8Encode(S) derlenir, çalışır, doğru uzunlukta bir değer döndürür ve argümanı zaten AnsiString olduğunda hiç conversion yapmaz. Yalnızca önce UnicodeString'e genişletmek bir şeyi decode ettirir. Üçüncü tuzak, doğru encoder'ın bile bozuk belge üretebilmesinin nedenidir: BuildXmpBytes packet'ı yerel bir Xmp: AnsiString içinde biriktirir ve Free Pascal concatenation operand'larının her birini destination variable'ın codepage'ine çevirir; multi-byte sequence'leri içeri alırken single ANSI byte'lara geri katlar

SetCodePage with False gerçekte neyi garanti eder?

SetCodePage(RawByteString(Result), DefaultSystemCodePage, False), string'i bayta dokunmadan yeniden etiketler. Üçüncü parameter Convert'tir; False geçirmek "payload zaten hedef codepage'de, yalnızca tag'i değiştir" demektir. İçerik hakkında bilerek söylenmiş bir yalandır: octet'ler gerçekte UTF-8'dir, ancak onları host codepage olarak etiketlemek üçüncü tuzaktaki concatenation'ın dönüştürmesini engeller. XMP buffer'a raw byte olarak katılır ve diğer taraftan değişmeden çıkar

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi: UTF8Encode zaten CP_UTF8 etiketli octet döndürür ve
  // AnsiString'e concatenation onları korur
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC: önce widen et; yoksa AnsiString argümanında UTF8Encode no-op'tur
  Result := UTF8Encode(UnicodeString(S));
  // Transcode etmeden yeniden etiketle; octet'ler XMP packet'larını ve
  // PDF string object'lerini kuran ANSI etiketli buffer'lara geçsin
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

Sınır konusunda açık olun. Retag yalnızca FPC'ye özgüdür ve tagged ile untagged string'leri karıştırmak için genel bir izin değildir. Burada çalışmasının nedeni aşağı akışta tam olarak tek consumer pattern olmasıdır: bir AnsiString'e append etmek, sonra buffer'ı byte olarak yazmak. Retag edilmiş değeri host codepage üzerinde text olarak yorumlamaya çalışan herhangi bir şey mojibake okur ve bunu haklı olarak yapar. Ters yön her iki compiler'da da aynı biçimde ele alınır: gelen buffer'ı SetCodePage(..., False) ile CP_UTF8 olarak etiketleyin, ardından UTF8ToString çağırın

Regresyon testleri neden aynı tuzağı taşıyordu?

Çünkü expected byte'larını source literal'dan kuran bir test kütüphaneyi değil compiler'ı test eder. Pascal source file'ına yazılan #$C3#$A9 gibi bir constant, o file'ın compile-time codepage'ini taşır; AnsiString parameter'ına geçirildiğinde RTL onu yeniden encode eder ve test edilen conversion tam olarak budur. Expectation runtime'da, byte byte kurulmalı ve byte byte karşılaştırılmalıdır; çünkü farklı tag'lere sahip iki AnsiString üzerinde =, karşılaştırmadan önce codepage'leri uzlaştırır ve neşeli bir false negative döndürür

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 edilen narrowing
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // UTF-8 olarak 'Caf' + U+00E9; literal yeniden encode edilmesin diye runtime'da kurulur
  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'ın kendisi bir LCL programıdır; DefaultSystemCodePage bu yüzden CP_UTF8'dir ve bug test onu değiştirene kadar görünmez. SetMultiByteConversionCodePage(1252), tek bir test süresince try..finally içinde düz console environment'ını yeniden üretir. End-to-end check iki yönü de daha ileri doğrular: marker injection tarafından üretilen XMP packet $43 $61 $66 $C3 $A9 içermeli ve içermemelidir $43 $61 $66 $E9; böylece raw single-byte output'a dönen gelecek regresyonu yalnızca hex dump'ta makul görünen bir dosya üretmek yerine sesli biçimde başarısız olur. Latin olmayan metadata ile çalışıyorsanız aynı widening disiplini Delphi'de WideChar handling'i bozan emoji ve CJK text durumlarını da yönetir

Narrowing başka nerelere düşer?

Görünen kurban XMP'dir, ancak aynı codebase'deki herhangi bir TBytes ile string bridge'i aynı riski taşıyordu. v3.103.1'de iki tane daha düzeltildi: FPdfProduction içindeki Utf8BytesToString ve StringToUtf8Bytes, MergePdfXfaDatasets'in bound value'ları değiştirebilmesi için XFA datasets packet'ını bir string üzerinden round-trip eder; ayrıca FPdfTrustedList içindeki BytesToUtf8, byte-order mark'ı çıkardıktan sonra European trusted-list XML'ini decode eder. İkisi artık buffer'ı bir RawByteString içinde stage eder, conversion yapmadan CP_UTF8 etiketler ve UTF8ToString ile decode eder. Bir module zaten bağışıktı ve nedenini kopyalamaya değer. XFDF writer kendi text type'ını XFDFString olarak bildirir; FPC altında WideString, Delphi altında UnicodeString olur. Bu nedenle encoder hiçbir zaman codepage-tagged AnsiString görmez. Yapısal düzeltme budur: text'i serialization'ın tam noktasına kadar UTF-16 type içinde tutun ve byte'lara dönüşümü tek bir narrow function'a bırakın. Bu ailedeki her bug, bir ucunda UTF-16, diğerinde octet olan pipeline'ın ortasına bir string field oturmasından doğdu

Kendi dual-compiler PDF kodunuzda neyi kontrol etmelisiniz?

Her iki compiler'da çalışan ve standards-conformant PDF'e metadata yazan Object Pascal yayımlıyorsanız, dört kontrol bu sınıfın çoğunu validator bulmadan yakalar

  • UTF8Encode için string argümanını grep'leyin. FPC'de bu çağrı no-op'tur ve denetlenecek en yüksek getirili tek satırdır
  • Mode Delphi'de her UTF8String variable'ını şüpheli sayın. Orada düz bir AnsiString'dir ve encode edilmiş baytları ona atamak onları geri transcode eder
  • Single-byte codepage ile SetMultiByteConversionCodePage altında en az bir regresyon çalıştırın. LCL test harness'ı CP_UTF8'de çalışır ve düz bir console programını asla yeniden üretmez
  • Expected byte vector'lerini runtime'da kurun ve octet octet karşılaştırın. Source literal'lar ve = ikisi de codepage reconciliation'dan geçer ve aradığınız kusuru gizler

Bunların hiçbiri egzotik Free Pascal trivia'sı değildir. Bir byte-oriented string type'ı UTF-16'nın yanında yaşatmanın sıradan bedelidir; iki compiler, string'in hangisi anlamına gelmesi gerektiği konusunda makul ama farklı seçimler yapmıştır. PDF işi için pratik sonuç dar ve keskindir: IDE'nizde doğru görünen metadata XMP packet'a geçersiz UTF-8 olarak ulaşabilir ve ISO 19005-1 6.7.2 onu oraya hangi compiler'ın koyduğuyla ilgilenmez. Bir arşiv pipeline'ı kuruyorsanız encoding layer'ı onu çevreleyen PDF/A archival compliance workflow kadar dikkatle ele alınmalıdır. PDFium Delphi Component bu conversion'ları library'nin parçası olarak sunar; böylece SaveAsPdfA ve beş standards sibling'i Delphi, Lazarus ve plain Free Pascal build'lerinde çağırandan codepage configuration istemeden conformant UTF-8 metadata üretir. Tam API documentation ve güncel release PDFium Delphi Component product page üzerindedir