Tehnički članak

Izmena PDF outline-a i premapiranje stranica u Delphi-ju

Izbacite sedam stranica iz priručnika od 200 stranica i svaki bookmark sleti negde pogrešno. Rešenje nije ponovna izgradnja outline-a iz ravne liste naslova. PDFiumPas izlaže TPdfOutlineEditor, koji učitava pravo stablo outline-a, dozvoljava vam da premeštate i preusmeravate stavke, pa pokreće ApplyPageMap da pomeri svaku eksplicitnu destinaciju kroz vaš plan stranica

Zašto brisanje stranica lomi svaki bookmark?

Zato što stavka outline-a ne čuva broj stranice. Ona čuva referencu na objekat stranice, i kad se objekti stranica promene referenca ili ukazuje na stranicu koja se pomerila ili na ništa. ISO 32000-1 §12.3.2.2 definiše eksplicitnu destinaciju kao niz čiji je prvi element indirektna referenca na rečnik stranice, praćen fit imenom kao što su /Fit ili /XYZ. Obrišite stranicu i ostaje vam viseća referenca; prerasporedite stranice i referenca je i dalje važeća ali sada opisuje drugo poglavlje. PDFiumPas razrešava taj niz nazad u broj stranice pri učitavanju, pa vam TPdfOutlineItem.PageNumber daje indeks stranice od jedan koji se poklapa sa javnim TPdf API-jem a ne sa brojem objekta. U tome je poenta apstrakcije: vaša logika premapiranja radi u istom koordinatnom sistemu kao plan stranica koji ste već napravili kad ste dokument rascepkali, prerasporedili ili impostirali. Ako taj plan gradite, ista konvencija od jedan prolazi kroz rascepkivanje PDF dokumenata u više fajlova i kroz n-up impostiranje i preraspoređivanje stranica

Outline je dvostruko povezano stablo, ne lista

Razlog zašto ne možete jednostavno serijalizovati ravan niz naslova je što ISO 32000-1 §12.3.3 povezuje svaku stavku outline-a u pet odvojenih veza: /Parent, /Prev, /Next, /First i /Last. Premeštanje jednog podstabla zato prepravlja starog roditelja, novog roditelja, oba susedna brata sa svake strane reza i mesta ubacivanja, i roditeljski pokazivač pomeranog čvora samog. Pogrešite jedno od toga i usaglašeni čitaoci pokazuju skraćeno stablo ili petlju. PDFiumPas drži stanje izmene kao depth-first niz TPdfOutlineItem zapisa sa stabilnim celobrojnim Id, pa je podstablo kontinuiran isečak a lanac braće se izvodi, nikad ručno održava. TPdfOutlineEditor.Move podigne taj isečak, ponovo ga ubaci pod novog roditelja na traženom indeksu braće, i ponovo dodeljuje samo koren bloka. Takođe odbija dva pomeranja koja bi pokvarila graf: premeštanje stavke u njeno sopstveno podstablo, i imenovanje roditelja koji ne postoji

Izmena outline-a u PDFiumPas-u u Delphi-ju: premeštanje Poglavlja 3 iz Dela I pod koren dokumenta prepravlja /Parent pokazivač pomeranog čvora plus /First i bratske /Prev i /Next veze oko reza i mesta ubacivanja
Jedan poziv Move-a prepravlja roditeljski pokazivač podignutog podstabla i bratske veze sa obe strane reza i mesta ubacivanja

Zašto /Count ima predznak?

Zato što predznak nosi stanje razvijenosti, ne veličinu. Pozitivan /Count znači da je stavka otvorena i broj je koliko potomaka je trenutno vidljivo; negativan /Count znači da je stavka skupljena. PDFiumPas upisuje broj potomaka za svaku stavku koja ima decu i negira ga kad je IsOpen False, a pri učitavanju čita stanje nazad kao IsOpen := HasCount and (CountValue > 0). Ovo je najčešća ručno kovana greška u piscima outline-a: ispuštanje broja bez predznaka i ušutkano prisiljavanje celog stabla da bude otvoreno

Kako PDFiumPas kodira stanje razvijenosti outline-a u Delphi-ju: pozitivan /Count znači da je stavka otvorena i broji vidljive potomke, negativan /Count znači skupljeno, a broj bez predznaka prisiljava svakog čitaoca da razvije celo stablo
Predznak /Count je stanje razvijenosti a veličina je broj vidljivih potomaka, pa broj bez predznaka ušutkano prisiljava celo stablo da bude otvoreno
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);           // postaje drugo dete korena
    Editor.SetTitle(ChapterId, 'Appendix B');
    Editor.SetStyle(ChapterId, [posBold, posItalic]);
    Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
    Editor.SetExpanded(RootId, False);           // upisuje negativan /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 rukuje oba oblika koje specifikacija dozvoljava. Prosledite DestinationInAction kao False i PDFiumPas upisuje direktan /Dest niz; prosledite True i upisuje Go-To akciju, /A << /S /GoTo /D [ page ref suffix ] >>, prema ISO 32000-1 §12.6.4.2. U oba slučaja prvo skida svaki postojeći /Dest i /A sa stavke da ta dva ne mogu koegzistirati i protivrečiti se. Sufiks po podrazumevanom je /Fit i mora počinjati PDF imenom, po čemu prazan ili neispravan sufiks odmah diže izuzetak umesto da proizvede destinacijski niz koji nijedan čitalac ne može raščlaniti

Kako ApplyPageMap troši plan stranica?

ApplyPageMap prima tačno niz koji je vaš plan stranica već validirao: NewPageNumbers, indeksiran starom stranicom minus jedan, koji drži novi broj stranice od jedan ili nulu kad ta stranica nije preživela. Prelazi niz stavki unazad da brisanje podstabla nikad ne poništi indeks koji tek treba da poseti, i izveštava šta je uradio kroz RemappedDestinationCount i RemovedDanglingItemCount

var
  NewPageNumbers: array of Integer;
  Report: TPdfOutlineValidationReport;
  I: Integer;
begin
  // Jedan unos po stranici ORIGINALNOG dokumenta
  SetLength(NewPageNumbers, OriginalPageCount);
  for I := 0 to OriginalPageCount - 1 do
    NewPageNumbers[I] := 0;              // 0 == ova stranica je ispuštena

  NewPageNumbers[0] := 1;                // stara stranica 1 -> nova stranica 1
  NewPageNumbers[1] := 2;
  NewPageNumbers[9] := 3;                // stara stranica 10 -> nova stranica 3

  // True: obriši celo viseće podstablo. False: zadrži stavku, skini joj cilj
  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;

Flag DeleteDangling odlučuje politiku za destinaciju koja se preslikala na nulu, i obe grane su namerne. Sa True, PDFiumPas briše stavku i njeno celo podstablo, jer čvor outline-a čiji je cilj nestao obično predvodi poglavlje koje je nestalo s njim. Sa False, stavka preživi sa naslovom i hijerarhijom netaknutim ali sa skinutim /Dest i /A, što želite kad će čovek preusmeriti nju tokom recenzije. Zaista neispravan ulaz i dalje pada glasno umesto da se zakrpi: negativan unos ili destinacija koja ukazuje iza kraja datog mapiranja vraća False sa IssueKind postavljenim na poviInvalidPageMap

Kako PDFiumPas ApplyPageMap preusmerava PDF bookmarkove u Delphi-ju: mapiranje stranica indeksirano starom stranicom minus jedan šalje preživele destinacije na njihove nove brojeve stranica, dok unosi koji se preslikaju na nulu budu ili obrisani sa svojim podstablom ili ostavljeni bez cilja
Mapiranje stranica je indeksirano starom stranicom minus jedan, i unos nule ili briše viseće podstablo ili ostavlja stavku sa skinutim ciljem

Neprozirne stavke i pošten kompromis

Nije svaka stavka outline-a ima broj stranice o kojem PDFiumPas može rasuđivati. Tri vrste se prenose netaknute: imenovane destinacije, akcije koje nisu /S /GoTo, i nepoznati ključevi rečnika dodati od strane onoga što je fajl proizvelo. Ove se učitavaju sa PageNumber jednakim nuli, čuvaju svoje originalne bajtove u stavci, i upisuju se nazad doslovno osim ako eksplicitno ne pozovete Retarget na njima

  • Imenovana destinacija je ključ u stablo imena dokumenta, pa njeno ispravno premapiranje znači razrešavanje stabla i prepravljanje ciljnog unosa, ne nagađanje na nivou outline-a
  • /URI, /Launch ili JavaScript akcija nema nikakvu semantiku stranice i ne sme se ušutkano pretvoriti u Go-To
  • Ključevi specifični za proizvođača i strukturne destinacije se čuvaju jer je ispuštanje onoga što ne razumete način na koji povratni putovi gube podatke

Cena je stvarna i vredi je reći ravno: ApplyPageMap u potpunosti preskače te stavke, pa će dokument čiji bookmarkovi svi koriste imenovane destinacije proći kroz brisanje stranica sa outline-om strukturno važećim ali semantički zastarelim. To je namerni izbor — zastareli link koji recenzent može uhvatiti bolji je od samouvereno pogrešnog koga niko ne primećuje. Ako triažirate dolazne fajlove pre nego što ih izmenite, prolaz inventara u PDF intake review workbench-u će vam reći koji dokumenti padaju u tu kategoriju

Čuvanje: inkrementalna revizija, pa nezavisno ponovno učitavanje

TPdfOutlineEditor.SaveIncremental dodaje redak inkrementalnu reviziju umesto da prepiše fajl. Stavke koje su bile učitane zadržavaju svoju originalnu indirektnu referencu objekta uključujući tačnu generaciju, pa postojeće unakrsne reference ostaju važeće; samo stavke koje ste dodali vuku svež broj, dodeljen od jednog iza maksimalnog broja objekta revizije. Katalog se ažurira u istoj reviziji, i nedostajući /Outlines unos mu se dodaje kad izvor uopšte nije imao outline

Ono što se dešava posle upisa je deo vredan kopiranja. PDFiumPas ponovo otvara odredišni tok potpuno nezavisnim editorom i poredi ponovo učitano stablo sa onim u memoriji — broj stavki, naslove, brojeve stranica, sufikse destinacija, oblik akcija-nasuprot-direktna destinacija, stilove, stanje razvijenosti i roditeljske odnose. Bilo koje nepoklapanje ili bilo koji neuspeh učitavanja briše odredišni tok i vraća poviVerificationFailure umesto da vam uruči fajl koji izgleda uverljivo. Šifrovani izvori se odbijaju unapred sa poviEncryptedInput, jer novi naslovi i destinacije stvaraju string sadržaj koji se ne može proizvesti kopiranjem /Encrypt trailer-a unapred

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;

Tretirajte outline kao što on jeste — povezani graf objekata sa svojim invarijantama — i brisanje stranica prestaje biti bookmark katastrofa i postaje mapiranje stranica koje predajete jednom pozivu metode. TPdfOutlineEditor, ApplyPageMap i verifikovani inkrementalni pisac isporučuju se u PDFiumPas-u od v3.98.0 za Delphi, C++Builder i Lazarus; možete pregledati ceo API i preuzeti probu na stranici proizvoda PDFium Delphi Component