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

PDFDocEncoding Off-by-One счупва FDF импорта в Delphi

Импортираш FDF във форма и стойностите на полетата се връщат тихо сбъркани: bullet пристига като dagger, ellipsis като em dash. PDFium Component проследи цялото това семейство симптоми до един-единствен ред в своята таблица за PDFDocEncoding — блокът със специалните символи в Annex D.2 на ISO 32000-1 започва от $80, а не от $7F

Защо FDF импортът връща съседния символ?

Защото таблица за кодиране, изместена с една позиция, пак изглежда напълно валидна и всяко търсене в нея успява. Блокът, който Annex D.2 отрежда на $80 до $9E, беше преписан с един ред по-рано, така че байт $80 се резолвваше към записа, който принадлежи на $81, байт $81 — към записа за $82, и така през деветнайсет поредни кодови позиции. Нищо не гърмеше, нищо не се логваше и нито едно поле не беше отхвърлено — TPdf.ImportFDF връщаше същия брой полета като винаги

Щетите бяха еднакви навсякъде и тихи. Байт със стойност $80, който Annex D.2 дефинира като BULLET (U+2022), се връщаше като DAGGER (U+2020). Байт $83, HORIZONTAL ELLIPSIS (U+2026), се връщаше като EM DASH (U+2014). Байт $8D, LEFT DOUBLE QUOTATION MARK (U+201C), се връщаше като RIGHT DOUBLE QUOTATION MARK (U+201D) — случаят, който никой не забелязва, защото сбърканата извита кавичка пак си прилича на извита кавичка в поле на форма. Имената на полетата ги сполетя същата участ като стойностите, което е по-лошо: име, което вече не съвпада с widget-а в документа, мълчаливо не се прилага

Диаграма на off-by-one в PDFDocEncoding зад повредите при FDF импорт в PDFium Component: Annex D.2 на ISO 32000-1 картира байт $80 към BULLET, $83 към HORIZONTAL ELLIPSIS и $8D към лява двойна кавичка, докато таблицата, преписана с един ред по-рано, връщаше за същите байтове DAGGER, EM DASH и дясната кавичка, като всяко търсене пак успяваше
Изместената таблица за кодиране пак изглежда валидна и всяко търсене в нея успява, така че bullet пристига като dagger и име на поле, което вече не съвпада, мълчаливо не се прилага

Текстовите низове в PDF носят две различни кодировки

ISO 32000-1 §7.9.2.2 дефинира типа text string като една от точно две кодировки, избрана чрез инспекция на първите два байта, а не чрез някакъв запис в речника. Низ, който започва с byte order mark FE FF, е UTF-16BE; всичко останало е PDFDocEncoding. Този единствен разклон решава кой от два напълно различни декодера ще се изпълни, и PDFium Component го прилага вътре в FDF парсера, преди да е докоснал дори един символ

Диаграма на разклонението за text string по раздел 7.9.2.2 на ISO 32000-1 в PDFium Component: низ, започващ с byte order mark FE FF, се декодира като UTF-16BE с валидирани surrogate двойки, всичко останало отива към еднобайтовата таблица FdfPdfDocEncodingChar, което обяснява защо UTF-16 писателите и чисто ASCII имената никога не разкриха изместения блок, докато не се появи един типографски символ
Инспекцията на първите два байта, а не на запис в речника, решава кой декодер ще работи, така че инструментите, които по подразбиране пишат UTF-16BE, никога не са упражнявали единствената таблица, която беше сбъркана
// FDF text string: UTF-16, когато BOM е наличен, иначе PDFDocEncoding
if (Length(Raw) >= 2) and
   (((Byte(Raw[1]) = $FE) and (Byte(Raw[2]) = $FF)) or
    ((Byte(Raw[1]) = $FF) and (Byte(Raw[2]) = $FE))) then
begin
  BigEndian := Byte(Raw[1]) = $FE;
  // ... декодиране на UTF-16 code units с валидация на surrogate двойките
end
else
begin
  SetLength(Result, Length(Raw));
  for I := 1 to Length(Raw) do
    Result[I] := FdfPdfDocEncodingChar(Byte(Raw[I]));
end;

Ето защо бъгът се кри толкова дълго от обикновеното тестване. Всеки FDF, произведен от инструмент, който по подразбиране пише UTF-16BE, изобщо не стига до FdfPdfDocEncodingChar, така че изместената таблица остава в латентно състояние. Само еднобайтовите низове — точно това, което много FDF генератори излъчват за обикновени латински имена на полета, защото са по-къси — минават по клона, където таблицата има значение. Чисто ASCII имената на полета пак се декодират правилно, защото под $80 PDFDocEncoding и ASCII съвпадат. Трябва ти еднобайтов низ, съдържащ един типографски символ, преди нещо да се сбърка видимо

Поправената таблица и двата котва, които я закрепват

PDFium Component вече изписва границата в самия FdfPdfDocEncodingChar, защото двата края на блока са точно това, с което имплементаторът се обърква. $7F е DEL и изобщо няма значение в PDFDocEncoding; блокът отваря на $80 с BULLET и стига до $9E със zcaron; $9F е неприсвоен; а знакът Euro стои сам на $A0

Диаграма на граничните котви, които закрепват таблицата от Annex D.2 в PDFium Component: $7F е DEL без значение в PDFDocEncoding, $80 отваря блока с BULLET, $9E го затваря със zcaron, $9F е неприсвоен, а знакът Euro стои сам на $A0, така че отместването на целия блок не може да удовлетвори едновременно краищата и празнините
Проверката на първия ред, последния ред и празнините закрепва двата края наведнъж — точно тестът, който отместен цял блок не може да преживее
function FdfPdfDocEncodingChar(Value: Byte): WideChar;
begin
  case Value of
    // ISO 32000-1 Annex D.2. Блокът върви от $80, не от $7F: $7F е DEL и
    // няма значение в PDFDocEncoding, а Euro стои на $A0, което
    // иначе default клонът би върнал като no-break space
    $7F: Result := WideChar($FFFD);
    $80: Result := WideChar($2022);   // BULLET, първи ред на блока
    $81: Result := WideChar($2020);   // DAGGER
    // ... $82 до $9D
    $9E: Result := WideChar($017E);   // zcaron, последният дефиниран ред
    $9F: Result := WideChar($FFFD);   // неприсвоен в Annex D.2
    $A0: Result := WideChar($20AC);   // EURO SIGN
  else
    Result := WideChar(Value);
  end;
end;

Погледни какво направи отместването с тези два края, защото точно там off-by-one престава да е козметичен. zcaron стоеше на $9E в спецификацията, но на $9D в изместената таблица, така че документен байт със стойност $9E падаше след последния ред, който някой беше преписал, и отиваше право към identity fallback-а, връщайки C1 control символ вместо буква. А $A0, Euro, изобщо не беше вписан в таблицата — identity клонът го връщаше като U+00A0 NO-BREAK SPACE, символ, който преживява всяка следваща валидация, не се отпечатва и никога не съвпада с името на полето, което търсиш

Как тестваш преписвана таблица за кодиране?

Тествай първия ред, последния ред и празнините — не извадка от средата. Това е частта от поправката, която си струва да пренесеш върху всяка друга таблица, която преписваш от спецификация, защото spot check в средата на блока е точно тестът, който отместеният цял блок преживява. Проверката, че $8D дава лява двойна кавичка, не доказва нищо, когато $8C също дава нещо правдоподобно; проверката, че $80 дава BULLET и $9E дава zcaron, закрепва двата края, а отместен блок не може да удовлетвори и двете едновременно

procedure CheckPdfDocEncodingAnchors;
begin
  // Двата края на специалния блок от Annex D.2: отместване на целия блок ги чупи
  Assert(FdfPdfDocEncodingChar($80) = WideChar($2022), 'first row: BULLET');
  Assert(FdfPdfDocEncodingChar($9E) = WideChar($017E), 'last row: zcaron');
  // Кодови позиции, които спецификацията оставя недефинирани, не трябва да декодират до буква
  Assert(FdfPdfDocEncodingChar($7F) = WideChar($FFFD), '$7F is DEL, unmapped');
  Assert(FdfPdfDocEncodingChar($9F) = WideChar($FFFD), '$9F is unassigned');
  // Изолираният запис над блока, лесен за цялостно пропускане
  Assert(FdfPdfDocEncodingChar($A0) = WideChar($20AC), 'EURO SIGN');
end;

Недефинираните позиции заслужават собствени assertions, вместо да се прескачат като безинтересни. Off-by-one трябва да сложи нещо някъде, и мястото, където го слага, почти винаги е кодова позиция, която спецификацията е оставила празна — $7F отпред, $9F отзад. Тест, който проверява само дефинираните позиции, може да мине, докато таблицата е сбъркана в двете посоки; тест, който закрепва дупките, хваща отместването още при първото пускане

Какво струва това в round-trip workflow с форми

FDF импорт, който тихо пренаписва по един символ на знак, е по-лош от такъв, който се проваля, защото провалът изплува по-надолу по веригата и изглежда като нещо друго. Имената на полетата са острият ръб тук. TPdf.ImportFDF съпоставя входящите имена с widget-ите в отворения документ, и име, което се е декодирало с един сбъркан символ, просто не съвпада — полето остава празно, върнатата стойност пак брои полето като разпарсено, а докладът, който излиза от pipeline-а, е с една стойност по-малко и нищо в лога не обяснява защо. Всеки, който строи export-and-reimport цикъл от вида, описан в експортирането и импортирането на XFDF form данни и анотации с PDFium Component, трябва да round-trip-не име на поле, съдържащо типографски символ, а не само чист ASCII, преди да се довери на цикъла

Струва си също да сме честни за границите на поправката. Таблицата вече съвпада с Annex D.2 за всичките 256 байтови стойности, което кара еднобайтовите FDF низове да се декодират правилно, но самата PDFDocEncoding покрива латински репертоар и нищо повече — всичко извън него трябва да пристигне като UTF-16BE с BOM, което е точно това, което §7.9.2.2 иска. А UTF-16 низът си има отделни опасности на страната на Delphi, защото code point отвъд U+FFFF се нуждае от surrogate двойка, а не от един WideChar — проблемът, разгледан в защо emoji и CJK символите чупят WideChar в Delphi. По-широкият извод се обобщава отвъд таблиците за кодиране: PDF парсер може да отхвърли нещо, което трябва да приеме, и това откриваш веднага, или да приеме нещо и тихо да го сбърка, което откриваш в production. Капаните с name token-и, описани в безопасното парсване на PDF речници, са във втората категория, а също и таблица, отместена с един ред — code path-ът успява, върнатата стойност изглежда разумна, и само тест, написан срещу спецификацията, а не срещу имплементацията, казва другото

ImportFDF, ExportFDF и ImportXFDF на TPdf, заедно с FDF и XFDF парсерите зад тях, са част от стандартния PDFium Component за Delphi и C++Builder