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

Аудит шрифтов PDF/UA в Delphi: Widths, CharSet, CIDSet

Отчёт veraPDF, сообщающий, что ширина какого-то глифа расходится со встроенной программой шрифта, почти ничего не говорит о том, какой именно глиф и почему. PDFlibPas отвечает на этот вопрос, разрешая каждый код символа через встроенный cmap до индекса глифа, нормализуя метрику программы к 1000 единиц на em и сравнивая уже там

Почему ширины глифов расходятся?

Потому что два сравниваемых числа живут в разных системах координат, и ничто в PDF-словаре не сообщает вам пересчёт. Словарь шрифта пишет /Widths в пространстве глифов, которое PDF фиксирует как одну тысячную em (ISO 32000-1 §9.2.4). Таблица hmtx внутри встроенной TrueType-программы пишет продвижения в дизайн-единицах шрифта, а таблица head решает, сколько таких единиц составляют em: 2048 для большинства TrueType-гарнитур, 1000 для производных от CFF, изредка нечто совсем иное. Сравните сырые значения, и каждый шрифт с 2048 upem в вашем корпусе выглядит сломанным. Вот ловушка, которую ISO 14289-1 §7.21.5 ставит всякому, кто пытается аудировать ширины чтением полей словаря

Аудит ширин глифов PDFlibPas в Delphi: запись /Widths в пространстве глифов и продвижение hmtx в дизайн-единицах шрифта приводятся к одной системе координат масштабированием метрики программы к 1000 единиц на em до всякого сравнения
PDFlibPas масштабирует каждое продвижение hmtx до тысячной em перед сравнением с шириной словаря и сообщает только о расхождении больше одной единицы

PDFlibPas нормализует при загрузке. TPDFTrueTypeParser хранит Advance * 1000 div unitsPerEm в своём массиве ширин, поэтому Parser.GetWidth(GID) уже отвечает в тех же тысячных em, которые использует PDF, а GetRawWidth остаётся доступным, когда нужны дизайн-единицы. Это всё ещё оставляет более трудную половину: переход от кода символа к индексу глифа. Для простого TrueType-шрифта маршрут зависит от флага Symbolic в FontDescriptor, бита 3 в /Flags

Parser := TPDFTrueTypeParser.Create;
try
  Parser.LoadFromString(FontProgram);
  if Symbolic then
  begin
    // К символьным гарнитурам обращаются через cmap программы напрямую,
    // с соглашением о старшем байте (3,0) как запасным вариантом
    GID := Parser.GetGlyphIndex(Code);
    if GID = 0 then
      GID := Parser.GetGlyphIndex($F000 + Code);
  end
  else
  begin
    // Не символьные: код -> имя глифа через кодировку, имя -> Unicode
    // через Adobe Glyph List, Unicode -> GID через cmap программы
    UnicodeValue := GetGlyphUnicode(EncodingNames[Code and $FF]);
    if UnicodeValue = 0 then
      Continue;
    GID := Parser.GetGlyphIndex(UnicodeValue);
  end;
  if (GID > 0) and (GID < Parser.GlyphCount) then
    if Abs(PDFWidth - Parser.GetWidth(GID)) > 1 then
      Inc(MismatchCount);
finally
  Parser.Free;
end;

Две детали в этом фрагменте несут вес. Допуск — одна единица, а не ноль, потому что нормализация — целочисленное деление, и корректно созданный файл может отклониться на единицу; это ровно та формулировка «в пределах одной тысячной em», которую сообщает диагностика 10036. И защита GID < Parser.GlyphCount — не украшение. GetWidth написан снисходительным для вызывающих отрисовку: зажимает индекс вне диапазона к последней записи hmtx и откатывается к 750, когда таблицы нет. Снисходительность верна для отрисовки и неверна для аудита, поэтому аудит отвергает индекс до запроса ширины, вместо того чтобы доверять зажиму

CIDFontType2 добавляет ещё одну непрямую

PDFlibPas проходит составные шрифты тем же способом, с /CIDToGIDMap, вставленным между CID и глифом. Ширины приходят в массиве /W, которому ISO 32000-1 §9.7.4.3 даёт две формы, свободно чередующиеся в одном массиве: начальный CID, за которым следует массив последовательных ширин, либо первый CID, последний CID и одна ширина на весь пробег. Аудит разбирает обе, затем передаёт каждую получившуюся пару тому же сравнению и сообщает итог под диагностикой 10037. Шаг отображения — то место, где составные шрифты различаются, и потому диагностика 10021 об отсутствующей карте важна до чтения любой ширины вовсе — отсутствующая или повреждённая /CIDToGIDMap не просто нарушает §7.21.3.2, она делает вопрос ширины неответимым

Три маршрута PDFlibPas от кода символа к индексу глифа в Delphi: cmap программы для символьных TrueType-шрифтов, обход через кодировку и Adobe Glyph List для несимвольных и шаг CMap плюс /CIDToGIDMap для CIDFontType2
Сравнение ширин не может начаться, пока код символа не разрешится в индекс глифа, и каждый вид шрифта достигает этого индекса своим маршрутом
// /CIDToGIDMap — это имя /Identity или поток 16-битных индексов глифов
// в порядке big-endian, по одному на CID (ISO 32000-1, раздел 9.7.4.2)
Obj := DerefIndRef(FDoc, CIDFont.FindValueByKeyName('CIDToGIDMap'));
if (Obj is TPDFName) and (TPDFName(Obj).Name = 'Identity') then
begin
  GID := CID;
  Result := True;
end
else if Obj is TPDFStream then
begin
  Data := TPDFStream(Obj).GetDecodedStream;
  P := CID * 2 + 1;                       // строки Pascal индексируются с 1
  if (P >= 1) and (P + 1 <= Length(Data)) then
  begin
    GID := (Integer(Byte(Data[P])) shl 8) or Integer(Byte(Data[P + 1]));
    Result := True;
  end;
end;

Что делать аудитору, когда программа шрифта не декодируется?

Молчите. Проверки полноты /CharSet и /CIDSet, которых требует ISO 14289-1 §7.21.4.2, — диагностики 10038 и 10039 — это то место, где чрезмерно ретивый валидатор превращается в обузу, потому что сообщение «ваш CharSet неполон» для читающего неотличимо от «наш декодер Type 1 сдался». Поэтому PDFlibPas сообщает об отсутствующей записи, только когда три вещи успешны одновременно: программа шрифта декодируется, отображение код-в-глиф разрешается и сам набор декодируется. TPDFType1Decoder.LoadPFBFromString должен вернуть True и дать счёт charstring, прежде чем хоть одно имя глифа сверяется со строкой /CharSet; путь /CIDSet требует, чтобы поток надулся, а счёт глифов вернулся положительным, прежде чем проверяется хоть один бит. Любое исключение по дороге схлопывается в «находок нет», а не в дефект

Консервативное правило отчётности в аудите PDF/UA от PDFlibPas: отсутствующая запись /CharSet или /CIDSet сообщается, только когда программа шрифта декодируется, отображение код-в-глиф разрешается и сам набор декодируется, а любой сбой даёт тишину
Три независимых успеха требуются, прежде чем выдаётся находка об отсутствующей записи, поэтому сдавшийся декодер стоит вам ложноотрицательного, а не ложного обвинения

Это намеренный сдвиг в сторону ложноотрицательных, и его стоит назвать прямо, а не прятать. Повреждённая таблица CFF, неподдерживаемый вариант Type 1 или /CIDSet короче диапазона глифов — всё это даёт тишину вместо диагностики. Рассуждение такое: отчёты аудита PDF/UA пересылаются авторам, которые не строили инструмент, и ложное обвинение дороже пропущенной находки: автор сжигает день, доказывая, что соответствующий файл соответствует, и перестаёт доверять всему отчёту. Протокол Matterhorn проводит то же различие в иной форме, разделяя проверки, которые машина может решить, и проверки, которые обязан человек, а его контрольная точка Fonts (31) — как раз их дом. Если нужно более строгое чтение, гоняйте PDFlibPas как быстрые ворота, а специализированный валидатор — как второе мнение — та же пара описана в проходе preflight для PDF/A и PDF/UA

Страничный /Contents — это список, а не поток

Самая дорогая ошибка аудита контентных потоков — трактовать /Contents как один поток. ISO 32000-1 §7.7.3.3 позволяет странице держать массив потоков, чья конкатенация, с пробельными символами между частями, и есть программа страницы; производители режут в произвольных местах, и BT может сидеть в одном элементе, а его парный ET — в следующем. Обработчик контента держит состояние — глубину вложенности marked-content, шрифт, выбранный последним Tf, флаг текстового объекта — и Process сбрасывает это состояние на входе. Вызывайте его по разу на элемент массива, и каждый поток после первого стартует без текущего шрифта, так что прекрасно тегированный текст читается как нетегированный, безшрифтовый шум. PDFlibPas сначала конкатенирует и обрабатывает один раз

function ContentObjectData(FDoc: TSmartPDFDocument; Obj: TPDFObject): AnsiString;
var
  I: Integer;
begin
  Result := '';
  Obj := DerefIndRef(FDoc, Obj);
  if Obj is TPDFStream then
    Result := TPDFStream(Obj).GetDecodedStream
  else if Obj is TPDFArray then
    for I := 0 to TPDFArray(Obj).Count - 1 do
      Result := Result + ContentObjectData(FDoc, TPDFArray(Obj).Item[I]) + #10;
end;

// Один вызов Process на всю конкатенацию, а не по вызову на элемент
Scanner.Process(ContentObjectData(FDoc, PageDict.FindValueByKeyName('Contents')));

Какие Form XObjects реально считаются неструктурированными?

Только те, которые страница реально вызывает, из места вызова вне marked content, и чей собственный контент показывает текст. Диагностика 10040 поддерживает ISO 14289-1 §7.20, записывая три независимых факта на номер объекта — есть текст, был вызван, был вызван внутри marked content — и сообщая только пересечение первых двух минус третий. Каждый из двух сокращённых путей неверен так, что вы бы это выпустили: помечать каждую несущую текст Form в /Resources — значит наказать библиотеку шаблонов, из которой никто не рисует, а помечать каждую вызванную Form — значит наказать векторные логотипы без текста, не нуждающиеся в тегировании. Место вызова разрешается по номеру объекта, а не по имени ресурса, поскольку одна и та же Form регулярно достигается под разными именами на разных страницах. Сопутствующая диагностика 10041 идёт по той же сконкатенированной программе для §7.21.8, разрешая каждый показывающий текст операнд через шрифт в области видимости и считая коды, попадающие на .notdef, что запрещено независимо от режима отрисовки текста — включая невидимый режим за отсканированными изображениями. Как следует обёртывать уцелевшие Forms — вопрос дерева структуры, раскрытый в статье о построении структуры тегированного PDF

Шрифты вовсе без FontDescriptor

Невстроенный шрифт — легитимный вход для этого аудита, а не состояние ошибки, и каждый хелпер ниже проверки встраивания обязан его переживать. Когда PDFlibPas не находит /FontDescriptor или находит дескриптор без FontFile, FontFile2 и FontFile3, он записывает диагностику 10020 — или 10022, когда имя входит в Standard 14, которые §7.21.4 NOTE 5 подчеркнуто отказывается освобождать — и затем продолжает по остальному файлу. В этом весь смысл отчёта: автор хочет все находки за один прогон, а не по находке за запуск. Поэтому ссылка на дескриптор, передаваемая хелперам ширины, cmap, CharSet и CIDSet, может быть Nil, и каждый из них проверяет это на входе, вместо того чтобы предполагать, что более ранняя проверка прервала аудит. Если починка — встроить недостающее, механика описана в заметке о встраивании отсутствующих шрифтов в существующий PDF

Запуск аудита

Один вызов, по файлу, который вы не обязательно производили. TPDFlib.CheckFileCompliance принимает селектор теста соответствия — 2 для PDF/UA-1 по ISO 14289-1:2014 — и возвращает либо ноль, либо хэндл списка строк, чьи записи — числовой код, двоеточие и читаемое сообщение. Обсуждаемые здесь находки шрифтов и контентных потоков занимают 10020–10041 в этом диапазоне, отделённые числово от кодов 00xxx PDF/A, чтобы смешанный журнал оставался читаемым. Передача 1 в Options даёт короткое замыкание на первой находке — это то, что нужно в шлюзе сборки, а не в инструменте автора. Для документа, ещё открытого в памяти, GetPDFUADiagnostics выполняет эквивалентную проверку без кругового рейса через диск

var
  Issues, Count, I: Integer;
begin
  // ComplianceTest = 2 выбирает PDF/UA-1; Options = 0 сообщает обо всех находках
  Issues := PDF.CheckFileCompliance('delivery.pdf', '', 2, 0);
  if Issues = 0 then
    WriteLn('delivery.pdf: PDF/UA-1 conformant')
  else
  begin
    Count := PDF.GetStringListCount(Issues);
    for I := 1 to Count do
      WriteLn('  ', PDF.GetStringListItem(Issues, I));   // напр. 10037 CIDFontType2 ...
  end;
end;

Ничто из этого не требует внешнего бинарника валидатора на машине, и в этом разница между проверкой, выполняющейся на каждой сборке, и проверкой, выполняющейся когда кто-то вспомнит. Описанные здесь API соответствия и диагностики входят в стандартную PDFlibPas Delphi PDF Library, на странице продукта которой — полная таблица диагностических кодов для PDF/UA-1 рядом с наборами тестов PDF/A, PDF/X и PDF/E