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

Инженерные документы PDF/E-1 в Delphi с PDFlibPas

PDF/E-1 — архивный профиль для инженерных документов, и PDFlibPas реализует его как author mode, который включается через SetPDFEMode, плюс ограниченный preflight, читающий потоки контента оператор за оператором. Это не PDF/A с другой этикеткой: у профиля своё пространство имён идентификации, своё требование к метаданным жизненного цикла и одно правило, делающее валидацию контента строже, чем в любом архивном профиле, который вы встречали

Инженерная документация — вот зачем профиль существует. Комплект чертежей, который должен читаться и быть доказуемо неизменным лет через двадцать, с историей ревизий, которая тоже выживает, и с цветом, который означает одно и то же на плоттере в соседнем здании. Из этих требований рождается спецификация, чьи запросы лежат по большей части за пределами контента страницы, в метаданных и управлении цветом, — ровно там, где обычный PDF-писатель их проваливает

Своя идентификация, а не вариация на тему PDF/A

Первое, что нужно сделать правильно: идентификацию PDF/E-1 нельзя получить адаптацией шаблона PDF/A или PDF/X. Здесь свой XMP namespace, http://www.aim.org/pdfe/ns/id/, и значение версии должно появиться в двух местах: как запись в информации о документе и как XMP-свойство с префиксом пространства имён. Выдать только свойство XMP или только запись информации — значит получить файл, который несёт намерение и проваливает валидацию

У output intent столь же специфичная форма. PDF/E-1 требует встроенного ICC-профиля с идентификатором подтипа ISO_PDFE1, и число компонентов профиля должно совпадать с семейством device-цветов, которое документ реально использует. Именно на последнем пункте реализации тихо ошибаются, потому что он означает: intent нельзя выбрать заранее и забыть

Почему device-цветам нужен проход по всему документу?

Потому что цветовые пространства прячутся в словарях ресурсов, куда постраничное сканирование не добирается. PDF/E-1 считает DeviceRGB и DeviceCMYK взаимоисключающими семействами в рамках документа, так что валидация профиля означает знание каждого device-цветового пространства, которое что-либо в файле использует. У form XObject свои ресурсы. У pattern — свои, и у изображения тоже. Tiling pattern внутри form XObject внутри страницы — это три уровня вглубь, и валидатор, проверяющий только ресурсы верхнего уровня страницы, пропустит документ, использующий оба семейства

Поэтому проход регистрирует цветовые пространства, идя по страницам, формам, изображениям и pattern как по одному обходу, и только потом решает, когерентен ли документ и совпадает ли output intent. Та же логика вообще движет архитектурой preflight: частичный обход даёт ложные «прошёл», а ложный пропуск на проверке соответствия хуже отсутствия проверки, потому что он записан как доказательство

var
  Lib: TPDFlib;
  Diag: WideString;
begin
  Lib := TPDFlib.Create(nil);
  try
    Lib.LoadFromFile('assembly-drawings.pdf');

    if Lib.SetPDFEMode(1) = 0 then
      raise Exception.Create('PDF/E author mode was refused');

    // Author mode синхронизирует метаданные жизненного цикла при каждом
    // сохранении. Перед сохранением спросите, пройдёт ли документ свой гейт
    if not Lib.PDFEReadyForSave then
    begin
      Diag := Lib.GetPDFEDiagnostics;
      Writeln('PDF/E blockers: ', Diag);
      Exit;
    end;

    Lib.SaveToFile('assembly-drawings-pdfe.pdf');
  finally
    Lib.Free;
  end;
end;

Метаданные жизненного цикла — обязанность на каждое сохранение

PDF/E-1 просит больше, чем идентификатор документа. Минимальный набор включает идентификатор документа media management, идентификатор версии, rendition class, время создания, время изменения, время метаданных и заголовок. Это словарь отслеживания ревизий, и существует он потому, что от инженерного результата ожидается перевыпуск, а не однократное написание

Следствие для реализации: эти поля нельзя выставить при создании документа. Если время изменения записано в момент включения режима, а документ редактируется потом, XMP-снимок и реальное состояние документа расходятся, и валидатор, сравнивающий их, сообщает о несоответствии, которое никто не закладывал. Поэтому author mode синхронизирует поля непосредственно перед каждым сохранением, чтобы метаданные описывали байты, которые вот-вот будут записаны, а не байты, существовавшие на момент включения режима

Это общий принцип для метаданных соответствия, и его стоит проговорить отдельно от PDF/E: производные метаданные живут на пути сохранения, а не на пути редактирования. Любое поле, вычисленное из состояния документа, обязано пересчитываться в момент заморозки состояния, иначе это кэш без инвалидации

Диаграмма PDF/E-1 в PDFlibPas: проход по device-цветам всего документа, который идёт по словарям ресурсов страницы, form XObject, tiling pattern и изображения, собирая семейства DeviceRGB и DeviceCMYK, прежде чем судить о когерентности, — рядом поля метаданных жизненного цикла, которые author mode пересинхронизирует непосредственно перед каждым сохранением, чтобы XMP-снимок соответствовал байтам, которые вот-вот будут записаны
О цветовой когерентности можно судить только после одного обхода, добравшегося до каждого словаря ресурсов, а производные метаданные жизненного цикла пересчитываются в момент заморозки состояния документа, а не при включении режима

Правило, делающее валидацию контента строгой

PDF/E-1 не позволяет операторам секции совместимости поглощать неизвестный контент. В обычном PDF BX и EX обрамляют область, внутри которой консюмер обязан игнорировать операторы, которые не распознаёт, — это лазейка, позволяющая продюсеру выдавать новые конструкции, не ломая старые ридеры. Под PDF/E-1 лазейка закрыта, поэтому любой оператор, который preflight не распознал, репортится безусловно, сидит ли он внутри секции совместимости

Эффект на валидатор существенный. Он не может пропускать области, которых не понимает, а значит, парсер операндов обязан реально разобрать каждый оператор в каждом потоке контента. Вот тут и появляются границы. Обход ограничен 128 уровнями вложенности, миллионом объектов и 64 MiB контента, и эти лимиты — не тюнинг производительности. Враждебный или просто битый файл может предъявить граф объектов с циклами или глубиной вложенности, превращающей рекурсивный валидатор в stack overflow, и лимиты — это то, что не даёт проходу валидации стать вектором denial-of-service. Та же оборонительная позиция описана в безопасном парсинге недоверенных PDF

// Автономная валидация файла, который вы не создавали, без загрузки
// его в экземпляр документа
var
  Issues: TStringList;
  Stream: TFileStream;
  I: Integer;
begin
  Issues := TStringList.Create;
  Stream := TFileStream.Create('incoming.pdf', fmOpenRead or fmShareDenyWrite);
  try
    if CheckCompliancePDFE(Stream, '', 0, Issues) = 0 then
      for I := 0 to Issues.Count - 1 do
        Writeln('PDF/E: ', Issues[I]);
  finally
    Stream.Free;
    Issues.Free;
  end;
end;

Что гейт сохранения чинит, а по чему отказывает

Гейт делит работу на две стадии, и само деление — пригодная дизайнерская идея. Сначала он нормализует то, что безопасно починить: print-флаги аннотаций, флаги no-zoom и no-rotate у текстовых аннотаций и флаг генерации внешнего вида в словаре формы. Это настройки с одним правильным значением в рамках профиля и без информационного содержимого, поэтому молча править их правильно, а отказываться из-за них — педантизм

Затем он проверяет ограничения, которые нельзя починить, не изменив смысл документа: версию, идентификацию, шифрование, output intent, когерентность device-цветов и наличие динамического содержимого форм. Документ, проваливающий любое из них, получает отказ, потому что выдумывать output intent или выбирать семейство цветов за автора — значит произвести файл, который пройдёт валидацию и исказит содержание

Диаграмма гейта сохранения PDF/E-1 в PDFlibPas для Delphi: ограниченный preflight, который сканирует каждый оператор потока контента при пределах в 128 уровней вложенности, миллион объектов и 64 MiB, молча чинит флаги print, zoom и rotate у аннотаций, отказывает при неверных версии, идентификации, шифровании, output intent, device-цветах или динамическом содержимом форм и репортит блокеры через GetPDFEDiagnostics
Гейт молча чинит только то, что не несёт информации, отказывает по каждому ограничению, которое починка исказила бы, и превращает отказ в список блокеров через GetPDFEDiagnostics до того, как хоть один байт попадёт на диск

Чтение диагностики через GetPDFEDiagnostics перед сохранением превращает отказ в применимый список, а не в провалившуюся операцию. В пакетном пайплайне вызывайте его на каждом документе, логируйте блокеры по файлам и отправляйте отказы в очередь, которую смотрит человек. Это куда полезнее сохранения, бросающего исключение: блокеры обычно кластеризуются, и сорок документов, отвалившихся по одному и тому же отсутствующему output intent, — это один фикс, а не сорок

Выбор между архивными профилями

PDF/E-1 — правильная цель, когда результат — инженерная документация с жизненным циклом ревизий, и особенно когда важна когерентность device-цветов, потому что вывод идёт на плоттеры и широкоформатные принтеры. PDF/A — правильная цель, когда задача — долгосрочная читаемость документов вообще, и это профиль с самой широкой поддержкой валидаторов. Они не взаимозаменяемы, и документ может удовлетворять одному и проваливать другой

Диаграмма решений PDFlibPas, сравнивающая архивные профили PDF/E-1 и PDF/A для Delphi: PDF/E-1 — для инженерных результатов с жизненным циклом ревизий, плоттерным цветом и договорной валидацией в своём XMP namespace с output intent ISO_PDFE1, PDF/A — для общей долгосрочной читаемости с самой широкой поддержкой валидаторов
Начните с того, кто валидирует файл на дальнем конце: профили требуют разных гарантий идентификации, метаданных и цвета, и документ может удовлетворять одному, проваливая другой

Если выбираете, начните с того, кто валидирует файл на дальнем конце. Инструменты валидации PDF/A повсюду, и соответствующий preflight в PDFlibPas описан в preflight для PDF/A и PDF/UA. Валидация PDF/E более специализирована и обычно является договорным требованием, а не дефолтом. Когда существующий архив надо привести к профилю, под который он никогда не писался, паттерн — путь починки метаданных из конвертации в PDF/A с починкой метаданных, и здесь форма та же: идентифицировать, починить безопасное, отказать остальному со списком

Author mode, ограниченный preflight контента и автономная проверка соответствия поставляются с Delphi PDF-библиотекой PDFlibPas, так что документ можно произвести под профилем и независимо верифицировать потом через отдельный путь кода — единственная схема, которой стоит доверять для заявления о соответствии