Техническая статья

Ловушки codepage FPC ломают PDF/A XMP metadata в Delphi

PDFium Delphi Component собирает XMP packet для PDF/A, конкатенируя UTF-8 fragments в AnsiString, а в Free Pascal 3.2.2 этот packet молча переставал быть валидным UTF-8 в тот момент, когда title документа содержал не-ASCII символ. ISO 19005-1 6.7.2 требует, чтобы metadata stream был валидным UTF-8, поэтому файл не проходил validation. Версия 3.103.1 исправляет сам encoder, в StringToUtf8. Интересен не patch, а то, что одна и та же строка source давала правильные байты в Delphi, правильные байты в Lazarus LCL application и повреждённые байты в обычной Free Pascal console program, собранной из идентичного unit. Чтобы это понять, должны сойтись три разных поведения строк Free Pascal, и каждое по отдельности вполне оправдано

Почему один и тот же metadata code выдаёт разные байты в Delphi и FPC?

Потому что string не является одним и тем же типом у двух compilers. FPC 3.2.2 в {$MODE Delphi} компилирует string в AnsiString с tag DefaultSystemCodePage, а Delphi компилирует его в UnicodeString. Каждое metadata field в TPdfASaveOptions объявлено как string, поэтому Title, Author, Subject, Keywords, Creator и Producer несут UTF-16 code units в одном compiler и однобайтные символы с label codepage в другом. Та же record, то же поле, другой payload. Сами values приходят из документа как UTF-16. TPdf.GetTitle и siblings возвращают WString, то есть WideString в FPC и string в Delphi, а SaveAsPdfAToStream заполняет каждое пустое option field из Info dictionary перед инъекцией markers. Для Free Pascal это narrowing conversion, и RTL выполняет его через codepage целевой строки. В LCL program LazUTF8 уже установил DefaultSystemCodePage в CP_UTF8, поэтому narrowing выдаёт UTF-8 и всё дальше случайно оказывается правильным. В обычной console program тот же narrowing попадает в ANSI codepage, а StringToUtf8 затем без изменений копирует эти octets, потому что считает их уже UTF-8. Шесть save bridges имеют такую форму: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream и SaveAsPdfVTToStream, у каждого свой option record

// PDFium.pas: document accessors всегда возвращают UTF-16
//   WString = WideString в FPC, = string (UnicodeString) в Delphi
function TPdf.GetTitle: WString;

// FPdfPdfa.pas: save option record хранит metadata как `string`
TPdfASaveOptions = record
  Conformance: TPdfAConformance;
  IccProfileData: TBytes;
  Title: string;      // UnicodeString в Delphi
                      // AnsiString + DefaultSystemCodePage в 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 заполняет пустые поля из Info dictionary.
// Теперь narrowing явно указан, а не оставлен неявным:
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

Направление всех шести bridges через один helper WStringToStr не меняет работу RTL, но помещает conversion туда, где его видит reader, и убирает 92 implicit-conversion warnings, скрывавших именно этот класс проблем. Это зеркальная версия corruption на стороне Delphi, описанной в заметках о cross-compiler pitfalls Delphi и FPC в PDFium builds, где concatenation в Delphi уничтожает high byte, который Free Pascal сохраняет

Три поведения Free Pascal, которые побеждают очевидное исправление

Очевидное исправление — вызвать UTF8Encode и закончить. В FPC 3.2.2 в mode Delphi это терпит поражение трижды, причём каждый раз молча

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // Ловушка 1: в mode Delphi переменная UTF8String — обычный AnsiString,
  // поэтому присваивание сразу транскодирует octets обратно в host codepage
  U := UTF8Encode(W);

  // Ловушка 2: S уже является AnsiString, поэтому UTF8Encode вообще ничего не делает
  R := UTF8Encode(S);                 // no decode, no encode, no error
  R := UTF8Encode(UnicodeString(S));  // только этот вариант действительно кодирует

  // Ловушка 3: concatenation приводит каждый operand к codepage назначения,
  // и destination RawByteString не является исключением
  Xmp := Xmp + R;
end;

Ловушка первая означает, что encoded result должен оставаться в том AnsiString или RawByteString, в котором был создан. Пропустите его через временный UTF8String на выходе — и отмените проделанную работу. Ловушка вторая дольше всего остаётся незаметной, потому что UTF8Encode(S) компилируется, выполняется, возвращает значение правильной длины и вообще не делает conversion, если его аргумент уже AnsiString; только предварительное расширение до UnicodeString заставляет вызов что-либо декодировать. Ловушка третья объясняет, почему правильный encoder всё ещё может создать сломанный документ: BuildXmpBytes накапливает packet в локальном Xmp: AnsiString, а Free Pascal приводит каждый operand concatenation к codepage destination variable и на входе сворачивает многобайтные sequences обратно в однобайтные ANSI values

Что на самом деле гарантирует SetCodePage с False?

SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) меняет label строки, не затрагивая ни одного байта. Третий параметр — Convert; передать False означает «считать payload уже находящимся в target codepage и только изменить tag». Это ложь о содержимом, рассказанная намеренно: octets действительно являются UTF-8, но tag host codepage не даёт concatenation из ловушки третьей конвертировать их. Они присоединяются к XMP buffer как raw bytes и выходят с другой стороны неизменными

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi: UTF8Encode уже возвращает octets с tag CP_UTF8, а
  // concatenation в AnsiString сохраняет их
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC: сначала расширить, иначе UTF8Encode ничего не делает с AnsiString argument
  Result := UTF8Encode(UnicodeString(S));
  // Сменить label без transcoding, чтобы octets пережили concatenation в
  // ANSI-tagged buffers, собирающих XMP packets и PDF string objects
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

Границу нужно понимать чётко. Retag относится только к FPC и не является общей лицензией на смешивание tagged и untagged strings. Здесь он работает потому, что downstream существует ровно один consumer pattern: append в AnsiString, затем записать buffer как байты. Всё, что попыталось бы интерпретировать retagged value как text в host codepage, прочитало бы mojibake, и это было бы правильно. Обратное направление устроено иначе и одинаково в обоих compilers: пометить входящий buffer как CP_UTF8 через SetCodePage(..., False), затем вызвать UTF8ToString

Почему regression tests содержали ту же ловушку?

Потому что test, строящий expected bytes из source literal, проверяет compiler, а не library. Constant вроде #$C3#$A9, записанный в Pascal source file, несёт compile-time codepage этого файла, а при передаче параметру AnsiString RTL перекодирует его, то есть выполняет именно conversion, который тестируется. Ожидание нужно собирать во время выполнения, byte by byte, и сравнивать byte by byte, потому что = для двух AnsiString с разными tags согласует codepages перед сравнением и возвращает обнадёживающий false negative

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;                    // проверяемое narrowing
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // 'Caf' + U+00E9 как UTF-8, строится в runtime, чтобы literal не был перекодирован
  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 является LCL program, поэтому DefaultSystemCodePage имеет значение CP_UTF8, и ошибка невидима, пока test не переключает его. SetMultiByteConversionCodePage(1252) внутри try..finally воспроизводит окружение plain console на время одного теста. End-to-end check идёт дальше и проверяет оба направления: XMP packet, созданный injection markers, должен содержать $43 $61 $66 $C3 $A9 и не должен содержать $43 $61 $66 $E9, поэтому будущая regression, возвращающаяся к raw single-byte output, громко провалится вместо того, чтобы создать файл, лишь правдоподобно выглядящий в hex dump. Если вы работаете с non-Latin metadata, та же дисциплина widening управляет случаями из статьи об emoji и CJK-тексте, ломающем WideChar handling в Delphi

Где ещё появляется narrowing

XMP — видимая жертва, но любой bridge TBytes в string в той же codebase был подвержен тому же. Ещё два исправлены в v3.103.1: Utf8BytesToString и StringToUtf8Bytes в FPdfProduction, которые прогоняют packet XFA datasets через string, чтобы MergePdfXfaDatasets мог подставить связанные values, и BytesToUtf8 в FPdfTrustedList, декодирующий European trusted-list XML после удаления byte-order mark. Оба теперь размещают buffer в RawByteString, помечают его как CP_UTF8 без conversion и декодируют через UTF8ToString. Один module уже был защищён, и причину стоит повторить. XFDF writer объявляет собственный text type XFDFString, который в FPC разрешается в WideString, а в Delphi — в UnicodeString, поэтому его encoder вообще не видит codepage-tagged AnsiString. Это структурное исправление: держать text в UTF-16 type до точной точки сериализации и отдать одной narrow function владение преобразованием в bytes. Каждый bug этого семейства возникал из-за поля string посреди pipeline, который на одном конце был UTF-16, а на другом — octets

Что проверить в собственном dual-compiler PDF code

Если вы поставляете Object Pascal, работающий в обоих compilers и записывающий metadata в standards-conformant PDF, четыре проверки находят большую часть таких проблем раньше validator

  • Ищите UTF8Encode с аргументом string. В FPC этот вызов no-op, и это самая результативная строка для аудита
  • Считайте каждую переменную UTF8String в mode Delphi подозрительной. Там это обычный AnsiString, и присваивание encoded bytes перекодирует их обратно
  • Запустите хотя бы одну regression под SetMultiByteConversionCodePage с однобайтной codepage. LCL test harness работает с CP_UTF8 и никогда не воспроизведёт plain console program
  • Создавайте expected byte vectors во время выполнения и сравнивайте их побайтно. Source literals и = оба проходят codepage reconciliation и скроют именно тот defect, который вы ищете

В этом нет экзотики Free Pascal. Это обычная цена языка, который сохранил ориентированный на байты string type рядом с UTF-16 type, а два compiler-а сделали разумный, но разный выбор, что должен означать string. Практическое следствие для PDF узкое и резкое: metadata, выглядящая нормально в IDE, может попасть в XMP packet как недопустимый UTF-8, и ISO 19005-1 6.7.2 всё равно, какой compiler её туда поместил. Если вы строите archival pipeline, encoding layer заслуживает такого же внимания, как и остальная часть workflow archival compliance PDF/A, которая его окружает. PDFium Delphi Component поставляет эти conversions в составе library, поэтому SaveAsPdfA и пять его standards siblings выдают conformant UTF-8 metadata в Delphi, Lazarus и plain Free Pascal builds без codepage configuration от вызывающего. Полная документация API и текущий release находятся на странице продукта PDFium Delphi Component