Словарь 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/): простые текстовые свойства
Строка 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
Две дата-грамматики не взаимозаменяемы, и парсеры держат каждую сторону в рамках её собственной. На стороне 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 не трогаются, а существующая привязка префикса переиспользуется при записи свойства назад
Сделать 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