Komponent PDFium dla Delphi składa pakiet XMP dla wyjścia PDF/A przez konkatenację fragmentów UTF-8 do AnsiString, a w Free Pascal 3.2.2 pakiet po cichu przestawał być poprawnym UTF-8 w chwili, gdy tytuł dokumentu zawierał znak spoza ASCII. ISO 19005-1 6.7.2 wymaga, aby strumień metadanych był poprawnym UTF-8, więc plik nie przechodził walidacji. Wersja 3.103.1 naprawia sam encoder, w StringToUtf8. Ciekawa nie jest poprawka, tylko fakt, że ta sama, niezmieniona linia źródłowa produkowała poprawne bajty pod Delphi, poprawne bajty w aplikacji Lazarus LCL i uszkodzone bajty w zwykłym programie konsolowym Free Pascal skompilowanym z identycznego unitu. Zanim to nabierze sensu, muszą zgrać się trzy osobne zachowania łańcuchów Free Pascal, a każde z nich z osobna da się obronić
Dlaczego ten sam kod metadanych emituje inne bajty w Delphi i FPC?
Ponieważ string nie jest tym samym typem w obu kompilatorach. FPC 3.2.2 w {$MODE Delphi} kompiluje string do AnsiString oznaczonego DefaultSystemCodePage, podczas gdy Delphi kompiluje go do UnicodeString. Każde pole metadanych w TPdfASaveOptions jest zadeklarowane jako string, więc Title, Author, Subject, Keywords, Creator i Producer niosą jednostki kodowe UTF-16 w jednym kompilatorze, a znaki jednobajtowe oraz etykietę codepage w drugim. Ten sam rekord, inny payload. Wartości trafiają z dokumentu jako UTF-16. TPdf.GetTitle i rodzeństwo zwracają WString, która jest WideString w FPC i string w Delphi, a SaveAsPdfAToStream wypełnia każde puste pole opcji ze słownika Info przed wstrzyknięciem markerów. To przypisanie jest zawężającą konwersją w Free Pascal, a RTL wykonuje je przez docelową stronę kodową łańcucha. W programie LCL LazUTF8 zdążył już ustawić DefaultSystemCodePage na CP_UTF8, więc zawężenie produkuje UTF-8 i wszystko dalej przypadkiem działa poprawnie. W zwykłym programie konsolowym to samo zawężenie trafia do strony ANSI, a StringToUtf8 kopiuje te oktety bez zmian, bo zakłada, że już są UTF-8. Sześć mostów zapisu ma ten sam kształt: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream i SaveAsPdfVTToStream, każde z własnym rekordem opcji
// PDFium.pas: akcesory dokumentu zawsze zwracają UTF-16
// WString = WideString w FPC, = string (UnicodeString) w Delphi
function TPdf.GetTitle: WString;
// FPdfPdfa.pas: rekord opcji zapisu przenosi metadane jako `string`
TPdfASaveOptions = record
Conformance: TPdfAConformance;
IccProfileData: TBytes;
Title: string; // UnicodeString w Delphi
// AnsiString + DefaultSystemCodePage w 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 uzupełnia puste pola ze słownika Info
// Zawężenie jest teraz zapisane jawnie zamiast pozostawione niejawnie
if Eff.Title = '' then
Eff.Title := WStringToStr(GetTitle);
Poprowadzenie wszystkich sześciu mostów przez jeden helper WStringToStr nie zmienia działania RTL, ale umieszcza konwersję w miejscu widocznym dla czytelnika i usunęło 92 ostrzeżenia o niejawnej konwersji, które zasłaniały dokładnie tę klasę problemu. To lustrzane odbicie uszkodzenia po stronie Delphi opisanego w naszych notatkach o pułapkach cross-kompilacji Delphi i FPC w buildach PDFium, gdzie konkatenacja w Delphi niszczy wysoki bajt zachowywany przez Free Pascal
Trzy zachowania Free Pascal, które pokonują oczywistą poprawkę
Oczywistą poprawką jest wywołać UTF8Encode i uznać sprawę za zamkniętą. W FPC 3.2.2 w trybie Delphi zawodzi to trzy razy, a każde zawiedzenie jest ciche
var
W: UnicodeString;
S: string;
U: UTF8String;
R: RawByteString;
Xmp: AnsiString;
begin
// Pułapka 1: w trybie Delphi zmienna UTF8String jest zwykłym AnsiString,
// więc przypisanie transkoduje oktety z powrotem do hostowej strony kodowej
U := UTF8Encode(W);
// Pułapka 2: S jest już AnsiString, więc UTF8Encode nie robi absolutnie nic
R := UTF8Encode(S); // bez dekodowania, bez kodowania, bez błędu
R := UTF8Encode(UnicodeString(S)); // ta wersja naprawdę koduje
// Pułapka 3: konkatenacja ujednolica każdy operand do strony kodowej celu,
// a docelowy RawByteString nie jest wyjątkiem
Xmp := Xmp + R;
end;
Pierwsza pułapka oznacza, że zakodowany wynik musi pozostać w AnsiString albo RawByteString, w którym powstał. Przepuść go po drodze przez tymczasowy UTF8String, a cofniesz wykonaną pracę. Druga jest tą, która najdłużej się ukrywa, ponieważ UTF8Encode(S) kompiluje się, działa, zwraca wartość o właściwej długości i nie wykonuje żadnej konwersji, gdy argument jest już AnsiString; dopiero wcześniejsze rozszerzenie do UnicodeString sprawia, że wywołanie cokolwiek dekoduje. Trzecia wyjaśnia, dlaczego poprawny encoder nadal może wyprodukować uszkodzony dokument: BuildXmpBytes gromadzi pakiet w lokalnym Xmp: AnsiString, a Free Pascal konwertuje każdy operand konkatenacji do strony kodowej zmiennej docelowej, składając wielobajtowe sekwencje z powrotem do jednobajtowych znaków w drodze do bufora
Co naprawdę gwarantuje SetCodePage z False?
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) zmienia etykietę łańcucha bez dotykania bajtu. Trzeci parametr to Convert; przekazanie False znaczy „załóż, że payload jest już w docelowej stronie kodowej i tylko zmień etykietę”. To kłamstwo o zawartości, wypowiedziane celowo: oktety naprawdę są UTF-8, ale oznaczenie ich jako hostowej strony kodowej powstrzymuje konkatenację w pułapce trzeciej przed konwersją. Dołączają do bufora XMP jako surowe bajty i wychodzą po drugiej stronie bez zmian
function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
// Delphi: UTF8Encode już zwraca oktety oznaczone CP_UTF8, a
// konkatenacja do AnsiString je zachowuje
Result := AnsiString(UTF8Encode(S));
{$ELSE}
// FPC: najpierw rozszerz, inaczej UTF8Encode nic nie zrobi dla AnsiString
Result := UTF8Encode(UnicodeString(S));
// Zmień etykietę bez transkodowania, aby oktety przetrwały konkatenację do
// buforów oznaczonych ANSI, które składają pakiety XMP i obiekty łańcuchów PDF
if Length(Result) > 0 then
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;
Jasno określ granicę. Zmiana etykiety dotyczy tylko FPC i nie jest ogólnym przyzwoleniem na mieszanie łańcuchów oznaczonych i nieoznaczonych. Działa tutaj, bo dokładnie jeden wzorzec konsumenta występuje dalej: dopisz do AnsiString, a potem zapisz bufor jako bajty. Wszystko, co próbowałoby interpretować ponownie oznaczoną wartość jako tekst w hostowej stronie kodowej, odczytałoby mojibake i słusznie. Kierunek odwrotny działa inaczej i identycznie w obu kompilatorach: oznacz przychodzący bufor jako CP_UTF8 przez SetCodePage(..., False), a następnie wywołaj UTF8ToString
Dlaczego testy regresji zawierały tę samą pułapkę?
Bo test budujący oczekiwane bajty z literału źródłowego testuje kompilator, a nie bibliotekę. Stała taka jak #$C3#$A9 zapisana w pliku źródłowym Pascala niesie stronę kodową z czasu kompilacji, a przekazanie jej do parametru AnsiString powoduje ponowne zakodowanie przez RTL, czyli dokładnie tę konwersję, którą testujesz. Oczekiwanie trzeba złożyć w czasie działania, bajt po bajcie, i porównać bajt po bajcie, ponieważ = na dwóch wartościach AnsiString z różnymi etykietami uzgadnia strony kodowe przed porównaniem i zwraca pocieszająco fałszywy negatyw
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; // badane zawężenie
Encoded := StringToUtf8(Narrowed);
finally
SetMultiByteConversionCodePage(Saved);
end;
// 'Caf' + U+00E9 jako UTF-8, zbudowane w czasie działania, aby żaden literał nie został ponownie zakodowany
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;
Sam harness jest programem LCL, więc DefaultSystemCodePage to CP_UTF8, a błąd pozostaje niewidoczny, dopóki test go nie przełączy. SetMultiByteConversionCodePage(1252) wewnątrz try..finally odtwarza środowisko zwykłej konsoli na czas jednego testu. Kontrola end-to-end idzie dalej i sprawdza oba kierunki: pakiet XMP utworzony przez wstrzyknięcie markerów musi zawierać $43 $61 $66 $C3 $A9 i nie może zawierać $43 $61 $66 $E9, więc przyszła regresja wracająca do surowego wyjścia jednobajtowego zawiedzie głośno zamiast produkować plik, który tylko wygląda wiarygodnie w zrzucie heksadecymalnym. Jeśli pracujesz z metadanymi niełacińskimi, ta sama dyscyplina rozszerzania rządzi przypadkami z artykułu o emoji i tekście CJK łamiącym obsługę WideChar w Delphi
Gdzie jeszcze trafia zawężenie
XMP jest widoczną ofiarą, ale każdy most TBytes do string w tej samej bazie kodu był narażony tak samo. Dwa kolejne poprawiono w v3.103.1: Utf8BytesToString i StringToUtf8Bytes w FPdfProduction, które wykonują cykl pakietu danych XFA przez łańcuch, aby MergePdfXfaDatasets mogło podstawić związane wartości, oraz BytesToUtf8 w FPdfTrustedList, który dekoduje XML europejskiej listy zaufania po usunięciu znacznika kolejności bajtów. Oba przypadki umieszczają teraz bufor w RawByteString, oznaczają go jako CP_UTF8 bez konwersji i dekodują przez UTF8ToString. Jeden moduł był już odporny i warto skopiować przyczynę. Writer XFDF deklaruje własny typ tekstu jako XFDFString, który rozwiązuje się do WideString pod FPC i UnicodeString pod Delphi, więc jego encoder nigdy nie widzi AnsiString oznaczonego stroną kodową. To jest poprawka strukturalna: utrzymuj tekst w typie UTF-16 aż do dokładnego punktu serializacji i pozwól jednej wąskiej funkcji posiadać konwersję do bajtów. Każdy błąd z tej rodziny wynikał z pola typu string siedzącego w środku pipeline’u, który na jednym końcu był UTF-16, a na drugim oktetami
Co sprawdzić we własnym kodzie PDF dla dwóch kompilatorów
Jeśli dostarczasz Object Pascal działający w obu kompilatorach i zapisujący metadane do zgodnego ze standardem PDF, cztery kontrole wykrywają większość tej klasy problemów, zanim zrobi to walidator
- Wyszukaj
UTF8Encodez argumentemstring. W FPC to wywołanie jest no-opem i jest pojedynczą linią o najwyższej wartości audytowej - Traktuj każdą zmienną
UTF8Stringw trybie Delphi jako podejrzaną. Jest tam zwykłymAnsiString, a przypisanie do niej zakodowanych bajtów transkoduje je z powrotem - Uruchom co najmniej jeden test regresji pod
SetMultiByteConversionCodePagez jednobajtową stroną kodową. Harness LCL działa zCP_UTF8i nigdy nie odtworzy zwykłego programu konsolowego - Buduj oczekiwane wektory bajtów w czasie działania i porównuj je oktet po oktecie. Literały źródłowe oraz
=przechodzą przez uzgadnianie stron kodowych i ukryją defekt, którego szukasz
To nie są egzotyczne ciekawostki Free Pascal. To zwykły koszt języka, który utrzymał typ łańcucha zorientowany na bajty obok typu UTF-16, a oba kompilatory dokonały rozsądnych, lecz różnych wyborów co do znaczenia string. Praktyczna konsekwencja dla pracy z PDF jest wąska i ostra: metadane czytelne w IDE mogą trafić do pakietu XMP jako niepoprawny UTF-8, a ISO 19005-1 6.7.2 nie obchodzi, który kompilator je tam umieścił. Jeśli budujesz pipeline archiwalny, warstwa kodowania zasługuje na tyle samo uwagi co reszta workflow zgodności archiwalnej PDF/A, który ją otacza. Komponent PDFium dla Delphi dostarcza te konwersje jako część biblioteki, więc SaveAsPdfA i pięć jego rodzeństwa dla standardów emituje zgodne metadane UTF-8 w Delphi, Lazarusie i zwykłych buildach Free Pascal bez konfiguracji codepage po stronie wywołującego. Pełna dokumentacja API i bieżące wydanie są na stronie produktu komponentu PDFium dla Delphi