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

Сохранение PDF точной версии в Delphi: соответствие спецификации в PDFiumPas

PDFiumPas, обёртка на Delphi и C++Builder вокруг движка PDFium от Google, сохраняет документ с точной версией PDF от 1.3 до 1.7 через параметр PdfVersion метода TPdf.SaveAs. Собственный вызов PDFium FPDF_SaveWithVersion лишь переписывает заголовок %PDF-M.m, не проверяя, законно ли фактическое содержимое документа для этой версии. PDFiumPas закрывает этот пробел проходом проверки соответствия после сохранения, который обходит активную цепочку ревизий перекрёстных ссылок и проверяет декларации Adobe Extension Level прежде, чем файл покинет метод

Это различие важнее всего в полиграфическом производстве, где профиль PDF/X называет точную версию PDF, а инструмент предпечатной проверки или RIP отклоняет всё, что незаметно расходится с собственным заголовком, — сценарий, описанный со стороны вывода в статье о проверке готовых к печати документов PDF/X с PDFiumPas. SaveAs предоставляет цель как перечисление TPdfVersion, от pv13 до pv17 наряду с более старыми значениями от pv10 до pv12, плюс независимый TSaveOption для инкрементных или полных перезаписей. Передайте PdfVersion, и PDFiumPas выполнит две задачи за один вызов: попросит PDFium проставить нужный заголовок, а затем перечитает только что записанные байты и откажется вернуть файл, чьё активное содержимое не может законно существовать в этой версии

var
  Pdf: TPdf;
begin
  Pdf:= TPdf.Create(nil);
  try
    Pdf.FileName:= 'source.pdf';
    Pdf.Active:= True;
    try
      Pdf.SaveAs('press-ready.pdf', saNoIncremental, pv17);
    except
      on E: Exception do
        // E.Message names the offending feature and the version or
        // extension level it actually needs, for example:
        // "RichMedia annotations and RichMediaExecute actions require
        // /Extensions /ADBE with /BaseVersion /1.7 and /ExtensionLevel 3
        // or newer."
        raise;
    end;
  finally
    Pdf.Free;
  end;
end;

Почему нельзя доверять последнему определению объекта в файле?

Последний физический объект с данным номером в файле PDF не обязательно является тем объектом, который соответствующая спецификации программа чтения разрешила бы для этого номера сегодня. PDF, прошедший через несколько инкрементных обновлений, имеет не один граф объектов, а историю таких графов, наслоенных внутри одного файла, и каждый цикл дозаписи может освободить объект, переопределить его под новым номером поколения или оставить его старое физическое тело лежать между двумя маркерами endobj, на которое больше не указывает ни одна запись перекрёстных ссылок

PDFiumPas сталкивался именно с этим отказом прежде, чем начал явно отслеживать ревизии xref: аннотация Redact, осиротевшая из-за последующей перезаписи объекта страницы, или словарь /MarkInfo, физически оставшийся присутствовать без записи xref, указывающей на него, всё равно могли обнаружиться при побайтовом сканировании и всё равно провалить проверку версийной функции, которая больше не относилась к документу, который программа чтения реально бы открыла. Направление отказа было ложным отклонением, а не ложным принятием: файл, чья текущая ревизия действительно уже переросла определённую функцию, всё равно мог быть заблокирован от сохранения в более низкой версии из-за содержимого, до которого больше никто не мог дотянуться

Как PDFiumPas определяет, какие определения объектов реально активны?

PDFiumPas разрешает активный набор объектов так же, как это делает соответствующая спецификации программа чтения, — обходя цепочку перекрёстных ссылок, а не сканируя байты на предмет заголовков объектов. Резолвер начинает с последнего смещения startxref в файле и следует по каждой ссылке /Prev назад через более старые ревизии, попутно разбирая классические таблицы перекрёстных ссылок, гибридные потоки, связанные через /XRefStm, и чистые потоки перекрёстных ссылок. Обход идёт от новейшего к старейшему и фиксирует каждый номер объекта при первом обнаружении, так что свободная запись в более поздней ревизии корректно затмевает тело объекта, записанное в более ранней, а переопределение под новым смещением или поколением всегда побеждает то, что оно заменяет

Члены потоков объектов получают дополнительную проверку, которую простой поиск по смещению сам по себе предоставить не может, — механизм, более подробно описанный в статье о проверке потоков объектов и перекрёстных ссылок с PDFiumPas. Сжатый объект, восстановленный из /ObjStm, должен иметь родительский поток, подтверждённый как активный в том же обходе, а его индекс должен согласовываться с собственной позицией члена внутри заголовка этого потока, прежде чем PDFiumPas сочтёт его живым содержимым. ISO 32000-1, раздел 7.5.8.4, даже описывает случай гибридных ссылок, где классическая таблица совместимости помечает объект как свободный, тогда как запись /XRefStm трейлера одновременно определяет тот же объект как сжатый член где-то ещё; PDFiumPas объединяет дополнительный поток xref с той же ревизией прежде, чем применяются классические записи, так что сжатое определение побеждает так, как это задумано спецификацией

Уровни расширения Adobe: барьер выше номера версии

Заголовок %PDF-1.7 обещает только тот набор функций, что ISO 32000-1 стандартизировал в 2008 году, тогда как несколько возможностей, на которые сегодня полагаются производители PDF, вышли позже как дополнения, доступные только в продуктах Adobe, надстроенные поверх того же номера версии. Adobe регистрировала каждое дополнение как пару BaseVersion и ExtensionLevel, записанную в словаре /Extensions каталога документа под префиксом разработчика, ADBE для собственных расширений Adobe, так что программа чтения может отличить обычный файл PDF 1.7 от того, что также реализует пронумерованный уровень расширения. Сохранение с pv17 без этой декларации само по себе не ошибка; она становится ошибкой только в тот момент, когда активное содержимое действительно зависит от функции, которую эта декларация должна покрывать

Какие функции высоких версий запускают барьер явной версии?

PDFiumPas проверяет конкретный список, основанный на спецификации, а не гадает по одному лишь номеру версии. Словари изображений, несущие явную запись /SMaskInData или значение /BitsPerComponent, равное 16, оба требуют PDF 1.5, причём случай с шестнадцатью битами напрямую следует правилам компонентов изображения из раздела 4.8 PDF Reference 1.5. Аннотации RichMedia и действия RichMediaExecute требуют /BaseVersion /1.7 с /ExtensionLevel 3 или выше. Потоки PRC 3D, идентифицируемые словарём, несущим одновременно /Type /3D и /Subtype /PRC, требуют ту же базовую версию, но только /ExtensionLevel 1. Словари Geospatial Measure и аннотации Projection требуют /BaseVersion /1.7 с /ExtensionLevel 3 — то же дополнение Adobe, от которого зависит RichMedia

Геопространственная проверка несёт деталь чтения спецификации, которую стоит знать, если вы когда-либо будете строить собственную логику, привязанную к версии, поверх PDFiumPas. Таблица 254 ISO 32000-1 помечает запись /Type словаря Measure как необязательную, отмечая лишь, что «если присутствует, должна быть Measure», тогда как таблица 311 делает /Type обязательным для словаря потока 3D, в котором живёт содержимое PRC. Реальный вывод GeoPDF из картографических инструментов регулярно опускает /Type в словаре Measure и пишет только /Subtype /GEO, так что детектор геопространственных данных в PDFiumPas сопоставляет только по /Subtype, а не требует оба ключа, как это безопасно делает его детектор PRC 3D. Требование /Type на обоих словарях позволило бы соответствующему спецификации содержимому GeoPDF незамеченным проскользнуть мимо барьера, оказавшись в обычном файле PDF 1.7 без декларации уровня расширения, которая бы это подтверждала

Понижает ли PDFiumPas автоматически неподдерживаемые функции?

Не как общая возможность, и предполагать иное — как раз та ошибка, которой здесь стоит избегать. SaveAs направляет целевую версию через внутреннюю процедуру ValidatePdfVersionCompliance, и когда эта процедура находит функцию, которую целевая версия или её декларация уровня расширения не может поддержать, SaveAs выбрасывает исключение, несущее текст ошибки процедуры, вместо записи файла; вызывающий код получает обратно точную причину с названием функции, но никогда — молча переписанный документ. Единственное место, где PDFiumPas действительно переписывает содержимое автоматически, — цель PDF 1.3, где он удаляет семантически нейтральные значения по умолчанию для прозрачности /BM /Normal, /CA 1 и /ca 1, которые PDFium всегда записывает в словари ExtGState независимо от целевой версии, потому что эти конкретные значения не несут никакого визуального смысла, а PDF 1.3 предшествует этим ключам целиком

// PDF 1.3 targets rewrite the saved bytes to strip transparency
// defaults PDFium always emits, so incremental mode cannot apply
Pdf.SaveAs('legacy-archive.pdf', saIncremental, pv13);
// raises: PDF 1.3 normalization is incompatible with incremental
// save mode

Настоящая, не относящаяся к значениям по умолчанию прозрачность и мягкие маски изображений по-прежнему безоговорочно проваливаются при цели PDF 1.3, потому что их удаление изменило бы то, как реально выглядит страница, а PDFiumPas не станет принимать это решение от вашего имени. Стоит спланировать заранее, до попадания точной версии в пакетный конвейер, два связанных ограничения. Вывод с явной версией никогда не несёт словарь /Encrypt; сохранение немедленно проваливается, если источник защищён, что случайно совпадает с профилями PDF/X и PDF/A, которые в любом случае запрещают шифрование, но это означает, что расшифровка — отдельный шаг в вашем рабочем процессе, а не то, что SaveAs делает за вас. У PDFiumPas также нет публичного метода для записи декларации /Extensions /ADBE в каталог, так что исходный файл, содержащий RichMedia, PRC 3D или геопространственное содержимое, но лишённый этой декларации, не пройдёт барьер, какую бы PdfVersion вы ни запросили; декларация должна уже существовать в источнике, обычно потому что её записал инструмент создания, либо функция должна быть удалена перед сохранением. Свойство TPdf.PdfVersion, доступное только для чтения, стоит проверить прежде, чем вообще пытаться сохранить с точной версией, поскольку оно разрешает ту же учитывающую каталог эффективную версию — заголовок или переопределение /Version, что бы ни было актуальным, — на которую опирается сам валидатор во время сохранения

Pdf.FileName:= 'incoming.pdf';
Pdf.Active:= True;
// PdfVersion resolves the same catalog-aware effective version the
// save-time validator uses, so a mismatch here is worth investigating
// before spending a full SaveAs attempt on it
LogSourceVersion('incoming.pdf', Pdf.PdfVersion);

Относитесь к исключению SaveAs при цели с точной версией как к отчёту предпечатной проверки, а не как к ошибке: сообщение называет точный пункт спецификации, который нарушает исходный документ, — а это именно та информация, что нужна типографии или архивному конвейеру прежде, чем файл пойдёт дальше. Путь сохранения с явной версией, резолвер активной ревизии xref и проверки Adobe Extension Level, описанные здесь, поставляются как часть стандартного компонента PDFiumPas для Delphi и C++Builder; на странице продукта представлен полный справочник TPdf.SaveAs наряду с остальным API соответствия спецификации и форм