Articol tehnic

Editarea outline-ului PDF și remaparea paginilor în Delphi

Scoateți șapte pagini dintr-un manual de 200 de pagini și fiecare bookmark aterizează undeva greșit. Soluția nu e reconstruirea outline-ului dintr-o listă plată de titluri. PDFiumPas expune TPdfOutlineEditor, care încarcă arborele real de outline, vă lasă să mutați și să rețintiți elementele, apoi rulează ApplyPageMap ca să mute fiecare destinație explicită prin planul dumneavoastră de pagini

De ce ștergerea paginilor strică fiecare bookmark?

Pentru că un outline item nu stochează un număr de pagină. Stochează o referință către un page object, iar când page objects se schimbă referința indică fie o pagină care s-a mutat, fie nimic deloc. ISO 32000-1 §12.3.2.2 definește o destinație explicită ca un array al cărui prim element e o referință indirectă către un page dictionary, urmat de un fit name precum /Fit sau /XYZ. Ștergeți pagina și rămâneți cu o referință atârnând; reordonați paginile și referința e încă validă, dar descrie acum un alt capitol. PDFiumPas rezolvă array-ul acela înapoi la un număr de pagină când încarcă, deci TPdfOutlineItem.PageNumber vă dă un indice de pagină cu baza unu care se potrivește cu API-ul public TPdf, nu cu un număr de obiect. Asta e tot sensul abstracției: logica dumneavoastră de remapare lucrează în același sistem de coordonate ca planul de pagini pe care deja l-ați construit când ați scindat, reordonat sau impus documentul. Dacă construiți planul acela, aceeași convenție cu baza unu străbate scindarea documentelor PDF în mai multe fișiere și impunerea n-up și reordonarea paginilor

Outline-ul e un arbore dublu înlănțuit, nu o listă

Motivul pentru care nu puteți pur și simplu serializa un array plat de titluri e că ISO 32000-1 §12.3.3 leagă fiecare outline item în cinci legături separate: /Parent, /Prev, /Next, /First și /Last. Mutarea unui singur subarbore rescrie deci părintele vechi, părintele nou, ambii frați învecinați de fiecare parte a tăieturii și a punctului de inserție, și pointerul de părinte al nodului mutat însuși. Greșiți unul dintre ele și cititorii conformi arată un arbore trunchiat, sau buclă. PDFiumPas păstrează starea de editare ca un array depth-first de înregistrări TPdfOutlineItem cu un Id întreg stabil, deci un subarbore e o secvență contiguă, iar lanțul de frați e derivat, niciodată ținut de mână. TPdfOutlineEditor.Move ridică secvența aceea, o reinserează sub părintele nou la indexul de frate cerut, și reasignează doar rădăcina blocului. Refuză de asemenea cele două mutări care ar corupe graful: mutarea unui element în propriul său subarbore, și numirea unui părinte care nu există

Editarea outline-ului în PDFiumPas în Delphi: mutarea Capitolului 3 din Partea I și sub rădăcina documentului rescrie pointerul /Parent al nodului mutat plus legăturile /First și frații /Prev și /Next din jurul tăieturii și al punctului de inserție
Un singur apel Move rescrie pointerul de părinte al subarborelui ridicat și legăturile de frați de ambele părți ale tăieturii și ale punctului de inserție

De ce /Count are semn?

Pentru că semnul poartă starea de expandare, nu mărimea. Un /Count pozitiv înseamnă că elementul e deschis și numărul e câți descendenți sunt vizibili acum; un /Count negativ înseamnă că elementul e pliat. PDFiumPas scrie numărul de descendenți pentru fiecare element care are copii și îl neagă când IsOpen e False, iar la încărcare citește starea înapoi ca IsOpen := HasCount and (CountValue > 0). Acesta e cel mai frecvent bug scris de mână din outline writers: emiterea unui count fără semn și forțarea tăcută a întregului arbore deschis

Cum encodează PDFiumPas starea de expandare a outline-ului în Delphi: un /Count pozitiv înseamnă că elementul e deschis și numără descendenții vizibili, un /Count negativ înseamnă pliat, iar un count fără semn forțează fiecare cititor să expandeze întregul arbore
Semnul lui /Count e starea de expandare, iar mărimea e numărul de descendenți vizibili, deci un count fără semn forțează tăcut întregul arbore deschis
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);           // devine al doilea copil al rădăcinii
    Editor.SetTitle(ChapterId, 'Appendix B');
    Editor.SetStyle(ChapterId, [posBold, posItalic]);
    Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
    Editor.SetExpanded(RootId, False);           // scrie un /Count negativ
    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 tratează ambele forme pe care specificația le permite. Pasați DestinationInAction ca False și PDFiumPas scrie un array direct /Dest; pasați True și scrie o acțiune Go-To, /A << /S /GoTo /D [ page ref suffix ] >>, conform ISO 32000-1 §12.6.4.2. Oricum ar fi, întâi îndepărtează orice /Dest și /A existente din element, ca cele două să nu poată coexista și să se contrazică. Sufixul implicit e /Fit și trebuie să înceapă cu un PDF name, de aceea un sufix gol sau malformat ridică excepție imediat în loc să producă un array de destinație pe care niciun cititor nu-l poate parsa

Cum consumă ApplyPageMap un plan de pagini?

ApplyPageMap ia exact array-ul pe care planul dumneavoastră de pagini l-a validat deja: NewPageNumbers, indexat după pagina veche minus unu, ținând noul număr de pagină cu baza unu sau zero când pagina respectivă nu a supraviețuit. El parcurge array-ul de elemente înapoi, ca ștergerea unui subarbore să nu invalideze vreun index pe care nu l-a vizitat încă, și raportează ce a făcut prin RemappedDestinationCount și RemovedDanglingItemCount

var
  NewPageNumbers: array of Integer;
  Report: TPdfOutlineValidationReport;
  I: Integer;
begin
  // O intrare per pagină a documentului ORIGINAL
  SetLength(NewPageNumbers, OriginalPageCount);
  for I := 0 to OriginalPageCount - 1 do
    NewPageNumbers[I] := 0;              // 0 == pagina asta a fost aruncată

  NewPageNumbers[0] := 1;                // pagina veche 1 -> pagina nouă 1
  NewPageNumbers[1] := 2;
  NewPageNumbers[9] := 3;                // pagina veche 10 -> pagina nouă 3

  // True: șterge întregul subarbore atârnând. False: păstrează elementul, îi îndepărtează ținta
  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-ul DeleteDangling decide politica pentru o destinație mapată la zero, iar ambele ramuri sunt deliberate. Cu True, PDFiumPas șterge elementul și întregul său subarbore, pentru că un nod de outline a cărui țintă a dispărut conduce de regulă un capitol dispărut odată cu ea. Cu False, elementul supraviețuiește cu titlul și ierarhia intacte, dar cu /Dest și /A îndepărtate, ceea ce vreți când un om urmează să-l rețintească în review. Input-ul cu adevărat malformat încă eșuează zgomotos în loc să fie peticit: o intrare negativă sau o destinație care arată dincolo de sfârșitul mapei furnizate întoarce False cu IssueKind setat pe poviInvalidPageMap

Cum redirectează ApplyPageMap din PDFiumPas bookmark-urile PDF în Delphi: o mapă de pagini indexată după pagina veche minus unu trimite destinațiile supraviețuitoare la noile lor numere de pagină, în timp ce intrările mapate la zero sunt fie șterse cu subarborele lor, fie private de ținta lor
Mapa de pagini e indexată după pagina veche minus unu, iar o intrare zero fie șterge subarborele atârnând, fie lasă elementul cu ținta îndepărtată

Intrări opace și compromisul onest

Nu fiecare outline item are un număr de pagină pe care PDFiumPas să-l poată raționa. Trei feluri sunt purtate mai departe neatinse: named destinations, acțiunile care nu sunt /S /GoTo, și cheile de dicționar necunoscute adăugate de orice a produs fișierul. Acestea se încarcă cu PageNumber egal cu zero, își păstrează bytes originali în element, și sunt scrise înapoi verbatim dacă nu apelați explicit Retarget pe ele

  • Un named destination e o cheie în name tree-ul documentului, deci remaparea lui corectă înseamnă rezolvarea arborelui și rescrierea intrării țintă, nu ghicirea la nivel de outline
  • O acțiune /URI, /Launch sau JavaScript nu are deloc semantică de pagină și nu trebuie convertită tăcut într-un Go-To
  • Cheile specifice vendor-ului și structure destinations sunt păstrate pentru că aruncarea a ceea ce nu înțelegeți e felul în care round-trip-urile pierd date

Prețul e real și merită spus pe față: ApplyPageMap sare acele elemente cu desăvârșire, deci un document ale cărui bookmark-uri folosesc toate named destinations va trece printr-o ștergere de pagini cu outline-ul structural valid și semantic perimat. Asta e alegerea deliberată — un link perimat pe care un reviewer îl poate prinde bate unul încrezător greșit pe care nimeni nu-l observă. Dacă triați fișierele primite înainte să le editați, o trecere de inventar într-un workbench de review de intake PDF vă va spune ce documente cad în categoria asta

Salvarea: revizie incrementală, apoi o redeschidere independentă

TPdfOutlineEditor.SaveIncremental adaugă o revizie incrementală spars în loc să rescrie fișierul. Elementele care au fost încărcate își păstrează referința indirectă originală de obiect inclusiv generația exactă, deci cross-references existente rămân valide; doar elementele pe care le-ați adăugat trag un număr proaspăt, alocat de la unul peste numărul maxim de obiect al reviziei. Catalogul e actualizat în aceeași revizie, iar o intrare /Outlines lipsă e adăugată la el când sursa nu avea deloc outline

Ce se întâmplă după scriere e partea care merită copiată. PDFiumPas redeschide stream-ul de destinație cu un editor complet independent și compară arborele reîncărcat cu cel din memorie — numărul de elemente, titlurile, numerele de pagină, sufixele de destinație, forma acțiune versus destinație directă, stilurile, starea de expandare, și relațiile de părinte. Oricare neconcordanță, sau orice eșec de încărcare, golește stream-ul de destinație și întoarce poviVerificationFailure în loc să vă dea un fișier cu aspect plauzibil. Sursele criptate sunt refuzate de la început cu poviEncryptedInput, întrucât titlurile și destinațiile noi creează conținut string care nu poate fi produs prin copierea trailer-ului /Encrypt mai departe

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;

Tratați outline-ul ca ceea ce e — un graf de obiecte legat cu propriii săi invarianți — iar ștergerea paginilor încetează să fie un dezastru de bookmark-uri și devine o mapă de pagini pe care o predați unui singur apel de metodă. TPdfOutlineEditor, ApplyPageMap și scriitorul incremental verificat sosesc în PDFiumPas de la v3.98.0 pentru Delphi, C++Builder și Lazarus; puteți revizui API-ul complet și descărca o versiune de probă pe pagina de produs PDFium Delphi Component