Teknisk artikel

Håll /ParentTree och /StructParents i sync i Delphi

PDFium Component taggar befintligt PDF-innehåll från Delphi via EditPdfDocumentStructure i FPdfDocumentStructure.pas. Operationerna psekAddMarkedContent och psekAddObjectReference gör mer än att fogar en MCR eller OBJR till ett strukturelements /K: de allokerar också /StructParents- eller /StructParent-nyckeln och skriver den matchande /ParentTree-posten, det omvända index som ISO 32000-1 §14.7.4.4 kräver

Den andra halvan är där eftermontering av taggar oftast går fel. Att lägga till en BDC-wrapper och en MCR är enkelt, och en visare som vandrar strukturträdet uppifrån hittar gärna det nya innehållet. Hjälpmedelsteknik går ofta åt andra hållet däremot: den utgår från en bit innehåll på sidan och frågar vilket strukturelement som äger den. Saknar sidan någon /StructParents-nyckel, eller saknar /ParentTree en post för den nya MCID:n, misslyckas det uppslaget, och kontrollörer rapporterar det som en saknad eller inkonsekvent förälderträd. Filen ser taggad ut och fungerar ändå inte

Vad mappar /ParentTree egentligen?

/ParentTree är ett talträd som mappar heltalsnycklar till strukturelement, och nycklarna kommer från innehållet, inte från trädet. ISO 32000-1 §14.7.4.4 ger varje sida eller Form XObject som innehåller markerat innehåll ett /StructParents-heltal, och förälderträdesvärdet under den nyckeln är en array indexerad efter MCID: post 3 i arrayen är strukturelementet som äger <</MCID 3>>. En annotering eller XObject använd som ett helt innehållsobjekt får en enda /StructParent-nyckel i stället, och dess förälderträdesvärde är strukturelementet själv, inte en array

Det omvända indexet i PDF/UA-förälderträdet skrivet av PDFium Component i Delphi: ett innehållsmärke som bär MCID 3 löses via sidans /StructParents-nyckel 17 in i /ParentTree-talträdet, vars arraypost 3 är strukturelementet som äger det markerade innehållet
Läsordningen spelar roll för taggad PDF: hjälpmedelsteknik vandrar från MCID:n på sidan genom /StructParents in i /ParentTree-arrayen, och en saknad nyckel eller post bryter det uppslaget fastän strukturträdet i sig ser fint ut

Containerformatet är det generiska talträdet från §7.9.7. Ett löv håller /Nums, en platt array av nyckel- och värdepar sorterade i stigande nyckelordning. Mellannoder och löv under roten bär /Limits med den minsta och största nyckel de täcker, medan roten själv inte får ha /Limits. Utöver det kan strukturträdets rot bära /ParentTreeNextKey (§14.7.2), nästa nyckel en skrivare bör dela ut. Varje redigering som taggar nytt innehåll rör därför upp till fyra objekt som måste hålla med: innehållsströmmen, sid- eller formulärsordboken, förälderträdslövet och strukturträdets rot

Den framåtriktade riktningen har egna regler, angivna i §14.7.4.2 och §14.7.4.3. En markerad-innehållsreferens (MCR) för innehåll i sidans egen innehållsström behöver bara /Pg och /MCID. När det markerade innehållet bor inne i ett Form XObject behöver MCR:n också /Stm för att namnge den strömmen, eftersom MCID:er numreras per innehållsström. En objektreferens (OBJR) namnger annoteringen eller XObject:et med /Obj och dess sida med /Pg

Att tagga en innehållsström med EditPdfDocumentStructure

EditPdfDocumentStructure är en snapshot-bevakad batcheditor. Du anropar först ReadPdfDocumentStructure för att få en TPdfDocumentStructureSnapshot, och skickar sedan tillbaka dess SourceSha256 tillsammans med en array av TPdfStructureEditOperation-poster. Har källbytena ändrats emellan avvisas redigeringen utan utdata. Befintliga element adresseras efter sitt objektnummer i NodeID, och 0 betecknar strukturträdets rot

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;            // befintligt element, efter objektnummer
    Ops[0].TargetObjectNumber := StreamObj; // sidans innehållsström eller sidans 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;

För den ena operationen wrappar EditPdfDocumentStructure målströmmen i /P <</MCID n>> BDC ... EMC, fogar MCR:n till elementets /K, ger sidan (eller formuläret) en /StructParents-nyckel om den saknar en, och skriver element n in i förälderträdesarrayen för den nyckeln. Resultatet skrivs som en inkrementell uppdatering efter originalbytena, och Report.CreatedMarkedContents talar om hur många MCR:er som skapades. Innehåller strömmen redan balanserade BDC/EMC-par wrappas bara den otaggade svansen efter sista toppnivå-EMC; annars hela strömmen

Hur väljs nya MCID:er och förälderträdesnycklar?

En ny MCID är aktuellt maximum plus ett, och en ny förälderträdesnyckel är Max(MaxKey + 1, ParentTreeNextKey). För MCID:n tar EditPdfDocumentStructure maxvärdet över de MCR:er som redan registrerats för den sidan (eller Form XObject:et) och varje /MCID den hittar i strömmens egna egenskapslistor. Att räkna båda spelar roll: ett strukturträd kan ha tappat en MCR medan innehållet fortfarande bär märket, och att återanvända det talet skulle låta två element göra anspråk på samma innehåll

Nyckellogiken följer samma defensiva tanke. Har ägaren redan ett /StructParents-värde utökas den befintliga arrayen vid index MCID, med null som fyller eventuella hål, och ingen ny nyckel allokeras. Annars skannar komponenten varje /Nums-löv efter högsta nyckel, jämför med /ParentTreeNextKey, och tar det större. En inaktuell /ParentTreeNextKey lägre än nycklar redan i bruk, och en som reserverar nycklar förbi sista posten, hanteras båda, eftersom det större av de två värdena alltid är ledigt. Efter allokeringen avanceras /ParentTreeNextKey till den nya nyckeln plus ett

Allokering av MCID och förälderträdesnyckel i PDFium Components strukturredigerare för Delphi: den nya MCID:n ligger ett över maxvärdet redan registrerat i MCR:er och strömegenskapslistor, och nyckeln är det större av högsta /Nums-nyckeln plus ett och /ParentTreeNextKey, med null som fyller arrayhål
EditPdfDocumentStructure härleder den nya MCID:n ur både MCR:er och strömmerken och allokerar förälderträdesnyckeln defensivt, så att en inaktuell /ParentTreeNextKey eller en återanvänd MCID inte kan låta två element göra anspråk på samma innehåll

Det nya paret fogas in i /Nums i stigande ordning i stället för att läggas till på slutet, eftersom talträdesuppslag förlitar sig på den ordningen. Bär lövet /Limits beräknas gränserna om från den omskrivna arrayen. Saknar strukturträdets rot någon /ParentTree alls, vilket är vanligt i filer taggade av verktyg som bara skrev det framåtriktade trädet, syntetiserar EditPdfDocumentStructure ett nytt indirekt << /Nums [] >>-objekt och länkar det från /StructTreeRoot innan första posten fogas in

Form XObjects, /StmOwn och objektreferenser

Markerat innehåll inne i ett Form XObject stöds bara när den formen används av exakt en sida. MCR:n som EditPdfDocumentStructure skriver för en form bär /Pg, /Stm som namnger formulärströmmen, och /StmOwn som namnger sidan, och /StructParents-nyckeln hamnar på formulärordboken i stället för på sidan. En form ritad av tre sidor har tre tänkbara ägare, så någon enskild /StmOwn skulle vara fel för två av dem. Komponenten avvisar redigeringen med ett fel i stället för att gissa

Enägares-regeln för att tagga Form XObject-innehåll med PDFium Component i Delphi: en MCR på en form använd av en enda sida bär /Pg, /Stm och /StmOwn med /StructParents på formulärordboken, medan en form ritad av tre sidor gör /StmOwn tvetydig och redigeringen avvisas
Nyckeln /StmOwn är bara meningsfull när en sida äger formen, vilket är varför delade former och delade annoteringar avvisas i stället för att taggas med en gissbar ägare

Samma unikhetsregel gäller psekAddObjectReference. Målet måste vara en annotering som förekommer i exakt en sidas /Annots, eller ett Form- eller bild-XObject som förekommer i exakt en sidas resurser; allt annat avvisas. OBJR:n får /Obj och /Pg, målet får en /StructParent-nyckel (återanvänd om den finns), och förälderträdesvärdet är en ren referens till elementet. Det är den vanliga fixen för en länkannotering eller en figur-bild som taggats visuellt men aldrig kopplats till trädet

// Wrappa en otaggad bild i ett nytt Figure-element, bind den sedan två sätt:
// som en OBJR för bild-XObject:et och som markerat innehåll på sidan
SetLength(Ops, 3);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekInsertContainer;
Ops[0].NodeID := -1;              // temporärt ID, måste vara negativt och unikt
Ops[0].ParentNodeID := SectID;    // befintligt Sect-element
Ops[0].Value := 'Figure';         // standardroll eller en RoleMap-post

Ops[1] := Default(TPdfStructureEditOperation);
Ops[1].Kind := psekAddObjectReference;
Ops[1].NodeID := -1;              // refererar till containern skapad ovan
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]));

Negativa NodeID-värden namnger container skapade tidigare i samma batch, och Report.InsertedNodes mappar varje begärt ID till objektnumret det fick. psekInsertContainer accepterar standardroller i PDF 1.7, PDF 2.0 och PDF/UA som P, H1 till H6, Figure, Table eller LI, plus anpassade roller redan mappade i /RoleMap. Att sätta BeforeNodeID på de två innehållsoperationerna placerar den nya MCR:n eller OBJR:n före ett befintligt barn i stället för på slutet

Varför kräver redigering av samma ström två gånger i en batch omsorg?

En batch kan träffa samma objekt upprepade gånger, och varje senare operation måste se resultatet av de tidigare. Två psekAddMarkedContent-operationer mot samma sida skriver båda om dess innehållsström, och båda uppdaterar samma förälderträdeslöv. En implementation som mellanlagrar varje redigering som ett separat tillfogat objekt skriver två versioner av objekt 12 i uppdateringen. Beroende på vilken läsaren behåller tappar du den första BDC-wrappern eller det första /Nums-paret, och den andra MCID:n kan kollidera med den första eftersom den beräknades ur inaktuella byte

EditPdfDocumentStructure nycklar mellanlagrade uppdateringar efter (ObjectNumber, Generation) och skriver över en befintlig post på plats, och den uppdaterar objektets minnesbundna kopia samtidigt. Den andra operationen parsar därför den redan wrappade strömmen, ser MCID 0, allokerar MCID 1, och utökar samma förälderträdesarray. Ett objekt, en slutversion. Innan någonting returneras läses utdata tillbaka och jämförs med den minnesbundna modellen: elementantal, innehållsobjektsantal och varje kvarhållen MCR eller OBJR måste matcha, annars misslyckas anropet utan utdata

Komprimerade strömmar följer samma väg. En innehållsström med /Filter /FlateDecode (eller en endeelements-[/FlateDecode]-array) och ingen /DecodeParms-prediktor inflateras med en avgränsad avkodare kapad vid 32 MiB eller MaxOutputBytes, det minsta av dem, wrappas, och komprimeras om till /FlateDecode med en ny /Length. En ström som skulle infla förbi kappen avvisas i stället för att avkapas, vilket spelar roll för filer från obetrodda källor

Gränser värda att känna innan en PDF/UA-reparationskörning

EditPdfDocumentStructure är konservativ av design, och de flesta av dess gränser ytrar sig som en tydlig Report.ErrorMessage snarare än som en tyst trasig fil. Ha de här i minnet när du planerar en batchreparation:

  • Bara ofiltrerade och vanliga FlateDecode-strömmar kan wrappas; andra filter, eller Flate med en prediktor, avvisas
  • En innehållsström måste tillhöra exakt en sidas /Contents, och Form XObjects, annoteringar och XObjects måste användas av exakt en sida
  • Wrappern använder alltid /P-taggen; den semantiska rollen kommer från strukturelementets /S, inte från BDC-taggnamnet
  • Förälderträdessyncen läser en platt /Nums-rot eller en nivå av /Kids-löv; djupare mellannoder vandras inte ner, så validera om sådana filer efter redigeringen
  • Filer som innehåller signaturer eller signaturfält avvisas rakt av, så signera efter strukturreparationen, inte före

Valideringssteget är inte valfritt för en reparationspipeline. Kör PDF/UA-strukturträdesvalideringen i PDFium Component före och efter batchen, och använd sidobjektens markerat-innehåll-API för att läsa BDC-taggar och MCID:er när du behöver bekräfta vilka sidobjekt som hamnade inne i de nya märkena. Ett rent förälderträd talar om att uppslag löser sig; det säger inte att läsordningen eller den alternativa texten är rätt

FAQ

Bygger EditPdfDocumentStructure om hela /ParentTree?

Nej. EditPdfDocumentStructure uppdaterar bara posterna för innehåll den taggar i aktuell batch, återanvänder befintliga /StructParents-nycklar och allokerar nya där det behövs, så befintliga förälderträdesposter lämnas orörda

Vad händer om PDF:en har ett strukturträd men ingen /ParentTree?

Komponenten skapar ett indirekt << /Nums [] >>-talträd, länkar det från /StructTreeRoot, och fogar in första nyckeln för innehållet som taggas

Kan jag tagga innehåll i ett Form XObject delat av flera sidor?

Inte med den här operationen. En delad form gör /StmOwn tvetydigt, så psekAddMarkedContent kräver att formen refereras av exakt en sida och rapporterar ett fel annars

Läsning och redigering av strukturträd, PDF/UA-validering och tillgång till markerat innehåll är del av PDFium Component for Delphi and C++Builder, som wrappar PDFium för Delphi-, C++Builder- och Lazarus-projekt