Artykuł techniczny

Pułapki codepage FPC psują metadane XMP PDF/A w Delphi

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 UTF8Encode z argumentem string. W FPC to wywołanie jest no-opem i jest pojedynczą linią o najwyższej wartości audytowej
  • Traktuj każdą zmienną UTF8String w trybie Delphi jako podejrzaną. Jest tam zwykłym AnsiString, a przypisanie do niej zakodowanych bajtów transkoduje je z powrotem
  • Uruchom co najmniej jeden test regresji pod SetMultiByteConversionCodePage z jednobajtową stroną kodową. Harness LCL działa z CP_UTF8 i 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