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

Preflight версии PDF в Delphi: правила Measure RL и GEO

PDFlibPas (PDF Library for Delphi) сверяет каждый объект с таблицей правил версий PDF, прежде чем записать файл, и до недавнего времени этот preflight версии PDF принимал обычные CAD-словари измерений за геопространственные. Одностраничный CAD-чертёж загружался нормально, затем SaveToFile возвращал 0 с LastErrorCode 602 и требовал 1.7 ExtensionLevel 3. Исправленные правила считают прямоугольные словари /Measure (/Subtype /RL) обычным PDF 1.6 и оставляют фильтр расширения настоящим геопространственным маркерам

Файл пришёл через приёмку корпуса: одна страница, одна группа optional content, два прямоугольных измерительных вьюпорта — типичный вывод архитектурного CAD-пакета, чтобы вьювер мог снимать расстояния прямо с плана этажа. Ничего экзотического, и именно поэтому отказ был так важен. Preflight, блокирующий валидный файл, хуже медленного: вызывающий получает солидно выглядящую диагностику, указывающую на фичу, которой в документе нет. Починка состояла из двух частей: перечитывания спецификации за одним правилом и понимания, что правило не могло различить два типа словарей на том уровне, на котором смотрело

Как работает preflight версии PDF в PDFlibPas при сохранении?

Фильтр сохранения PrepareAndCheckSaveVersion сверяет каждый косвенный объект с PDFFeatureRules и падает на первом правиле, которое и совпало, и требует больше, чем позволяет цель. Цель — версия документа (или версия, зафиксированная LockSaveVersion) плюс уровень расширения Adobe, заявленный под /Extensions /ADBE. Каждая запись TPDFFeatureRule несёт MinVersion, MinExtensionLevel, MatchKind вроде fmkDictKey или fmkDictSubtype, строку Match, человекочитаемое имя Feature и опциональный колбэк. AddRule регистрирует обычное правило версии; AddExtensionRule всегда приколачивает MinVersion к 17 и добавляет уровень расширения сверху, так что правило расширения может удовлетворить только PDF 1.7 плюс правильная запись /Extensions. Когда фильтр срабатывает, требуемая версия и имя фичи сохраняются для вызывающего, и ключи 311, 312 и 313 GetInformation их выдают

Preflight версии при сохранении в PDFlibPas: PrepareAndCheckSaveVersion сверяет каждый объект с записями PDFFeatureRules, несущими MinVersion, уровень расширения и вид совпадения, AddExtensionRule приколачивает требование к PDF 1.7 плюс уровень расширения, а первое совпадение, которое цель не может удовлетворить, останавливает сохранение с ошибкой 602
Ключи 311, 312 и 313 GetInformation превращают отказ в диагноз, сообщая требуемую версию, фичу, которая её вызвала, и зафиксированную цель, так что вызывающий может починить файл или правило вместо гадания
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('floor-plan.pdf', '') <> 1 then
      raise Exception.Create('load failed');
    if Pdf.SaveToFile('floor-plan-out.pdf') <> 1 then
      if Pdf.LastErrorCode = PDFLIB_ERROR_VERSION_COMPLIANCE then
        // 311: требуемая версия, 312: фича, которая её вызвала,
        // 313: версия, на которой зафиксирована цель сохранения ('' если не зафиксирована)
        Writeln('Needs ', Pdf.GetInformation(311),
          ' for ', Pdf.GetInformation(312),
          ', locked at [', Pdf.GetInformation(313), ']');
  finally
    Pdf.Free;
  end;
end;

Почему обычный CAD-чертёж падал с ошибкой 602?

В таблице правил сидело AddExtensionRule(3, fmkDictKey, 'Measure', '/Measure geospatial dictionary', Nil), срабатывавшее на любом словаре, у которого просто был ключ /Measure, а он есть у каждого измерительного вьюпорта. Массив /VP страницы держит словари вьюпортов, каждый вьюпорт указывает на свой словарь измерений через /Measure, и совпадение по наличию ключа на этом останавливалось, не глядя, чем словарь измерений на самом деле является. Скан фич при загрузке мог поднять номер версии документа до 1.7, но он никогда не пишет декларацию /Extensions от имени входного файла, так что фильтр сохранения видел PDF 1.7 на уровне расширения 0 и сообщал 1.7 ExtensionLevel 3. Этот отказ выдумывать декларацию расширения намеренный: библиотека не повышает входной файл исподтишка, чтобы замазать неверное правило

Спецификация по прямоугольному случаю однозначна. Словари Measure появились в PDF 1.6, и ISO 32000-1 §12.9 даёт /Subtype значение по умолчанию RL — прямоугольная система координат, описываемая собственным набором записей: масштаб, форматы чисел X и Y, расстояние и площадь. Геопространственные измерения — позднее добавление из Adobe Extension Level 3 поверх PDF 1.7, опознаётся по /Subtype /GEO и несёт массивы географических точек, словари систем координат и единицы отображения — структуры, разобранные в статье о чтении вьюпортов GeoPDF и массивов GPTS и LPTS в Delphi. Оба словаря висят на одном и том же ключе /Measure, так что правило, останавливающееся на ключе, не может быть верным для обоих. Различающая информация лежит уровнем ниже — в самом словаре измерений

Один ключ /Measure, два словаря в PDFlibPas: прямоугольные измерения с /RL или без subtype требуют только PDF 1.6, а геопространственному словарю нужен 1.7 ExtensionLevel 3, поэтому CB_GeospatialDictionary решает по содержимому там, где старое правило по ключу их не различало
Preflight, блокирующий валидный файл, хуже медленного: вызывающий получает солидную диагностику о фиче, которой в документе никогда не было, — вот почему различающая проверка спустилась уровнем ниже

Что исправленный набор правил по-прежнему требует?

Починка удаляет безусловное правило по ключу и оставляет фильтры, описывающие реальные требования версий. Страница с /VP или /UserUnit по-прежнему требует PDF 1.6 через CB_PagePDF16Entries, ключ /PtData по-прежнему требует уровень расширения 3, а CB_GeospatialDictionary решает, геопространственный ли словарь измерений, по его содержимому, а не по ключу, через который он дошёл

// Удалено: каждый словарь с ключом /Measure считался геопространственным
// AddExtensionRule(3, fmkDictKey, 'Measure', '/Measure geospatial dictionary', Nil);

AddRule(16, fmkCustom, '', 'Page PDF 1.6 entry /UserUnit /VP', CB_PagePDF16Entries);
AddExtensionRule(3, fmkDictKey, 'PtData', '/PtData geospatial dictionary', Nil);
AddExtensionRule(3, fmkCustom, '', 'geospatial measure dictionary', CB_GeospatialDictionary);

function CB_GeospatialDictionary(Obj: TPDFObject; const Ctx: TPDFRuleContext): Boolean;
var
  Dict: TPDFDictionary;
begin
  Result := False;
  if not (Obj is TPDFDictionary) then
    Exit;
  Dict := TPDFDictionary(Obj);
  Result := (Dict.StringValue('Subtype') = 'GEO') or
    (Dict.FindIndexByKeyName('GCS') >= 0) or (Dict.FindIndexByKeyName('DCS') >= 0) or
    (Dict.FindIndexByKeyName('GPTS') >= 0) or (Dict.FindIndexByKeyName('LPTS') >= 0) or
    (Dict.FindIndexByKeyName('PDU') >= 0);
end;

Общие регрессы Delphi и FPC прикалывают эту границу с обеих сторон. Вьюпорт со словарём измерений без /Subtype и вьюпорт с явным /RL оба проходят на PDF 1.6, та же страница по-прежнему отвергается на PDF 1.5, и детекция фич больше не сообщает для неё расширение. Добавление массива /GPTS переворачивает вердикт обратно к 1.7 ExtensionLevel 3, что проходит, как только уровень расширения заявлен, а голый словарь /Subtype /GEO без него отвергается. Колбэк консервативен намеренно: прямоугольный словарь, таскающий вдобавок лишний ключ /GCS или /PDU, считается геопространственным, потому что в RL-модели эти ключи смысла не имеют

LockSaveVersion — то место, где это изменение становится видимым вызывающим. TPDFlib.LockSaveVersion принимает '1.0'–'1.7', на всё остальное возвращает 0, фиксирует версию документа и не даёт вызовам со стороны писателя молча её поднять, но фильтр сохранения по-прежнему сверяется с зафиксированным значением. С исправленными правилами CAD-файл, зафиксированный на 1.6, сохраняется чисто. Настоящий GeoPDF, зафиксированный на 1.6, всё ещё получает 602 — это правильный ответ, а геопространственные вызовы авторинга вроде SetMeasureDictCoordinateSystem сами заявляют уровень расширения 3, когда вы строите такое содержимое через API

if Pdf.LockSaveVersion('1.6') <> 1 then
  raise Exception.Create('unsupported version string');
if Pdf.SaveToFile('floor-plan-16.pdf') <> 1 then
begin
  if Pdf.LastErrorCode = PDFLIB_ERROR_VERSION_COMPLIANCE then
    // Настоящее содержимое выше 1.6, например GEO-словарь измерений
    raise Exception.CreateFmt('Locked at 1.6 but %s needs %s',
      [string(Pdf.GetInformation(312)), string(Pdf.GetInformation(311))]);
end;
Pdf.UnlockSaveVersion;

Почему скан правил версий был медленнее, чем мог бы?

Скан копировал каждую запись TPDFFeatureRule в локальную переменную перед проверкой, а поскольку запись держит два поля AnsiString, каждая копия двигала два счётчика ссылок и освобождала прежние значения. Preflight обходит каждый узел каждого дерева объектов, включая скаляры, так что эта цена умножала число объектов на число правил, и правила, даже не применимые к целевой версии, сперва копировались, а потом пропускались. Поскольку PDFFeatureRules заполняется один раз при инициализации юнита и считается read-only, v3.539.17 передаёт записи таблицы прямо в MatchSingleRule и RuleExceedsTarget, чьи параметры const Rule берут ссылку, не трогая строки

Ускорение скана правил в PDFlibPas: preflight раньше копировал каждую запись TPDFFeatureRule перед проверкой, двигая счётчики ссылок AnsiString для каждого посещённого объекта, теперь const-параметры читают read-only таблицу на месте, снизив медиану раунда сопоставления правил с 0.711 с до 0.203 с
Выигрыш настоящий, но узкий: полное сохранение платит ещё и за отложенную детекцию фич, декодирование объектов и сериализацию, так что измеренное отношение относится к пути сопоставления правил, а не ко всему времени сохранения
// Раньше: управляемая копия записи на каждое правило и каждый посещённый объект
Rule := PDFFeatureRules[X];
if not RuleExceedsTarget(Rule, TargetVersion, TargetExtensionLevel) then
  Continue;

// Теперь: const-параметры читают неизменяемую запись таблицы на месте
if not RuleExceedsTarget(PDFFeatureRules[X], TargetVersion, TargetExtensionLevel) then
  Continue;
if MatchSingleRule(Obj, Ctx, PDFFeatureRules[X]) then
begin
  RequiredVersion := RequiredVersionString(PDFFeatureRules[X]);
  FeatureName := PDFFeatureRules[X].Feature;
  Result := False;
  Exit;
end;

Измеренный эффект узок, и цитировать его нужно именно так. Бенчмарк десять раз за раунд сверяет массив из 20 000 числовых объектов с целью PDF 1.4; в сборке FPC Win64 с -O2 медиана пяти раундов упала с 0.711 с до 0.203 с, а прогон двух сборок в обратном порядке дал 0.459 с против 0.150 с. Это примерно трёхкратный выигрыш на одном лишь пути сопоставления правил. Реальное сохранение платит ещё и за отложенную детекцию фич, декодирование объектов и сериализацию, так что отношение на полное время сохранения не переносится. Порядок правил, колбэки, пороги версий и диагностика первой ошибки не изменились, и ни одно правило не кэшировалось между сохранениями и не пропускалось ради этого

Что проверять, когда загруженный PDF проваливает preflight версии?

Прочитайте ключи 311 и 312, прежде чем трогать версию. Если фича называет геопространственный словарь, а файл рисует лишь прямоугольные измерения, это был тот самый ложный positive, и актуальная сборка сохранит файл без изменений. Если фича настоящая, либо заявите расширение, либо зафиксируйте версию, которая честно вмещает содержимое; поднять версию лишь чтобы заткнуть фильтр, — значит спрятать вопрос, смогут ли downstream-потребители прочитать то, что вы отгружаете. Тот же принцип ограниченных проверок, опирающихся на улики, ведёт preflight режима автора PDF/E-1 для инженерных документов, где CAD-чертежи встречаются со стандартом соответствия, а не с номером версии

Проверки соответствия версии, словари измерений и геопространственные словари и фиксация версии сохранения — всё это часть PDF Library for Delphi, набора PDFlibPas для разработчиков на Delphi, C++Builder и Lazarus