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

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 форма, мачка namespaces по URI и сравнява датите като моменти

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

Защо PDF/A отхвърля метаданни, съществуващи и на двете места?

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

Каква RDF форма трябва да има всяко картнато XMP свойство?

Всяко от осемте картирания има фиксиран XMP тип, а коректна стойност в грешния контейнер пак се проваля. ComparePdfAInfoAndXmp в FPdfPdfa.pas търси свойствата по namespace URI, така че packet, свързал 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 string, така че списък от автори, разделен с точки и запетаи, остава един-единствен запис
  • Keywords → pdf:Keywords и Producer → pdf:Producer (namespace http://ns.adobe.com/pdf/1.3/): прости текстови свойства
  • Creator → xmp:CreatorTool, CreationDate → xmp:CreateDate, ModDate → xmp:ModifyDate (namespace http://ns.adobe.com/xap/1.0/): прости текстови свойства
Осемте картнати двойки, които ComparePdfAInfoAndXmp проверява за PDF/A метаданна еквивалентност в Delphi: Title и Subject искат rdf:Alt с x-default запис, Author — rdf:Seq с един запис, Keywords, Producer, Creator и двете дати са прост текст, всяко търсено по XMP namespace URI, а не по префикс
Щом 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 code point-и, без подрязване, сгъване на регистра или нормализация. Интервал в края, или precomposed é от едната страна и decomposed e плюс комбиниращо ударение от другата, е истинско разминаване. Info страната винаги идва от собствения декодинг на PDFium на PDFDocEncoding и UTF-16 текст чрез FPDF_GetMetaText, което спира библиотеката да пре-имплементира string декодиране и да го обърка фино; XMP страната е толкова чиста, колкото байтовете, които са я родили, затова и codepage капаните, които развалят XMP метаданни под Free Pascal, имат значение и тук

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

PDF дата и XMP дата са равни, когато описват същия момент с точност до секундата, с еднакво знание за часова зона и от двете страни. И двата parser-а приемат легална съкратена прецизност, така че 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 решава, че PDF Info дата и XMP дата са равни в Delphi: две зони конвертират към UTC и сравняват моменти, две стойности без зона се сравняват каквито са записани, една-единствена зона е pamsValueMismatch, ненулева дробна секунда не може да бъде изразена, а непарсващи се стойности се докладват поотделно
Равенство значи същият момент с точност до секундата и еднакво знание за часова зона и от двете страни, така че измислено отместване или закръглена дробна секунда биха скрили истинско несъответствие

Наличието има свое правило. TPdfAMetadataValues.Present е множество, пълнено от обхождането на /Info речника на активния trailer, и държи „ключ отсъства“ отделно от „ключ присъства с празен string“. Липсващ ключ дава pamsNotRequired и не иска нищо от XMP; /Title () е налице, така че XMP packet-ът трябва да носи и празна x-default title

Как инспектирате Info и XMP метаданни преди запис?

TPdf.InspectPdfAMetadata връща TPdfAMetadataReport с по един TPdfAMetadataComparison на поле, всеки носещ Info стойността, XMP стойността и TPdfAMetadataState, така че провал може да бъде обяснен без reverse-engineering на един-единствен validation флаг. MismatchFields обобщава провалилото се множество, HasXmpPacket казва дали е намерен packet, а XmpParseError носи съобщението на parser-а, когато packet съществува, но не може да бъде прочетен

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; всичко останало в packet-а оцелява. Title и Subject се записват в x-default записа, докато другите езикови алтернативи остават непокътнати, Author става rdf:Seq с един запис, непознати namespaces и несвързани свойства се запазват, а XMP свойства за липсващи Info ключове не се пипат. Info дата със зона се записва като канонична UTC XMP дата със суфикс Z; такава без зона пази своите локални компоненти. File overload-ът записва през временен файл и атомарна подмяна, а самият XMP update се долепя като incremental update

Какво пренаписва NormalizePdfAMetadata при поправка на PDF/A метаданни в Delphi с PDFium Component: Info е източникът на истина, само записите от MismatchFields се връщат като x-default Alt текст, Seq с един запис или канонична UTC дата, докато непознати namespaces, несвързани свойства и свойства на липсващи ключове оцеляват непокътнати
Поправката отказва липсващ XMP packet, неправилна Info дата и подписани документи, защото изграждането на пълен PDF/A метаданен набор е работата на SaveAsPdfA, не на насочена еквивалентна поправка

Отказите са нарочни. Без XMP packet методът вдига EPdfError, защото изграждането на пълен PDF/A identification и метаданен набор е работата на 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 дата или нечитаем packet
        raise Exception.CreateFmt('Cannot normalize %s: %s', [Source, E.Message]);
    end;
  finally
    Pdf.Free;
  end;
end;

Пускане на сравнението върху собствен XMP packet

ComparePdfAInfoAndXmp и SynchronizePdfAInfoToXmp са обикновени функции в FPdfPdfa, които работят върху TPdfXmpPacket без зареден документ, което подхожда на unit тестове и pipeline-и, сглобяващи XMP от шаблон. Единственият капан е Present: record, инициализиран с 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 архивира документи, които други системи продължават да редактират, съчетайте nightly InspectPdfAMetadata обхождане с NormalizePdfAMetadata за файловете, които са се отдръпнали, и дръжте ValidatePdfA като гейт, преди каквото и да е тръгне към дългосрочно съхранение. Типизираният report, пътят на поправка и останалият PDF/A инструментариум се доставят в PDFium Component за Delphi и C++Builder