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

Эквивалентность метаданных PDF/A Info и XMP в Delphi

Словарь Info, держащий /ModDate (D:20260827093659+08'00'), и пакет XMP с xmp:ModifyDate в виде 2026-08-27T01:36:59Z описывают одну и ту же секунду, и у PDF/A-валидатора нет причин отвергать эту пару. А вот скопируйте строку Subject документа в dc:subject — и пакет провалится, даже если текст совпадает посимвольно. ISO 19005-1 §6.7.3 требует эквивалентных значений, в конкретных XMP-свойствах, в конкретных RDF-формах

Баг-репорт, с которого обычно открывается эта тема, выглядит безобидно. Система документооборота штампует свежий /ModDate в словарь Info при каждом инкрементальном сохранении и пакет XMP не трогает. Полгода спустя архивный аудит помечает тысячи файлов. Обе даты на месте; они перестали совпадать с первой правки, а проверка, которая ищет лишь само наличие свойства, этого так и не увидела. Правки Title через API, работающий только с Info, проваливаются так же, как и строка Author вроде Finance; Controlling, которую какой-то инструмент разбил на два элемента dc:creator

Что ISO 19005-1 требует от Info и XMP

Таблица 1 ISO 19005-1 ставит восемь ключей Info в пару с XMP-свойствами, а §6.7.3 говорит: раз ключ Info присутствует, смапленное XMP-свойство обязано нести эквивалентное значение. Отсутствующий ключ ничего не требует. PDF/A-2 и PDF/A-3 держат то же требование эквивалентности для тех же восьми ключей. PDF/A-4 идёт с другого боку и ограничивает сам словарь Info: ему можно появляться только рядом с каталоговым /PieceInfo и тогда держать один ModDate, что TPdf.ValidatePdfA репортит через pvaiPdfA4InfoWithoutPieceInfo и pvaiPdfA4InfoInvalid

В PDFium Component сравнение живёт в ComparePdfAInfoAndXmp в FPdfPdfa.pas. Каждое свойство ищется по URI пространства имён, так что пакет, связавший http://purl.org/dc/elements/1.1/ с необычным префиксом, читается в точности как пакет с dc. Смапленные формы таковы:

  • Title → dc:title и Subject → dc:description: языковая альтернатива rdf:Alt, сравниваемая только с её элементом x-default; языковой тег сверяется без учёта регистра, а Alt без x-default считается отсутствующим
  • Author → dc:creator: rdf:Seq ровно с одним текстовым элементом, держащим всю строку Info, так что список авторов через точку с запятой остаётся одной записью
  • Keywords → pdf:Keywords и Producer → pdf:Producer (пространство http://ns.adobe.com/pdf/1.3/): простые текстовые свойства
  • Creator → xmp:CreatorTool, CreationDate → xmp:CreateDate, ModDate → xmp:ModifyDate (пространство http://ns.adobe.com/xap/1.0/): простые текстовые свойства
Восемь смапленных пар, которые ComparePdfAInfoAndXmp проверяет на эквивалентность метаданных PDF/A в Delphi: Title и Subject требуют rdf:Alt с элементом x-default, Author — одноэлементный rdf:Seq, Keywords, Producer, Creator и обе даты — простой текст, и всё ищется по URI пространства имён XMP, а не по префиксу
Раз ключ Info присутствует, ISO 19005-1 требует, чтобы смапленное XMP-свойство держало эквивалентное значение в требуемой RDF-форме, поэтому значение в неправильном контейнере всё равно проваливается

Строка Subject — то место, где самодельные шаблоны спотыкаются чаще всего. dc:subject из Dublin Core — это неупорядоченная сумка ключевых слов, и само имя подталкивает свалить туда Info-шный Subject. Сравнение ищет dc:description, не находит ничего и репортит Subject как pamsXmpMissing, пока «правильный» текст сидит в одном свойстве от него. Три строки в правильных формах выглядят так:

<dc:title><rdf:Alt><rdf:li xml:lang="x-default">Quarterly Report 2026</rdf:li></rdf:Alt></dc:title>
<dc:creator><rdf:Seq><rdf:li>Finance; Controlling</rdf:li></rdf:Seq></dc:creator>
<pdf:Producer>PDFium Component</pdf:Producer>

Почему корректная строка всё равно проваливает сравнение?

Корректная строка проваливается, когда сидит в неправильном контейнере или отличается одним кодовым пунктом. Текстовые значения сравниваются как точные последовательности Unicode-кодовых точек, без обрезки, приведения регистра или нормализации. Хвостовой пробел — уже расхождение. Как и прекомпозированное é с одной стороны против декомпозированного e с комбинируемым ударением с другой, хотя на экране оба обычно выглядят одинаково

Ошибки формы получают отдельное состояние. dc:title, записанный простым текстом, dc:creator, объявленный как rdf:Bag, или Seq из двух элементов у dc:creator — всё это даёт pamsXmpTypeMismatch, а не pamsValueMismatch, так что отчёт отличает «инструмент выбрал неверную RDF-структуру» от «инструмент написал другой текст». Две эти проблемы обычно приезжают из разных мест конвейера, и чинить не ту — верный способ похерить день

Сторона Info всегда приходит из собственного декодирования PDFium текста PDFDocEncoding и UTF-16BE через FPDF_GetMetaText — тот же путь, что использует TPdf.GetMetadata, — так что строка с меткой порядка байтов или экранированной октальной последовательностью читается так, как её читает движок. Сторона XMP чиста ровно настолько, насколько чисты породившие её байты. Пакет, собранный с неверной кодовой страницей, может нести mojibake, которого не простит никакое сравнение, — потому кодостраницные ловушки, портящие метаданные XMP под Free Pascal, принадлежат тому же чек-листу

Когда PDF-дата и XMP-дата равны?

PDF-дата и XMP-дата равны, когда описывают один и тот же момент с точностью до секунды при одинаковой осведомлённости о часовом поясе с обеих сторон. Оба парсера принимают легальную усечённую точность, так что D:2026 и 2026 значат 1 января 2026 года, полночь. Когда зону несут оба значения, каждое сперва конвертируется в UTC: D:20260827093659+08'00' равно 2026-08-27T01:36:59Z. Когда зоны нет ни у одного, локальные компоненты сравниваются как написано

Когда зона есть только у одной стороны, результат — pamsValueMismatch. Выдумывать смещение для значения без зоны значило бы гадать, а гадание, случайно совпавшее с часовым поясом сервера, прошло бы на одной машине и провалилось бы на другой. Ненулевая дробная секунда вроде .250 в XMP тоже форсирует несовпадение, ведь PDF-дата не способна её выразить; .000 принимается. Непарсящиеся значения репортятся как pamsInvalidInfoDate или pamsInvalidXmpDate

Как PDFium Component решает в Delphi, равны ли PDF-дата из Info и XMP-дата: две зоны конвертируются в UTC и сравниваются как моменты, два значения без зоны сравниваются как написаны, зона лишь у одной — это pamsValueMismatch, ненулевая дробная секунда не выразима, а непарсящиеся значения репортятся отдельно
Равенство — это один и тот же момент с точностью до секунды при одинаковой осведомлённости о поясе с обеих сторон, поэтому выдуманное смещение или округлённая дробная секунда спрятали бы настоящее расхождение

Две дата-грамматики не взаимозаменяемы, и парсеры держат каждую сторону в рамках её собственной. На стороне PDF апострофы вокруг минут смещения опциональны, а Z принимается. На стороне XMP смещению нужна двоеточие: 2026-08-27T09:36:59+08:00 парсится, тогда как +0800 — это pamsInvalidXmpDate. Времени XMP также нужны хотя бы часы и минуты, как только появился T, а смещения дальше 14 часов отвергаются с обеих сторон. Окружающие пробелы перед разбором даты обрезаются — любезность, которой текстовым полям не достаётся

У наличия своё правило. TPdfAMetadataValues.Present — множество, заполняемое обходом словаря /Info активного trailer, и оно разводит „ключа нет“ и „ключ есть с пустой строкой“. Отсутствующий ключ даёт pamsNotRequired. /Title () присутствует, так что пакет обязан нести и пустой заголовок x-default

Читать отчёт по полям, прежде чем трогать файл

TPdf.InspectPdfAMetadata возвращает TPdfAMetadataReport с одним TPdfAMetadataComparison на поле. Каждая запись держит значение Info, значение XMP и TPdfAMetadataState, так что провал можно объяснить поле за полем. MismatchFields собирает провалившийся набор, HasXmpPacket сообщает, найден ли пакет, а XmpParseError несёт сообщение парсера, когда пакет есть, но не читается. В этом последнем случае каждое присутствующее поле репортит pamsXmpMissing, потому что из пакета не удалось прочесть ничего

uses
  System.SysUtils, PDFium, FPdfPdfa;

const
  FieldNames: array[TPdfAMetadataField] of string = (
    'Title', 'Author', 'Subject', 'Keywords',
    'Creator', 'Producer', 'CreationDate', 'ModDate');
  StateNames: array[TPdfAMetadataState] of string = (
    'not required', 'equivalent', 'XMP missing', 'XMP type mismatch',
    'value mismatch', 'invalid Info date', 'invalid XMP date');

procedure ReportMetadata(Pdf: TPdf);
var
  Report: TPdfAMetadataReport;
  Item: TPdfAMetadataComparison;
begin
  Report := Pdf.InspectPdfAMetadata;
  if Report.XmpParseError <> '' then
    Writeln('XMP packet unreadable: ', Report.XmpParseError)
  else if not Report.HasXmpPacket then
    Writeln('No XMP packet at all');
  for Item in Report.Comparisons do
    if not Item.IsEquivalent then
      Writeln(Format('%-12s %-18s Info="%s" XMP="%s"',
        [FieldNames[Item.Field], StateNames[Item.State],
         Item.InfoValue, Item.XmpValue]));
end;

TPdf.ValidatePdfA вливает то же сравнение в свой набор проблем. Побайтовый скан, описанный в статье PDF/A preflight-валидация с PDFium Component, репортит pvaiMissingXmpDates, когда в пакете нет обеих дата-свойств, и pvaiInfoXmpDateMismatch, когда Info несёт CreationDate или ModDate, а в пакете нет соответствующего дата-свойства. Сверх того ValidatePdfA гоняет полное сравнение значений и добавляет pvaiInfoXmpValueMismatch всякий раз, когда пакет существует, но расходится с Info, — нечитаемый пакет включительно. Документ вовсе без пакета репортится единственным pvaiMissingXmpMetadata, так что один дефект никогда не всплывает дважды. Потоковая функция ValidatePdfACompliance выполняет один лишь побайтовый скан

function MetadataGateOk(Pdf: TPdf): Boolean;
var
  Res: TPdfAValidationResult;
begin
  Res := Pdf.ValidatePdfA;
  if pvaiMissingXmpMetadata in Res.Issues then
    Writeln('No usable XMP packet: rebuild the file with SaveAsPdfA');
  if pvaiInfoXmpDateMismatch in Res.Issues then
    Writeln('Info has a date property the XMP packet lacks entirely');
  if pvaiInfoXmpValueMismatch in Res.Issues then
    Writeln('Mapped values disagree: run InspectPdfAMetadata for details');
  Result := Res.IsCompliant;
end;

Что переписывает NormalizePdfAMetadata и где останавливается

TPdf.NormalizePdfAMetadata считает словарь Info источником истины и переписывает только те XMP-свойства, чьё поле попало в MismatchFields. Title и Subject пишутся в элемент x-default, пока прочие языковые альтернативы остаются нетронутыми. Author становится одноэлементным rdf:Seq. Дата Info с зоной пишется как каноническая UTC-дата XMP с суффиксом Z, дата без зоны сохраняет локальные компоненты. Неизвестные пространства, неродственные свойства и XMP-свойства для отсутствующих ключей Info не трогаются, а существующая привязка префикса переиспользуется при записи свойства назад

Что NormalizePdfAMetadata переписывает при починке метаданных PDF/A в Delphi с PDFium Component: Info — источник истины, обратно пишутся только записи из MismatchFields как Alt-текст x-default, одноэлементный Seq или каноническая UTC-дата, тогда как неизвестные пространства, неродственные свойства и свойства отсутствующих ключей выживают нетронутыми
Починка отказывает отсутствующему пакету XMP и кривой дате Info, а по умолчанию — подписанному документу, потому что сборка полного набора метаданных PDF/A — работа SaveAsPdfA, а не точечного фикса эквивалентности

Сделать Info авторитетным стоит цены, которая всплывает на настоящих файлах. dc:creator-Seq, перечисляющий «Finance» и «Controlling» как двух людей, заменяется одним элементом с текстом Finance; Controlling, а dc:title, хранившийся в неправильном контейнере, перестраивается свежим Alt, держащим один x-default, — что бы ни лежало в старом контейнере, оно исчезло. Здесь эта потеря приемлема: таблица 1 разрешает ровно один элемент создателя, строка Info уже держит весь список одним куском, а исходный пакет по-прежнему лежит в файле-источнике, если кому-то нужен более богатый список

Отказы намеренные. Без пакета XMP метод бросает EPdfError: сборка полной PDF/A-идентификации и набора метаданных — работа SaveAsPdfA, разобранная в статье создание архивных файлов PDF/A с PDFium Component. Нечитаемый пакет или кривая дата Info бросают EPdfXmpError до всякой записи — правдоподобно выглядящая неверная дата в XMP хуже отсутствия вывода. Подписанные документы бросают EPdfError, пока вызывающий не передаст AllowSignedDocument = True; вывод — полное сохранение с новыми метаданными, дописанными инкрементальным обновлением, так что подписанные байты не переносятся нетронутыми

У пути сохранения есть ещё одно следствие. NormalizePdfAMetadata пишет новый файл, даже когда каждое поле уже совпадает, — синхронизация пустоты всё равно заканчивается сохранением. Сперва зовите InspectPdfAMetadata и пропускайте эквивалентные файлы: ночное задание, переписывающее тысячи нетронутых архивов, не покупает ничего, кроме новых меток времени. Файловая перегрузка пишет через временный файл и заменяет цель атомарно. Исходный файл остаётся открытым, пока загружен документ, так что дайте выводу другой путь

uses
  System.SysUtils, PDFium, FPdfPdfa, FPdfXmp;

procedure NormalizeArchive(const Source, Target: string);
var
  Pdf: TPdf;
  Report: TPdfAMetadataReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := Source;
    Pdf.Active := True;
    Report := Pdf.InspectPdfAMetadata;
    if Report.IsEquivalent then
      Exit;                      // уже согласовано, файл не трогаем
    if not Report.HasXmpPacket then
      raise Exception.Create('No XMP packet: convert with SaveAsPdfA instead');
    try
      if not Pdf.NormalizePdfAMetadata(Target) then
        raise Exception.Create('Normalized save failed');
    except
      on E: EPdfXmpError do      // кривая дата Info или нечитаемый пакет
        raise Exception.CreateFmt('Cannot normalize %s: %s', [Source, E.Message]);
      on E: EPdfError do         // подписанный вход без AllowSignedDocument
        raise Exception.CreateFmt('Skipped %s: %s', [Source, E.Message]);
    end;
  finally
    Pdf.Free;
  end;
end;

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

Сравнение пакета без загруженного документа

ComparePdfAInfoAndXmp и SynchronizePdfAInfoToXmp — простые функции в FPdfPdfa, работающие с TPdfXmpPacket без открытого PDF, что удобно юнит-тестам и конвейерам, наполняющим XMP из шаблона до того, как документ вообще существует. SynchronizePdfAInfoToXmp возвращает множество переписанных ею полей и бросает EPdfXmpError, получив nil-пакет

Единственная ловушка — Present. Запись, инициализированная через Default(TPdfAMetadataValues), имеет пустое множество, каждое поле тогда репортит pamsNotRequired, и сравнение проходит, какие бы значения вы ни вписали. Строковые поля записи несут значения; решает, какие из них стандарт реально требует, только Present

uses
  System.SysUtils, System.IOUtils, FPdfPdfa, FPdfXmp;

procedure AlignTemplate(const TemplateFile: string);
var
  Info: TPdfAMetadataValues;
  Packet: TPdfXmpPacket;
  Changed: TPdfAMetadataFields;
begin
  Info := Default(TPdfAMetadataValues);
  Info.Title := 'Quarterly Report 2026';
  Info.Author := 'Finance; Controlling';
  Info.ModDate := 'D:20260827093659+08''00''';
  // Present решает, какие поля обязательны; одни значения игнорируются
  Info.Present := [pamfTitle, pamfAuthor, pamfModDate];

  Packet := TPdfXmpPacket.Parse(TFile.ReadAllText(TemplateFile, TEncoding.UTF8));
  try
    Changed := SynchronizePdfAInfoToXmp(Info, Packet);
    // разошедшийся xmp:ModifyDate становится 2026-08-27T01:36:59Z,
    // разошедшийся dc:creator становится одноэлементным rdf:Seq
    if Changed <> [] then
      TFile.WriteAllBytes(TemplateFile, Packet.ToUtf8);
  finally
    Packet.Free;
  end;
end;

Прогон того же шаблона через ComparePdfAInfoAndXmp после этого должен вернуть пустой MismatchFields — дешёвый ассерт для шага сборки, регенерирующего шаблоны

Архивы, которые продолжают править другие системы, уплывают по одному полю за раз, и пораздельный отчёт показывает, какое именно двинулось. InspectPdfAMetadata, NormalizePdfAMetadata и остальной инструментарий PDF/A-валидации и конверсии — часть PDFium Component для Delphi и C++Builder