Отчёт 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 нормализует при загрузке. 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, она делает вопрос ширины неответимым
// /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 требует, чтобы поток надулся, а счёт глифов вернулся положительным, прежде чем проверяется хоть один бит. Любое исключение по дороге схлопывается в «находок нет», а не в дефект
Это намеренный сдвиг в сторону ложноотрицательных, и его стоит назвать прямо, а не прятать. Повреждённая таблица 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