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 в форме массива, — и какая запись победит по правилу приоритета читателя, та и становится извлечённым текстом
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 картами
// Сокращено из 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 для каждого символа блока
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