Технічна стаття

Еквівалентність метаданих Info та XMP у PDF/A в Delphi

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

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

Чому PDF/A відхиляє метадані, які є в обох місцях?

PDF/A відхиляє їх, бо ISO 19005-1 §6.7.3 — це правило значення, а не правило присутності: Таблиця 1 відображає вісім ключів Info на властивості XMP, і щойно ключ Info присутній, відображена властивість XMP мусить тримати еквівалентне значення. Байтовий сканер, описаний у preflight-валідації PDF/A з 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 вирішує, що дата Info PDF і дата XMP рівні у Delphi: два пояси конвертуються в 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 пишуться назад як текст x-default Alt, 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 без завантаженого документа, що пасує юніт-тестам і pipeline-ам, які збирають 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;

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