Ż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
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 arkuszafData→/Data: dane czytelne maszynowo, z których pochodzi widoczna treść albo które reprezentujeafAlternative→/Alternative,afSupplement→/Supplement,afUnspecified→/UnspecifiedafEncryptedPayload,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
// 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
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