Technisch artikel

PDF/A-3 associated files en AFRelationship in Delphi

Om vanuit Delphi een bronbestand aan een PDF/A-3-document te koppelen, schrijft PDFium Component een associated-file-keten volgens PDF 2.0: een embedded file-stream met een MIME-/Subtype, een bestandsspecificatie die /AFRelationship draagt, en een /AF-array die aan de catalog of een pagina hangt. InjectAssociateFiles en TPdf.SaveAsWithAssociateFiles bouwen die keten in één incrementele update, en sinds v3.121.2 wordt het MIME-type geserialiseerd als één correct geëscapete PDF-naam. De rest van dit bericht behandelt wat een validator controleert, de één-teken-bug die text/plain brak, en de plekken waar oudere releases geruisloos iets anders deden dan gevraagd

Wat heeft een PDF/A-3 associated file werkelijk nodig?

Een PDF/A-3-bijlage haalt de validatie alleen wanneer drie objecten het met elkaar eens zijn: de embedded file-stream declareert /Type /EmbeddedFile plus een MIME-/Subtype, de bestandsspecificatie-dictionary (ISO 32000-2 §7.11.3) draagt /F, /UF, /EF en /AFRelationship, en iets in het document verwijst via een /AF-array (ISO 32000-2 §14.13) naar die bestandsspecificatie. Gewoon inbedden via de /Names /EmbeddedFiles-boom, wat TPdf.CreateAttachment doet, zet de associatievelden helemaal niet. De eigen PDF/A-3b-validatiefixture van PDFium Component maakt de afhankelijkheid concreet: hernoem alleen de /AFRelationship-sleutel en het bestand faalt precies één regel in clausule 6.8 van ISO 19005-3; laat alleen het MIME-/Subtype vallen en een andere 6.8-regel faalt; stop dezelfde bijlage in een PDF/A-1b-kandidaat en hij wordt ronduit geweigerd, want PDF/A-1 verbiedt embedded files hoe netjes de metadata er ook uitziet

De drievoudige objectketen van een PDF A-3 associated file in PDFium Component: een EmbeddedFile-stream met een MIME-Subtype zoals application xml, een bestandsspecificatie met F, UF, EF en AFRelationship op Data, en een AF-array ervoor vanuit de catalog of een pagina — de drie objecten die een validator controleert voordat clausule 6.8 van ISO 19005-3 slaagt
Stream, bestandsspecificatie en AF-array moeten het eens zijn; het gewone naamboom-inbedden van TPdf.CreateAttachment zet geen enkel associatieveld en zal dat ook nooit doen

De relatie-waarde is het deel waar mensen doorgaans gokken. TPdfAFRelationship in FPdfAssocFiles mapt één enum-lid op elk naamtoken dat de injector kan uitzenden, en alleen de eerste vijf behoren tot de subset die ISO 19005-3 herkent:

  • afSource → /Source: het origineel waaruit de PDF is gemaakt, zoals een tekstverwerkingsbestand of een spreadsheet
  • afData → /Data: machinaleesbare data waaruit de zichtbare inhoud is afgeleid of die hij vertegenwoordigt
  • afAlternative → /Alternative, afSupplement → /Supplement, afUnspecified → /Unspecified
  • afEncryptedPayload, afFormData, afTemplate: PDF 2.0-aanvullingen die buiten de PDF/A-3-subset vallen, dus houd ze uit archiefuitvoer

Waarom brak /Subtype /text/plain de validatie?

De MIME-bug was een tokenisatiefout, geen conformiteitsgat: vóór v3.121.2 plakte de injector de string van de aanroeper ronduit achter een slash, wat /Subtype /text/plain opleverde. In PDF-syntaxis begint de tweede slash een nieuw naamobject (ISO 32000-1 §7.3.5), dus de stream-dictionary bevatte plotseling de sleutel /Subtype, de naam /text, en een bungelende extra naam /plain die de sleutel-waarde-paren uit balans bracht. Een onafhankelijke PDF/A-validator weigerde het bestand al bij het parsen van de EmbeddedFile-dictionary, voordat hij ooit een PDF/A-regel bereikte, wat verklaart waarom de faling leek op bestandscorruptie in plaats van op een ontbrekende bijlageneigenschap

De fix stuurt de MIME-waarde door EscapePdfName, wat /text#2Fplain uitzendt: één naam waarvan de gedecodeerde waarde text/plain is. De escaping is bewust ruimer dan de slash. Elke byte op of onder 32 (spatie, tab, CR, LF), elke byte op of boven 127, de scheidingstekens ()<>[]{}/% en het #-escape-teken zelf worden #XX. Alleen de slash escapen zou een ander gat hebben achtergelaten: een MIME-string met >> of witruimte kon de dictionary vroegtijdig sluiten of extra sleutels injecteren, dus de regressietest voert een vijandige waarde met elk scheidingsteken plus tab, LF en CR en controleert de exacte gecodeerde uitvoer

Waarom het MIME-subtype text slash plain het PDF A-3-parsen in PDFium Component brak: de waarde achter een slash plakken produceerde twee naamobjecten, /text als waarde plus een bungelende /plain die de EmbeddedFile-dictionary uit balans bracht, en de v3.121.2-fix stuurt de waarde door EscapePdfName zodat /text#2Fplain één naam is die decodeert naar text/plain
De faling leek op bestandscorruptie omdat ze bij de parser gebeurde, vóór elke PDF/A-regel; de geëscapete naam houdt de paren in balans en de validator lezend
// Wat de injector wegschrijft voor MIMEType = 'text/plain'
//   vóór v3.121.2:  /Type /EmbeddedFile /Subtype /text/plain     (twee namen)
//   v3.121.2:       /Type /EmbeddedFile /Subtype /text#2Fplain   (één naam)
//
// Aanroepers geven altijd de gewone MIME-waarde door. Zelf vooraf escapen
// codeert de '#' dubbel, wat 'text#2Fplain' in 'text#232Fplain' verandert
Options.Files[0].MIMEType := 'text/plain';

Een PDF/A-3-bestand bouwen met InjectAssociateFiles

Produceer voor PDF/A-3-uitvoer het conforme basisdocument met TPdf.SaveAsPdfAToStream en roep daarna InjectAssociateFiles op die stream aan; die tweestaps-pipeline is precies wat de validatiefixture draait voordat ze PDF/A-3b haalt. TPdf.SaveAsWithAssociateFiles is de gemakswikkel, maar hij slaat op via het gewone SaveAs-pad met saRemoveSecurity in plaats van via de PDF/A-writer, dus hij voegt de XMP-identificatie en output intent die PDF/A vereist niet toe. Bedenk dat de recordtypes in FPdfAssocFiles en FPdfPdfa wonen, dus beide units horen in uw uses-clausule. Sinds v3.121.3 hoeven FileName en Description niet meer plat ASCII te zijn: /UF en /Desc worden als PDF-tekststrings weggeschreven, printbaar ASCII letterlijk en al het restant als UTF-16BE met bytevolgorde-markering, terwijl de legacy-/F-naam altijd draagbaar printbaar ASCII is met elk ander teken vervangen door _, zodat readers die /F met hun eigen codepagina decoderen een underscore tonen in plaats van mojibake. Eerdere builds converteerden alle drie via de systeem-ANSI-codepagina op Delphi of schreven rauwe UTF-8-bytes op Free Pascal, dus houd namen alleen-ASCII als oudere builds dezelfde uitvoer moeten produceren

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 op catalogusniveau
  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';  // weggeschreven als /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);  // spoelt Base terug; gooit EPdfAssocFilesError bij faling
    finally
      Output.Free;
    end;
  finally
    Base.Free;
  end;
end;

Catalog of pagina: waar belandt de /AF-array?

TAssocFilesOptions.TargetPage bepaalt de eigenaar van de /AF-array: 0 hangt haar aan de catalog als documentniveau-associatie, en 1..N hangt haar aan die pagina-dictionary, 1-based. De injector voegt alles toe als één incrementele update in een vaste indeling (de embedded streams, dan de bestandsspecificaties, dan de /AF-array, dan een herschreven catalog- of pagina-object), dus bestaande objecten houden hun offsets en niets wordt opnieuw gecomprimeerd. Elke eerdere /AF-entry op de doeldictionary wordt vervangen, niet samengevoegd, wat een herhaalde opslag idempotent maakt maar ook betekent dat een tweede aanroep met een andere bestandslijst wint. Twee gedragingen verdienden voorheen een vangrail in uw eigen code, en beide zijn veranderd. Vóór v3.122.0 faalde een TargetPage buiten bereik niet; hij viel terug op de catalog, dus een typefout veranderde een pagina-niveau-associatie geruisloos in een op documentniveau. Sinds v3.122.0 werpen SaveAsWithAssociateFiles en SaveAsWithAssociateFilesToStream EPdfError wanneer TargetPage buiten 0..PageCount ligt, en InjectAssociateFiles werpt de nieuwe EPdfAssocFilesError voor een negatieve TargetPage of één die naar geen enkele bestaande pagina wijst, met de bestemmingsstream onveranderd. Vóór v3.121.4 zocht de paginalookup de opgeslagen bytes af naar /Type /Page-dictionaries in bestandsvolgorde, wat het bestand een keer aan een andere pagina kon hangen zodra pagina-objecten in een andere volgorde waren opgeslagen dan ze worden getoond, bijvoorbeeld na het herordenen of inservoegen van pagina's; sinds v3.121.4 noemt TargetPage de pagina op die positie in de documentpagina-volgorde

Waar de AF-array belandt in PDFium Component: TargetPage nul hangt haar aan de catalog, pagina's 1 tot N hangen haar aan de pagina-dictionary, en een waarde buiten bereik, die vóór v3.122.0 geruisloos terugviel op de catalog, werpt nu een exception op, terwijl de injector alles als één incrementele update in een vaste indeling toevoegt die bestaande offsets houdt en elke eerdere AF-entry vervangt
Vóór v3.122.0 werd een TargetPage buiten bereik geruisloos een associatie op documentniveau; huidige releases werpen in plaats daarvan op, en een tweede aanroep met een andere bestandslijst wint nog steeds
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
  const CsvBytes: TBytes; const OutPath: string);
var
  Options: TAssocFilesOptions;
begin
  // Sinds v3.122.0 werpt een TargetPage buiten bereik EPdfError op (oudere builds
  // vielen geruisloos terug op een /AF op catalogusniveau); eerst controleren noemt de pagina
  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;

Hoe leest u AFRelationship betrouwbaar terug?

TPdf.AttachmentRelationship[Index] geeft de /AFRelationship-naam van een bijlage terug via de native FPDFAttachment_GetAFRelationship-export, maar een lege string heeft twee mogelijke betekenissen, dus roep eerst AttachmentRelationshipFeaturesAvailable aan. De binding wordt coulant geladen: mist de PDFium-DLL die export, dan leest elke relatie als leeg, wat niet te onderscheiden is van een bestandsspecificatie die simpelweg geen /AFRelationship heeft. De eigenschap deelt zijn index met AttachmentCount, dat entries in de /Names /EmbeddedFiles-boom telt. De injector schrijft alleen de /AF-keten en voegt geen naamboom-entry toe, dus een bestand dat via InjectAssociateFiles is gekoppeld valt buiten die index; bevestig de geïnjecteerde keten door de opgeslagen bytes te inspecteren of een PDF/A-validator te draaien. De binnenkant van die naamboom wordt behandeld in het werken met PDF-bijlagen in Delphi met 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;  // een leeg antwoord zou dubbelzinnig zijn, dus vraag niet
  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;

Wat garandeert SaveAsWithAssociateFiles niet?

TPdf.SaveAsWithAssociateFiles garandeert de bestandsformaatomhulling en dat de gevraagde bestanden zijn geïnjecteerd, geen conformiteit. Het injecteerdeel is nieuw: vóór v3.122.0, wanneer de opgeslagen bytes geen leesbare trailer hadden of de catalog-dictionary niet te vinden was, kopieerde InjectAssociateFiles de invoer ongewijzigd door en gaf de methode nog steeds True terug. Sinds v3.122.0 werpt InjectAssociateFiles EPdfAssocFilesError in die gevallen voordat er iets wordt weggeschreven, SaveAsWithAssociateFiles geeft False terug, en omdat hij nu de volledige uitvoer in een opslagwinkel bouwt voordat hij het doel opent, kapt een geweigerde of mislukte opslag een bestaand bestand niet langer af. Een lege Files-array kopieert het document nog steeds ongewijzigd door, bij ontwerp. De inhoud van de payload is ook uw verantwoordelijkheid: de injector controleert niet dat een XML-bestand welgevormd is, dat het MIME-type bij de bytes past, of dat het basisdocument überhaupt PDF/A is. Beschouw het definitieve bestand als ongeverifieerd totdat een validator het heeft gezien, dezelfde discipline als in PDFium Component en PDF/A-archiefconformiteit. Parseert u ook inkomende dictionaries zelf, dan gelden dezelfde #XX-naamregels in omgekeerde richting, een onderwerp uit naamtoken-valkuilen bij het parsen van PDF-dictionaries

Associated files, PDF/A-uitvoer, bijlagemetadata en validatie verschepen in dezelfde component, dus de pipeline hierboven draait zonder een tweede PDF-library in de build. De API-referentie, de proefdownload en de licentieopties staan op de productpagina van PDFium Component