Technický článek

Editace osnovy PDF a přemapování stránek v Delphi

Vyjměte sedm stránek z příručky o 200 stránkách a každá záložka dopadne někde špatně. Oprava nestojí v znovuvybudování osnovy z plochého seznamu titulů. PDFiumPas vystavuje TPdfOutlineEditor, který načte skutečný strom osnovy, dovolí přesouvat a přecílit položky, pak spustí ApplyPageMap, aby posunul každou explicitní destinaci vaším plánem stránek

Proč mazání stránek rozbije každou záložku?

Protože položka osnovy neukládá číslo stránky. Ukládá referenci na objekt stránky a když se objekty stránek změní, reference buď ukazuje na stránku, která se přesunula, nebo na nic. ISO 32000-1 §12.3.2.2 definuje explicitní destinaci jako pole, jehož první prvek je nepřímá reference na slovník stránky, následovaná názvem fit jako /Fit nebo /XYZ. Smažte stránku a zbude vám visící reference; přeuspořádejte stránky a reference je stále platná, ale teď popisuje jinou kapitolu. PDFiumPas vyřeší to pole zpět na číslo stránky při načtení, takže TPdfOutlineItem.PageNumber dává index stránky od 1, který odpovídá veřejnému API TPdf, nikoli číslo objektu. To je celá pointa abstrakce: vaše logika přemapování pracuje ve stejném souřadném systému jako plán stránek, který jste už postavili, když jste dokument rozdělili, přeuspořádali nebo imponovali. Stavíte-li ten plán, totéž podjedničkové konvence probíhá dělením dokumentů PDF do více souborů i n-up impozicí a přeuspořádáním stránek

Osnova je dvakrát řetězený strom, nikoli seznam

Důvod, proč nemůžete prostě serializovat ploché pole titulů, je, že ISO 32000-1 §12.3.3 zapojuje každou položku osnovy do pěti oddělených odkazů: /Parent, /Prev, /Next, /First a /Last. Přesun jediného podstromu proto přepíše starého rodiče, nového rodiče, oba sousední sourozence na každé straně řezu i místa vložení a ukazatel rodiče přesouvaného uzlu samého. Uděláte-li chybu v jednom z nich, konformní čtenáři ukáží useknutý strom nebo se zacyklí. PDFiumPas drží stav editace jako depth-first pole záznamů TPdfOutlineItem se stabilním celočíselným Id, takže podstrom je souvislý řez a řetězec sourozenců je odvozený, nikdy ručně udržovaný. TPdfOutlineEditor.Move zvedne ten řez, znovu jej vloží pod nového rodiče na požadovaném indexu sourozence a přiřadí znovu jen kořen bloku. Odmitne též dva přesuny, které by graf zkorumpovaly: přesun položky do jejího vlastního podstromu a pojmenování rodiče, který neexistuje

Editace osnovy PDFiumPas v Delphi: přesun Kapitoly 3 z Části I pod kořen dokumentu přepíše ukazatel /Parent přesouvaného uzlu plus odkazy /First a sourozenecké /Prev a /Next kolem řezu i místa vložení
Jediné volání Move přepíše ukazatel rodiče zvednutého podstromu a sourozenecké odkazy na obou stranách řezu i místa vložení

Proč je /Count se znaménkem?

Protože znaménko nese rozvinutý stav, ne velikost. Kladné /Count znamená, že položka je otevřená a číslo je, kolik potomků je právě viditelných; záporné /Count znamená, že položka je sbalená. PDFiumPas zapíše počet potomků pro každou položku, která má děti, a zneguje jej, když je IsOpen False, a při načtení načte stav zpět jako IsOpen := HasCount and (CountValue > 0). Toto je nejčastější ručně válcovaná chyba v zapisovačích osnov: vydání počtu bez znaménka a tiché vynucení celého stromu otevřeného

Jak PDFiumPas kóduje stav rozvinutí osnovy v Delphi: kladné /Count znamená, že položka je otevřená a počítá viditelné potomky, záporné /Count znamená sbalené a počet bez znaménka donutí každého čtenáře rozvinout celý strom
Znaménko /Count je rozvinutý stav a velikost je počet viditelných potomků, takže počet bez znaménka tiše donutí celý strom otevřený
var
  Source, Dest: TMemoryStream;
  Editor: TPdfOutlineEditor;
  Options: TPdfOutlineEditOptions;
  Report: TPdfOutlineValidationReport;
  RootId, ChapterId: Integer;
begin
  Source := TMemoryStream.Create;
  Dest := TMemoryStream.Create;
  Editor := nil;
  try
    Source.LoadFromFile('handbook.pdf');
    Options := TPdfOutlineEditOptions.Default;   // MaxItems 100000, MaxDepth 64
    if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
      raise Exception.Create(Report.ErrorMessage);

    RootId := Editor[0].Id;
    ChapterId := Editor[2].Id;

    Editor.Move(ChapterId, RootId, 1);           // stane se druhým dítětem kořene
    Editor.SetTitle(ChapterId, 'Appendix B');
    Editor.SetStyle(ChapterId, [posBold, posItalic]);
    Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
    Editor.SetExpanded(RootId, False);           // zapíše záporné /Count
    Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');

    if not Editor.SaveIncremental(Source, Dest, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Dest.SaveToFile('handbook-edited.pdf');
  finally
    Editor.Free;
    Dest.Free;
    Source.Free;
  end;
end;

Retarget zvládne oba tvary, které specifikace dovoluje. Předejte DestinationInAction jako False a PDFiumPas zapíše přímé pole /Dest; předejte True a zapíše akci Go-To, /A << /S /GoTo /D [ page ref suffix ] >>, dle ISO 32000-1 §12.6.4.2. V obou případech nejprve oloupe z položky jakékoli existující /Dest a /A, aby obě nemohly koexistovat a rozcházet se. Přípona má výchozí /Fit a musí začínat názvem PDF, proto prázdná nebo deformovaná přípona vyvolá okamžitě, místo aby vytvořila pole destinace, které žádný čtenář nenaparsuje

Jak ApplyPageMap konzumuje plán stránek?

ApplyPageMap bere přesně to pole, které váš plán stránek už validoval: NewPageNumbers, indexované starou stránkou minus jedna, držící nové číslo stránky od 1, nebo nulu, když ta stránka nepřežila. Projde pole položek pozpátku, takže mazání podstromu nikdy nezneplatní index, který ještě nenavštívilo, a hlásí, co udělalo, přes RemappedDestinationCount a RemovedDanglingItemCount

var
  NewPageNumbers: array of Integer;
  Report: TPdfOutlineValidationReport;
  I: Integer;
begin
  // Jedna položka na stránku PŮVODNÍHO dokumentu
  SetLength(NewPageNumbers, OriginalPageCount);
  for I := 0 to OriginalPageCount - 1 do
    NewPageNumbers[I] := 0;              // 0 == tato stránka byla upuštěna

  NewPageNumbers[0] := 1;                // stará stránka 1 -> nová stránka 1
  NewPageNumbers[1] := 2;
  NewPageNumbers[9] := 3;                // stará stránka 10 -> nová stránka 3

  // True: smaž celý visící podstrom. False: podrž položku, oloupi její cíl
  if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
    raise Exception.Create(Report.ErrorMessage);

  WriteLn(Format('%d remapped, %d dangling items removed',
    [Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;

Příznak DeleteDangling rozhoduje politiku pro destinaci, která se mapovala na nulu, a obě větve jsou záměrné. S True PDFiumPas smaže položku a její celý podstrom, protože uzel osnovy, jehož cíl zmizel, obvykle stojí v čele kapitoly, která zmizela s ním. S False položka přežije s titulem a hierarchií nedotčenými, ale bez svých /Dest a /A, což chcete, když ji člověk bude přecilovat v revizi. Skutečně deformovaný vstup stále selhává nahlas, místo aby se opravil: záporná položka nebo destinace ukazující za konec dodané mapy vrátí False s IssueKind nastaveným na poviInvalidPageMap

Jak PDFiumPas ApplyPageMap přesměrovává záložky PDF v Delphi: mapa stránek indexovaná starou stránkou minus jedna posílá přeživší destinace na jejich nová čísla stránek, zatímco položky mapující se na nulu se buď smaží s jejich podstromem, nebo jim se oloupe cíl
Mapa stránek je indexovaná starou stránkou minus jedna a položka s nulou buď smaže visící podstrom, nebo nechá položku s oloupaným cílem

Neprůhledné položky a poctivý kompromis

Ne každá položka osnovy má číslo stránky, o kterém PDFiumPas může uvažovat. Tři druhy se přenáší nedotčeny: pojmenované destinace, akce, které nejsou /S /GoTo, a neznámé klíče slovníku přidané tím, co soubor vytvořilo. Ty se načtou s PageNumber rovným nule, podrží své původní bajty v položce a zapíší se zpět doslova, pokud explicitně nezavoláte Retarget na ně

  • Pojmenovaná destinace je klíč do stromu názvů dokumentu, takže její správné přemapování znamená vyřešit strom a přepsat cílovou položku, ne hádat na úrovni osnovy
  • Akce /URI, /Launch nebo JavaScript nemá vůbec žádnou sémantiku stránek a nesmí být tiše převedena na Go-To
  • Klíče specifické pro dodavatele a destinace struktur se podrží, protože upouštění toho, čemu nerozumíte, je způsob, jakým zpáteční cesty ztrácejí data

Cena je reálná a stojí za jasné vyjádření: ApplyPageMap tyto položky přeskočí úplně, takže dokument, jehož záložky všechny používají pojmenované destinace, projde mazáním stránek se svou osnovou strukturálně platnou a sémanticky zastaralou. To je záměrná volba — zastaralý odkaz, který revidující chytí, porazí sebejistě špatný, kterého si nikdo nevšimne. Tříďte-li příchozí soubory před úpravou, inventární průchod v PDF intake review workbench vám řekne, které dokumenty spadají do tého kbelíku

Ukládání: inkrementální revize a pak nezávislé znovunačtení

TPdfOutlineEditor.SaveIncremental připojí řídkou inkrementální revizi, místo aby přepisoval soubor. Položky, které byly načteny, podrží svou původní nepřímou referenci objektu včetně přesné generace, takže stávající křížové reference zůstávají platné; jen položky, které jste přidali, vyberou čerstvé číslo, alokované od jedné za maximálním číslem objektu revize. Katalog se aktualizuje ve stejné revizi a chybějící položka /Outlines se mu přidá, když zdroj neměl vůbec žádnou osnovu

Co se stane po zápisu je část, kterou stojí za zkopírování. PDFiumPas znovuotevře cílový proud zcela nezávislým editorem a srovná znovunačtený strom s tím v paměti — počet položek, tituly, čísla stránek, přípony destinací, podobu akce versus přímé destinace, styly, rozvinutý stav a vztahy rodičů. Jakákoli neshoda nebo jakékoli selhání načtení vymaže cílový proud a vrátí poviVerificationFailure místo podání souboru vypadajícího věrohodně. Šifrované zdroje se odmítnou rovnou s poviEncryptedInput, protože nové tituly a destinace vytvářejí obsah řetězců, který se nedá vytvořit zkopírováním traileru /Encrypt dopředu

if not Editor.SaveIncremental(Source, Dest, Report) then
  case Report.IssueKind of
    poviEncryptedInput:
      Log('Source is encrypted; outline editing needs an unprotected copy');
    poviInvalidDestination:
      Log(Format('Item %d %d targets a missing page',
        [Report.ObjectNumber, Report.Generation]));
    poviVerificationFailure:
      Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
  else
    Log(Report.ErrorMessage);
  end;

Zacházejte s osnovou jako s tím, čím je — řetězeným grafem objektů s vlastními invariantami — a mazání stránek přestane být katastrofou záložek a stane se mapou stránek, kterou podáte jednomu volání metody. TPdfOutlineEditor, ApplyPageMap a ověřený inkrementální zapisovač se dodávají v PDFiumPas od v3.98.0 pro Delphi, C++Builder a Lazarus; plné API si můžete prohlédnout a zkušební verzi stáhnout na produktové stránce PDFium Delphi Component