Technical Article

Ошибка в EndDoc, которая незаметно отключала подмножества шрифтов

Создайте отчет, внедрите шрифт TrueType, и результат корректно откроется в любой программе для просмотра. Глифы отображаются правильно, текст можно выделить, файл валиден. Единственной проблемой является размер. Документ, использующий несколько десятков латинских символов, содержит весь шрифт размером 350 КБ. Документ, в котором напечатан абзац на китайском языке, содержит шрифт CJK объемом 14 МБ вместо половины мегабайта, которая ему действительно нужна. Никаких исключений не возникло, никаких предупреждений не было записано в лог, и файл успешно прошел валидацию. Так со стороны выглядит неправильный порядок шагов финализации: ничего не ломается, а единственным доказательством является слишком большой размер

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

Что должно делать создание подмножества шрифта

Подмножество шрифта представляет собой часть файла TrueType, которую документ действительно использует. Стандарт ISO 32000-1 §9.9 описывает, как встроенная программа шрифта передается в потоке, на который ссылается дескриптор шрифта. Для программы TrueType этим потоком является /FontFile2 с параметром /Length1, указывающим несжатый размер в байтах. Создание подмножества перезаписывает таблицы glyf и loca так, чтобы они содержали только те глифы, на которые ссылается документ, перенумеровывает идентификаторы глифов и добавляет к имени /BaseFont шестибуквенный префикс (например, ABCDEF+), чтобы пометить шрифт как подмножество, как того требует спецификация. Уменьшение латинского шрифта до десяти или пятнадцати килобайт определяет разницу между компактным PDF и файлом, который переносит целую гарнитуру ради одного заголовка

Момент, когда это происходит, имеет значение. Создание подмножества не является преобразованием, которое применяется к байтам на диске. Оно изменяет граф объектов в памяти: уменьшает содержимое потока /FontFile2, корректирует /Length1 и перезаписывает строку /BaseFont. Все это должно быть готово, когда сериализатор обходит граф и выводит байты. Если изменения происходят после записи байтов, они обновляют объекты, которые никто никогда не прочитает

Симптом и причины отсутствия предупреждений

Заявленное поведение выражалось во внедрении полных шрифтов в выходной файл без каких-либо предупреждений. Пользователь, который зарегистрировал шрифт Unicode TrueType и создал обычный документ, обнаружил, что встроенный объект шрифта имел ту же длину, что и исходный файл .ttf, а имя /BaseFont не содержало шестибуквенного префикса подмножества. Размер файла не уменьшался как при использовании десяти глифов, так и при использовании десяти тысяч

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

Первопричиной был порядок финализации

В HotPDF работа по закрытию происходит внутри EndDoc. Шаг создания подмножества представляет собой внутреннюю процедуру с именем BuildAndApplyUnicodeFontSubset. Она считывает набор используемых кодовых точек для каждого документа, хранящийся в битовой карте, которую путь вывода текста заполняет по мере отображения глифов, сопоставляет каждую использованную кодовую точку через кэшированную таблицу кодовых точек в глифы с реальным идентификатором глифа и перезаписывает программу шрифта. Когда шрифт Unicode TrueType зарегистрирован, путь вывода устанавливает бит в наборе использованных кодовых точек для каждого выводимого символа, поэтому к моменту закрытия документа движок точно знает, какие глифы должно сохранить подмножество

Дефект заключался в том, что BuildAndApplyUnicodeFontSubset вызывался после того, как SaveToStream или SaveToFile уже сериализовали документ. Изменения, внесенные в /FontFile2, скорректированная длина /Length1 и шестибуквенный префикс /BaseFont вычислялись для графа объектов, который уже был преобразован в байты. Решением стало изменение порядка вызовов: вызов создания подмножества был перенесен перед сериализацией, чтобы средство записи выводило именно подмножество шрифта, а не оригинал. В исправленной последовательности сначала запускается создание подмножества, а затем выполняется сериализация

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
    Pdf.CurrentPage.TextOut(72, 760, 0, '报表标题 Report Heading');
    Pdf.EndDoc;                 // subsetting runs here, before the write
    Pdf.SaveToFile('Report.pdf');
  finally
    Pdf.Free;
  end;
end;

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

Почему один неверный шаг создает целый класс проблем

Этот случай заслуживает подробного разбора, поскольку EndDoc выполняет ряд шагов закрытия, и каждый из них чувствителен к своему положению относительно записи. Создание подмножества шрифтов является лишь одним из них. Вывод PDF/A требует потока /CIDSet, который перечисляет идентификаторы глифов, присутствующих в подмножестве. Это ограничение стандарта ISO 19005 позволяет валидатору подтвердить, что встроенная программа соответствует заявленному дескриптору. Этот поток выводится в том же окне финализации и зависит от того, было ли сначала создано подмножество. Стандарт PDF/UA-1 требует, согласно ISO 14289-1 §7.18.3, чтобы каждая страница с аннотацией объявляла /Tabs со значением /S, и внутренняя процедура EnsurePDFUATabsOnAnnotatedPages записывает этот ключ на том же этапе. Проверки намерений вывода (Output Intent) также выполняются здесь

Та же ошибка упорядочивания, которая отключала подмножества, приводила к потере ключа порядка табуляции PDF/UA на страницах с аннотациями, так как этот шаг находился по ту же неверную сторону от записи. Программы veraPDF и PAC сообщают об отсутствии /Tabs /S как о нарушении контрольной точки 21-001 протокола Matterhorn. Таким образом, один неверно расположенный вызов не просто увеличил размер файла, но и незаметно нарушил требования соответствия доступности без вывода каких-либо ошибок. В этом и заключается опасность этапа финализации: его шаги имеют общее предварительное условие, и одна ошибка в порядке вызовов может нарушить работу нескольких механизмов одновременно, при этом все вызовы будут возвращать успешный результат

Как выявляется незаметный сбой вывода

Ошибка, которая не вызывает исключений, не обнаруживается обычным запуском программы. Ее можно выявить только путем проверки выходных данных и их сравнения с тем, что должен был создать входной поток. Для подмножеств шрифтов проверки вполне конкретны. Сравните размер выходного файла с ожидаемым: документ, содержащий всего несколько глифов, не должен быть размером со весь шрифт. Откройте встроенный объект шрифта и прочитайте его длину в байтах: подмножество /FontFile2 для латинского шрифта составляет малую часть от исходного файла. Проверьте имя /BaseFont и убедитесь, что шестибуквенный префикс присутствует, так как его отсутствие прямо указывает на то, что подмножество не было применено

var
  Pdf: THotPDF;
  Output: TMemoryStream;
begin
  Output := TMemoryStream.Create;
  try
    Pdf := THotPDF.Create(nil);
    try
      Pdf.RegisterUnicodeTTF('C:\Fonts\DejaVuSans.ttf');
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('DejaVu Sans', [], 11);
      Pdf.CurrentPage.TextOut(72, 760, 0, 'Subset me');
      Pdf.EndDoc;
      Pdf.SaveToStream(Output);
    finally
      Pdf.Free;
    end;
    // A few glyphs from a ~700 KB face must not yield a multi-hundred-KB stream.
    if Output.Size > 100 * 1024 then
      raise Exception.Create('Font subset did not shrink the output');
  finally
    Output.Free;
  end;
end;

Для вывода PDF/A проверка становится еще более точной, так как валидатор делает всю работу за вас. Установите уровень соответствия и пропустите результат через veraPDF: отсутствие /CIDSet или подмножество, не соответствующее дескриптору, будет зарегистрировано как ошибка соответствия, а не останется незамеченным. Параметры соответствия, управляющие этой финальной работой, представляют собой свойства документа. Свойство PDFACompliance принимает строку, например, '2B' для уровня PDF/A-2 Level B, а PDFUACompliance - это логическое значение, которое включает требования к тегированному PDF и порядку табуляции

Pdf := THotPDF.Create(nil);
try
  Pdf.PDFACompliance := '2B';     // PDF/A-2 Level B, drives /CIDSet emission
  Pdf.PDFUACompliance := True;    // stamps /Tabs /S on annotated pages
  Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
  Pdf.BeginDoc;
  Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
  Pdf.CurrentPage.TextOut(72, 760, 0, '合规报告');
  Pdf.EndDoc;
  Pdf.SaveToFile('Report_PDFA.pdf');
finally
  Pdf.Free;
end;

Инженерный урок

Из этой ситуации следуют два правила. Во-первых, любой шаг финализации, который изменяет объекты, должен выполняться до сериализации этих объектов, а завершающий этап работы движка документов должен рассматриваться как упорядоченный конвейер, где сериализация является последним действием, а не одним из многих. Второе правило сэкономило больше всего времени: отсутствие ошибок при выводе не является доказательством успеха. Процедура, которая строит правильное подмножество и применяет его к неверному, уже записанному графу, не сообщает о проблемах, поскольку с ее собственной точки зрения все прошло успешно. Проверка должна исследовать сам артефакт, а не код возврата. Проверяйте размер выходных данных, длину встроенного шрифта в байтах и префикс его имени /BaseFont, а также используйте veraPDF для оценки вывода PDF/A, где отсутствие /CIDSet превращает незаметный сбой в явную ошибку

Сторона создания при работе со шрифтами, их регистрация и внедрение для вывода отчетов подробно описаны в нашей статье о шрифтах и изображениях в выводе отчетов. Сторона валидации, проверяющая шаги финализации на соответствие стандартам, рассматривается в руководстве по валидации PDF/A и PDF/UA. Оба аспекта дополняют работу по созданию подмножеств и проверке соответствия, описанную здесь, которая поставляется в составе компонента HotPDF для Delphi и C++Builder вместе с API для загрузки, редактирования, шифрования и подписи документов