Teknisk artikel

Hold /ParentTree og /StructParents synkroniseret i Delphi

PDFium Component tager eksisterende PDF-indhold fra Delphi gennem EditPdfDocumentStructure i FPdfDocumentStructure.pas. Operationerne psekAddMarkedContent og psekAddObjectReference gør mere end at tilføje en MCR eller OBJR til et struktur elements /K: de allokerer også /StructParents- eller /StructParent-nøglen og skriver den matchende /ParentTree-indgang, hvilket er det omvendte indeks, som ISO 32000-1 §14.7.4.4 kræver

Den anden halvdel er dér, eftermontering af tags plejer at gå galt. At tilføje en BDC-wrapper og en MCR er let, og en viewer, der går strukturtræet igennem ovenfra, finder gladeligt det nye indhold. Assisterende teknologi går ofte den anden vej: den starter fra et stykke indhold på siden og spørger, hvilket struktur element ejer det. Har siden ingen /StructParents-nøgle, eller har /ParentTree ingen indgang til den nye MCID, fejler det opslag, og checkere rapporterer det som et manglende eller inkonsistent parent tree. Filen ligner tagget og virker stadig ikke

Hvad mapper /ParentTree egentlig?

/ParentTree er et number tree, der mapper heltalsnøgler til struktur elementer, og nøglerne kommer fra indholdet, ikke fra træet. ISO 32000-1 §14.7.4.4 giver hver side eller Form XObject, der indeholder marked content, et /StructParents-heltal, og parent tree-værdien under den nøgle er et array indekseret efter MCID: indgang 3 af arrayet er det struktur element, der ejer <</MCID 3>>. En annotation eller XObject brugt som et helt indholdsemne får i stedet en enkelt /StructParent-nøgle, og dens parent tree-værdi er struktur elementet selv, ikke et array

PDF/UA parent tree omvendt indeks skrevet af PDFium Component i Delphi: et indholdsmærke med MCID 3 resolves gennem sidens /StructParents-nøgle 17 ind i /ParentTree number tree, hvis arrayindgang 3 er det struktur element, der ejer det markede indhold
Læserækkefølgen betyder noget for tagget PDF: assisterende teknologi går fra MCID'en på siden gennem /StructParents ind i /ParentTree-arrayet, og en manglende nøgle eller indgang knækker det opslag, selvom strukturtræet selv ser fint ud

Containerformatet er det generiske number tree fra §7.9.7. Et blad holder /Nums, et fladt array af nøgle-værdi-par sorteret i stigende nøglerækkefølge. Mellemnoder og bladnoder under roden bærer /Limits med den mindste og største nøgle, de dækker, mens roden selv ikke må have /Limits. Dertil kan strukturtræ-roden bære /ParentTreeNextKey (§14.7.2), den næste nøgle, en writer bør udlevere. Enhver redigering, der tager nyt indhold, rører derfor op til fire objekter, der skal være enige: content streamen, side- eller form-dictionaryen, parent tree-bladet og strukturtræ-roden

Fremadretningen har sine egne regler, opstillet i §14.7.4.2 og §14.7.4.3. En marked-content-reference (MCR) til indhold i sidens egen content stream behøver kun /Pg og /MCID. Ligger det markede indhold inde i en Form XObject, behøver MCR'en også /Stm til at navngive den stream, fordi MCID'er nummereres pr. content stream. En objektreference (OBJR) navngiver annotationen eller XObject'en med /Obj og dens side med /Pg

Tagning af en content stream med EditPdfDocumentStructure

EditPdfDocumentStructure er en snapshot-bevogtet batcheditor. Du kalder først ReadPdfDocumentStructure for at få en TPdfDocumentStructureSnapshot og giver derefter dens SourceSha256 tilbage sammen med et array af TPdfStructureEditOperation-poster. Har kildebytes ændret sig i mellemtiden, afvises redigeringen uden output. Eksisterende elementer adresseres efter deres objektnummer i NodeID, og 0 betegner strukturtræ-roden

uses
  System.SysUtils, System.Classes, System.IOUtils, FPdfDocumentStructure;

function TagContentStream(const FileName, OutName: string;
  ElementID, StreamObj, StreamGen: Integer): Boolean;
var
  Source: TMemoryStream;
  Options: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  Ops: TPdfStructureEditOperations;
  Report: TPdfStructureEditReport;
  Output: TBytes;
begin
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile(FileName);
    Options := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, Options, Snapshot, Report) then
      raise Exception.Create(Report.ErrorMessage);

    SetLength(Ops, 1);
    Ops[0] := Default(TPdfStructureEditOperation);
    Ops[0].Kind := psekAddMarkedContent;
    Ops[0].NodeID := ElementID;            // eksisterende element, efter objektnummer
    Ops[0].TargetObjectNumber := StreamObj; // sideindhold-stream eller side-Form XObject
    Ops[0].TargetGeneration := StreamGen;

    Result := EditPdfDocumentStructure(Source, Snapshot.SourceSha256,
      Ops, Options, Output, Report);
    if not Result then
      raise Exception.Create(Report.ErrorMessage);
    TFile.WriteAllBytes(OutName, Output);
  finally
    Source.Free;
  end;
end;

For dén ene operation pakker EditPdfDocumentStructure målstreamen ind i /P <</MCID n>> BDC ... EMC, tilføjer MCR'en til elementets /K, giver siden (eller formen) en /StructParents-nøgle, hvis den mangler en, og skriver element n ind i parent tree-arrayet for den nøgle. Resultatet skrives som en trinvis opdatering efter de originale bytes, og Report.CreatedMarkedContents fortæller dig, hvor mange MCR'er der blev skabt. Indeholder streamen allerede afbalancerede BDC/EMC-par, pakkes kun den utaggede hale efter den sidste topniveau-EMC; ellers pakkes hele streamen

Hvordan vælges nye MCID'er og parent tree-nøgler?

En ny MCID er det aktuelle maksimum plus én, og en ny parent tree-nøgle er Max(MaxKey + 1, ParentTreeNextKey). Til MCID'en tager EditPdfDocumentStructure maksimum hen over de MCR'er, der allerede er registreret for den side (eller den Form XObject) og hver /MCID, den finder i streamens egne egenskabslister. At tælle begge betyder noget: et strukturtræ kan have mistet en MCR, mens indholdet stadig bærer mærket, og at genbruge det nummer ville få to elementer til at gøre krav på samme indhold

Nøglelogikken følger samme defensive idé. Har ejeren allerede en /StructParents-værdi, udvides det eksisterende array ved indeks MCID, med null, der fylder et eventuelt hul, og ingen ny nøgle allokeres. Har den det ikke, scanner komponenten hvert /Nums-blad for den højeste nøgle, sammenligner den med /ParentTreeNextKey og tager den større. En forældet /ParentTreeNextKey lavere end nøgler allerede i brug, og én, der reserverer nøgler forbi den sidste indgang, håndteres begge, fordi den største af de to værdier altid er ledig. Efter allokering skrider /ParentTreeNextKey frem til den nye nøgle plus én

MCID- og parent tree-nøgleallokering i PDFium Components struktureditor til Delphi: den nye MCID er én over maksimum allerede registreret i MCR'er og stream-egenskabslister, og nøglen er den større af den højeste /Nums-nøgle plus én og /ParentTreeNextKey, med null, der fylder arrayhuller
EditPdfDocumentStructure udleder den nye MCID fra både MCR'er og in-stream-mærker og allokerer parent tree-nøglen defensivt, så en forældet /ParentTreeNextKey eller en genbrugt MCID ikke kan få to elementer til at gøre krav på samme indhold

Det nye par indsættes i /Nums i stigende rækkefølge frem for at blive tilføjet, fordi number tree-opslag er afhængige af den rækkefølge. Bærer bladet /Limits, genberegnes grænserne fra det omskrevne array. Har strukturtræ-roden slet ingen /ParentTree, hvilket er almindeligt i filer tagget af værktøjer, der kun skrev det fremadrettede træ, syntetiserer EditPdfDocumentStructure et nyt indirekte << /Nums [] >>-objekt og linker det fra /StructTreeRoot, før den første indgang indsættes

Form XObjects, /StmOwn og objektreferencer

Marked content inde i en Form XObject understøttes kun, når den form bruges af præcis én side. Den MCR, EditPdfDocumentStructure skriver for en form, bærer /Pg, /Stm, der navngiver formstreamen, og /StmOwn, der navngiver siden, og /StructParents-nøglen går på form-dictionaryen frem for på siden. En form tegnet af tre sider har tre plausible ejere, så enhver enkelt /StmOwn ville være forkert for to af dem. Komponenten afviser redigeringen med en fejl i stedet for at gætte

Én-ejer-regel for tagning af Form XObject-indhold med PDFium Component i Delphi: en MCR på en form brugt af en enkelt side bærer /Pg, /Stm og /StmOwn med /StructParents på form-dictionaryen, mens en form tegnet af tre sider gør /StmOwn tvetydig, og redigeringen afvises
/StmOwn-nøglen giver kun mening, når én side ejer formen, hvilket er grunden til, at delte forms og delte annotationer afvises i stedet for at blive tagget med en gættelig ejer

Samme unikhedsregel gælder psekAddObjectReference. Målet skal være en annotation, der optræder i præcis én sides /Annots, eller en Form- eller Image XObject, der optræder i præcis én sides ressourcer; alt andet afvises. OBJR'en får /Obj og /Pg, målet får en /StructParent-nøgle (genbrugt, hvis til stede), og parent tree-værdien er en almindelig reference til elementet. Dette er det sædvanlige fix til en link-annotation eller et figurbillede, der blev tagget visuelt men aldrig forbundet til træet

// Pak et utagget billede ind i et nyt Figure-element, og bind det så på to måder:
// som en OBJR for image XObject'en og som marked content på siden
SetLength(Ops, 3);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekInsertContainer;
Ops[0].NodeID := -1;              // midlertidigt ID, skal være negativt og unikt
Ops[0].ParentNodeID := SectID;    // eksisterende Sect-element
Ops[0].Value := 'Figure';         // standardrolle eller en RoleMap-indgang

Ops[1] := Default(TPdfStructureEditOperation);
Ops[1].Kind := psekAddObjectReference;
Ops[1].NodeID := -1;              // refererer containeren oprettet ovenfor
Ops[1].TargetObjectNumber := ImageObj;
Ops[1].TargetGeneration := 0;

Ops[2] := Default(TPdfStructureEditOperation);
Ops[2].Kind := psekAddMarkedContent;
Ops[2].NodeID := -1;
Ops[2].TargetObjectNumber := ContentObj;
Ops[2].TargetGeneration := 0;

if EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops, Options,
  Output, Report) then
  Writeln(Format('Figure is object %d, %d OBJR, %d MCR', [
    Report.InsertedNodes[0].ObjectNumber,
    Report.CreatedObjectReferences, Report.CreatedMarkedContents]));

Negative NodeID-værdier navngiver containere oprettet tidligere i samme batch, og Report.InsertedNodes mapper hver anmodet ID til det objektnummer, den fik. psekInsertContainer accepterer standardroller i PDF 1.7, PDF 2.0 og PDF/UA som P, H1 til H6, Figure, Table eller LI plus custom-roller allerede mappet i /RoleMap. At sætte BeforeNodeID på de to indholdsoperationer placerer den nye MCR eller OBJR før et eksisterende barn i stedet for til sidst

Hvorfor behøver redigering af samme stream to gange i én batch omtanke?

En batch kan ramme samme objekt gentagne gange, og hver senere operation skal se resultatet af de tidligere. To psekAddMarkedContent-operationer mod samme side omskriver begge dens content stream, og begge opdaterer samme parent tree-blad. En implementering, der scenariserer hver redigering som et separat tilføjet objekt, skriver to versioner af objekt 12 i opdateringen. Afhængigt af, hvilken readeren beholder, mister du den første BDC-wrapper eller det første /Nums-par, og den anden MCID kan kollidere med den første, fordi den blev beregnet af forældede bytes

EditPdfDocumentStructure nøgler scenariserede opdateringer efter (ObjectNumber, Generation) og overskriver en eksisterende indgang på stedet, og den opfrisker samtidig objektets in-memory-kopi. Den anden operation parser derfor den allerede pakkede stream, ser MCID 0, allokerer MCID 1 og udvider samme parent tree-array. Ét objekt, én endelig version. Før noget returneres, læses outputtet tilbage og sammenlignes med in-memory-modellen: elementantal, indholdsemneantal og hver bevaret MCR eller OBJR skal matche, ellers fejler kaldet uden output

Komprimerede streams følger samme sti. En content stream med /Filter /FlateDecode (eller et ét-element [/FlateDecode]-array) og ingen /DecodeParms-predictor inflateres med en afgrænset decoder lofter ved 32 MiB eller MaxOutputBytes, hvad end det er mindst, pakkes ind og komprimeres tilbage til /FlateDecode med en ny /Length. En stream, der ville inflate forbi loftet, afvises frem for at blive afkortet, hvilket betyder noget for filer fra utroværdige kilder

Grænser værd at kende før en PDF/UA-reparationskørsel

EditPdfDocumentStructure er konservativ af design, og de fleste af dens grænser viser sig som en klar Report.ErrorMessage frem for som en stille knækket fil. Hav disse i mente, når du planlægger en batchreparation:

  • Kun ufiltrerede og almindelige FlateDecode-streams kan pakkes ind; andre filtre, eller Flate med en predictor, afvises
  • En content stream skal tilhøre præcis én sides /Contents, og Form XObjects, annotationer og XObjects skal bruges af præcis én side
  • Wrapperen bruger altid /P-tagget; den semantiske rolle kommer fra struktur elementets /S, ikke fra BDC-tagnavnet
  • Parent tree-synkroniseringen læser en flad /Nums-rod eller ét niveau af /Kids-blade; dybere mellemnoder gennemgås ikke, så re-validér sådanne filer efter redigering
  • Filer, der indeholder signaturer eller signaturfelter, afvises kategorisk, så signér efter strukturreparationen, ikke før

Valideringstrinet er ikke valgfrit for en reparationspipeline. Kør PDF/UA-strukturtrævalidering i PDFium Component før og efter batchen, og brug sideobjekt marked-content-API'en til at læse BDC-tags og MCID'er, når du skal bekræfte, hvilke sideobjekter der endte inde i de nye mærker. Et rent parent tree fortæller dig, at opslag resolver; det fortæller dig ikke, at læserækkefølgen eller den alternative tekst er rigtig

FAQ

Genopbygger EditPdfDocumentStructure hele /ParentTree?

Nej. EditPdfDocumentStructure opdaterer kun indgangene for indhold, den tager i den aktuelle batch, genbruger eksisterende /StructParents-nøgler og allokerer nye, hvor behov, så eksisterende parent tree-indgange forbliver urørt

Hvad sker der, hvis PDF'en har et strukturtræ men ingen /ParentTree?

Komponenten opretter et indirekte << /Nums [] >> number tree, linker det fra /StructTreeRoot og indsætter den første nøgle for det indhold, der tages

Kan jeg tage indhold i en Form XObject delt af flere sider?

Ikke med denne operation. En delt form gør /StmOwn tvetydig, så psekAddMarkedContent kræver, at formen refereres af præcis én side, og rapporterer ellers en fejl

Strukturtræ-læsning og -redigering, PDF/UA-validering og marked-content-adgang er en del af PDFium Component for Delphi og C++Builder, som wrapper PDFium til Delphi-, C++Builder- og Lazarus-projekter