Techninis straipsnis

PDF struktūros redagavimas ir puslapių permapavimas Delphi

Išmeskite septynis puslapius iš 200 puslapių vadovo, ir kiekviena žymė nukrenta kažkur ne ten. Gydymas nėra struktūros atkūrimas iš plokščio pavadinimų sąrašo. PDFiumPas atskleidžia TPdfOutlineEditor, kuris įkelia tikrą struktūros medį, leidžia perkelti ir persukti elementus, tada vykdo ApplyPageMap, kad perkeltų kiekvieną explicit paskirtį per jūsų puslapių planą

Kodėl puslapių trynimas sulaužo kiekvieną žymę?

Nes struktūros elementas nesaugo puslapio numerio. Jis saugo nuorodą į puslapio objektą, ir kai puslapio objektai keičiasi, nuoroda arba rodo į perkeltą puslapį, arba į nieką. ISO 32000-1 §12.3.2.2 apibrėžia explicit paskirtį kaip masyvą, kurio pirmasis elementas yra netiesioginė nuoroda į puslapio žodyną, o po jos eina fit vardas, pavyzdžiui /Fit arba /XYZ. Ištrinkite puslapį — lieka kabančioji nuoroda; perrikiuokite puslapius — nuoroda vis dar galioja, bet dabar aprašo kitą skyrių. PDFiumPas įkeliant atkuria tą masyvą atgal į puslapio numerį, tad TPdfOutlineItem.PageNumber duoda vienetu prasidedantį puslapio indeksą, atitinkantį viešą TPdf API, o ne objekto numerį. Visa abstrakcijos esmė tame: jūsų permapavimo logika dirba toje pačioje koordinačių sistemoje kaip puslapių planas, kurį jau sukonstravote, kai skaidėte, perrikiuojote ar montuodavote dokumentą. Jei tą planą konstrujuojate, ta pati vienetu prasidedanti konvencija eina per PDF dokumentų skaidymą į kelis failus ir per n-up montavimą ir puslapių perrikiavimą

Struktūra yra dvipusiais ryšiais sujungtas medis, o ne sąrašas

Priežastis, kodėl negalite tiesiog išrašyti plokščio pavadinimų masyvo, yra ta, kad ISO 32000-1 §12.3.3 sujungia kiekvieną struktūros elementą su penkiais atskirais ryšiais: /Parent, /Prev, /Next, /First ir /Last. Vieno poomedžio perkėlimas todėl perrašo seną tėvinį, naują tėvinį, abu kaimyninius brolius abiejose pjūvio ir įterpimo vietos pusėse bei perkelto mazgo tėvinę rodyklę. Suklyskite viename — atitinkantys skaitytuvai rodo nukirstą medį arba užsuka į ciklą. PDFiumPas laiko redagavimo būseną kaip depth-first TPdfOutlineItem įrašų masyvą su stabiliu sveiku Id, tad poomedis yra vientisa iškarpa, o brolių grandinė yra išvedama, o ne vedama rankomis. TPdfOutlineEditor.Move pakelia tą iškarpą, vėl įterpia ją po nauju tėviniu pageidaujamame brolio indekse ir iš naujo priskiria tik bloko šaknį. Jis taip pat atsisako dviejų perkėlimų, kurie sugadintų grafo: elemento perkėlimo į jo paties poomedį ir neegzistuojančio tėvinio nurodymo

PDFiumPas struktūros redagavimas Delphi: 3 skyriaus perkėlimas iš I dalies po dokumento šaknimi perrašo perkelto mazgo /Parent rodyklę ir /First bei brolių /Prev ir /Next ryšius aplink pjūvį ir įterpimo tašką
Vienas Move kvietimas perrašo pakelto poomedžio tėvinę rodyklę ir brolių ryšius abiejose pjūvio ir įterpimo taško pusėse

Kodėl /Count yra su ženklu?

Nes ženklas neša išskleisto būseną, o ne dydį. Teigiamas /Count reiškia, kad elementas atidarytas, o skaičius yra, kiek palikuonių šiuo metu matoma; neigiamas /Count reiškia, kad elementas suskleistas. PDFiumPas rašo palikuonių skaičių kiekvienam elementui, turinčiam vaikų, ir neigia jį, kai IsOpen yra False, o įkeliant skaito būseną atgal kaip IsOpen := HasCount and (CountValue > 0). Tai dažniausia rankomis rašoma klaida struktūrų rašytojuose: išduodamas be ženklo esantį count ir tyliai priverčia visą medį atsiskleisti

Kaip PDFiumPas koduoja struktūros išskleidimo būseną Delphi: teigiamas /Count reiškia atidarytą elementą ir skaičiuoja matomus palikuonis, neigiamas /Count — suskleistą, o be ženklo count priverčia kiekvieną skaitytuvą išskleisti visą medį
/Count ženklas yra išskleista būsena, o modulis — matomas palikuonių skaičius, tad be ženklo count tyliai priverčia visą medį atsiskleisti
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);           // tampa antru šaknies vaiku
    Editor.SetTitle(ChapterId, 'Appendix B');
    Editor.SetStyle(ChapterId, [posBold, posItalic]);
    Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
    Editor.SetExpanded(RootId, False);           // rašo neigiamą /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 apdoroja abi formas, kurias leidžia specifikacija. Paduokite DestinationInAction kaip False, ir PDFiumPas rašo tiesioginį /Dest masyvą; paduokite True — rašo Go-To veiksmą, /A << /S /GoTo /D [ page ref suffix ] >>, pagal ISO 32000-1 §12.6.4.2. Bet kuriuo atveju pirmiausia nuplėšia bet kokius esamus /Dest ir /A nuo elemento, kad abu negalėtų egzistuoti kartu ir prieštarauti. Priesaga pagal numatytuosius yra /Fit ir turi prasidėti PDF vardu — todėl tuščia ar sugadinta priesaga kelia išimtį iškart, vietoj to, kad pagamintų paskirties masyvą, kurio niekas neperskaitys

Kaip ApplyPageMap suvartoja puslapių planą?

ApplyPageMap priima būtent tą masyvą, kurį jūsų puslapių planas jau patikrino: NewPageNumbers, indeksuotą seno puslapio minus vienas, laikantį naują vienetu prasidedantį puslapio numerį arba nulį, kai tas puslapis neišgyveno. Jis eina per elementų masyvą atgal, kad poomedžio trynimas niekada negaliointų indekso, kurio jis dar neapėjo, ir praneša, ką padarė, per RemappedDestinationCount ir RemovedDanglingItemCount

var
  NewPageNumbers: array of Integer;
  Report: TPdfOutlineValidationReport;
  I: Integer;
begin
  // Vienas įrašas kiekvienam ORIGINALAUS dokumento puslapiui
  SetLength(NewPageNumbers, OriginalPageCount);
  for I := 0 to OriginalPageCount - 1 do
    NewPageNumbers[I] := 0;              // 0 == šis puslapis buvo išmestas

  NewPageNumbers[0] := 1;                // senas puslapis 1 -> naujas puslapis 1
  NewPageNumbers[1] := 2;
  NewPageNumbers[9] := 3;                // senas puslapis 10 -> naujas puslapis 3

  // True: ištrinti visą kabančią poomedį. False: palikti elementą, nuplėšti jo taikinį
  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;

DeleteDangling žyma nusprendžia politiką paskirčiai, atvaizdavusiai į nulį, ir abi šakos sąmoningos. Su True PDFiumPas ištrina elementą ir visą jo poomedį, nes struktūros mazgas, kurio taikinys dingo, paprastai vadovauja skyriui, dingsusiam kartu su juo. Su False elementas išgyvena su savo pavadinimu ir hierarchija, bet be /Dest ir /A — tai, ko norite, kai žmogus jį persuks peržiūros metu. Genuiškai sugadintas įėjimas vis tiek žlunga garsiai, o ne lopomas: neigiamas įrašas arba paskirtis, rodanti už pateikto žemėlapio pabaigos, grąžina False su IssueKind, nustatytu į poviInvalidPageMap

Kaip PDFiumPas ApplyPageMap peradresuoja PDF žymes Delphi: puslapių žemėlapis, indeksuotas senu puslapiu minus vienas, nukreipia išgyvenusias paskirtis į jų naujus puslapių numerius, o į nulį atvaizdavę įrašai arba ištrinami su poomedžiu, arba nuplėšiami nuo taikinio
Puslapių žemėlapis indeksuotas senu puslapiu minus vienas, o nulinis įrašas arba ištrina kabančią poomedį, arba palieka elementą be taikinio

Nepermatomi įrašai ir sąžiningas kompromisas

Ne kiekvienas struktūros elementas turi puslapio numerį, apie kurį PDFiumPas gali mąstyti. Trys rūšys perkeliamos nelietos: vardinės paskirtys (named destinations), veiksmai, kurie nėra /S /GoTo, ir nežinomi žodyno raktai, pridėti to, kas pagamino failą. Šie įkelia su PageNumber, lygiu nuliui, išlaiko originalius baitus elemente ir rašomi atgal nepaliesti, nebent aiškiai kviečiate Retarget jų atžvilgiu

  • Vardinė paskirtis yra raktas į dokumento vardų medį, tad teisingas jos permapavimas reiškia medžio išsprendimą ir taikinio įrašo perrašymą, o ne spėliojimą struktūros lygiu
  • /URI, /Launch arba JavaScript veiksmas visai neturi puslapio semantikos ir negali būti tyliai konvertuotas į Go-To
  • Tiekėjo specifiniai raktai ir struktūros paskirtys išsaugomi, nes išmesti tai, ko nesuprantate, yra būtent tai, kaip round-trip praranda duomenis

Kaina tikra ir verta pasakyti atvirai: ApplyPageMap tuos elementus praleidžia visiškai, tad dokumentas, kurio visos žymės naudoja vardines paskirtis, išgyvens puslapio trynimą su struktūriškai galiojančia, bet semantiškai pasenusia struktūra. Tai sąmoningas pasirinkimas — pasenusi nuoroda, kurią recenzentas gali pastebėti, geriau nei užtikrintai neteisinga, kurios niekas nepastebi. Jei rūšiuojate gaunamus failus prieš redaguodami, inventorizacijos perėjimas straipsnyje PDF intake review workbench pasakys, kurie dokumentai patenka į tą kategoriją

Išsaugojimas: papildoma revizija, tada nepriklausomas pakartotinis įkėlimas

TPdfOutlineEditor.SaveIncremental prijungia retą papildomą reviziją, vietoj to, kad perrašytų failą. Įkelti elementai išlaiko originalią netiesioginę objekto nuorodą su tikslia karta, tad esamos kryžminės nuorodos lieka galiojančios; tik jūsų pridėti elementai pasiima šviežią numerį, išduotą vienu daugiau už revizijos didžiausią objekto numerį. Katalogas atnaujinamas toje pačioje revizijoje, ir į jį pridedamas dingęs /Outlines įrašas, kai šaltinis visai neturėjo struktūros

Kas nutinka po rašymo, yra dalis, verta nukopijuoti. PDFiumPas vėl atidaro paskirties srautą su visiškai nepriklausomu redaktoriumi ir lygina pakartotinai įkeltą medį su atmintyje esančiu — elementų skaičius, pavadinimai, puslapių numeriai, paskirčių priesagos, veiksmo prieš tiesioginę paskirtį forma, stiliai, išskleista būsena ir tėviniai ryšiai. Bet koks neatitikimas arba bet koks įkėlimo gedimas išvalo paskirties srautą ir grąžina poviVerificationFailure, vietoj to, kad įteiktų tikėtinai atrodantį failą. Šifruoti šaltiniai atmetami iškart su poviEncryptedInput, nes nauji pavadinimai ir paskirtys kuria eilučių turinį, kurio negalima pagaminti kopijuojant /Encrypt trailerį pirmyn

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;

Elkitės su struktūra kaip su tuo, kuo ji yra — susietu objektų grafu su savais invariantais — ir puslapių trynimas nustoja būti žymių katastrofa ir tampa puslapių žemėlapiu, kurį įduodate vienam metodo kvietimui. TPdfOutlineEditor, ApplyPageMap ir patikrintasis papildomas rašytojas yra PDFiumPas nuo v3.98.0 Delphi, C++Builder ir Lazarus; pilną API galite peržiūrėti ir bandomąją versiją atsisiųsti PDFium Delphi Component produkto puslapyje