Artykuł techniczny

Pliki powiązane PDF/A-3 i AFRelationship w Delphi

Żeby załączyć plik źródłowy do dokumentu PDF/A-3 z Delphi, PDFium Component zapisuje łańcuch associated-file z PDF 2.0: strumień osadzonego pliku z MIME /Subtype, specyfikację pliku niosącą /AFRelationship i tablicę /AF powieszoną na katalogu albo stronie. InjectAssociateFiles i TPdf.SaveAsWithAssociateFiles budują ten łańcuch w jednej aktualizacji przyrostowej, a od v3.121.2 typ MIME jest serializowany jako pojedyncza, poprawnie escapowana nazwa PDF. Reszta tego wpisu pokrywa, co sprawdza walidator, błąd jednego znaku, który połamał text/plain, oraz miejsca, gdzie starsze wydania po cichu robiły coś innego, niż prosiłeś

Czego faktycznie potrzebuje plik powiązany PDF/A-3?

Załącznik PDF/A-3 przechodzi walidację tylko wtedy, gdy trzy obiekty zgadzają się ze sobą: strumień osadzonego pliku deklaruje /Type /EmbeddedFile plus MIME /Subtype, słownik specyfikacji pliku (ISO 32000-2 §7.11.3) niesie /F, /UF, /EF i /AFRelationship, a coś w dokumencie powołuje się na tę specyfikację przez tablicę /AF (ISO 32000-2 §14.13). Gołe osadzenie przez drzewo /Names /EmbeddedFiles, czyli to, co robi TPdf.CreateAttachment, w ogóle nie ustawia pól skojarzenia. Fixtura walidacji PDF/A-3b samego PDFium Component robi tę zależność konkretną: przemianujesz tylko klucz /AFRelationship i plik wywala dokładnie jedną regułę w punkcie 6.8 ISO 19005-3; zrzucisz tylko MIME /Subtype i wywala się inna reguła 6.8; włożysz ten sam załącznik do kandydata PDF/A-1b i jest odrzucany wprost, bo PDF/A-1 zakazuje osadzonych plików, niezależnie od tego, jak schludne są metadane

Łańcuch trzech obiektów pliku powiązanego PDF/A-3 w PDFium Component: strumień EmbeddedFile z MIME Subtype, jak application xml, specyfikacja pliku z F, UF, EF i AFRelationship ustawionym na Data, oraz tablica AF dla niego z katalogu albo strony — trzy obiekty, które walidator sprawdza, zanim punkt 6.8 ISO 19005-3 przejdzie
Strumień, specyfikacja pliku i tablica AF muszą się zgadzać; gołe osadzenie przez drzewo nazw w TPdf.CreateAttachment nie ustawia żadnego pola skojarzenia i nigdy nie ustawi

Wartość relacji to ta część, którą ludzie lubią zgadywać. TPdfAFRelationship w FPdfAssocFiles mapuje po jednym członku enuma na każdy token nazwy, jaki injector potrafi wyemitować, i tylko pierwsze pięć należy do podzbioru rozpoznawanego przez ISO 19005-3:

  • afSource → /Source: oryginał, z którego powstał PDF, na przykład plik edytora tekstu albo arkusz
  • afData → /Data: dane czytelne maszynowo, z których pochodzi widoczna treść albo które reprezentuje
  • afAlternative → /Alternative, afSupplement → /Supplement, afUnspecified → /Unspecified
  • afEncryptedPayload, afFormData, afTemplate: dodatki PDF 2.0 wypadające poza podzbiór PDF/A-3, więc trzymaj je z dala od wyjścia archiwalnego

Dlaczego /Subtype /text/plain połamał walidację?

Błąd MIME był błędem tokenizacji, nie luką zgodności: przed v3.121.2 injector kleił łańcuch wywołującego wprost po ukośniku, produkując /Subtype /text/plain. W składni PDF drugi ukośnik zaczyna nowy obiekt nazwy (ISO 32000-1 §7.3.5), więc słownik strumienia nagle trzymał klucz /Subtype, nazwę /text i nadmiarową wiszącą nazwę /plain, która rozbalansowała pary klucz-wartość. Samodzielny walidator PDF/A odrzucał plik w trakcie parsowania słownika EmbeddedFile, zanim w ogóle doszedł do reguły PDF/A, dlatego awaria wyglądała jak uszkodzenie pliku, a nie brakująca właściwość załącznika

Poprawka prowadzi wartość MIME przez EscapePdfName, które emituje /text#2Fplain: jedną nazwę, której zdekodowana wartość to text/plain. Escapowanie jest celowo szersze niż ukośnik. Każdy bajt równy 32 lub niższy (spacja, tabulator, CR, LF), każdy bajt równy 127 lub wyższy, separatory ()<>[]{}/% i sam znak escapowania # stają się #XX. Escapowanie samego ukośnika zostawiłoby inną dziurę: łańcuch MIME zawierający >> albo biały znak mógłby zamknąć słownik za wcześnie albo wstrzyknąć dodatkowe klucze, więc test regresji podaje wartość wroga z każdym separatorem plus tabulatorem, LF i CR i sprawdza dokładne zakodowane wyjście

Dlaczego subtype MIME text slash plain połamał parsowanie PDF/A-3 w PDFium Component: sklejenie wartości po ukośniku produkowało dwa obiekty nazw, /text jako wartość plus wiszącą /plain rozbalansowującą słownik EmbeddedFile, a poprawka z v3.121.2 prowadzi wartość przez EscapePdfName, więc /text#2Fplain to jedna nazwa dekodująca się do text/plain
Awaria wyglądała jak uszkodzenie pliku, bo działo się to na parserze, przed jakąkolwiek regułą PDF/A; escapowana nazwa trzyma pary w równowadze i walidator czyta dalej
// Co injector zapisuje dla MIMEType = 'text/plain'
//   przed v3.121.2:  /Type /EmbeddedFile /Subtype /text/plain     (dwie nazwy)
//   v3.121.2:         /Type /EmbeddedFile /Subtype /text#2Fplain   (jedna nazwa)
//
// Wywołujący zawsze podają zwykłą wartość MIME. Pre-escapowanie jej samemu
// podwójnie koduje '#', co zamienia 'text#2Fplain' w 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';

Budowanie pliku PDF/A-3 z InjectAssociateFiles

Dla wyjścia PDF/A-3 wyprodukuj zgodny dokument bazowy przez TPdf.SaveAsPdfAToStream, a potem wołaj InjectAssociateFiles na tym strumieniu; ten dwuetapowy pipeline to dokładnie to, co robi fixtura walidacyjna, zanim przejdzie PDF/A-3b. TPdf.SaveAsWithAssociateFiles to wygodny wrapper, ale zapisuje zwykłą ścieżką SaveAs z saRemoveSecurity, a nie przez pisarza PDF/A, więc nie dodaje identyfikacji XMP i output intent, których PDF/A wymaga. Zauważ, że typy rekordów mieszkają w FPdfAssocFiles i FPdfPdfa, więc obie jednostki należą do twojej klauzuli uses. Od v3.121.3 FileName i Description nie muszą być już gołym ASCII: /UF i /Desc są zapisywane jako łańcuchy tekstowe PDF, drukowalne ASCII literalnie, a cokolwiek innego jako UTF-16BE ze znacznikiem kolejności bajtów, podczas gdy wiekowa nazwa /F jest zawsze przenośnym drukowalnym ASCII z każdym innym znakiem zamienionym na _, więc czytniki dekodujące /F własną stroną kodową pokażą podkreślnik zamiast krzaków. Starsze buildy konwertowały wszystkie trzy przez systemową stronę kodową ANSI na Delphi albo pisały surowe bajty UTF-8 na Free Pascal, więc trzymaj nazwy w ASCII tylko wtedy, gdy starsze buildy muszą produkować identyczne wyjście

uses
  System.SysUtils, System.Classes, System.IOUtils,
  PDFium, FPdfPdfa, FPdfAssocFiles;

procedure SaveWithSourceData(Pdf: TPdf; const XmlPath, OutPath: string);
var
  PdfAOptions: TPdfASaveOptions;
  Options: TAssocFilesOptions;
  Base: TMemoryStream;
  Output: TFileStream;
begin
  PdfAOptions := TPdfASaveOptions.Default;
  PdfAOptions.Conformance := pac3b;

  Options := TAssocFilesOptions.Default;      // TargetPage = 0: /AF na poziomie dokumentu
  SetLength(Options.Files, 1);
  Options.Files[0].FileName := 'invoice-data.xml';
  Options.Files[0].Description := 'Structured invoice data';
  Options.Files[0].Content := TFile.ReadAllBytes(XmlPath);
  Options.Files[0].Relationship := afData;
  Options.Files[0].MIMEType := 'application/xml';  // zapisywane jako /application#2Fxml

  Base := TMemoryStream.Create;
  try
    if not Pdf.SaveAsPdfAToStream(Base, PdfAOptions) then
      raise Exception.Create('PDF/A-3 base save failed');
    Output := TFileStream.Create(OutPath, fmCreate);
    try
      InjectAssociateFiles(Base, Output, Options);  // przewija Base; rzuca EPdfAssocFilesError przy porażce
    finally
      Output.Free;
    end;
  finally
    Base.Free;
  end;
end;

Katalog albo strona: gdzie ląduje tablica /AF?

TAssocFilesOptions.TargetPage rozstrzyga, czyj jest tablica /AF: 0 przypina ją do katalogu jako skojarzenie na poziomie dokumentu, a 1..N do słownika tej strony, z numeracją od 1. Injector dokłada wszystko jako pojedynczą aktualizację przyrostową w stałym układzie (osadzone strumienie, potem specyfikacje plików, potem tablica /AF, potem przepisany katalog albo obiekt strony), więc istniejące obiekty zachowują swoje przesunięcia i nic nie jest rekompresowane. Każdy wcześniejszy wpis /AF na słowniku docelowym jest zastępowany, nie scalany, co czyni powtórzony zapis idempotentnym, ale znaczy też, że drugie wywołanie z inną listą plików wygrywa. Dwa zachowania dawniej zasługiwały na straż we własnym kodzie i oba się zmieniły. Przed v3.122.0 TargetPage poza zakresem nie padał; cofał się do katalogu, więc literówka zamieniała skojarzenie na poziomie strony w dokumentowe bez żadnego sygnału. Od v3.122.0 SaveAsWithAssociateFiles i SaveAsWithAssociateFilesToStream podnoszą EPdfError, gdy TargetPage jest poza 0..PageCount, a InjectAssociateFiles podnosi nowy EPdfAssocFilesError dla ujemnego TargetPage albo takiego, który nie nazywa istniejącej strony, zostawiając strumień docelowy nietknięty. Przed v3.121.4 wyszukiwanie strony skanowało zapisane bajty pod kątem słowników /Type /Page w kolejności plikowej, co mogło przypiąć plik do innej strony, skoro tylko obiekty stron były zapisane w innej kolejności niż są wyświetlane, na przykład po przetasowaniu albo wstawieniu stron; od v3.121.4 TargetPage nazywa stronę na tej pozycji w kolejności stron dokumentu

Gdzie ląduje tablica AF w PDFium Component: TargetPage zero przypina ją do katalogu, strony od 1 do N do słownika strony, a wartość poza zakresem, która przed v3.122.0 po cichu cofała się do katalogu, teraz podnosi wyjątek, podczas gdy injector dokłada wszystko jako jedną aktualizację przyrostową w stałym układzie, który zachowuje istniejące przesunięcia i zastępuje każdy wcześniejszy wpis AF
Przed v3.122.0 TargetPage poza zakresem po cichu stawał się skojarzeniem na poziomie dokumentu; bieżące wydania podnoszą zamiast tego wyjątek, a drugie wywołanie z inną listą plików nadal wygrywa
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
  const CsvBytes: TBytes; const OutPath: string);
var
  Options: TAssocFilesOptions;
begin
  // Od v3.122.0 TargetPage poza zakresem podnosi EPdfError (starsze buildy
  // po cichu cofały się do /AF na poziomie katalogu); sprawdzenie najpierw nazywa stronę
  if (PageNumber < 1) or (PageNumber > Pdf.PageCount) then
    raise EArgumentOutOfRangeException.CreateFmt('No page %d', [PageNumber]);

  Options := TAssocFilesOptions.Default;
  Options.TargetPage := PageNumber;
  SetLength(Options.Files, 1);
  Options.Files[0].FileName := 'chart-data.csv';
  Options.Files[0].Content := CsvBytes;
  Options.Files[0].Relationship := afSource;
  Options.Files[0].MIMEType := 'text/csv';

  if not Pdf.SaveAsWithAssociateFiles(OutPath, Options) then
    raise Exception.Create('Associated-file save failed');
end;

Jak czytać AFRelationship z powrotem niezawodnie?

TPdf.AttachmentRelationship[Index] zwraca nazwę /AFRelationship załącznika przez natywny eksport FPDFAttachment_GetAFRelationship, ale pusty łańcuch ma dwa możliwe znaczenia, więc wołaj najpierw AttachmentRelationshipFeaturesAvailable. Wiązanie jest ładowane pobłażliwie: gdy DLL PDFium nie ma tego eksportu, każda relacja czyta się jako pusta, co jest nie do odróżnienia od specyfikacji pliku, która po prostu nie ma /AFRelationship. Właściwość dzieli też swój indeks z AttachmentCount, które liczy wpisy w drzewie /Names /EmbeddedFiles. Injector zapisuje tylko łańcuch /AF i nie dodaje wpisu w drzewie nazw, więc plik załączony przez InjectAssociateFiles jest poza tym indeksem; żeby potwierdzić wstrzyknięty łańcuch, obejrzyj zapisane bajty albo odpal walidator PDF/A. Wnętrza tego drzewa nazw opisuje praca z załącznikami PDF w Delphi z PDFium Component

procedure ReportRelationships(const FileName: string);
var
  Pdf: TPdf;
  I: Integer;
  Rel: string;
begin
  if not AttachmentRelationshipFeaturesAvailable then
  begin
    Writeln('This PDFium build cannot report /AFRelationship');
    Exit;  // pusta odpowiedź byłaby wieloznaczna, więc nie pytaj
  end;

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    for I := 0 to Pdf.AttachmentCount - 1 do
    begin
      Rel := Pdf.AttachmentRelationship[I];
      if Rel = '' then
        Rel := '(no /AFRelationship)';
      Writeln(Pdf.AttachmentName[I], ': ', Rel);
    end;
  finally
    Pdf.Free;
  end;
end;

Czego SaveAsWithAssociateFiles nie gwarantuje?

TPdf.SaveAsWithAssociateFiles gwarantuje kopertę formatu plikowego i to, że żądane pliki zostały wstrzyknięte, nie zgodność ze standardem. Część z wstrzykiwaniem jest nowa: przed v3.122.0, gdy zapisane bajty nie miały czytelnego trailera albo nie dało się zlokalizować słownika katalogu, InjectAssociateFiles przepuszczał wejście bez zmian, a metoda i tak zwracała True. Od v3.122.0 InjectAssociateFiles podnosi EPdfAssocFilesError w tych przypadkach przed czymkolwiek zapisem, SaveAsWithAssociateFiles zwraca False, a ponieważ buduje teraz kompletne wyjście w magazynie zapisu przed otwarciem celu, odrzucony albo nieudany zapis nie skraca już istniejącego pliku. Pusta tablica Files nadal przepuszcza dokument bez zmian, z założenia. Treść ładunku też jest twoją odpowiedzialnością: injector nie sprawdza, czy plik XML jest dobrze uformowany, czy typ MIME zgadza się z bajtami ani czy dokument bazowy jest w ogóle PDF/A. Traktuj finalny plik jako niezweryfikowany, dopóki nie zobaczy go walidator — ta sama dyscyplina, którą opisuje PDFium Component i zgodność archiwalna PDF/A. Jeśli sam parsujesz przychodzące słowniki, te same reguły nazw #XX obowiązują w drugą stronę — temat opisuje pułapki tokenów nazw przy parsowaniu słowników PDF

Pliki powiązane, wyjście PDF/A, metadane załączników i walidacja płyną w tym samym komponencie, więc pipeline powyżej chodzi bez drugiej biblioteki PDF w projekcie. Referencja API, pobranie próbne i opcje licencjonowania są na stronie produktu PDFium Component