Tehnički članak

Uređivanje PDF obrisa i remapiranje stranica u Delphiju

Izbacite sedam stranica iz priručnika od 200 stranica i svaka oznaka sleti negdje krivo. Rješenje nije gradnja obrisa iz ravne liste naslova. PDFiumPas izlaže TPdfOutlineEditor, koji učita pravo drvo obrisa, pusti vas premještati i preusmjeravati stavke, a zatim provede ApplyPageMap da svako izričito odredište provuče kroz vaš plan stranica

Zašto brisanje stranica slomi svaku oznaku?

Jer stavka obrisa ne sprema broj stranice. Sprema referencu na objekt stranice, i kad se objekti stranica promijene referenca ili pokazuje na stranicu koja se premjestila ili na ništa. ISO 32000-1 §12.3.2.2 definira izričito odredište kao niz čiji je prvi element neizravna referenca na rječnik stranice, a iza njega ime pristajanja poput /Fit ili /XYZ. Izbrišite stranicu i ostaje referenca koja visi u prazno; preuredite stranice i referenca je još valjana ali sada opisuje drugo poglavlje. PDFiumPas taj niz razrješuje natrag u broj stranice pri učitavanju, pa vam TPdfOutlineItem.PageNumber daje indeks stranice od jedan koji odgovara javnom TPdf API-ju umjesto broja objekta. U tome je cijela poanta apstrakcije: vaša logika remapiranja radi u istom koordinatnom sustavu kao plan stranica koji ste već sagradili kad ste dokument rastavljali, preuređivali ili slagali. Ako taj plan gradite, ista konvencija od jedinice prolazi kroz rastavljanje PDF dokumenata u više datoteka i kroz n-up slaganje i preuređivanje stranica

Obris je dvostruko povezano drvo, ne lista

Razlog zašto ne možete jednostavno serializirati ravni niz naslova je što ISO 32000-1 §12.3.3 svaku stavku obrisa poveže u pet odvojenih veza: /Parent, /Prev, /Next, /First i /Last. Premještanje jednog podstabla zato prepravlja starog roditelja, novog roditelja, oba susjedna brata s obje strane reza i točke umetanja, i pokazivač roditelja samog premještenog čvora. Pogriješite u jednom i sukladni čitači pokazuju odrezano drvo, ili petlju. PDFiumPas stanje uređivanja drži kao dubinski niz zapisa TPdfOutlineItem sa stabilnim cjelobrojnim Id, pa je podstablo povezani odsječak a lanac braće izveden, nikad ručno održavan. TPdfOutlineEditor.Move podigne taj odsječak, ponovno ga umetne pod novog roditelja na zatraženom bratskom indeksu i dodijeli ponovno samo korijen bloka. Odbija i dva premještanja koja bi pokvarila graf: premještanje stavke u njeno vlastito podstablo i imenovanje roditelja koji ne postoji

Uređivanje obrisa u PDFiumPasu u Delphiju: izvlačenje Poglavlja 3 iz Dio I i pod korijen dokumenta prepravlja pokazivač /Parent premještenog čvora plus veze /First i bratske /Prev i /Next oko reza i točke umetanja
Jedan poziv Move prepravlja pokazivač roditelja podignutog podstabla i bratske veze s obje strane reza i točke umetanja

Zašto je /Count predznačen?

Jer predznak nosi stanje proširenosti, 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 djecu i negira ga kad je IsOpen False, a pri učitavanju stanje čita natrag kao IsOpen := HasCount and (CountValue > 0). Ovo je najčešća ručna greška u piscima obriša: ispisati nepredznačen broj i tiho prisiliti cijelo drvo otvoreno

Kako PDFiumPas u Delphiju kodira stanje proširenosti obrisa: pozitivan /Count znači da je stavka otvorena i broji vidljive potomke, negativan /Count znači skupljeno, a nepredznačen broj prisiljava svaki čitač da proširi cijelo drvo
Predznak /Count je stanje proširenosti a veličina je broj vidljivih potomaka, pa nepredznačen broj tiho prisiljava cijelo drvo 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);           // becomes second child of root
    Editor.SetTitle(ChapterId, 'Appendix B');
    Editor.SetStyle(ChapterId, [posBold, posItalic]);
    Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
    Editor.SetExpanded(RootId, False);           // writes a negative /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 dopušta. Predajte DestinationInAction kao False i PDFiumPas upisuje izravni niz /Dest; predajte 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 oguli postojeći /Dest i /A sa stavke da dvoje ne može koegzistirati i proturječiti. Sufiks je po zadanom /Fit i mora početi PDF imenom, zato prazan ili pokvaren sufiks odmah podiže iznimku umjesto da proizvede niz odredišta koji nijedan čitač ne može raščlaniti

Kako ApplyPageMap troši plan stranica?

ApplyPageMap prima točno niz koji vaš plan stranica već provjerio: NewPageNumbers, indeksiran starom stranicom minus jedan, drži novi broj stranice od jedan ili nulu kad ta stranica nije preživjela. Prolazi niz stavki unatrag da brisanje podstabla nikad ne poništi indeks koji još treba posjetiti, a ono što je učinio izvještava kroz RemappedDestinationCount i RemovedDanglingItemCount

var
  NewPageNumbers: array of Integer;
  Report: TPdfOutlineValidationReport;
  I: Integer;
begin
  // One entry per page of the ORIGINAL document
  SetLength(NewPageNumbers, OriginalPageCount);
  for I := 0 to OriginalPageCount - 1 do
    NewPageNumbers[I] := 0;              // 0 == this page was dropped

  NewPageNumbers[0] := 1;                // old page 1 -> new page 1
  NewPageNumbers[1] := 2;
  NewPageNumbers[9] := 3;                // old page 10 -> new page 3

  // True: delete the whole dangling subtree. False: keep the item, strip its target
  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;

Zastavica DeleteDangling odlučuje politiku za odredište mapirano na nulu, i obje grane su namjerne. S True, PDFiumPas briše stavku i cijelo njeno podstablo, jer čvor obrisa čiji je cilj nestao obično čeliko poglavlje koje je nestalo s njim. S False, stavka preživi s naslovom i hijerarhijom netaknutima ali bez /Dest i /A, što hoćete kad će je čovjek u pregledu preusmjeriti. Stvarno pokvaren ulaz i dalje propada glasno umjesto da se zakrpi: negativan unos ili odredište koje pokazuje preko kraja predane mape vraća False s IssueKind postavljenim na poviInvalidPageMap

Kako PDFiumPas ApplyPageMap preusmjerava PDF oznake u Delphiju: mapa stranica indeksirana starom stranicom minus jedan šalje preživjela odredišta na njihove nove brojeve stranica, dok se unosi mapirani na nulu brišu s podstablom ili ostaju bez cilja
Mapa stranica indeksirana je starom stranicom minus jedan, a unos nula ili briše visi podstablo ili ostavlja stavku bez cilja

Neprozirne stavke i pošten kompromis

Nije svaka stavka obrisa ima broj stranice o kojem PDFiumPas može razmišljati. Tri vrste provlače se nedirnute: imenovana odredišta, akcije koje nisu /S /GoTo i nepoznati ključevi rječnika dodani od onoga tko je datoteku proizveo. Ove se učitavaju s PageNumber jednakim nuli, čuvaju svoje izvorne bajtove u stavci i upisuju se natrag doslovno osim ako im izričito ne pozovete Retarget

  • Imenovano odredište je ključ u stablo imena dokumenta, pa ga remapiranje ispravno znači razrješiti stablo i prepraviti ciljni unos, a ne nagađati na razini obrisa
  • Akcija /URI, /Launch ili JavaScript nema nikakvu semantiku stranice i ne smije se tiho pretvoriti u Go-To
  • Ključevi specifični za dobavljača i odredišta strukture čuvaju se jer je bacanje onoga što ne razumijete način na koji povratni putevi gube podatke

Cijena je stvarna i vrijedi je izreći ravnim jezikom: ApplyPageMap te stavke potpuno preskače, pa dokument čije sve oznake koriste imenovana odredišta proći će brisanje stranica s obrisom strukturno valjanim ali semantički zastarjelim. To je namjerni izbor — zastarjela veza koju recenzent može uhvatiti bolja je od samouvjereno krive koje nitko ne primijeti. Ako prihvatne datoteke trijažirate prije uređivanja, prolaz inventara u PDF radnoj stolici za pregled prijema reći će vam koji dokumenti padaju u taj koš

Spremanje: inkrementalna dopuna pa neovisno ponovno učitavanje

TPdfOutlineEditor.SaveIncremental dopisuje rijetku inkrementalnu reviziju umjesto da prepravi datoteku. Stavke koje su učitane čuvaju svoju izvornu neizravnu referencu objekta uključujući točnu generaciju, pa postojeće unakrsne reference ostaju valjane; samo stavke koje ste dodali crpe svježi broj, alociran od jednog preko maksimalnog broja objekta revizije. Katalog se ažurira u istoj reviziji, a nedostajući unos /Outlines dodaje mu se kad izvor uopće nije imao obris

Ono što slijedi nakon upisa je dio vrijedan kopiranja. PDFiumPas ponovno otvara odredišni tok s potpuno neovisnim uređivačem i uspoređuje ponovno učitano drvo s onim u memoriji — broj stavki, naslovi, brojevi stranica, sufiksi odredišta, oblik akcija nasuprot izravnom odredištu, stilovi, stanje proširenosti i odnosi roditeljstva. Svako odstupanje, ili svaki neuspjeh učitavanja, briše odredišni tok i vraća poviVerificationFailure umjesto da vam uruči datoteku koja samo izgleda vjerodostojno. Šifrirani izvori se unaprijed odbijaju s poviEncryptedInput, jer novi naslovi i odredišta stvaraju sadržaj nizova koji se ne može proizvesti kopiranjem /Encrypt prikolice naprijed

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 obris kao ono što jest — povezani graf objekata sa svojim nepromjenjivim pravilima — pa brisanje stranica prestaje biti katastrofa oznaka i postaje mapa stranica koju predajete jednom pozivu metode. TPdfOutlineEditor, ApplyPageMap i provjereni inkrementalni pisac stižu u PDFiumPasu od v3.98.0 za Delphi, C++Builder i Lazarus; puni API možete pregledati i probnu verziju preuzeti na stranici proizvoda PDFium Delphi komponente