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

Шрифты и текст в PDF: почему глифы превращаются в квадратики

PDF-файл, который идеально выглядит на вашей машине, а на чужой отображается как ряд пустых квадратиков — это самый распространенный дефект шрифта в программном обеспечении для работы с документами, и это почти никогда не означает, что текст неправильный. Символы не повреждены, кодировка в порядке, глифов просто нет. Что изменилось между двумя машинами, так это то, какие шрифты были установлены в операционной системе, и разница между переносимым файлом и уязвимым заключается в одном решении, принятом при создании страницы: перемещался ли шрифт внутри PDF или предполагалось, что он присутствует на принимающей стороне

Чтобы понять, почему это происходит и почему отдельная ошибка создает текст, который выглядит доступным для поиска, но при копировании превращается в тарабарщину, нужно взглянуть на то, как PDF хранит текст. Он не хранит предложения. Он хранит коды глифов плюс программу шрифта плюс таблицы, которые сопоставляют одно с другим, и каждая ошибка рендеринга или извлечения скрывается в зазоре между этими тремя компонентами. Ниже приводится обзор этого механизма на основе ISO 32000 с вызовами Delphi, которые управляют им там, где это важно

Символы, коды и глифы — это три разные вещи

Словарь сбивает людей с толку, потому что в повседневной речи три различные концепции объединяются в слово «буква». Символ (character) — это абстрактная единица письма, идея заглавной буквы А, идентифицируемая в Unicode как U+0041. Глиф (glyph) — это нарисованная фигура, контур из кривой и штриха, который использует конкретный шрифт для изображения этого символа. Между ними находится код (code): байт или байты в потоке содержимого, которые сообщают средству просмотра, какой глиф в текущем шрифте следует нарисовать

PDF работает с кодами. Когда поток содержимого показывает строку, эти байты являются индексами в активном шрифте, а не символами Unicode. Кодировка шрифта решает, что код 65 означает «нарисовать глиф, находящийся под номером 65», и ничто в этой операции не знает, что результат выглядит для человека как «А». Именно это заставляет PDF отображаться одинаково везде, где он может найти глифы, и это также причина того, почему извлечение текста — это отдельная проблема от отображения: для рисования требуется только сопоставление кода и глифа, для чтения требуется сопоставление кода и Unicode, и это две разные таблицы, которые могут не совпадать или отсутствовать независимо друг от друга

Типы шрифтов, с которыми вы действительно столкнетесь

ISO 32000 определяет несколько типов словарей шрифтов, и на практике документ, который вы получаете или генерируете, использует один из трех. Знание того, на какой из них вы смотрите, объясняет большую часть того, что может пойти не так

Type 1 — это оригинальный формат контуров PostScript от Adobe, построенный из кубических кривых Безье. Четырнадцать стандартных шрифтов, которые должно поддерживать каждое совместимое средство просмотра (семейства Helvetica, Times, Courier, Symbol и ZapfDingbats), имеют тип Type 1, и словарь шрифтов, называющий один из них, может на законных основаниях не содержать программу шрифта. Это единственный случай, когда оставить шрифт невнедренным безопасно по спецификации, а не по счастливой случайности. Для любого другого шрифта Type 1 программа должна быть внедрена, иначе средство просмотра подставит что-то другое, обычно метрически похожий, но визуально отличающийся шрифт

TrueType использует квадратичные кривые и пришел из мира Apple и Microsoft. Именно такими являются большинство системных шрифтов, и вы будете внедрять их чаще всего. Простой шрифт TrueType в PDF ограничен однобайтовыми кодами, поэтому один такой шрифт может адресовать не более 256 глифов одновременно. Это ограничение является структурной причиной того, почему CJK (китайский, японский, корейский) и другие крупные системы письма не могут работать с простым шрифтом

Type 0, составной или CID-keyed шрифт, является ответом на это ограничение. Он использует многобайтовые коды и CMap (карту символов) для маршрутизации их через дочерний CIDFont, контуры которого сами по себе являются либо TrueType, либо CFF/Type 1. Это единственный тип шрифта, который может нести тысячи глифов, поэтому любой PDF-файл, содержащий китайский, японский, корейский или широкую многоязычную смесь, использует Type 0, независимо от того, думал ли об этом автор. Платой за это является сложность: больше движущихся частей, большинство из которых должны быть правильными как для рендеринга, так и для извлечения

Один шрифт TrueType, отображаемый кеглем 12, 18, 24 и 36 пунктов в PDF, показывающий, что один внедренный контур масштабируется до любого размера

Одна деталь, скрытая за этой картинкой, влияет на размер файла. Шрифт — это библиотека контуров, а не растровые изображения фиксированного размера, поэтому одна и та же внедренная программа обслуживает каждый размер шрифта на странице. Масштабирование — это преобразование, применяемое во время рисования, вот почему заголовок и основной текст используют один внедренный шрифт и почему стоимость внедрения рассчитывается для каждого шрифта, а не для каждого размера

Внедрение — это разница между переносимостью и уязвимостью

Внедрение означает, что программа шрифта, фактические данные контуров, записываются в PDF в виде потока. Средство просмотра на машине, которая никогда не слышала о вашем шрифте, читает эти контуры прямо из файла и рисует точные глифы. Пропустите внедрение, и вы сделаете ставку на то, что на целевой машине есть шрифт с тем же именем; если это не так, средство просмотра перейдет к замене. Для стандартных четырнадцати эта замена определена и безопасна. Для всего остального она варьируется от близкого совпадения в другом гарнитуре шрифта до результата в виде пустых квадратиков, когда ни одна замена вообще не покрывает систему письма

В HotPDF элементом управления является одно свойство, задаваемое до открытия документа. FontEmbedding указывает библиотеке упаковать шрифты, которыми она рисует, в файл:

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'report.pdf';
    Pdf.Compression := cmFlateDecode;
    Pdf.FontEmbedding := True;          // outlines travel inside the file
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Calibri', [], 11);
    Pdf.CurrentPage.TextOut(72, 760, 0, 'This renders the same on a machine without Calibri.');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Порядок не является косметическим. BeginDoc — это место, где HotPDF фиксирует структуру документа, поэтому FontEmbedding должно иметь значение true перед этим вызовом. Назначьте его позже, и не будет ни ошибки, ни предупреждения, просто файл, который тихо вышел без своих шрифтов. Это худший вид ошибки: она проходит каждый тест на машине разработчика, где шрифт оказался установлен, и всплывает только у клиента, у которого его нет

Внедрение — это также место, где лицензирование встречается с инженерией. Программа шрифта несет флаги, описывающие, может ли она быть свободно внедрена, только для предварительного просмотра или вообще не может быть внедрена. Учет этих флагов — ваша ответственность, а не рендерера, и «это сработало» не означает «это было разрешено»

Создание подмножеств: внедряйте только те глифы, которые вы использовали

Полное внедрение записывает в файл всю программу шрифта. Большой шрифт CJK TrueType может достигать нескольких мегабайт, и внедрять его целиком, чтобы показать дюжину символов, расточительно, причем это усугубляется в многостраничном документе. Создание подмножества решает эту проблему, записывая только глифы, на которые ссылается документ, а затем переименовывая шрифт, добавляя шестибуквенный тег и знак плюс — форма ABCDEF+Calibri в списке шрифтов любого PDF-файла с подмножеством, поэтому средство просмотра никогда не спутает частичный шрифт с полным системным шрифтом с тем же именем

Для большинства сгенерированных документов создание подмножества является правильным значением по умолчанию. Оно сохраняет размер файла пропорционально содержимому, а не исходному шрифту, что важнее всего для больших многоязычных шрифтов, которые в противном случае доминировали бы в файле. Единственное предостережение заключается в том, что подмножество содержит только то, что использовалось во время создания. Если последующий процесс попытается позже добавить текст к шрифту с подмножеством, необходимых ему глифов может не оказаться в файле, что является реальным ограничением при постепенном редактировании чужого PDF-файла

Шрифты Unicode и проблема квадратиков CJK

Когда текст не является обычной латиницей, путь простых шрифтов заканчивается, и решение состоит в том, чтобы явно зарегистрировать шрифт с поддержкой Unicode и позволить HotPDF создать из него шрифт Type 0. RegisterUnicodeTTF загружает файл TrueType по пути; после этого зарегистрированное имя можно использовать в SetFont, как любое другое:

Pdf.FontEmbedding := True;
Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansCJKsc-Regular.ttf');
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('NotoSansCJKsc-Regular', [], 14);
Pdf.CurrentPage.TextOut(72, 720, 0, '你好,世界 こんにちは 안녕하세요');
Pdf.EndDoc;

Две вещи делают или ломают это. Шрифт должен охватывать системы письма в строке: латинский TrueType не вырастит китайские глифы только потому, что вы его попросили, и результатом снова будут пустые квадратики, на этот раз потому, что глиф действительно не существует в этом шрифте. И внедрение должно оставаться включенным, потому что шрифт Type 0, собранный из зарегистрированного TTF, бессмысленен для средства просмотра, которое не может найти контуры. Для смешанного содержимого надежным выбором будет шрифт с широким охватом (обычные ответы — семейства Noto и Arial Unicode MS), внедренный и в виде подмножества

Письмо справа налево и сложные системы письма добавляют слой формирования поверх охвата. HotPDF предоставляет RtLTextOut для арабского и иврита, который обрабатывает изменение направления, поэтому вы передаете логический порядок и позволяете библиотеке компоновать его. Правильное отображение арабского языка — это охват, плюс формирование, плюс направление (три разные вещи), и квадрат здесь может означать, что любой из этих этапов не удался

Таблица ToUnicode: где живет копирование и вставка

Всё вышеперечисленное касается рисования. Извлечение является зеркальным отражением и дает сбой по своим собственным причинам. Средство просмотра отрисовывает страницу, используя сопоставление кода шрифта с глифом, но когда пользователь выделяет текст и копирует его, средству просмотра необходимо преобразовать эти же коды обратно в Unicode. Это обратное сопоставление представляет собой ToUnicode CMap, необязательный поток, прикрепленный к шрифту

Когда он присутствует и правильный, скопированный текст выходит в виде правильных символов. Когда он отсутствует, неверен или шрифт был создан в виде подмножества с пользовательскими кодами глифов и ToUnicode не был записан, страница выглядит идеально, а буфер обмена заполняется мусором: коды глифов читаются так, как если бы они были Unicode, хотя для пользовательского подмножества это не так. Вот почему отсканированный документ с текстовым слоем OCR может быть доступен для поиска, а PDF-файл, созданный в цифровом виде небрежным генератором — нет. Рендеринг и извлечение используют разные таблицы, поэтому файл может удовлетворять одному условию и не соответствовать другому. Если извлечение имеет значение для вашего вывода, относитесь к правильной карте ToUnicode как к требованию и проверяйте это, копируя текст из образца, а не надеясь, что она там есть

Как быстро диагностировать ошибку шрифта

Режим сбоя подсказывает вам, где искать. Пустые квадратики на другой машине почти всегда означают шрифт, который не был внедрен, поэтому сначала проверьте внедрение, а затем охват глифов. Квадратики, которые появляются даже на вашей собственной машине, указывают на покрытие: шрифт не содержит эту систему письма, независимо от внедрения. Текст, который отображается правильно, но копируется как бессмыслица — это проблема ToUnicode, а не проблема рендеринга, и возня со шрифтами или внедрением не исправит ее, потому что рисование никогда не было нарушено. Чтобы прочитать готовый файл, откройте его в Acrobat и посмотрите "Свойства документа" (Document Properties) -> "Шрифты" (Fonts): здоровая запись показывает тип, говорит "Внедрено" (Embedded) или "Внедренное подмножество" (Embedded Subset) и называет кодировку. Шрифт, который должен быть внедрен, но таковым не является, заявляет о себе там до того, как это сделает клиент

Во всем этом нет ничего экзотического, как только становится ясно разделение между символом, кодом и глифом. Внедряйте шрифты, которыми вы рисуете, создавайте подмножества больших шрифтов, обращайтесь к шрифтам Unicode и используйте RegisterUnicodeTTF в тот момент, когда текст выходит за рамки латиницы, и сохраняйте правильную карту ToUnicode, если кто-то будет извлекать текст. Сделайте все правильно, и квадратики перестанут появляться. Что касается окружающей механики, статья создание простого PDF-документа с нуля показывает, где в дереве объектов находится словарь шрифтов, а технический обзор структуры файла PDF охватывает то, как ресурсы разделяются между страницами

Показанные здесь вызовы SetFont, FontEmbedding и RegisterUnicodeTTF являются частью компонента HotPDF для Delphi и C++Builder