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 TBytes–string 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-rastringargumentummal. FPC alatt ez no-op, és ez a legnagyobb hozamú egyetlen sor, amit auditálni kell - Tekints minden
UTF8Stringváltozót gyanúsnak Delphi módban. Ott simaAnsiString, és a kódolt byte-ok hozzárendelése visszatranszkódolja őket - Futtass legalább egy regressziót
SetMultiByteConversionCodePagemellett, egybájtos codepage-gel. Egy LCL tesztharnessCP_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