Tehnični članak

Kopiranje objektov PDF med dokumenti v Delphiju: cikli

Združite dva PDFa ročno, premaknite en sam objekt strani v ciljni dokument, in kopija trči naravnost v kršitev dostopa. PDFlibPas to reši v CopyForeignObject: globoko kopira en posreden objekt skupaj z njegovo celotno sklicno celoto, ciklične povratne sklice, kot je /Parent, pa razreši v null namesto da bi rekurziral

Zakaj se kopiranje ene strani med dokumenti sesuje?

Ker je drevo strani PDF drevo le, če ga berete navzdol. Sledite mu na način rekurzivnega kopirnika, ki opazuje vsako vrednost v vsakem slovarju, in slovar strani vam da /Parent, ki kaže nazaj na vozlišče /Pages, s katerega ste prišli, to vozlišče pa vam da /Kids, ki kaže nazaj na stran. ISO 32000-1 §7.7.3 zahteva /Parent na vsakem vozlišču drevesa strani razen korena, zato to ni deformirana datoteka, ki bi jo lahko zavrnili — to je običajna oblika vsakega dokumenta, ki vam bo kdaj prišel v roke

Druga polovica težave je številčenje. Posredne objekte identificira številka objekta, lokalna za eno datoteko (ISO 32000-1 §7.3.10), zato je treba objekt, vlečen iz dokumenta A v dokument B, preštevilčiti, vsak sklic nanj znotraj kopirane celote pa preštevilčiti na enak način, sicer dva sklica, ki sta prej kazala na eno deljeno pisavo, kazeta na dve nepovezani stvari. To preštevilčevanje je isto delo, ki ga hiter združevalnik opravi na ravni bajtov, in vredno je brati oboje drug ob drugem: premikanje sklicev na ravni bajtov za hitro združevanje PDF to reši s prevajanjem celih datotek, kopija na ravni objektov pa ga mora reševati povezavo za povezavo

Zakaj kopija PDF med dokumenti v Delphiju zahteva pozornost: slovar strani in njegovo vozlišče /Pages zapreta cikel skozi /Parent in /Kids, celota pisave teče navzdol in se zaključi, PDFlibPas pa preslika vsako številko objekta, lokalno za datoteko
Drevo strani zapre zanko skozi /Parent in /Kids, medtem ko se celote vsebine zaključijo, vsaka kopirana številka objekta pa mora biti na poti preslikana

Kaj CopyForeignObject v PDFlibPas dejansko kopira

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) klonira en posreden objekt in vse, kar je od njega dosegljivo — gnezdene slovarje, tabele, nize, imena, številke in toke z njihovimi nedotaknjenimi slovarji — v trenutno izbrani dokument, in vrne neničelni ročaj na nov posredni sklic. Izvorne številke objektov se preslikajo skozi živo preslikavo, ki jo klic vzdržuje ves čas trajanja, zato je objekt, dosežen dvakrat v celoti, kloniran enkrat in deljen dvakrat. Vrne nič, brez izjeme, ko je ID izvornega dokumenta neznan, ko je vir sam izbrani dokument ali ko je ObjectNumber pod 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 vrne 1 ob uspehu
    SourceDoc := Lib.SelectedDocument;   // nalaganje je izbralo, kar je naložilo
    Lib.SelectDocument(TargetDoc);       // kopija cilja izbrani dokument
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

Dve podrobnosti ugrizneta ljudi ob prvem zagonu. LoadFromFile odgovori 1 ali 0, ne ID dokumenta, zato ročaj, ki ga potrebujete, pride od SelectedDocument takoj po nalaganju; kopija pa vedno piše v tisto, kar je SelectDocument nazadnje naredil trenutno, nikoli v dokument, iz katerega ste naložili. Navznoter rekurzija nosi še trdo omejitev globine 64, ki je varovalka proti patološkemu gnezdenju in ne mehanizem za cikle — obravnava ciklov je ločena in namerna

Zakaj rezervacija preslikave Nil ne prekine cikla?

Ker Nil v tabeli preslikave pomeni dve različni stvari hkrati, koda pa jih ne zna ločiti. Očitna obramba pred ciklom je dodati vnos preslikave, preden rekurziraš v objekt, da vse, kar zanki nazaj, najde vnos in obstane. A vnos še ne more držati pravega cilja — cilj ne obstaja, dokler ni zapisana celota pod njim — zato drži Nil, iskanje, ki bi moralo uloviti povratno povezavo, pa prebere Nil in sklepa, da objekt ni bil nikoli preslikan

// Pokvarjeno: rezerviran cilj Nil je nerazločljiv od »še ni preslikano«
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;          // rezervirano, še vedno Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // dopolnjeno šele ob izhodu
end;

Sledite temu skozi zanko strani. Klon strani doseže /Parent, rekurzira v vozlišče /Pages, to doseže /Kids, ki rekurzira nazaj v stran — njegov rezerviran vnos pa še vedno bere Nil, zato je kloniran drugič, in tretjič, vsaka raven porine nov okvir in nov na pol zgrajen objekt. Tisto, kar opazujete, ni niti čisto prelivanje sklada: zunanji okvirji sedijo na sklicih, katerih cilji niso bili nikoli dodeljeni, zato je prvi zapis skozi enega od teh rež kršitev dostopa nekje, kar sploh ne spominja na kopijo strani, ki jo je povzročila

Zakaj rezervacija cilja Nil v preslikavi ne ustavi cikla pri kopiji PDFlibPas med dokumenti: iskanje ne more ločiti rezerviranega vnosa od nepreslikanega, zato kopirnik pada skozi vedno globlje na pol zgrajene okvirje, dokler zapis ne sesuje
Ker cilj Nil odgovarja na dve različni vprašanji hkrati, povratna povezava ni nikoli prepoznana in stran je klonirana znova ob vsakem prehodu

Popravek: izrecno stanje v teku

Popravilo je opustiti preobremenjevanje Nil in vprašati naravnost. Vnos preslikave, katere cilj je še vedno nedodeljen, pomeni ta objekt se trenutno klonira, predikat InProgress pa točno to preizkusi, preden teče običajno iskanje. Ko je res, je povezava cikel nazaj v prednika trenutnega klona, PDFlibPas pa zanjo izda ničelni objekt namesto da bi ji sledil

// Vnos preslikave s ciljem Nil označuje klon v teku
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;

// ... znotraj CloneObject, za posredni sklic:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // ciklična povratna povezava, ne rekurziraj
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);            // viseči izvorni sklic
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // rezerviraj, nato rekurziraj
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // dopolni nazaj
end;
Exit(NewRef);

To je varno posplošiti le zaradi strukturnega dejstva o PDFu: cikli v grafu objektov se pojavljajo na povratnih povezavah, ne na povezavah vsebine. /Parent v drevesu strani in /Prev v verigi orisa kažeta navzgor ali nazaj na nekaj, kar je že obiskano; celota pisave, slikovnega XObjecta ali XObjecta obrazca teče navzdol in se zaključi. Kopija deskriptorja pisave, barvnega prostora ali slovarja senčenja zato ne čuti zamenjave z null — nič v teh celotah ne doseže InProgress. Cena, odkrito povedana, je, da ciklična povezava ne preživi kopije. Slovar strani, kloniran na ta način, prispe s /Parent kot ničelnim objektom, kar ISO 32000-1 §7.3.9 izenači z odsotnim vnosom, zato je kopirana stran veljaven objekt, ki ne pripada nobenemu drevesu strani, dokler je ne povežete v ciljno vozlišče /Pages in sami popravite /Count. Kopirana postavka orisa izgubi /Prev na enak način in potrebuje na novo zgrajeno verigo sorojencev. To je pošten kompromis: CopyForeignObject vam da pravilno celoto in strukturno ponovno povezovanje pusti klicatelju, kar je ista meja, znotraj katere deluje zamenjava strani z ohranjanjem številk objektov

Popravek v CopyForeignObject v PDFlibPas za Delphi: izrecen preizkus InProgress teče pred iskanjem po preslikavi, ciklična povratna povezava postane ničelni objekt, klicatelj pa kasneje znova poveže kopirano stran v ciljno drevo strani
Izrecno stanje v teku nadomesti preobremenjeni Nil, povratna povezava se razreši v null in klicatelju ostane ena strukturna popravila

Zakaj je treba vnos preslikave rezervirati pred NewObjRef

Očitna alternativa bi se celotnemu plesu v teku izognila: najprej dodeli prazen objekt-lupino, registriraj njegovo pravo številko v preslikavi, nato lupino zapolni, ko so otroci klonirani. Tu ne deluje, ker je TPDFIndObj.Obj samo za branje in njegove vsebine ni mogoče zamenjati po izgradnji — ni lupine, ki bi jo zapolnili. Številko in vsebino skupaj odloči NewObjRef, kar pomeni, da vnos preslikave nastane pred rekurzivnim klicem in se dopolni po njem, razmik med tema trenutkoma pa je ravno tisto, kar mora InProgress pokriti. Ena posledica, vredna pozornosti, preden primerjate izhod: ker NewObjRef teče, potem ko je otroška celota zapisana, številčenje v cilju pride od spodaj navzgor in številke objektov ne bodo zrcalile izvornega vrstnega reda. Format datoteke se ničesar ne zmeni, primerjava bajtov z ročno sestavljenim pričakovanjem pa se. Če pobuda pusti objekte, ki se jih odločite, da jih ne povežete v nič, so ti nesklicani in ne pokvarjeni, orodje, ki jih pred shranjevanjem počisti, pa je zbiranje nedosegljivih objektov PDF po metodi mark-and-sweep

Regresija, ki to pokrije, potrebuje eno podrobnost, ki preseneti ljudi, ki pišejo preizkuse proti TPDFlib: konstruktor že drži privzeti dokument, zato DocumentCount začne pri 1 in dvodokumentni model mora zagovarjati >= 2, ne = 2. Ob uspešni kopiji preizkus pripne tri zavrnitve — neznan ID vira, izbrani dokument kot svoj lasten vir in številka objekta nič — vse vrnejo 0 namesto da bi sprožile izjemo, ker je zanka združevanja slabo mesto za odkritje, da zaščitni stavek meče

Kje to stoji v cevovodu združevanja

Kopiranje na ravni objektov je primitiv, po katerem segate, kadar je združevanje celih datotek preveč grobo: dvigniti en program pisave iz predloge, vleči en sam XObject obrazca v dokument za žigosanje ali prestaviti pripombo z njenimi tokovi videza med datotekami, brez vlečenja preostanka strani za sabo. PDFlibPas ga izpostavi kot en sam klic proti naloženim dokumentom, kako stoji ob preostanku nizkonivojskega objektnega API-ja, pa vidite v referenci PDFlibPas Delphi PDF Library