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

Фикс ToUnicode: NBSP и мягкий перенос в извлечении (Delphi)

PDFium Component для Delphi встраивает системные шрифты, используемые TPdf.AddText, как CID-шрифты с ключом по кодовой точке Unicode, так что каждый CID несёт ровно одно отображение ToUnicode. Именно это останавливает превращение извлечённых пробелов в U+00A0 (неразрывный пробел) и дефисов в U+00AD (мягкий перенос) — что в живом документе, что в сохранённом файле

Симптом мерзкий, потому что невидимый. Поисковый индекс не находит «two-x», потому что в сохранённой строке сидит мягкий перенос, CSV-экспорт режет по-другому, инструмент диффа помечает строки, которые выглядят идентично в любом вьюере. На отрисованной странице всё правильно; неправильный только 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, как и
  // греческая омега (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 побеждает, пара, различающаяся только старшим битом, сходится к меньшей базовой латинской кодовой точке, а всё остальное сохраняет первое отображение

Самое интересное — отрицательный результат. Чистая пересборка конфликтующего CMap, хоть в форме стартовых кодов, хоть в форме массива, выглядела очевидным ходом, и PDFium отверг каждую пересобранную карту, откатываясь к Identity. Единственный вывод, который нативный читатель принял, — равная по длине замена конфликтующих hex-значений на месте, с нетронутой раскладкой блоков и покрытием CID. Второй урок был скромнее: наша тогдашняя заметка валила случай в памяти на отсутствие у живого документа 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
      // Только правки равной длины; файлы без ремонтопригодного конфликта,
      // а также файлы с cross-reference-стримами или object-стримами копируются как есть
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Ключом шрифта становится кодовая точка вместо глифа

Корневой фикс — перестать вовсе просить PDFium генерировать CMap. TPdf.LoadCachedFont теперь отдаёт байты системного шрифта в TPdf.LoadUnicodeKeyedCidFont, которая читает собственную sfnt-таблицу cmap шрифта, предпочитая подтаблицу формата 12 и откатываясь к формату 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, low больше high, и весь блок помечается невалидным. Отказ тихий: SetText завершается успешно, страница рисуется идеально, а извлечение возвращает U+0000 для каждого символа блока

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

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

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

Путь с ключом по кодовой точке покрывает каждый шрифт, выставляющий Unicode-подтаблицу cmap, и откатывается к старому поведению с ключом по глифу для остальных. Границы, которые стоит знать, прежде чем на него полагаться:

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

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