Artykuł techniczny

Równoważność metadanych Info i XMP w PDF/A w Delphi

PDFium Component sprawdza równoważność metadanych Info–XMP w PDF/A przez TPdf.InspectPdfAMetadata, a naprawia ją przez TPdf.NormalizePdfAMetadata. ISO 19005-1 (po korekcie Cor.1) wymaga, by każdy z ośmiu mapowanych wpisów Info, od Title po ModDate, niósł tę samą wartość co jego właściwość XMP — nie tylko, by istniał; sprawdzenie czyta poprawny kształt RDF, dopasowuje przestrzenie nazw po URI i porównuje daty jako momenty

Zgłoszenie błędu, które zwykle rozpoczyna tę rozmowę, wygląda niewinnie. System zarządzania dokumentami wpiasuje nowe /ModDate do słownika Info przy każdym zapisie przyrostowym, zostawia pakiet XMP w spokoju, a pół roku później audyt archiwum flaguje tysiące plików jako niezgodne. Obie daty są. Po prostu przestały się zgadzać przy pierwszej edycji, a sprawdzenie obecności nigdy tego nie zauważyło. Edycje tytułu przez API tylko-Info oraz string Autora jak Finance; Controlling, który jakieś narzędzie rozbiło na dwa wpisy dc:creator, zawodzą tak samo

Dlaczego PDF/A odrzuca metadane obecne w obu miejscach?

PDF/A odrzuca je, bo ISO 19005-1 §6.7.3 to reguła wartości, nie reguła obecności: tabela 1 mapuje osiem kluczy Info na właściwości XMP, a skoro klucz Info jest obecny, zmapowana właściwość XMP musi trzymać równoważną wartość. Skaner bajtowy opisany w walidacji preflight PDF/A z PDFium Component potwierdza tylko, że xmp:CreateDate i xmp:ModifyDate istnieją (pvaiMissingXmpDates). Od v3.72.0 TPdf.ValidatePdfA dodatkowo uruchamia pełne porównanie wartości i dokłada pvaiInfoXmpValueMismatch do zbioru problemów, gdy pakiet XMP istnieje, ale rozmija się z Info (pakiet, którego nie da się sparsować, liczy się jako rozmijający się). Brakujący pakiet dalej jest raportowany jako pvaiMissingXmpMetadata, więc oba problemy nigdy nie liczą dwa razy tej samej wady

Jakiego kształtu RDF potrzebuje każda mapowana właściwość XMP?

Każde z ośmiu mapowań ma stały typ XMP, i poprawna wartość w złym pojemniku nadal zawodzi. ComparePdfAInfoAndXmp w FPdfPdfa.pas wyszukuje właściwości po URI przestrzeni nazw, więc pakiet wiążący http://purl.org/dc/elements/1.1/ z nietypowym prefiksem jest czytany dokładnie tak samo jak ten używający dc. Wymagane kształty to:

  • Title → dc:title oraz Subject → dc:description: alternatywa językowa rdf:Alt, porównywana wyłącznie z jej wpisem x-default (tag języka jest dopasowywany bez rozróżniania wielkości liter); Alt bez x-default liczy się jako brak
  • Author → dc:creator: rdf:Seq z dokładnie jednym wpisem tekstowym niosącym cały string Info, więc lista autorów rozdzielana średnikami pozostaje jednym wpisem
  • Keywords → pdf:Keywords oraz Producer → pdf:Producer (przestrzeń nazw http://ns.adobe.com/pdf/1.3/): proste właściwości tekstowe
  • Creator → xmp:CreatorTool, CreationDate → xmp:CreateDate, ModDate → xmp:ModifyDate (przestrzeń nazw http://ns.adobe.com/xap/1.0/): proste właściwości tekstowe
Osiem mapowanych par, które ComparePdfAInfoAndXmp sprawdza pod kątem równoważności metadanych PDF/A w Delphi: Title i Subject potrzebują rdf:Alt z wpisem x-default, Author jednoelementowego rdf:Seq, Keywords, Producer, Creator i dwie daty to prosty tekst, każde wyszukiwane po URI przestrzeni nazw XMP, a nie po prefiksie
Skoro klucz Info jest obecny, ISO 19005-1 wymaga, by zmapowana właściwość XMP trzymała równoważną wartość w wymaganym kształcie RDF, więc wartość w złym pojemniku nadal zawodzi
<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>

Wartości tekstowe są porównywane jako dokładne sekwencje punktów kodowych Unicode, bez ucinania białych znaków, składania wielkości liter ani normalizacji. Spacja na końcu albo złożone é z jednej strony i rozłożone e plus łączący akcent z drugiej to prawdziwa niezgodność. Strona Info zawsze pochodzi z własnego dekodowania PDFium tekstu PDFDocEncoding i UTF-16 przez FPDF_GetMetaText, co chroni bibliotekę przed reimplementacją dekodowania stringów i subtelną pomyłką; strona XMP jest tak czysta, jak bajty, które ją urodziły — dlatego pułapki stron kodowych psujące metadane XMP pod Free Pascalem mają tu też znaczenie

Kiedy data PDF i data XMP są równe?

Data PDF i data XMP są równe, gdy opisują ten sam moment z dokładnością do sekundy i tą samą wiedzą o strefie czasowej po obu stronach. Oba parsery akceptują legalną obniżoną precyzję, więc D:2026 i 2026 znaczą oba 1 stycznia 2026, 00:00:00. Gdy obie wartości niosą strefę, są konwertowane na UTC przed porównaniem: D:20260827093659+08'00' równa się 2026-08-27T01:36:59Z. Gdy żadna nie niesie strefy, lokalne składniki są porównywane tak, jak zapisano. Gdy tylko jedna strona ma strefę, wynikiem jest pamsValueMismatch, bo wymyślenie offsetu byłoby zgadywaniem. Niezerowa ułamkowa sekunda jak .250 w XMP też wymusza niezgodność, bo data PDF nie ma jak jej wyrazić, a ciche jej zaokrąglenie ukryłoby realną niezgodę; .000 jest akceptowane. Wartości nieparsowalne są raportowane osobno jako pamsInvalidInfoDate albo pamsInvalidXmpDate

Jak PDFium Component rozstrzyga w Delphi, że data Info PDF i data XMP są równe: dwie strefy konwertują na UTC i porównują momenty, dwie wartości bez strefy porównują się tak, jak zapisano, pojedyncza strefa to pamsValueMismatch, niezerowa część ułamkowa sekundy nie daje się wyrazić, a wartości nieparsowalne są raportowane osobno
Równość znaczy ten sam moment z dokładnością do sekundy i tę samą wiedzę o strefie czasowej po obu stronach, więc wymyślenie offsetu albo zaokrąglenie ułamkowej sekundy ukryłoby realną niezgodę

Obecność ma własną regułę. TPdfAMetadataValues.Present to zbiór wypełniany przez przejście po słowniku /Info aktywnego traileru, i rozdziela „klucza brak” od „klucz obecny z pustym stringiem”. Nieobecny klucz daje pamsNotRequired i nie żąda niczego od XMP; /Title () jest obecny, więc pakiet XMP musi nieść także pusty tytuł x-default

Jak obejrzeć metadane Info i XMP przed zapisem?

TPdf.InspectPdfAMetadata zwraca TPdfAMetadataReport z jednym TPdfAMetadataComparison na pole, każdy niesie wartość Info, wartość XMP i TPdfAMetadataState, więc porażkę da się wyjaśnić bez reverse-engineeringu pojedynczej flagi walidacji. MismatchFields streszcza zbiór niezgodnych pól, HasXmpPacket mówi, czy pakiet został znaleziony, a XmpParseError niesie komunikat parsera, gdy pakiet istnieje, ale nie da się go przeczytać

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;

Co zmienia NormalizePdfAMetadata i czego odmawia?

TPdf.NormalizePdfAMetadata traktuje słownik Info jako źródło prawdy i przepisuje wyłącznie właściwości XMP, których pole wylądowało w MismatchFields; wszystko inne w pakiecie przeżywa. Title i Subject są wpisywane do wpisu x-default, podczas gdy inne alternatywy językowe zostają nietknięte, Author staje się jednoelementowym rdf:Seq, nieznane przestrzenie nazw i niepowiązane właściwości są zachowywane, a właściwości XMP dla nieobecnych kluczy Info zostają nietknięte. Data Info ze strefą jest zapisywana jako kanoniczna data XMP w UTC z sufiksem Z; data bez strefy zachowuje swoje lokalne składniki. Overload plikowy zapisuje przez plik tymczasowy i atomową podmianę, a sama aktualizacja XMP jest doklejana jako aktualizacja przyrostowa

Co NormalizePdfAMetadata przepisuje przy naprawianiu metadanych PDF/A w Delphi z PDFium Component: Info jest źródłem prawdy, tylko wpisy z MismatchFields są zapisywane z powrotem jako tekst Alt x-default, jednoelementowy Seq albo kanoniczna data UTC, podczas gdy nieznane przestrzenie nazw, niepowiązane właściwości i właściwości nieobecnych kluczy przeżywają nietknięte
Naprawa odmawia brakującego pakietu XMP, zniekształconej daty Info i dokumentów podpisanych, bo budowa kompletnego zestawu metadanych PDF/A to robota SaveAsPdfA, a nie punktowej naprawy równoważności

Odmowy są celowe. Bez pakietu XMP metoda rzuca EPdfError, bo budowa kompletnej identyfikacji i zestawu metadanych PDF/A to robota SaveAsPdfA, opisana w tekście o tworzeniu plików archiwalnych PDF/A z PDFium Component. Zniekształcona data Info rzuca EPdfXmpError, zamiast zapisać wiarygodnie wyglądającą złą wartość, i nic nie jest zapisywane. Dokumenty podpisane są odrzucane, dopóki wywołujący nie przekaże AllowSignedDocument = True. Równoważność to też tylko jedna z reguł ISO 19005-1, więc znormalizowany plik nie jest automatycznie zgodny

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;                      // już spójne, zostaw plik w spokoju
    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      // zniekształcona data Info albo nieczytelny pakiet
        raise Exception.CreateFmt('Cannot normalize %s: %s', [Source, E.Message]);
    end;
  finally
    Pdf.Free;
  end;
end;

Uruchamianie porównania na własnym pakiecie XMP

ComparePdfAInfoAndXmp i SynchronizePdfAInfoToXmp to zwykłe funkcje w FPdfPdfa działające na TPdfXmpPacket bez wczytanego dokumentu, co pasuje testom jednostkowym i potokom składającym XMP z szablonu. Jedna pułapka to Present: rekord zainicjalizowany przez Default(TPdfAMetadataValues) ma pusty zbiór, każde pole raportuje wtedy pamsNotRequired, a porównanie przechodzi próżniowo, niezależnie od tego, jakie wartości wpisałeś

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 rozstrzyga, które pola są obowiązkowe; same wartości są ignorowane
  Info.Present := [pamfTitle, pamfAuthor, pamfModDate];

  Packet := TPdfXmpPacket.Parse(TFile.ReadAllText(TemplateFile, TEncoding.UTF8));
  try
    Changed := SynchronizePdfAInfoToXmp(Info, Packet);
    // xmp:ModifyDate to teraz 2026-08-27T01:36:59Z, dc:creator to jednoelementowy rdf:Seq
    if Changed <> [] then
      TFile.WriteAllBytes(TemplateFile, Packet.ToUtf8);
  finally
    Packet.Free;
  end;
end;

Jeśli twój potok archiwizuje dokumenty, które inne systemy ciągle edytują, sparuj nocny przegląd InspectPdfAMetadata z NormalizePdfAMetadata dla plików, które odjechały, i trzymaj ValidatePdfA jako bramkę, zanim cokolwiek odjedzie do długoterminowego magazynu. Typowany raport, ścieżka naprawy i reszta narzędzi PDF/A jest w PDFium Component dla Delphi i C++Buildera