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

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

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

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

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

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

PDF работает с кодами. Когда поток содержимого отображает строку, эти байты являются индексами в активном шрифте, а не символами Unicode. Кодировка шрифта решает, что код 65 означает "нарисовать глиф, находящийся под номером 65", и ничто в этой операции не знает, что результат выглядит как 'A' для человека. Именно это заставляет PDF рендериться одинаково везде, где он может найти глифы, и это также является причиной того, что извлечение — это отдельная от отображения проблема: для рисования требуется только преобразование код-глиф (code-to-glyph), для чтения требуется преобразование код-Unicode (code-to-Unicode), и это две разные таблицы, которые могут конфликтовать или отсутствовать независимо друг от друга

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

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

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

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

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

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

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

Внедрение — это разница между переносимым и хрупким

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

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

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 может занимать несколько мегабайт, и внедрять его целиком для отображения дюжины символов расточительно, что усугубляется в многостраничном документе. Создание подмножеств (subsetting) решает эту проблему путем записи только тех глифов, на которые ссылается документ, с последующим переименованием шрифта с добавлением шестибуквенного тега и знака плюс — форма 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;

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

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

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

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

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

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

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

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

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