Műszaki cikk

FPC codepage-csapdák a PDF/A XMP-metaadataiban

A PDFium Delphi Component a PDF/A kimenet XMP-csomagját UTF-8 fragmentumok egy AnsiString-be konkatenálásával állítja össze, és Free Pascal 3.2.2 alatt ez a csomag csendben érvénytelen UTF-8 lett abban a pillanatban, amikor a dokumentum címe nem ASCII-karaktert tartalmazott. Az ISO 19005-1 6.7.2 érvényes UTF-8-at követel a metaadat-streamben, ezért a fájl elbukott a validáción. A v3.103.1 magát az encodert javítja, a StringToUtf8-ban. Az érdekes rész nem a patch, hanem az, hogy egy változatlan forrássor helyes byte-okat adott Delphiben, helyes byte-okat egy Lazarus LCL-alkalmazásban, és korrupt byte-okat egy azonos unitból fordított, sima Free Pascal konzolprogramban. Három külön Free Pascal-stringviselkedésnek kell egyszerre a helyére kerülnie, mire ez érthetővé válik, és mindegyik önmagában védhető

Miért ad ugyanaz a metaadatkód más byte-okat Delphiben és FPC-ben?

Azért, mert a string nem ugyanaz a típus a két compileren. Az FPC 3.2.2 {$MODE Delphi} módban a string-et DefaultSystemCodePage értékkel címkézett AnsiString-ként fordítja, a Delphi pedig UnicodeString-ként. A TPdfASaveOptions minden metaadatmezője string, ezért a Title, Author, Subject, Keywords, Creator és Producer az egyik compileren UTF-16 kódegységeket, a másikon egybájtos karaktereket és codepage-címkét hordoz. Ugyanaz a rekord, ugyanaz a mező, eltérő payload. Maguk az értékek UTF-16-ként érkeznek a dokumentumból. A TPdf.GetTitle és testvérei WString-et adnak vissza, ami FPC alatt WideString, Delphiben pedig string, a SaveAsPdfAToStream pedig minden üres opciómezőt az Info dictionaryból tölt fel a markerek beadása előtt. Ez a hozzárendelés Free Pascalon szűkítő konverzió, az RTL pedig a cél string codepage-én keresztül végzi el. Egy LCL-programban a LazUTF8 már CP_UTF8-ra állította a DefaultSystemCodePage-et, ezért a szűkítés UTF-8-at eredményez, és downstream minden véletlenül helyes. Egy sima konzolprogramban ugyanez a szűkítés ANSI codepage-re kerül, a StringToUtf8 pedig változatlanul továbbmásolta ezeket az oktetteket, mert már UTF-8-nak feltételezte őket. Hat save bridge ugyanilyen alakú: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream és SaveAsPdfVTToStream, mind saját option recorddal

// PDFium.pas: a dokumentum accessora mindig UTF-16
//   WString = WideString FPC alatt, = string (UnicodeString) Delphiben
function TPdf.GetTitle: WString;

// FPdfPdfa.pas: a save option record stringként hordozza a metaadatot
TPdfASaveOptions = record
  Conformance: TPdfAConformance;
  IccProfileData: TBytes;
  Title: string;      // UnicodeString Delphiben
                      // AnsiString + DefaultSystemCodePage FPC alatt
  Author: string;
  Subject: string;
  Keywords: string;
  Creator: string;
  Producer: string;
  CreationDate: string;
  ModDate: string;
  DocumentId: TBytes;
  InstanceId: TBytes;
  class function Default: TPdfASaveOptions; static;
end;

// A SaveAsPdfAToStream az Info dictionaryból tölti vissza az üres mezőket.
// A szűkítés most explicit, nem implicit módon szerepel:
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

Mind a hat bridge egyetlen WStringToStr helperen átvezetése nem változtat azon, amit az RTL tesz, de látható helyre rakja a konverziót, és 92 implicit-conversion warningot szüntetett meg, amelyek pontosan ezt a hibacsaládot takarták el. Ez a Delphi-oldali korrupció tükörképe, amelyet a PDFium buildjeinek Delphi- és FPC cross-compiler csapdáiról szóló jegyzet ír le, ahol egy Delphi-konkatenáció olyan high byte-ot semmisít meg, amelyet a Free Pascal megőriz

Három Free Pascal-viselkedés, amely legyőzi a kézenfekvő javítást

A kézenfekvő javítás az, hogy meghívod a UTF8Encode-ot, és kész. Ez háromszor is elbukik FPC 3.2.2 alatt Delphi módban, mindannyiszor csendben

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // 1. csapda: Delphi módban egy UTF8String *változó* sima AnsiString,
  // ezért a hozzárendelés visszatranszkódolja az oktetteket a host codepage-re
  U := UTF8Encode(W);

  // 2. csapda: S már AnsiString, ezért a UTF8Encode egyáltalán nem csinál semmit
  R := UTF8Encode(S);                 // nincs decode, nincs encode, nincs hiba
  R := UTF8Encode(UnicodeString(S));  // ez valóban encode-ol

  // 3. csapda: a konkatenáció minden operandust a cél codepage-ére egységesít,
  // és a RawByteString cél sem kivétel
  Xmp := Xmp + R;
end;

Az első csapda azt jelenti, hogy az encoded eredménynek abban az AnsiString-ben vagy RawByteString-ben kell maradnia, amelyben létrejött. Vidd át útközben egy UTF8String temporaryn, és visszavontad a munkát. A második rejtőzik a legtovább, mert a UTF8Encode(S) lefordul, lefut, helyes hosszúságú értéket ad vissza, és semmilyen konverziót nem végez, ha az argumentuma már AnsiString; csak a UnicodeString-gé szélesítés előtt végzett hívás dekódol valamit. A harmadik miatt tud egy helyes encoder is hibás dokumentumot készíteni: a BuildXmpBytes egy lokális Xmp: AnsiString-ben gyűjti a csomagot, a Free Pascal pedig a konkatenáció minden operandusát a célváltozó codepage-ére konvertálja, így a többbyte-os szekvenciákat útközben visszalapítja egybájtos ANSI-értékekre

Mit garantál valójában a SetCodePage False értékkel?

A SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) úgy címkézi újra a stringet, hogy egyetlen byte-ot sem érint. A harmadik paraméter a Convert; a False azt jelenti: „feltételezd, hogy a payload már a cél codepage-en van, és csak a címkét változtasd meg”. Ez szándékosan hazugság a tartalomról: az oktettek valójában UTF-8-ak, de a host codepage-ként való címkézés akadályozza meg, hogy a harmadik csapda konkatenációja konvertálja őket. Nyers byte-ként kerülnek az XMP-pufferbe, és változatlanul jönnek ki a túloldalon

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi: a UTF8Encode már CP_UTF8-címkézett oktetteket ad, és az
  // AnsiStringbe végzett konkatenáció megtartja őket
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC: előbb szélesíts, különben a UTF8Encode no-op AnsiString argumentummal
  Result := UTF8Encode(UnicodeString(S));
  // Transzkódolás nélkül címkézd át, hogy az oktettek túléljék az
  // XMP-csomagokat és PDF-stringobjektumokat összeállító ANSI-buffer konkatenációját
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

A határt világosan kell látni. Az újracímkézés FPC-only, és nem általános engedélyezés tagelt és tag nélküli stringek keverésére. Itt azért működik, mert pontosan egy downstream fogyasztói minta létezik: append egy AnsiString-hez, majd byte-ként írd ki a buffert. Bármi, ami a retagelt értéket host codepage-en lévő szövegként próbálná értelmezni, mojibake-et olvasna, helyesen. A fordított irány ugyanúgy, mindkét compileren azonos: az érkező buffert CP_UTF8-ként címkézd a SetCodePage(..., False) segítségével, majd hívd a UTF8ToString-et

Miért hordozta ugyanazt a csapdát a regressziós suite?

Azért, mert az a teszt, amely a várt byte-okat egy forrásliterálból építi, a könyvtár helyett a compilert teszteli. Egy olyan konstans, mint a #$C3#$A9 Pascal-forrásban, a fájl compile-time codepage-ét hordozza, és amikor AnsiString paraméternek adod, az RTL újrakódolja, vagyis pontosan a tesztelt konverzió történik meg. Az elvárást futásidőben, byte-ról byte-ra kell felépíteni és byte-ról byte-ra összehasonlítani, mert két különböző tagű AnsiString = operátora összevetés előtt egyezteti a codepage-eket, és vidám false negative-et ad

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;                    // a tesztelt szűkítés
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // 'Caf' + U+00E9 UTF-8-ként, futásidőben építve, hogy literált ne lehessen újrakódolni
  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;

Maga a harness LCL-program, ezért a DefaultSystemCodePage értéke CP_UTF8, a hiba pedig csak akkor látszik, amikor a teszt átállítja. A SetMultiByteConversionCodePage(1252) egy try..finally belsejében a sima konzolkörnyezetet reprodukálja egyetlen teszt idejére. A végponttól végpontig futó ellenőrzés mindkét irányt tovább vizsgálja: a marker-injection által készített XMP-csomagnak tartalmaznia kell a $43 $61 $66 $C3 $A9 byte-okat, a $43 $61 $66 $E9 sorozatot viszont nem, így egy jövőbeli regresszió, amely visszatér a nyers egybájtos kimenethez, hangosan elbukik ahelyett, hogy csak hihetőnek látszana egy hex dumpban. Ha nem latin metaadatokkal dolgozol, ugyanezt a szélesítési fegyelmet követik az emojit és CJK-szöveget kezelő, WideChar-kezelést megtörő esetek Delphiben

Hol máshol történik meg a szűkítés?

Az XMP a látható áldozat, de ugyanilyen kitettsége volt a kódbázis minden TBytesstring bridge-ének. Kettőt még javítottak a v3.103.1-ben: a Utf8BytesToString és StringToUtf8Bytes függvényt az FPdfProduction-ban, amelyek az XFA datasets csomagot stringen át round-trippelik, hogy a MergePdfXfaDatasets kötött értékeket helyettesíthessen, valamint a BytesToUtf8-at az FPdfTrustedList-ben, amely az európai trusted-list XML-t dekódolja a byte-order mark eltávolítása után. Mindkettő most RawByteString-ben stagingeli a buffert, konverzió nélkül CP_UTF8-ként címkézi, majd UTF8ToString-gel dekódolja. Egy modul már eleve védett volt, és az okát érdemes lemásolni. Az XFDF-író saját szövegtípust deklarál XFDFString néven, amely FPC alatt WideString-re, Delphiben UnicodeString-re oldódik, ezért az encodere soha nem lát codepage-címkézett AnsiString-et. Ez a strukturális javítás: tartsd a szöveget UTF-16 típusban egészen a szerializáció pontos pontjáig, és egyetlen narrow függvény birtokolja a byte-okká alakítást. Ebben a családban minden hiba abból született, hogy egy string mező egy olyan pipeline közepére került, amelynek egyik vége UTF-16, a másik pedig oktettek voltak

Mit ellenőrizz a saját kétcompileres PDF-kódodban?

Ha olyan Object Pascal kódot szállítasz, amely mindkét compileren fut és szabványkonform PDF-be ír metaadatot, négy ellenőrzés a legtöbb ilyen hibát elkapja, még a validator előtt

  • Greppelj UTF8Encode-ra string argumentummal. FPC alatt ez no-op, és ez a legnagyobb hozamú egyetlen sor, amit auditálni kell
  • Tekints minden UTF8String változót gyanúsnak Delphi módban. Ott sima AnsiString, és a kódolt byte-ok hozzárendelése visszatranszkódolja őket
  • Futtass legalább egy regressziót SetMultiByteConversionCodePage mellett, egybájtos codepage-gel. Egy LCL tesztharness CP_UTF8-on fut, és soha nem reprodukálja a sima konzolprogramot
  • A várt byte-vektorokat futásidőben építsd, és oktettenként hasonlítsd össze. A forrásliterálok és az = egyaránt codepage-egyeztetésen mennek át, és elrejtik azt a hibát, amelyet keresel

Ez nem egzotikus Free Pascal-trivia. Ez annak a nyelvi árát jelenti, hogy egy byte-orientált stringtípust a UTF-16 mellett életben tartott, a két compiler pedig ésszerű, de eltérő döntést hozott arról, mit jelentsen a string. A PDF-munka gyakorlati következménye szűk és éles: az IDE-ben jól olvasható metaadat érvénytelen UTF-8-ként érhet el az XMP-csomagba, az ISO 19005-1 6.7.2-t pedig nem érdekli, melyik compiler tette oda. Ha archiválási pipeline-t építesz, a kódolási réteg ugyanannyi figyelmet érdemel, mint az azt körülvevő PDF/A archiválási megfelelőségi workflow. A PDFium Delphi Component ezeket a konverziókat a library részeként szállítja, így a SaveAsPdfA és öt szabványos testvére Delphiben, Lazarusban és sima Free Pascal buildben is konform UTF-8 metaadatot ír, a hívó codepage-konfigurációja nélkül. A teljes API-dokumentáció és a jelenlegi kiadás a PDFium Delphi Component termékoldalán érhető el