Odborný článok

Úprava osnovy PDF a remapovanie stránok v Delphi

Vytiahnite zo 200-stranovej príručky sedem stránok a každá záložka dopadne niekde zle. Opravou nie je znovu vybudovať osnovu z plochého zoznamu titulov. PDFiumPas vystavuje TPdfOutlineEditor, ktorý načíta skutočný strom osnovy, dovolí položky presúvať a precieľovať a potom spustí ApplyPageMap, aby posunul každú explicitnú destináciu podľa vášho plánu stránok

Prečo mazanie stránok rozbije každú záložku?

Pretože položka osnovy neukladá číslo stránky. Ukladá odkaz na objekt stránky a keď sa objekty stránok zmenia, odkaz buď ukazuje na stránku, ktorá sa presunula, alebo na nič. ISO 32000-1 §12.3.2.2 definuje explicitnú destináciu ako pole, ktorého prvý prvok je nepriamy odkaz na slovník stránky, nasledovaný fit názvom ako /Fit alebo /XYZ. Zmazaním stránky vám zostane visiaci odkaz; preusporiadaním stránok odkaz ostane platný, ale teraz opisuje inú kapitolu. PDFiumPas toto pole pri načítaní vyrieši späť na číslo stránky, takže TPdfOutlineItem.PageNumber vám dá index stránky od jednej, ktorý sedí s verejným API TPdf namiesto čísla objektu. V tom je celý zmysel abstrakcie: vaša remapovacia logika pracuje v tom istom súradnicovom systéme ako plán stránok, ktorý ste už vybudovali, keď ste dokument delili, preusporiadali alebo sadali. Ak tento plán stavite, tá istá konvencia od jednej prechádza delením PDF dokumentov do viacerých súborov aj n-up sadením a preusporiadaním stránok

Osnova je obojsmerne zreťazený strom, nie zoznam

Dôvod, prečo nemôžete jednoducho serializovať ploché pole titulov, je, že ISO 32000-1 §12.3.3 zapája každú položku osnovy do piatich samostatných odkazov: /Parent, /Prev, /Next, /First a /Last. Presun jediného podstromu preto prepíše starého rodiča, nového rodiča, oboch susediacich súrodencov na každej strane rezu a bodu vloženia a ukazovateľ na rodiča presúvaného uzla samého. Ak jedno z nich pokazíte, konformné prehliadače zobrazia skrátený strom alebo sa zacyklia. PDFiumPas drží stav úprav ako pole záznamov TPdfOutlineItem usporiadané do hĺbky so stabilným celočíselným Id, takže podstrom je súvislý rez a reťazec súrodencov je odvodený, nikdy ručne udržiavaný. TPdfOutlineEditor.Move tento rez zdvihne, znovu vloží pod nového rodiča na požadovanom indexe súrodencov a prerozdelí len koreň bloku. Odmietne aj tie dva presuny, ktoré by graf pokazili: presun položky do jej vlastného podstromu a pomenovanie rodiča, ktorý neexistuje

Úprava osnovy v PDFiumPas v Delphi: presun Kapitoly 3 von z Časti I a pod koreň dokumentu prepíše ukazovateľ /Parent presúvaného uzla plus odkazy /First a súrodenecké /Prev a /Next okolo rezu aj bodu vloženia
Jedno volanie Move prepíše ukazovateľ rodiča zdvihnutého podstromu a súrodenecké odkazy na oboch stranách rezu aj bodu vloženia

Prečo je /Count podpísané?

Pretože znamienko nesie stav rozbalenia, nie veľkosť. Kladné /Count znamená, že položka je otvorená a číslo hovorí, koľko potomkov je práve viditeľných; záporné /Count znamená, že položka je zbalená. PDFiumPas zapisuje počet potomkov pre každú položku s deťmi a neguje ho, keď IsOpen je False, a pri načítaní prečíta stav späť ako IsOpen := HasCount and (CountValue > 0). Toto je najbežnejší ručne robený bug v zapisovačoch osnov: vypustiť nepodpísaný počet a ticho vynútiť celý strom rozbalený

Ako PDFiumPas kóduje stav rozbalenia osnovy v Delphi: kladné /Count znamená, že položka je otvorená a počíta viditeľných potomkov, záporné /Count znamená zbalené a nepodpísaný počet prinúti každý prehliadač rozbalený celý strom
Znamienko /Count je stav rozbalenia a absolútna hodnota je počet viditeľných potomkov, takže nepodpísaný počet ticho vynúti celý strom rozbalený
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 sa druhým dieťaťom koreňa
    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 ošetrí oba tvary, ktoré špecifikácia dovoľuje. Prejdete DestinationInAction ako False a PDFiumPas zapíše priame pole /Dest; prejdete True a zapíše akciu Go-To, /A << /S /GoTo /D [ page ref suffix ] >> podľa ISO 32000-1 §12.6.4.2. V oboch prípadoch najprv zoškrabne existujúce /Dest a /A z položky, aby tieto dva nemohli koexistovať a rozchádzať sa. Prípona má predvolené /Fit a musí začínať PDF názvom, a preto prázdna alebo zdeformovaná prípona okamžite vyvolá výnimku namiesto vyrobenia poľa destinácie, ktoré nedokáže žiadny prehliadač vyparsovať

Ako ApplyPageMap spracováva plán stránok?

ApplyPageMap berie presne to pole, ktoré váš plán stránok už overil: NewPageNumbers, indexované starou stránkou mínus jedna, nesúce nové číslo stránky od jednej alebo nulu, keď tá stránka neprežila. Pole položiek prechádza odzadu, takže mazanie podstromu nikdy nezneplatní index, ktorý ešte má navštíviť, a to, čo urobila, hlási cez 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 == táto stránka bola zahodená

  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: zmazať celý visiaci podstrom. False: ponechať položku, odstrihnúť jej cieľ
  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;

Príznak DeleteDangling rozhoduje politiku pre destináciu namapovanú na nulu a obe vetvy sú zámerné. Pri True PDFiumPas zmaže položku aj celý jej podstrom, pretože uzol osnovy, ktorého cieľ zmizol, zvykne čeliť kapitole, ktorá zmizla s ním. Pri False položka prežije s titulom a hierarchiou nedotknutými, ale so svojím /Dest a /A odstránenými — presne to chcete, keď ju človek bude v recenzii precieľovať. Skutočne zdeformovaný vstup stále zlyhá nahlas namiesto záplatovania: záporná položka alebo destinácia ukazujúca za koniec dodanej mapy vráti False s IssueKind nastaveným na poviInvalidPageMap

Ako ApplyPageMap v PDFiumPas presmerováva záložky PDF v Delphi: mapa stránok indexovaná starou stránkou mínus jedna posiela preživšie destinácie na ich nové čísla stránok, zatiaľ čo položky namapované na nulu sa buď zmažu s podstromom, alebo prídu o cieľ
Mapa stránok je indexovaná starou stránkou mínus jedna a nulová položka buď zmaže visiaci podstrom, alebo nechá položku s odstrihnutým cieľom

Nepriehľadné položky a úprimný kompromis

Nie každá položka osnovy má číslo stránky, o ktorom PDFiumPas dokáže uvažovať. Tri druhy sa prenášajú nedotknuté: pomenované destinácie, akcie, ktoré nie sú /S /GoTo, a neznáme kľúče slovníka pridané tým, čo súbor vyrobil. Tieto sa načítajú s PageNumber rovným nule, držia v položke svoje pôvodné bajty a zapisujú sa späť doslovne, pokiaľ na nich explicitne nezavoláte Retarget

  • Pomenovaná destinácia je kľúč do stromu mien dokumentu, takže jej správne remapovanie znamená vyriešiť strom a prepísať cieľovú položku, nie hádať na úrovni osnovy
  • Akcia /URI, /Launch alebo JavaScript nemá žiadnu sémantiku stránky a nesmie byť ticho prevedená na Go-To
  • Dodávateľské kľúče a štruktúrne destinácie sa zachovávajú, pretože zahadzovanie toho, čomu nerozumiete, je spôsob, akým round-tripy strácajú dáta

Cena je reálna a stojí za to povedať ju napriamo: ApplyPageMap tieto položky úplne preskočí, takže dokument, ktorého záložky všetky používajú pomenované destinácie, prejde mazaním stránok so štrukturálne platnou a sémanticky zastaranou osnovou. To je zámerná voľba — zastaraný odkaz, ktorý recenzent chytí, je lepší než sebavedomo nesprávny, ktorého si nikto nevšimne. Ak triážete prichádzajúce súbory skôr, než ich upravíte, inventárny priechod v PDF intake review workbenchi vám povie, ktoré dokumenty do tej skupiny padajú

Ukladanie: prírastková revízia a potom nezávislé znovunačítanie

TPdfOutlineEditor.SaveIncremental pripojí riedku prírastkovú revíziu namiesto prepisu súboru. Položky, ktoré boli načítané, držia svoj pôvodný nepriamy odkaz na objekt vrátane presnej generácie, takže existujúce krížové odkazy zostávajú platné; len položky, ktoré ste pridali, si vyžrebovajú čerstvé číslo, alokované od jednej za maximálnym číslom objektu revízie. Katalóg sa aktualizuje v tej istej revízii a chýbajúca položka /Outlines sa doň pridá, keď zdroj nemal žiadnu osnovu

Čo sa stane po zápise, je časť hodná napodobnenia. PDFiumPas znovuotvorí cieľový prúd s úplne nezávislým editorom a porovná znovunačítaný strom s tým v pamäti — počet položiek, tituly, čísla stránok, prípony destinácií, formu akcia verzus priama destinácia, štýly, stav rozbalenia a vzťahy rodičovstva. Akákoľvek nezhoda alebo akékoľvek zlyhanie načítania vyčistí cieľový prúd a vráti poviVerificationFailure namiesto toho, aby vám podstrčil súbor, ktorý len vyzerá uveriteľne. Šifrované zdroje sa odmietajú hneď na začiatku s poviEncryptedInput, pretože nové tituly a destinácie vytvárajú reťazcový obsah, ktorý nedá vyrobiť skopírovaním traileru /Encrypt dopredu

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;

Berte osnovu ako to, čím je — zreťazený graf objektov s vlastnými invariantmi — a mazanie stránok prestane byť katastrofou záložiek a stane sa mapou stránok, ktorú odovzdáte jednému volaniu metódy. TPdfOutlineEditor, ApplyPageMap a overený prírastkový zapisovač sa dodávajú v PDFiumPas od v3.98.0 pre Delphi, C++Builder a Lazarus; celé API si môžete pozrieť a stiahnuť skúšobnú verziu na produktovej stránke PDFium Delphi Component