Технічна стаття

Аудит шрифтів 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-бітових
// індексів гліфів у старшому байті, один на 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-рядки індексуються від одиниці
  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 Protocol робить те саме розрізнення в іншій формі, коли відділяє перевірки, які машина може вирішити, від перевірок, які мусить людина, і його контрольна точка 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, що заборонено незалежно від режиму рендерингу тексту — включно з невидимим режимом за сканованими зображеннями. Як загортати Form, що вижили, — це питання дерева структури, покрите в статті про побудову структури тегованого 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 коротко замикає на першій знахідці, що є тим, чого хочеш у build-воротах, а не в інструменті авторства. Для документа, ще відкритого в памʼяті, 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