Articol tehnic

Copiere de obiecte PDF între documente în Delphi: cicluri

Îmbinați două PDF-uri de mână, mutați un singur obiect de pagină în documentul țintă, iar copierea ajunge drept într-o încălcare de acces. PDFlibPas remediază asta în CopyForeignObject: copiază în profunzime un obiect indirect plus întreaga sa închidere de referințe și rezolvă referințele circulare de întoarcere precum /Parent la null, în loc să recurgă

De ce se prăbușește copierea unei pagini între documente?

Pentru că un arbore de pagini PDF este un arbore doar dacă îl citiți în jos. Parcurgeți-l așa cum o face un copiator recursiv, urmărind fiecare valoare din fiecare dicționar, iar dicționarul paginii vă dă /Parent, care pointează înapoi la nodul /Pages din care ați venit, iar acel nod vă dă /Kids, care pointează înapoi la pagină. ISO 32000-1 §7.7.3 face /Parent obligatoriu pe fiecare nod al arborelui de pagini, cu excepția rădăcinii, deci nu este un fișier malformat pe care să îl puteți respinge — este forma normală a oricărui document care v-ar fi dat vreodată

A doua jumătate a problemei este numerotarea. Obiectele indirecte sunt identificate printr-un număr de obiect local unui singur fișier (ISO 32000-1 §7.3.10), astfel încât un obiect tras din documentul A în documentul B trebuie renumerotat, iar fiecare referință către el din închiderea copiată trebuie renumerotată la fel, altfel două referințe care pointau spre un font partajat pointează acum spre două lucruri fără legătură. Acea renumerotare este aceeași treabă pe care un merge rapid o face la nivel de octeți, și merită citite împreună: deplasarea referințelor la nivel de octeți pentru merge rapid de PDF o rezolvă traducând fișiere întregi, în timp ce o copiere la nivel de obiect trebuie să o rezolve muchie cu muchie

De ce o copiere PDF între documente în Delphi cere atenție: dicționarul paginii și nodul său /Pages închid un ciclu prin /Parent și /Kids, o închidere de font coboară și se termină, iar PDFlibPas remapează fiecare număr de obiect local fișierului
Arborele de pagini închide o buclă prin /Parent și /Kids, în timp ce închiderile de conținut se termină, iar fiecare număr de obiect copiat trebuie remapat pe drum

Ce copiază efectiv PDFlibPas CopyForeignObject

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) clonează un obiect indirect și tot ce este accesibil din el — dicționare imbricate, vectori, șiruri, nume, numere și fluxuri cu dicționarele lor intacte — în documentul selectat curent și întoarce un handle nenul către noua referință indirectă. Numerele de obiect sursă sunt remapate printr-o hartă vie ținută pe durata apelului, astfel încât un obiect atins de două ori în închidere este clonat o dată și partajat de două ori. Întoarce zero, fără a ridica excepție, când ID-ul documentului sursă este necunoscut, când sursa este chiar documentul selectat sau când ObjectNumber este sub 1

var
  Lib: TPDFlib;
  SourceDoc, TargetDoc, Handle: Integer;
begin
  Lib := TPDFlib.Create;
  try
    TargetDoc := Lib.NewDocument;
    if Lib.LoadFromFile('source.pdf', '') <> 1 then
      Exit;                              // LoadFromFile întoarce 1 la succes
    SourceDoc := Lib.SelectedDocument;   // încărcarea a selectat ce a încărcat
    Lib.SelectDocument(TargetDoc);       // copierea are ca țintă documentul selectat
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

Două detalii mușcă lumea la prima rulare. LoadFromFile răspunde 1 sau 0, nu un ID de document, astfel încât handle-ul de care aveți nevoie vine de la SelectedDocument imediat după încărcare; iar copierea scrie întotdeauna în documentul pe care SelectDocument l-a făcut curent ultima dată, niciodată în documentul din care ați încărcat. Intern, recursiunea poartă și un plafon rigid de adâncime de 64, care este un paratrăsnet contra imbricării patologice, nu mecanismul care tratează ciclurile — tratarea ciclurilor este separată și deliberată

De ce rezervarea unei mapări Nil nu rupe ciclul?

Pentru că Nil în tabela de mapare înseamnă două lucruri diferite deodată, iar codul nu le poate deosebi. Apărarea evidentă contra unui ciclu este să adăugați intrarea în hartă înainte de a recurge în obiect, astfel încât orice buclă înapoi să găsească intrarea și să se oprească. Dar intrarea nu poate încă ține ținta reală — ținta nu există până când închiderea de sub ea nu a fost scrisă — deci ține Nil, iar căutarea care ar trebui să prindă muchia de întoarcere citește Nil și concluzionează că obiectul n-a fost niciodată mapat

// Greșit: o țintă Nil rezervată este de nedistins de „încă nemapat”
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // rezervată, încă Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // completată retroactiv doar la ieșire
end;

Urmăriți asta prin bucla de pagini. Clona paginii ajunge la /Parent, recurge în nodul /Pages, care ajunge la /Kids, care recurge înapoi în pagină — a cărei intrare rezervată citește încă Nil, deci este clonată a doua oară, și a treia, fiecare nivel împingând un cadru nou și un obiect nou pe jumătate construit. Ce observați nici măcar nu este o depășire curată a stivei: cadrele exterioare stau pe referințe ale căror ținte nu au fost niciodată atribuite, deci prima scriere prin unul dintre aceste sloturi este o încălcare de acces undeva care nu seamănă deloc cu copierea paginii care a cauzat-o

De ce rezervarea unei ținte Nil în hartă nu oprește ciclul într-o copiere PDFlibPas între documente: căutarea nu poate deosebi o intrare rezervată de una nemapată, astfel încât copiatorul coboară prin cadre tot mai adânci, pe jumătate construite, până când o scriere se prăbușește
Pentru că o țintă Nil răspunde la două întrebări diferite deodată, muchia de întoarcere nu este recunoscută niciodată, iar pagina este clonată din nou la fiecare trecere

Reparația: o stare explicită de tip in-progress

Reparația constă în a nu mai supraîncărca Nil și a pune întrebarea direct. O intrare din hartă a cărei țintă este încă neatribuită înseamnă acest obiect este clonat chiar acum, iar un predicat InProgress testează exact asta înainte de rularea căutării obișnuite. Când este adevărat, muchia este un ciclu înapoi către un strămoș al clonei curente, iar PDFlibPas emite pentru ea un obiect null în loc să o urmeze

// O intrare din hartă cu țintă Nil marchează o clonă în curs
function InProgress(Num: Integer): Boolean;
var
  I: Integer;
begin
  Result := False;
  for I := 0 to High(Map) do
    if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
      Exit(True);
end;

// ... în interiorul CloneObject, pentru o referință indirectă:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // muchie circulară de întoarcere, nu recurge
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
  if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
    Exit(FStructure.NewNull);            // referință sursă atârnând
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // rezervă, apoi recurge
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // completează retroactiv
end;
Exit(NewRef);

Aceasta este sigură de generalizat doar datorită unui fapt structural despre PDF: ciclurile din graful de obiecte apar pe legăturile de întoarcere, nu pe muchiile de conținut. /Parent în arborele de pagini și /Prev într-un lanț de outline pointează în sus sau înapoi spre ceva deja vizitat; închiderea unui font, a unui XObject de imagine sau a unui XObject de formular coboară și se termină. Astfel, o copiere a unui descriptor de font, a unui spațiu de culoare sau a unui dicționar de shading nu este afectată de substituția cu null — nimic din acele închideri nu lovește vreodată InProgress. Costul, spus pe față, este că muchia circulară nu supraviețuiește copierii. Un dicționar de pagină clonat astfel sosește cu /Parent ca obiect null, ceea ce ISO 32000-1 §7.3.9 face echivalent unei intrări absente, astfel încât pagina copiată este un obiect valid care nu aparține niciunui arbore de pagini până când nu o legați singur în nodul țintă /Pages și vă corectați singur /Count. Un element de outline copiat își pierde /Prev la fel și are nevoie de refacerea lanțului de frați. Acesta este compromisul onest: CopyForeignObject vă dă o închidere corectă și lasă re-legarea structurală în sarcina apelantului, aceeași frontieră în care lucrează și înlocuirea paginilor cu păstrarea numerelor de obiect

Reparația din PDFlibPas CopyForeignObject pentru Delphi: un test explicit InProgress rulează înaintea căutării din hartă, o muchie circulară de întoarcere devine obiect null, iar apelantul re-leagă pagina copiată în arborele de pagini țintă ulterior
O stare explicită de tip in-progress înlocuiește Nil-ul supraîncărcat, astfel încât muchia de întoarcere se rezolvă în null, iar apelantului îi rămâne o singură reparație structurală de făcut

De ce intrarea din hartă trebuie rezervată înainte de NewObjRef

O alternativă evidentă ar ocoli tot dansul de tip in-progress: alocați întâi un obiect-coajă gol, îi înregistrați numărul real în hartă, apoi umpleți coaja odată ce copiii au fost clonați. Asta nu funcționează aici, pentru că TPDFIndObj.Obj este doar-citire, iar conținutul lui nu poate fi înlocuit după construcție — nu există nicio coajă de umplut. Numărul și conținutul sunt hotărâte împreună de NewObjRef, ceea ce înseamnă că intrarea din hartă trebuie creată înaintea apelului recursiv și completată după el, iar intervalul dintre cele două momente este exact ceea ce InProgress trebuie să acopere. O consecință de știut înainte de a compara rezultatele prin diferență: deoarece NewObjRef rulează după ce închiderea copiilor este scrisă, numerotarea în țintă iese de jos în sus, iar numerele de obiect nu vor oglindi ordinea din sursă. Nimic din formatul de fișier nu are ce să-i pese, dar o comparație de octeți față de o așteptare construită de mână va avea. Dacă o rulare lasă obiecte pe care ați decis să nu le legați în nimic, ele sunt nereferite, nu corupte, iar colectarea mark-and-sweep a obiectelor PDF inaccesibile este unealta care le curăță înainte de salvare

Testul de regresie care acoperă asta are nevoie de un detaliu care surprinde lumea ce scrie teste contra TPDFlib: constructorul ține deja un document implicit, deci DocumentCount pornește de la 1, iar un dispozitiv cu două documente trebuie să afirme >= 2, nu = 2. Alături de copierea reușită, testul fixează cele trei respingeri — un ID de sursă necunoscut, documentul selectat ca sursă proprie și un număr de obiect zero — toate întorcând 0 în loc să ridice, pentru că o buclă de merge este un loc prost pentru a descoperi că o clauză de gardă aruncă excepții

Unde se încadrează asta într-o conductă de merge

Copierea la nivel de obiect este primitiva la care apelați când merge-ul de fișiere întregi este prea grosier: scoaterea unui program de font dintr-un șablon, aducerea unui singur XObject de formular într-un document de ștampilare sau mutarea unei adnotări cu fluxurile ei de aspect între fișiere fără a trage după ea restul paginii. PDFlibPas o expune ca un singur apel contra documentelor încărcate, iar cum se așază față de restul API-ului de obiecte la nivel jos puteți vedea în referința PDFlibPas Delphi PDF Library