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/): простые текстовые свойства
<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
У наличия своё правило. 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 дописывается как инкрементальное
Отказы намеренные. Без пакета 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