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

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

PDFium Component проверяет эквивалентность метаданных PDF/A Info и XMP методом TPdf.InspectPdfAMetadata и чинит её методом TPdf.NormalizePdfAMetadata. ISO 19005-1 (с учётом поправки Cor.1) требует, чтобы каждая из восьми смапленных записей Info, от Title до ModDate, несла то же значение, что и её XMP-свойство, а не просто существовала; проверка читает правильную RDF-форму, сопоставляет пространства имён по URI и сравнивает даты как моменты времени

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

Почему PDF/A отвергает метаданные, которые есть в обоих местах?

PDF/A отвергает её, потому что ISO 19005-1 §6.7.3 — правило о значениях, а не о наличии: таблица 1 мапит восемь ключей Info на XMP-свойства, и раз ключ Info присутствует, смапленное XMP-свойство обязано держать эквивалентное значение. Байтовый сканер из статьи про PDF/A preflight-валидацию с PDFium Component только подтверждает, что xmp:CreateDate и xmp:ModifyDate существуют (pvaiMissingXmpDates). С v3.72.0 TPdf.ValidatePdfA дополнительно прогоняет полное сравнение значений и добавляет pvaiInfoXmpValueMismatch в набор проблем, когда пакет XMP существует, но расходится с Info (пакет, который не парсится, считается расходящимся). Отсутствующий пакет по-прежнему репортится как pvaiMissingXmpMetadata, так что две проблемы никогда не считают один дефект дважды

Какую RDF-форму требует каждое смапленное XMP-свойство?

У каждого из восьми маппингов фиксированный XMP-тип, и корректное значение в неправильном контейнере всё равно проваливается. 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-форме, поэтому значение в неправильном контейнере всё равно проваливается
<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 с комбинируемым ударением с другой, — настоящее расхождение. Сторона Info всегда приходит из собственного декодирования PDFium текста PDFDocEncoding и UTF-16 через FPDF_GetMetaText, что избавляет библиотеку от переизобретения декодирования строк с коварной ошибкой где-нибудь в углу; сторона XMP чиста ровно настолько, насколько чисты породившие её байты, — потому кодостраницные ловушки, портящие метаданные XMP под Free Pascal, значимы и здесь

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

PDF-дата и XMP-дата равны, когда описывают один и тот же момент с точностью до секунды, с одинаковой осведомлённостью о часовом поясе с обеих сторон. Оба парсера принимают легальную усечённую точность, так что D:2026 и 2026 оба значат 1 января 2026, 00:00:00. Когда зону несут оба значения, они конвертируются в 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, ненулевая дробная секунда не выразима, а непарсящиеся значения репортятся отдельно
Равенство — это один и тот же момент с точностью до секунды при одинаковой осведомлённости о поясе с обеих сторон, поэтому выдуманное смещение или округлённая дробная секунда спрятали бы настоящее расхождение

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

Как инспектировать метаданные Info и XMP перед сохранением?

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

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;

Что меняет NormalizePdfAMetadata и от чего отказывается?

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

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

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

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]);
    end;
  finally
    Pdf.Free;
  end;
end;

Гоняем сравнение на собственном XMP-пакете

ComparePdfAInfoAndXmp и SynchronizePdfAInfoToXmp — простые функции в FPdfPdfa, работающие с TPdfXmpPacket без загруженного документа, что удобно юнит-тестам и конвейерам, собирающим XMP из шаблона. Одна ловушка — Present: запись, инициализированная через Default(TPdfAMetadataValues), имеет пустое множество, каждое поле тогда репортит pamsNotRequired, и сравнение проходит вакуумно, какие бы значения вы ни вписали

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;

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