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

Как HotPDF читает заявления стандартов из загруженного PDF

Ваш детектор рапортует, что PDF/UA-файл чужого производителя не заявляет ничего, а скан PDF/R перестаёт быть PDF/R в тот момент, когда редактор дописывает к нему стикер. HotPDF читает эти заявления из загруженного документа через аксессоры вроде IsLoadedPDFUA, GetLoadedPDFR1Version и GetLoadedPDFDeclarations, и каждый ищет там, где его собственный стандарт хранит идентификатор

Где каждый стандарт PDF записывает свой идентификатор

Дочерние стандарты так и не договорились об одном месте для заявления. Большинство использует XMP-свойства в потоке /Metadata каталога, несколько старых по-прежнему полагаются на ключи в информационном словаре документа, PDF/R кладёт строку комментария рядом с трейлером файла, а поколение PDF 2.0 добавляет ещё два механизма: словари developer extension в каталоге и PDF Declarations внутри XMP. Идентификатор — это заявление файла о самом себе; дотягивает ли содержимое до него, решает валидатор, а производство соответствующего вывода PDF/A, PDF/X и PDF/UA — отдельная тема. Для маршрутизации (очередь печати для PDF/X, архив для PDF/A, конвейер доступности для WTPDF) нужно именно заявление, прочитанное из правильного места:

СтандартГде живёт заявлениеАксессор HotPDF
PDF/A (ISO 19005)XMP pdfaid:part и pdfaid:conformance, плюс pdfaid:rev в PDF/A-4IsLoadedPDFA, GetLoadedPDFAVersion, GetLoadedPDFARevision
PDF/X (ISO 15930)GTS_PDFXVersion в Info-словаре для PDF/X-1a и PDF/X-3, в XMP для поздних частейIsLoadedPDFX, GetLoadedPDFXVersion
PDF/UA (ISO 14289)XMP pdfuaid:partIsLoadedPDFUA, GetLoadedPDFUAVersion
PDF/VT (ISO 16612-2)XMP pdfvtid:GTS_PDFVTVersionIsLoadedPDFVT, GetLoadedPDFVTVersion
PDF/E (ISO 24517-1)ISO_PDFEVersion, читается из Info-словаря, каталога или XMPIsLoadedPDFE, GetLoadedPDFEVersion
PDF/R-1 (ISO 23504-1)комментарий %PDF-raster-x.y прямо над последним startxrefIsLoadedPDFR1, GetLoadedPDFR1Version
PDF/VCR-1 (ISO 16613-1)GTS_PDFVCRVersion в каталоге или XMPIsLoadedPDFVCR, GetLoadedPDFVCRVersion
WTPDF 1.0, ISO/TS 32005записи pdfd:declarations в XMPIsLoadedWTPDF, GetLoadedWTPDFVersion, IsLoadedISO32005
Developer extensions PDF 2.0/Extensions /ISO_ в каталогеHasLoadedISODeveloperExtension, GetLoadedISODeveloperExtensionLevels
uses
  System.SysUtils, HPDFDoc;

procedure ReportClaims(const FileName: string; const Password: AnsiString);
var
  Pdf: THotPDF;
  PDFX, PDFXLevel: AnsiString;
begin
  Pdf := THotPDF.Create(nil);   // отдельный экземпляр, только для инспекции
  try
    // Перегрузка с паролем расшифровывает потоки, включая пакет XMP;
    // передайте '' для файлов, открывающихся без запроса
    if Pdf.LoadFromFile(FileName, Password) <= 0 then
      raise Exception.CreateFmt('Cannot open %s', [FileName]);

    if Pdf.IsLoadedPDFA then
      Writeln('PDF/A: ', Pdf.GetLoadedPDFAVersion);        // например PDF/A-2b
    PDFX := Pdf.GetLoadedPDFXVersion;                       // сначала XMP, затем Info
    if PDFX <> '' then
    begin
      PDFXLevel := Pdf.GetLoadedInfoKeyValue('GTS_PDFXConformance');
      if PDFXLevel <> '' then
        Writeln('PDF/X: ', PDFX, ', conformance ', PDFXLevel)
      else
        Writeln('PDF/X: ', PDFX);
    end;
    if Pdf.IsLoadedPDFUA then
      Writeln('PDF/UA: ', Pdf.GetLoadedPDFUAVersion);      // например PDF/UA-1
    if Pdf.IsLoadedPDFVT then
      Writeln('PDF/VT: ', Pdf.GetLoadedPDFVTVersion);
    if Pdf.IsLoadedPDFE then
      Writeln('PDF/E: ', Pdf.GetLoadedPDFEVersion);
    if Pdf.IsLoadedPDFR1 then
      Writeln('PDF/R: ', Pdf.GetLoadedPDFR1Version);       // например PDF/R-1.0
    if Pdf.IsLoadedWTPDF then
      Writeln('WTPDF: ', Pdf.GetLoadedWTPDFVersion);
  finally
    Pdf.Free;
  end;
end;

Для инспекции заводите отдельный экземпляр THotPDF, как в примере. Функции IsLoaded… отвечают за любой документ, который держит компонент, включая тот, что он сам строит: IsLoadedPDFUA также True, когда PDFUACompliance включена для вывода, а IsLoadedPDFX согласуется с непустой PDFXCompliance. Удобно посреди построения, но на компоненте, используемом для обеих задач, это подмешивает ваши настройки вывода в вердикт о чужом файле

PDF/X — единственное семейство, чей идентификатор переезжал между частями. PDF/X-1a и PDF/X-3 — профили PDF 1.3, кладущие GTS_PDFXVersion в информационный словарь документа и вовсе не требующие XMP, тогда как PDF/X-4 и поздние части требуют XMP-свойство pdfx:GTS_PDFXVersion. GetLoadedPDFXVersion сначала смотрит в XMP, под префиксом pdfx или pdfxid, затем в Info-словарь, затем в ключи каталога, которые пишут некоторые старые производители, и возвращает заявленное значение как есть. У PDF/X-1a есть одна складка, которую стоит обработать в коде: значение версии у неё PDF/X-1:2001, а уровень соответствия PDF/X-1a:2001 живёт в собственном ключе /GTS_PDFXConformance, который пример читает через GetLoadedInfoKeyValue, когда надо отличить X-1a от простого X-1

Где каждый стандарт PDF записывает свой идентификатор соответствия и как HotPDF для Delphi ищет в XMP, Info-словаре и ключах каталога загруженного PDF
каждый стандарт хранит заявление там, где выбрала его спецификация, а ридер в фиксированном порядке обходит места, между которыми заявление может переезжать

Как выглядит корректное заявление в XMP?

XMP — это RDF, сериализованный в XML, и RDF/XML позволяет записать простое свойство двумя способами внутри rdf:Description: как дочерний элемент, <pdfuaid:part>1</pdfuaid:part>, или как атрибут самого описания, pdfuaid:part="1", в одинарных или двойных кавычках. ISO 16684-1 принимает оба, и пакет может разложить свои свойства по нескольким элементам rdf:Description. Это разбиение не гипотетическое. HotPDF пишет дочерне-элементную форму для PDF/UA, PDF/VT, PDF/E и PDF/X, а тестовый корпус veraPDF и многие внешние производители пишут сокращённую форму, где описания вроде pdfuaid:part="1" или pdfvtid:GTS_PDFVTVersion="PDF/VT-1" несут всё заявление в атрибутах. Детектор, построенный вокруг Pos('<pdfuaid:part>', Xmp) и обкатанный только на собственном выводе, проходит все локальные проверки, а затем объявляет первый же образец veraPDF незаявленным. Читатель XMP в HotPDF обрабатывает обе формы для каждого аксессора из таблицы. GetLoadedPDFAVersion собирает pdfaid:part и pdfaid:conformance в идентификаторы вроде PDF/A-1b, PDF/A-2u, PDF/A-4 или PDF/A-4f, а GetLoadedPDFARevision возвращает pdfaid:rev — год издания, который PDF/A-4 требует (2020), а части с 1 по 3 не определяют:

procedure ReportPDFALevel(Pdf: THotPDF);
var
  Level, Revision: AnsiString;
begin
  Level := Pdf.GetLoadedPDFAVersion;      // 'PDF/A-1b', 'PDF/A-2u', 'PDF/A-4f', ...
  if Level = '' then
    Exit;
  Revision := Pdf.GetLoadedPDFARevision;  // '2020' для PDF/A-4, пусто для частей 1-3
  if Revision <> '' then
    Writeln(Level, ', edition ', Revision)
  else
    Writeln(Level);
  // Ожидаемо: "PDF/A-2b" независимо от того, записал ли производитель
  // <pdfaid:part>2</pdfaid:part> или pdfaid:part="2"
end;

Префиксы — вторая подвижная часть. В XML префикс лишь локальный псевдоним; схему PDF/A идентифицирует URI пространства имён http://www.aiim.org/pdfa/ns/id/, который одобренные исправления к ISO 19005-4 допускают и в написании https://, а производитель может привязать его к любому удобному префиксу. GetLoadedPDFAVersion сначала выясняет, какой префикс пакет объявил для этого URI, и лишь потом читает часть и уровень, поэтому пакет, пишущий pdfa:part вместо pdfaid:part, всё равно идентифицируется. Любое другое XMP-свойство, которое вы читаете сами из декодированного пакета, возвращаемого GetLoadedXMPMetadata, заслуживает той же аккуратности: обе сериализации, префикс, разрешённый по его объявлению xmlns, и пробелы, допустимые вокруг знака равенства

Две корректные сериализации одного заявления PDF/UA рядом с правилом пространства имён, из-за которого переименованный префикс всё равно опознаёт PDF/A для HotPDF в Delphi
написание дочерним элементом и атрибутом одинаково корректно, а схему идентифицирует URI пространства имён, а не префикс

Шифрованные файлы прячут заявление от поиска по байтам

Сканирование сырого файла на pdfaid или ISO_PDFEVersion пропускает разбор и ломается на двух заурядных случаях. Большинство потоков XMP сжаты Flate, поэтому имена свойств никогда не появляются на диске как текст. А в шифрованном документе поток метаданных — шифротекст: ISO 32000-2 §7.6.4.2 определяет EncryptMetadata со значением по умолчанию true, так что если производитель явно не отказался, пакет зашифрован вместе со всеми прочими потоками. Побайтовый поиск по шифрованному PDF/E-1 файлу рапортует, что заявление ISO_PDFEVersion отсутствует, хотя оно там. Надёжный порядок: загрузить, расшифровать, декодировать, затем искать. Открывайте файл через перегрузку с паролем, LoadFromFile(FileName, Password), и передавайте пустую строку для файлов, которые несут только пароль владельца и открываются в любом просмотрщике без запроса. Одноаргументная LoadFromFile(FileName) загружает структуру документа, не расшифровывая потоки, и пакет XMP остаётся нечитаемым; после перегрузки с паролем GetLoadedXMPMetadata и все аксессоры выше работают по расшифрованному, разжатому пакету

Почему PDF/R-файл теряет самоидентификацию после правки?

PDF/R-1 идентифицирует себя способом, которого нет ни у одного другого дочернего стандарта. Пункт 5 ISO 23504-1 требует строку комментария %PDF-raster-1.0 (или какую версию положено) на строке непосредственно перед последней строкой, содержащей startxref. Парсер PDF выбрасывает комментарии, пока читает данные перекрёстных ссылок, поэтому объектной модели нечего рапортовать; улика существует только в сырых байтах в конце файла. GetLoadedPDFR1Version возвращается к источнику, из которого документ был загружен, читает не более последних 64 КиБ, находит последний startxref и проверяет строку над ним. Теперь проследите инкрементное обновление. Редактор, добавляющий аннотацию, дописывает новые объекты, новый раздел перекрёстных ссылок, новый трейлер и новый startxref после исходного %%EOF. Строка %PDF-raster-1.0 всё ещё в файле, но теперь она сидит над предпоследним startxref, а стандарт однозначен насчёт того, какой из них считается. Ожидаемо: IsLoadedPDFR1 возвращает False после правки, если только инструмент не переиздаст комментарий в собственном обновлении. Детектор, сканирующий весь файл на %PDF-raster-, сказал бы «да», и по правилу пункта 5 этот ответ неверен. Процесс захвата, обязанный держать файлы PDF/R, складывает аннотации в sidecar-файл или в инструмент, переписывающий весь файл целиком как PDF/R

Хвосты файла до и после инкрементного обновления: комментарий PDF/R остаётся, но теряет якорь последнего startxref, который проверяет HotPDF в Delphi
редактор, дописывающий обновление, оставляет комментарий PDF/R над предпоследним startxref, где стандарт его больше не считает

Developer extensions: целые уровни, имена и массивы

Документы PDF 2.0 объявляют технические спецификации ISO, на которые опираются (хеши SHA-3 из ISO/TS 32001, подписи ECC из ISO/TS 32002, glTF-ассеты из ISO/TS 32007), через словарь /Extensions каталога, определённый в ISO 32000-2 §7.12. Под зарегистрированным префиксом /ISO_ таблица 48 допускает либо одиночный словарь developer extensions, либо — новинка PDF 2.0 — массив таких словарей, а таблица 49 вместе с §7.12.5 делает /ExtensionLevel целым числом. Ридер, умеющий только одну из двух форм контейнера, пропустит каждый файл, записанный другой. HasLoadedISODeveloperExtension и GetLoadedISODeveloperExtensionLevels принимают обе и по пути разрешают косвенные ссылки, хотя §7.12.1 говорит, что эти словари должны быть прямыми объектами:

procedure ReportISOExtensions(Pdf: THotPDF);
var
  Levels: THPDFIntegerArray;
  Revision, URL: AnsiString;
  I: Integer;
begin
  Levels := Pdf.GetLoadedISODeveloperExtensionLevels;
  for I := 0 to High(Levels) do
    Writeln('ISO_ extension level ', Levels[I]);

  if Pdf.HasLoadedISODeveloperExtension(32001, Revision, URL) then
    Writeln('ISO/TS 32001 revision ', Revision, ', documented at ', URL);
  // Ожидаемо для файла, записанного с EnsureISO32001Extensions:
  //   ISO/TS 32001 revision :2022, documented at https://www.iso.org/standard/45874.html
  if Pdf.HasLoadedISODeveloperExtension(32007) then
    Writeln('glTF 3D assets per ISO/TS 32007');
end;

Первое издание ISO/TS 32007:2024 содержит в §4.8 пример, пишущий /ExtensionLevel /32007 как имя; PDF Errata Issue #835 поправил его на целое 32007, но производитель, скопировавший исходный пример, всё ещё пишет имя. Поэтому читатель HotPDF откатывается к разбору имени или строкового значения, с ведущей косой чертой или без, когда поиск целого возвращается пустым, так что HasLoadedISODeveloperExtension(32007) находит оба написания. Вторая поправка касается /URL: таблица 49 в ISO 32000-2:2020 перечисляет /URL как необязательный, а Issue #732 поднимает его до обязательного. На стороне записи HotPDF выдаёт целую форму и /URL в каждом вызове EnsureISO…Extensions; на стороне чтения пустой выходной параметр URL нормален для файлов, записанных по тексту 2020 года, так что считайте это информацией и никогда — поводом отвергнуть расширение

Какой URI WTPDF записал производитель?

Заявления WTPDF 1.0 и ISO/TS 32005 — это PDF Declarations: записи в bag-контейнере pdfd:declarations в XMP, в пространстве имён http://pdfa.org/declarations/, определённом технической заметкой PDF Declarations ассоциации PDF Association. Каждая запись несёт URI conformsTo и необязательные поля claimBy, claimDate, claimCredentials и claimReport, которые GetLoadedPDFDeclarations возвращает как массив записей THPDFDeclarationEntry; на стороне записи AddPDFDeclaration кладёт те же пять полей в пакет XMP, рядом с пространствами идентификации PDF 2.0, PDF/A-4 и PDF/UA-2. У разрезания bag на записи простым текстовым поиском есть своя заноза: режьте на каждом вхождении conformsTo — и закрывающий тег </pdfd:conformsTo> дочерне-элементной формы сойдёт за начало новой записи, обрезав первое объявление до его собственного конца. Читатель HotPDF перешагивает закрывающие теги и принимает обе сериализации

Сами URI — самая хитрая часть. Одно и то же заявление доходит до вас как http:// или https://, а WTPDF 1.0 Issue #395 поменял, стоит ли / перед #, поэтому файлы, записанные до и после исправления, различаются этим одним символом; простое сравнение строк сочтёт их разными стандартами. HasLoadedPDFDeclaration сравнивает URI без учёта регистра, схлопывает https:// в http:// и убирает / непосредственно перед # с обеих сторон, поэтому запрос ниже совпадает с любым написанием, какое выбрал производитель. Фрагмент остаётся нетронутым: #accessibility1.0 и #accessibility1.0-validated — разные заявления, второе утверждает, что файл проверил валидатор, и спрашивать надо то, которое важно для вашего процесса. GetLoadedWTPDFVersion схлопывает оба в 'WTPDF 1.0 Accessibility' или 'WTPDF 1.0 Reuse' для маршрутизации

procedure ReportDeclarations(Pdf: THotPDF);
var
  Decls: THPDFDeclarationEntryArray;
  I: Integer;
begin
  Decls := Pdf.GetLoadedPDFDeclarations;
  for I := 0 to High(Decls) do
    Writeln(Decls[I].ConformsTo, ' claimed by ', Decls[I].ClaimBy,
      ' on ', Decls[I].ClaimDate);

  // Совпадает с http или https, с косой чертой перед '#' или без
  if Pdf.HasLoadedPDFDeclaration(
    'https://pdfa.org/declarations/wtpdf#accessibility1.0') then
    Writeln('WTPDF 1.0 Accessibility (unvalidated claim)');

  // Покрывает и обычный, и -validated варианты Reuse и Accessibility
  Writeln('WTPDF profile: ', Pdf.GetLoadedWTPDFVersion);
end;

Для захвата или печатного маршрутизатора порядок операций важнее любого отдельного аксессора: проверяйте IsLoadedPDFR1 на файле в том виде, в каком он пришёл, до того как шаг с аннотацией или штампом допишет своё обновление, а PDF/X маршрутизируйте по GetLoadedPDFXVersion, который покрывает и Info-словарь заданий PDF/X-1a и PDF/X-3, и XMP заданий PDF/X-4, читая /GTS_PDFXConformance только там, где X-1a надо отличить от X-1. Обе проверки работают на любом документе, загруженном в HotPDF Delphi Component, включая шифрованные файлы, открытые с паролем