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 их выдают
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, так что правило, останавливающееся на ключе, не может быть верным для обоих. Различающая информация лежит уровнем ниже — в самом словаре измерений
Что исправленный набор правил по-прежнему требует?
Починка удаляет безусловное правило по ключу и оставляет фильтры, описывающие реальные требования версий. Страница с /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 берут ссылку, не трогая строки
// Раньше: управляемая копия записи на каждое правило и каждый посещённый объект
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