Techninis straipsnis

PDF/A-3 associated files ir AFRelationship Delphi

Norėdami iš Delphi prisegti šaltinio failą prie PDF/A-3 dokumento, PDFium Component rašo PDF 2.0 associated-file grandinę: įmontuotą failo srautą su MIME /Subtype, failo specifikaciją, nešančią /AFRelationship, ir /AF masyvą, pakabintą ant katalogo arba puslapio. InjectAssociateFiles ir TPdf.SaveAsWithAssociateFiles tą grandinę pastato vienu inkrementiniu atnaujinimu, o nuo v3.121.2 MIME tipas serializuojamas kaip vienas, teisingai išegspintas PDF vardas. Likusi šio įrašo dalis — apie tai, ką tikrina validatorius, vieno simbolio bugą, sulaužiusį text/plain, ir apie vietas, kur senesni leidimai tyliai darė ką nors kita, nei paprašėte

Ko iš tikrųjų reikia PDF/A-3 associated file?

PDF/A-3 priedas praeina validaciją tik tada, kai trys objektai sutaria tarpusavyje: įmontuotas failo srautas deklaruoja /Type /EmbeddedFile plus MIME /Subtype, failo specifikacijos žodynas (ISO 32000-2 §7.11.3) neša /F, /UF, /EF ir /AFRelationship, o kažkas dokumente rodo į tą failo specifikaciją per /AF masyvą (ISO 32000-2 §14.13). Paprastas įmontavimas per /Names /EmbeddedFiles medį, kurį daro TPdf.CreateAttachment, asociacijos laukų apskritai nenustato. PDFium Component paties PDF/A-3b validacijos fixture priklausomybę padaro konkrečia: pervadinkite vien /AFRelationship raktą, ir failas krenta lygiai ant vienos ISO 19005-3 6.8 skyriaus taisyklės; numeskite vien MIME /Subtype — krenta kita 6.8 taisyklė; tą patį priedą įstatykite į PDF/A-1b kandidatą ir jis atmetamas iš karto, nes PDF/A-1 draudžia įmontuotus failus, kiek besitvarkytų metadata

PDF A-3 associated file trijų objektų grandinė PDFium Component: EmbeddedFile srautas su MIME Subtype, tokiu kaip application xml, failo specifikacija su F, UF, EF ir AFRelationship, nustatytu į Data, ir /AF masyvas jam iš katalogo arba puslapio — trys objektai, kuriuos validatorius tikrina, kol ISO 19005-3 6.8 skyrius praeina
Srautas, failo specifikacija ir AF masyvas privalo sutarti; paprastasis name-tree įmontavimas per TPdf.CreateAttachment nustato nė vieno asociacijos lauko ir niekada nenustatys

Santykio reikšmė — dalis, kurios žmonės linkę spėlioti. TPdfAFRelationship iš FPdfAssocFiles vieną enum narį sieja su kiekvienu vardo token, kurį injektorius gali išduoti, ir tik pirmi penki priklauso ISO 19005-3 pripažįstamam poaibiui:

  • afSource → /Source: originalas, iš kurio pagamintas PDF, pavyzdžiui teksto apdorojimo failas ar skaičiuoklė
  • afData → /Data: mašininė duomenų forma, iš kurios gautas ar kurią atstovauja matomas turinys
  • afAlternative → /Alternative, afSupplement → /Supplement, afUnspecified → /Unspecified
  • afEncryptedPayload, afFormData, afTemplate: PDF 2.0 papildymai, iškritę iš PDF/A-3 poaibio, tad laikykite juos nuošaly nuo archyvinės išvesties

Kodėl /Subtype /text/plain sulaužė validaciją?

MIME bugas buvo tokenizacijos klaida, o ne atitikties plyšys: iki v3.121.2 injektorius kvietėjo stringą prikabindavo tiesiai po pasviro brūkšnio, gaudamas /Subtype /text/plain. PDF sintaksėje antrasis pasvirasis brūkšnis pradeda naują vardo objektą (ISO 32000-1 §7.3.5), tad srauto žodyne staiga atsirado raktas /Subtype, vardas /text ir pakibęs papildomas vardas /plain, išbalansavęs rakto ir reikšmės poras. Nepriklausomas PDF/A validatorius failą atmetė dar išskaidydamas EmbeddedFile žodyną, dar prieš pasiekdamas bet kurią PDF/A taisyklę — todėl nesėkmė atrodė kaip failo sugadinimas, o ne kaip trūkstama priedo savybė

Pataisymas MIME reikšmę praleidžia per EscapePdfName, kuris išduoda /text#2Fplain: vieną vardą, kurio dekoduota reikšmė yra text/plain. Escaping sąmoningai platesnis už vien pasvirąjį brūkšnį. Kiekvienas baitas iki 32 imtinai (tarpsimbolis, tab, CR, LF), kiekvienas baitas nuo 127 imtinai, skiriamieji ()<>[]{}/% ir pats # escape simbolis virsta #XX. Vieno brūkšnio escaping paliktų kitokią skylę: MIME string, turintis >> ar tarpą, galėtų per anksti uždaryti žodyną arba įkišti papildomų raktų, todėl regresijos testas sulea priešišką reikšmę su kiekvienu skiriamuoju plus tab, LF ir CR ir tikrina tikslią užkoduotą išvestį

Kodėl MIME subtype text slash plain sulaužė PDF A-3 išskaidymą PDFium Component: reikšmės prikabinimas po pasviro brūkšnio pagimdė du vardo objektus, /text kaip reikšmę plus pakibusį /plain, išbalansavusį EmbeddedFile žodyną, o v3.121.2 pataisymas reikšmę praleidžia per EscapePdfName, tad /text#2Fplain yra vienas vardas, dekoduojantis į text/plain
Nesėkmė atrodė kaip failo sugadinimas, nes įvyko pas parserį, dar prieš bet kurią PDF/A taisyklę; pataisytasis vardas išlaiko poras subalansuotas, ir validatorius skaito toliau
// Ką injektorius rašo MIMEType = 'text/plain' atveju
//   iki v3.121.2:  /Type /EmbeddedFile /Subtype /text/plain     (du vardai)
//   v3.121.2:      /Type /EmbeddedFile /Subtype /text#2Fplain   (vienas vardas)
//
// Kvietėjai visada perduoda paprastą MIME reikšmę. Patiems ją iš anksto
// užkodavus, '#' užkoduojasi dar kartą: 'text#2Fplain' virsta 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';

PDF/A-3 failo statymas su InjectAssociateFiles

PDF/A-3 išvesčiai pagaminkite atitinkantį bazinį dokumentą per TPdf.SaveAsPdfAToStream, o paskui ant to srauto kvieskite InjectAssociateFiles; būtent tą dviejų žingsnių konvejerį paleidžia validacijos fixture, prieš praleisdamas PDF/A-3b. TPdf.SaveAsWithAssociateFiles — patogus apvalkalas, bet jis išsaugo per įprastą SaveAs kelią su saRemoveSecurity, o ne per PDF/A rašytoją, tad neprideda XMP identifikacijos ir output intent, kurių PDF/A reikalauja. Turėkite omenyje, jog įrašų tipai gyvena FPdfAssocFiles ir FPdfPdfa, tad abu vienetus reikia įtraukti į savą uses eilutę. Nuo v3.121.3 FileName ir Description nebeprivalo būti paprastas ASCII: /UF ir /Desc rašomi kaip PDF text string, spausdinamas ASCII — pažodžiui, o visa kita kaip UTF-16BE su baitų eiliškumo ženklu, tuo tarpu paveldėtasis /F vardas visada yra perkeliamas spausdinamas ASCII, kiekvieną kitą simbolį pakeičiant _, tad skaitytojai, dekoduojantys /F savo kodų puse, vietoj mojibake parodys pabraukimą. Ankstesni build visus tris pavardavo per sistemos ANSI kodų pusę Delphi arba rašydavo žalius UTF-8 baitus Free Pascal, tad jei senesni build turi duoti tą patį rezultatą — laikykitės vien ASCII vardų

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: katalogo lygio /AF
  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';  // rašoma kaip /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);  // sugražina Base į pradžią; nesėkmės atveju kelia EPdfAssocFilesError
    finally
      Output.Free;
    end;
  finally
    Base.Free;
  end;
end;

Katalogas ar puslapis: kur atsiduria /AF masyvas?

TAssocFilesOptions.TargetPage nusprendžia /AF masyvo savininką: 0 prisega jį prie katalogo kaip dokumento lygio asociaciją, o 1..N — prie to puslapio žodyno, numeruojant nuo 1. Injektorius viską prideda vienu inkrementiniu atnaujinimu fiksuotame išdėstyme (įmontuotieji srautai, tada failo specifikacijos, tada /AF masyvas, tada perrašytas katalogas ar puslapio objektas), tad esami objektai išlaiko savo poslinkius, ir niekas nespaudžiamas iš naujo. Bet koks ankstesnis /AF įrašas ant target žodyno pakeičiamas, o ne sujungiamas, kas pakartotinį išsaugojimą daro idempotentišką, bet ir reiškia, jog antras kvietimas su kitokiu failų sąrašu laimi. Dvi elgsenos anksčiau nusipelnydavo apsaugos jūsų pačių kode, ir abi pasikeitė. Iki v3.122.0 už ribų esantis TargetPage nepavykdavo; jis grįždavo prie katalogo, tad spausdinimo klaida be jokio signalo puslapio lygio asociaciją paveldavo į dokumento lygio. Nuo v3.122.0 SaveAsWithAssociateFiles ir SaveAsWithAssociateFilesToStream kelia EPdfError, kai TargetPage už 0..PageCount ribų, o InjectAssociateFiles kelia naująjį EPdfAssocFilesError neigiamam TargetPage ar tokiam, kuris neįvardija jokio egzistuojančio puslapio, palikdamas paskirties srautą nepaliestą. Iki v3.121.4 puslapio paieška skenavo išsaugotus baitus ieškodama /Type /Page žodynų failo tvarka, kas galėjo prisegti failą prie kito puslapio, vos tik puslapio objektai būdavo saugomi kita tvarka, nei rodomi, pavyzdžiui po puslapių perrikiavimo ar įterpimo; nuo v3.121.4 TargetPage įvardija puslapį toje pozicijoje dokumento puslapių tvarkoje

Kur atsiduria AF masyvas PDFium Component: TargetPage nulinis prisega jį prie katalogo, 1 iki N puslapiai — prie puslapio žodyno, o už ribų esanti reikšmė, kuri iki v3.122.0 tyliai grįždavo prie katalogo, dabar kelia išimtį, o injektorius viską prideda vienu inkrementiniu atnaujinimu fiksuotame išdėstyme, išlaikantis esamus poslinkius ir pakeičiantis bet kurį ankstesnį AF įrašą
Iki v3.122.0 už ribų esantis TargetPage tyliai tapdavo dokumento lygio asociacija; dabartiniai leidimai vietoj to kelia išimtį, o antras kvietimas su kitokiu failų sąrašu vis tiek laimi
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
  const CsvBytes: TBytes; const OutPath: string);
var
  Options: TAssocFilesOptions;
begin
  // Nuo v3.122.0 už ribų esantis TargetPage kelia EPdfError (senesni build
  // tyliai grįždavo prie katalogo lygio /AF); patikrinimas pirmiau įvardija puslapį
  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;

Kaip patikimai perskaityti AFRelationship atgal?

TPdf.AttachmentRelationship[Index] grąžina priedo /AFRelationship vardą per natyviąjį FPDFAttachment_GetAFRelationship eksportą, bet tuščia eilutė turi dvi galimas reikšmes, tad pirmiau pakvieskite AttachmentRelationshipFeaturesAvailable. Binding įkeliamas kantriai: kai PDFium DLL neturi to eksporto, kiekvienas santykis skaitomas kaip tuščias, kas neatskiriama nuo failo specifikacijos, paprasčiausiai neturinčios /AFRelationship. Savybė taip pat dalijasi savo indeksu su AttachmentCount, skaičiuojančiu /Names /EmbeddedFiles medžio įrašus. Injektorius rašo vien /AF grandinę ir neprideda name-tree įrašo, tad per InjectAssociateFiles prisegtas failas yra už to indekso ribų; kad įsitikintumėte dėl įleistosios grandinės, apžiūrėkite išsaugotus baitus arba paleiskite PDF/A validatorių. To name-tree vidaus reikalai aprašyti kaip dirbti su PDF priedais Delphi su 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;  // tuščias atsakymas būtų dviprasmis, todėl nebeklauskite
  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;

Ko SaveAsWithAssociateFiles negarantuoja?

TPdf.SaveAsWithAssociateFiles garantuoja failo formato voką ir tai, jog prašyti failai buvo įleisti, o ne atitiktį. Įleidimo dalis nauja: iki v3.122.0, kai išsaugotuose baituose nebūdavo įskaitomo trailer ar nepavykdavo rasti katalogo žodyno, InjectAssociateFiles pervedinėdavo įvestį nepakeistą, o metodas vis tiek grąžindavo True. Nuo v3.122.0 InjectAssociateFiles tais atvejais kelia EPdfAssocFilesError dar prieš ką nors rašydamas, SaveAsWithAssociateFiles grąžina False, ir, kadangi jis dabar visą išvestį supila į save store dar prieš atverdamas target, atmestas ar nepavykęs išsaugojimas daugiau nenukerpa esamo failo. Tuščias Files masyvas pagal projektą vis tiek perveda dokumentą nepakeistą. Ir pačios naštą turinys — jūsų atsakomybė: injektorius netikrina, ar XML failas geras, ar MIME tipas atitinka baitus, ar bazinis dokumentas apskritai yra PDF/A. Laikykite galutinį failą nepatikrintu, kol jo nematė validatorius — ta pati drausmė, aprašyta PDFium Component ir PDF/A archyvavimo atitiktyje. Jei įeinančius žodynus išskaidote ir patys, tos pačios #XX vardo taisyklės galioja atvirkščiai — tema, aptarta vardo token spąstuose išskaidant PDF žodynus

Associated files, PDF/A išvestis, priedų metadata ir validacija keliauja tame pačiame komponente, tad aukščiau aprašytas konvejeris veikia be antros PDF bibliotekos projekte. API nuoroda, bandomasis atsisiuntimas ir licencijavimo parinktys yra PDFium Component produkto puslapyje