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

Преобразование PDF в PDF/A и восстановление его метаданных

Возьмите файл, чей словарь информации говорит /Title (Annual Maintenance Report), тогда как в XMP-пакете нет никакого dc:title. Просмотрщики показывают заголовок без возражений, но валидатор PDF/A отвергает файл, потому что две копии метаданных расходятся. Второй файл выглядит столь же здоровым, но не имеет записи pdfaid:part в XMP, и валидатор отвечает десятками ошибок: он судит файл как обычный PDF и докладывает каждое правило заявленной части проваленным — всё из одной отсутствующей строки

Двум проблемам нужны очень разные объёмы работы. Согласование метаданных — узкая починка, которую можно гнать на любом файле. Превратить произвольный PDF в архивный — значит срезать то, что целевая часть запрещает, добавить то, что требует, заявить претензию, а затем провалидировать байты, которые реально вышли. Мы построили конвертацию вокруг того последнего шага нарочно. ConvertToPDFA, поставляемый в издании Enterprise (TPDFlibEnterprise) вместе с GetPDFAConversionReport, сохраняет конвертированный документ в память, гоняет проверку PDF/A по тем байтам и возвращает 1, только когда проверка проходит

Диаграмма конвейера ConvertToPDFA в PDF Library for Delphi: удаление запрещённого содержимого, исправление метаданных, указание части PDF/A и проверка перед архивированием при положительном вердикте
ConvertToPDFA зачищает, дополняет, штампует и проверяет одним вызовом, и только успешная проверка превращает заявление об архивности в нечто, безопасное для архивирования

Почему корректный на вид PDF не проходит проверку PDF/A?

Обе неудачи — из правил метаданных. PDF записывает заголовок, автора, производителя и даты в словарь информации документа, и PDF/A требует тех же фактов в XMP-пакете метаданных; ISO 19005-1 §6.7.3 просит эквивалентности двух, и PDF/A-2 с PDF/A-3 правило сохраняют. Среди файлов, падающих на этом правиле, обычный рисунок — производитель заполнил словарь информации и так и не написал XMP-половину, поэтому валидатор находит заголовок в одном месте и ничего в другом

Отсутствующая идентификация тише. Записи pdfaid:part и pdfaid:conformance — то, что помечает файл как PDF/A, и файл может быть совершенно корректным без них. Случай, который мы видим чаще всего, — приложение подсовывает собственный XMP-пакет, заменяя тот, что сгенерировал PDF-писатель; кастомный пакет несёт собственные поля приложения и никаких записей pdfaid, поэтому список ошибок указывает вовсе не туда, где настоящая причина

RepairDocumentMetadata разбирает оба случая и возвращает число изменённых записей. Он доступен в каждом издании, как и SetPDFAMode с SanitizeDocument. Починка проходит по Title, Author, Subject, Keywords, Creator, Producer, CreationDate и ModDate. Где значение несёт только XMP — словарь информации заполняется из него, так что ничего уже записанного не выбрасывается. Где несут обе и они расходятся — побеждает словарь информации. Мы так решили, потому что словарь информации — та половина, которую производители выставляют чаще всего, что делает её более вероятной записью того, что имел в виду автор

Если ваш XMP — авторитетная копия (скажем, выхоленная в системе управления документами), затолкайте нужные значения в словарь информации через SetInformation и лишь потом гоните починку. Наоборот — значит затереть выхоленный XMP-заголовок тем, что написал исходный производитель

Диаграмма: RepairDocumentMetadata приводит словарь Info и пакет XMP к согласию и восстанавливает утраченную идентификацию стандарта PDF/A в Delphi
RepairDocumentMetadata заполняет каждую пустую половину метаданных из другой и восстанавливает утраченную идентификацию стандарта, чтобы валидаторы видели задуманную часть PDF/A

Починке идентификации нужно от вас одно: документ должен знать, на какую часть метит. У свежезагруженного файла режим PDF/A не выставлен, поэтому вызовите SetPDFAMode до починки. С выставленным режимом RepairDocumentMetadata пишет соответствующие записи pdfaid, добавляет pdfaid:rev с годом 2020 для режимов части 4 и ставит dc:format в application/pdf, что валидаторы тоже проверяют

uses
  SysUtils, PDFlibrary;

procedure RepairIncoming(const InName, OutName: string);
var
  Lib: TPDFlib;
  Fixed: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile(InName, '') <> 1 then
      raise Exception.CreateFmt('Cannot open %s', [InName]);

    // Выхоленный заголовок идёт в словарь информации первым,
    // потому что та копия побеждает, когда половины расходятся
    Lib.SetInformation(2, 'Annual Maintenance Report 2025');

    Lib.SetPDFAMode(3);                  // 3 = PDF/A-2b, см. таблицу ниже
    Fixed := Lib.RepairDocumentMetadata;
    Writeln(Format('%d metadata entries brought into agreement', [Fixed]));

    Lib.SaveToFile(OutName);
  finally
    Lib.Free;
  end;
end;

Выбор целевой части и её номера режима

Берите часть по тому, что документ обязан нести, и лишь потом глядите номер. SetPDFAMode и ConvertToPDFA делят одну схему нумерации, и она не идёт в том порядке, какой подсказывают имена частей

РежимЧасть и уровеньПримечания
1PDF/A-1aPDF 1.4, с тегами
2PDF/A-1bPDF 1.4
3PDF/A-2bPDF 1.7
4PDF/A-2aPDF 1.7, с тегами
5, 6, 7PDF/A-3b, 3a, 3uВстроенные файлы любого типа разрешены
8PDF/A-2uPDF 1.7, требуется Unicode-текст
9, 10, 11PDF/A-4, 4e, 4fPDF 2.0; 4e добавляет 3D и rich media, 4f разрешает встроенные файлы

Режим 3 — это PDF/A-2b, а режим 4 — PDF/A-2a. Та инверсия ловит тех, кто считает от 1a и предполагает, что a-уровень всегда впереди. PDF/A-2u появляется под номером 8, после режимов части 3

Выбор с реальными последствиями — про вложения. Части 1 и 2 запрещают встроенные файлы, поэтому ConvertToPDFA убирает их для режимов с 1 по 4 и режима 8. Конвертируйте счёт Factur-X или ZUGFeRD в режим 3 — и структурированный XML-счёт уйдёт из вывода вместе с причиной существования файла. Для всего, что несёт вложение, целевой режим — 5 (PDF/A-3b), на котором те форматы счетов и определены, либо 11, если нужен PDF 2.0. Каждое вложение в файле части 3 или части 4 обязано через /AFRelationship заявить, как оно относится к документу, и PDF Library for Delphi пишет ту запись для каждого встроенного файла, который сохраняет в тех режимах

Часть 4 идентифицирует себя иначе. У неё нет буквы соответствия для простого PDF/A-4, E или F — для двух расширений, и требуется pdfaid:rev с годом публикации её части. ConvertToPDFA поднимает файл до PDF 2.0 для режимов с 9 по 11, потому что ISO 19005-4 §6.1.2 строит часть на PDF 2.0, и проверка судит такие файлы по PDF 2.0 вместо 1.7

Что ConvertToPDFA меняет в файле

ConvertToPDFA делает фиксированную последовательность починок, отмечает каждую в текстовом отчёте, сохраняет результат в память и валидирует те байты. Проваленная проверка возвращает 0, как и режим вне 1–11 — тот возвращает ещё до записи какого-либо отчёта. Последовательность стоит знать, потому что каждый шаг — то, что вывод потеряет или приобретёт:

  • Зашифрованный документ с неизвестным паролем останавливает конвертацию сразу строкой blocked:; внутри него нечего читать, не то что чинить
  • JavaScript уровня документа и открывающее действие, которое его запускает, удаляются вместе со всеми записями дополнительных действий и типами действий, которых не допускает ни одна часть, — среди них Launch, Sound, Movie, ResetForm, ImportData, Hide, SetOCGState, Rendition и Trans. Ссылки GoTo и URI остаются, так что навигация продолжает работать
  • Встроенные файлы удаляются для частей, которые их запрещают, как описано выше
  • Аннотации, скрытые или выставленные никогда не показываться, удаляются. PDF/A требует, чтобы аннотации были видимы и печатаемы, а выставлять их напоказ значило бы менять вид страницы, поэтому мы выбрали удаление
  • Виды полей формы перегенерируются до сброса флага /NeedAppearances, поэтому вписанные значения остаются видимыми
  • Используемые, но не встроенные шрифты встраиваются из шрифтов, установленных на машине
  • Объекты, на которые больше никто не ссылается, выбрасываются, и если у файла нет sRGB output intent, он добавляется
  • Наконец часть и уровень заявляются в XMP, и байты проверяются

На сервере шаг шрифтов — тот, что чаще всего стопорит конвертацию. Документ, использующий шрифт, которого нет нигде на хосте конвертации, нельзя починить изнутри файла, и отчёт так и скажет. Установите шрифты, которые ваши документы реально используют, на машину конвертации; разбор встраивания недостающих шрифтов в существующий PDF покрывает тот шаг отдельно

Как пакетному заданию обращаться с провалившейся конвертацией?

Считайте возвращаемое значение шлюзом, а отчёт — рабочим талоном. Архивируйте файлы, вернувшие 1, а остальные маршрутизируйте человеку с приложенным отчётом. Не сохраняйте вывод провалившейся конвертации в архив лишь потому, что он выглядит чище входа. Тот файл теперь несёт претензию PDF/A, которую проверка отказалась подтвердить, — ровно та ситуация, ради предотвращения которой существует шаг проверки

Отчёт из GetPDFAConversionReport — по строке на действие, каждая начинается с глагола: removed:, regenerated:, repaired:, embedded:, added:, stated:, плюс blocked:, если что-то остановило прогон, и закрывающая строка result:. Проверка за вердиктом останавливается на первой неудаче, поэтому когда файл падает, конвертация гоняет проверку ещё раз целиком, и отчёт показывает все проблемы разом. Маршрутизация по префиксам — несколько строк

uses
  Classes, SysUtils, PDFlibrary, PDFlibEnterprise;

function ArchiveOne(const InName, ArchiveDir, ReviewDir: string): Boolean;
var
  Lib: TPDFlibEnterprise;
  Report: TStringList;
  I: Integer;
  Blocked: Boolean;
begin
  Result := False;
  Lib := TPDFlibEnterprise.Create;
  Report := TStringList.Create;
  try
    if Lib.LoadFromFile(InName, '') <> 1 then
      Exit;
    Result := Lib.ConvertToPDFA(5) = 1;      // 5 = PDF/A-3b, сохраняет вложения
    Report.Text := Lib.GetPDFAConversionReport;
    if Result then
      Lib.SaveToFile(IncludeTrailingPathDelimiter(ArchiveDir) + ExtractFileName(InName))
    else
    begin
      Blocked := False;
      for I := 0 to Report.Count - 1 do
        if Pos('blocked:', Report[I]) = 1 then
          Blocked := True;
      // Оригинал не трогаем; с ним уходит только отчёт
      Report.SaveToFile(IncludeTrailingPathDelimiter(ReviewDir) +
        ChangeFileExt(ExtractFileName(InName), '.pdfa-report.txt'));
      if Blocked then
        Writeln(InName, ': needs a password or a rebuilt source file');
    end;
  finally
    Report.Free;
    Lib.Free;
  end;
end;

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

Чтение метки, которую файл уже несёт

Перед конвертацией проверьте, что документ уже заявляет о себе. Файлы от сканеров, офисных пакетов и прочих PDF-инструментов часто уже несут метку PDF/A, а переконвертация валидного PDF/A-3 в часть 2 стоила бы ему вложений ни за что

Ловушка в том, что XMP позволяет записать одно свойство двумя способами. Одни производители пишут часть дочерним элементом, <pdfaid:part>2</pdfaid:part>; другие — атрибутом на rdf:Description, pdfaid:part="2". Обе — обычный валидный XMP. Детектор, ищущий лишь одну форму, решает, что файлы от другого семейства производителей метки не несут, а собранный так чекер кончает тем, что судит валидный PDF/A-2 по части 1 и докладывает его немаркированным и слишком высокой версии — точная противоположность правды. Валидатор в PDF Library for Delphi читает обе формы. Принюхиваетесь сами — делайте так же:

function DeclaredPDFAPart(Lib: TPDFlib): string;
const
  ElemTag = '<pdfaid:part>';
  AttrDQ  = 'pdfaid:part="';
  AttrSQ  = 'pdfaid:part=''';
var
  Packet: string;
  P: Integer;
begin
  Result := '';
  Packet := Lib.GetDocumentMetadata;
  P := Pos(ElemTag, Packet);                   // форма элемента
  if P > 0 then
    Exit(Copy(Packet, P + Length(ElemTag), 1));
  P := Pos(AttrDQ, Packet);                    // форма атрибута, двойные кавычки
  if P = 0 then
    P := Pos(AttrSQ, Packet);                  // форма атрибута, одинарные кавычки
  if P > 0 then
    Result := Copy(Packet, P + Length(AttrDQ), 1);
end;

Пустой результат значит метки нет ни в одной форме. Цифра говорит, какую часть имел в виду производитель, и именно с тем числом сравнивайте свою цель, прежде чем решать, конвертировать ли вообще

Чистка скриптов до попадания файла в архив

Архивная конвертация и охранная чистка обычно идут вместе, поскольку содержимое, которое политика безопасности хочет убрать, сильно пересекается с тем, что запрещает PDF/A. SanitizeDocument берёт битовую маску флагов SANITIZE_* и возвращает, сколько удалений применил. Сам ConvertToPDFA зовёт его с SANITIZE_JAVASCRIPT or SANITIZE_OPEN_ACTION первым шагом

Валидаторам важна одна деталь. Скрипты уровня документа живут в дереве имён /JavaScript под словарём /Names каталога, и когда последний скрипт удалён, опустевшее дерево удаляется вместе с ним. Пустое дерево всё ещё объявляет, что документ несёт скрипты, и некоторые чекеры реагируют на сам ключ, не считая, что под ним

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

var
  Lib, Check: TPDFlib;
begin
  Lib := TPDFlib.Create;
  Check := TPDFlib.Create;
  try
    Lib.LoadFromFile('upload.pdf', '');
    Lib.SanitizeDocument(SANITIZE_JAVASCRIPT or SANITIZE_OPEN_ACTION);
    Lib.SaveToFile('upload-clean.pdf');

    Check.LoadFromFile('upload-clean.pdf', '');
    if Check.GlobalJavaScriptCount > 0 then
      raise Exception.Create('Document-level scripts survived the save');
  finally
    Check.Free;
    Lib.Free;
  end;
end;

Для соседних шагов архивного конвейера — руководства по предполётной проверке PDF/A и PDF/UA, истинному редактированию и удалению содержимого и схемам расширения XMP PDF/A-3 для Factur-X, которая подхватывает сторону метаданных, когда архивный документ ещё и несёт структурированные данные счёта

Вся конвертация, починка метаданных и валидация, показанные здесь, идут внутри вашего собственного процесса Delphi, C++Builder или Lazarus, без внешнего конвертера или командного валидатора в цепочке. Части PDF/A, издания и целевые платформы перечисляет страница продукта PDF Library for Delphi