Технічна стаття

Фікс ToUnicode: NBSP і м'який дефіс у вилученні PDF

PDFium Component для Delphi вбудовує системні шрифти, якими користується TPdf.AddText, як CID-шрифти, ключовані кодовою точкою Unicode, тож кожен CID несе рівно одне відображення ToUnicode. Саме це не дає вилученим пробілам повертатися як U+00A0 (no-break space), а дефісам — як U+00AD (soft hyphen), і в живому документі, і в збереженому файлі

Симптом огидний, бо він невидимий. Пошуковий індекс промахується повз «two-x», бо в збереженому рядку сидить м'який дефіс, CSV-експорт розбивається інакше, diff-інструмент мічить рядки, які в кожному глядачі виглядають ідентичними. На відрендереній сторінці все добре; неправильно лише Unicode за гліфами

Чому вилучені пробіли повертаються як U+00A0?

Вилучені пробіли перетворюються на U+00A0, бо ToUnicode CMap, який PDFium генерує в FPDFText_LoadFont, ключований гліфом, а до одного гліфа ведуть дві кодові точки. У Arial гліф 3 обслуговує і U+0020, і U+00A0, а дефісний гліф — і U+002D, і U+00AD. Згенерований CMap тому відображає той самий CID двічі — раз через запис bfchar і раз через bfrange у формі масиву, — і який запис переможе за правилом пріоритету читача, той і стає вилученим текстом

Чому один гліф Arial зламав вилучення тексту PDF у Delphi: U+0020 і U+00A0 ведуть до гліфа 3, а U+002D і U+00AD — до дефісного гліфа, тож згенерований ToUnicode CMap відображає CID 0003 двічі — через запис bfchar і bfrange-масив, — і правило пріоритету читача вирішує, яка кодова точка вилучиться
Пріоритет найменшого роками тримав пробіли простими, доки апстрім-перемикання на останній-перемагає не змусило кожен пробіл AddText вилучатися як NBSP, а кожен дефіс — як м'який дефіс
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

Довгий час та суперечність була нешкідливою, бо читач PDFium давав перемогти найменшому відображенню. Апстрім-зміна перемкнула читача на останній-перемагає, і від тієї збірки кожен пробіл, написаний через AddText, вилучався як NBSP, а кожен дефіс — як м'який дефіс. Зверніть увагу на візерунок у парах: 0x20/0xA0 і 0x2D/0xAD відрізняються лише старшим бітом, а саме цього чекаєш від шрифту, чий cmap шле Latin-1 двійників в один контур. Якщо ваш код вилучення вчора був справний, а сьогодні падає на невидимих символах, дампіть кодові точки замість довіри до вікна дебагера; основи витягування тексту покриває вилучення тексту з PDF-документів з PDFium у Delphi

uses
  SysUtils, PDFium;

const
  // Пробіл/U+00A0 і дефіс/U+00AD ділять один гліф Arial, як і
  // грецька Omega (U+03A9) зі знаком Ома (U+2126)
  Sample: WString = 'two-x'#$00A0'y'#$00AD'z '#$03A9#$2126;

function CodePoints(const S: WString): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    Result := Result + 'U+' + IntToHex(Ord(S[I]), 4) + ' ';
end;

var
  Pdf: TPdf;
  Live, Reloaded: WString;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    Pdf.AddPage(1, 595, 842);
    Pdf.AddText(Sample, 'Arial', 12, 72, 770);
    Live := Pdf.Text;                  // живий, незбережений документ
    Pdf.SaveAs('codepoints.pdf');
  finally
    Pdf.Free;
  end;

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'codepoints.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;
    Reloaded := Pdf.Text;              // після повного збереження і перезавантаження
  finally
    Pdf.Free;
  end;

  if (Live <> Sample) or (Reloaded <> Sample) then
    Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;

Чому латання CMap після збереження було недостатньо

Латання збереженого файлу чинить лише збережений файл, і лише якщо латка тримає структуру CMap недоторканою байт у байт. Перший фікс, RepairSubsetToUnicodeCMaps в юніті FPdfCompress, бігає після кожного не-інкрементального TPdf.SaveAs і розв'язує кожен конфліктний CID: запис bfchar перемагає, пара, що відрізняється лише старшим бітом, розв'язується в меншу базову Latin кодову точку, а все інше тримає своє перше відображення

Цікава частина — негативний результат. Чисто перебудувати конфліктний CMap, у формі start-code чи масиву, виглядало очевидним ходом, і PDFium відхиляв кожен перебудований CMap з порогу, падаючи назад на Identity. Єдиний вихід, який нативний читач прийняв, — це заміна рівної довжини на місці конфліктних hex-значень, з блоковим layout-ом і покриттям CID недоторканими. Другий урок був скромніший: наша тодішня примітка звинувачувала in-memory випадок у тому, що живий документ не має жодного потоку ToUnicode. Прямий виклик DLL це спростував, бо живий документ несе той самий двозначний потік, а отже, справжній фікс мусив статися до того, як PDFium взагалі згенерує CMap. Рутина лагодження лишається в бібліотеці як захист для PDF, зроблених іншими інструментами на PDFium

uses
  Classes, FPdfCompress;

var
  Source, Dest: TFileStream;
begin
  Source := TFileStream.Create('from-other-tool.pdf',
    fmOpenRead or fmShareDenyWrite);
  try
    Dest := TFileStream.Create('repaired.pdf', fmCreate);
    try
      // Лише правки рівної довжини; файли без лагабельного конфлікту,
      // а також файли з xref-потоками чи object-потоками, копіюються як є
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Ключування шрифту кодовою точкою замість гліфа

Кореневий фікс — перестати просити PDFium генерувати CMap взагалі. TPdf.LoadCachedFont тепер передає байти системного шрифту в TPdf.LoadUnicodeKeyedCidFont, який читає власну таблицю sfnt cmap шрифту, надаючи перевагу субтаблиці format 12 і падаючи назад на format 4. Кодові точки повертаються відсортованими й дедуплікованими, і CID k+1 призначається k-й кодовій точці, з CID 0, залишеним як .notdef. Явний CIDToGIDMap шле кожен CID у його гліф, тож U+0020 і U+00A0 отримують два різні CID, які малюють той самий контур, а ToUnicode CMap відображає кожен CID рівно в одну кодову точку. Потім шрифт завантажується через FPDFText_LoadCidType2Font — той самий точ входу, що стоїть за гліф-рівневим записом у вбудовуванні шрифтів CID Type 2 з явними CID-to-GID мапами

Фікс з ключуванням за кодовою точкою в PDFium Component: LoadUnicodeKeyedCidFont читає sfnt cmap шрифту, призначає CID k+1 кожній відсортованій кодовій точці з CID 0 як notdef, проводить явний CIDToGIDMap, тож U+0020 і U+00A0 тримають різні CID, а BuildUnicodeKeyedCidCMap дає кожному CID рівно одну кодову точку
NBSP, м'який дефіс і знак Ома тоді виживають як самі собі за будь-якого правила пріоритету — у живому документі і після будь-якого збереження, тож лагодженню CMap не лишається нічого лагодити
// Стиснуто з TPdf.LoadUnicodeKeyedCidFont
SetLength(CidToGidMap, (Length(Entries) + 1) * 2);   // CID 0 = .notdef
for I := 0 to High(Entries) do
begin
  CidToGidMap[(I + 1) * 2]     := Byte(Entries[I].GlyphID shr 8);
  CidToGidMap[(I + 1) * 2 + 1] := Byte(Entries[I].GlyphID and $FF);
end;
ToUnicode := BuildUnicodeKeyedCidCMap(Entries);       // один CID, одна кодова точка
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

Коли FPDFText_SetText пізніше пише рядок, зворотний пошук приземляється на один CID за символ, тож NBSP, м'який дефіс і знак Ома кожен виживає як сам собі за будь-якого правила пріоритету — у пам'яті і після будь-якого збереження. Оскільки збережений файл несе власний потік ToUnicode компонента, а не згенерований рушієм, RepairSubsetToUnicodeCMaps не знаходить у ньому нічого лагодити

Чому один запис bfrange здатен стерти цілий блок?

Єдиний bfrange, чий пробіг CID переходить межу xxFF, змушує PDFium викинути весь блок, у якому він сидить. ISO 32000-1 §9.10.3 дозволяє мінливість лише останнього байта призначення всередині проміжку, але сторона CID має власну пастку: HandleBeginBFRange PDFium виводить старший CID як (low and $FFFFFF00) or (high and $FF). Пробіг від CID 00FE до 0101 тому читається як 00FE до 0001 — низ більше за верх, — і весь блок мічиться невалідним. Відмова тиха: SetText вдається, сторінка рендериться бездоганно, а вилучення повертає U+0000 для кожного символу того блока

Тиха пастка bfrange у парсингу CMap PDF: пробіг CID від 00FE до 0101 переходить межу xxFF, HandleBeginBFRange виводить старший CID як 0001, низ більше за верх мічить весь блок невалідним, SetText і рендеринг усе ще вдаються, а вилучення повертає U+0000 для кожного символу блока
BuildUnicodeKeyedCidCMap обходить пастку, закінчуючи кожен пробіг перед низьким байтом FF, тримаючи блоки в межі ліміту 100 записів і пишучи кодові точки додаткових площин окремими записами bfchar

BuildUnicodeKeyedCidCMap закінчує пробіг до того, як кодова точка чи CID досягне низького байта FF, тримає кожен блок у межах ліміту 100 записів граматики CMap і пише кодові точки додаткових площин окремими записами bfchar із призначеннями у вигляді сурогатних пар UTF-16, бо інкремент сурогатної пари всередині проміжку не має визначеного сенсу; сурогатна сторона тієї історії — у обробці emoji, CJK і сурогатних пар у Delphi. CMap лише з bfchar обійшов би проблему межі цілком — за кілька разів більший розміром

Чого не покриває шрифт, ключований кодовою точкою?

Шлях з ключуванням за кодовою точкою покриває кожен шрифт, який виставляє Unicode-субтаблицю cmap, і падає назад на стару поведінку з ключуванням гліфами для решти. Межі, які варто знати, перш ніж на це покладатися:

  • Символьні шрифти лише з cmap (3,0) і будь-який шрифт, який CID-шлях не зміг завантажити, ідуть через FPDFText_LoadFont як і раніше, тож гліф, поділений двома кодовими точками, може там вилучатися двозначно
  • Без субтаблиці format 12 мапа обмежена BMP, а кількість записів закрита на 65535, щоб кожен CID вміщувався в два байти вище нуля
  • Інкрементальні збереження (saIncremental) пропускають RepairSubsetToUnicodeCMaps задумано, бо інкрементальна ревізія мусить лишатися append-only; шрифти з ключуванням за кодовою точкою роблять те нерелевантним для тексту, який компонент пише сам
  • TrueType Collections потребують додаткової уваги: GDI GetFontData повертає весь .ttc, а FPDFText_LoadCidType2Font не має параметра індексу face, тож прохання NSimSun з simsun.ttc колись вбудовувало і рендерило SimSun, face 0. Компонент тепер звіряє ім'я родини з таблицею імен (nameID 1 і 16) і витягає проханий face як окремий sfnt до парсингу cmap; якщо парсинг падає, байти колекції проходять наскрізь, і поведінка повертається до face 0

Запис тексту, вбудовування шрифтів і вилучення ділять одну модель сторінки в Delphi, C++Builder і Lazarus, а повний API описано на сторінці продукту PDFium Component for Delphi