Techninis straipsnis

PDF objektų kopija tarp dokumentų Delphi: ciklų avarijos

Sujunkite du PDF rankomis, perkelkite vieną puslapio objektą į tikslinį dokumentą — ir kopija tiesiai įkrenta į prieigos pažeidimą. PDFlibPas tai išsprendžia CopyForeignObject viduje: jis daro giluminę vieno netiesioginio objekto kopiją kartu su visu jo nuorodų uždariniu, o ciklines atgalines nuorodas, tokias kaip /Parent, paverčia null vietoj rekursijos

Kodėl kopijuojant vieną puslapį tarp dokumentų programa nulūžta?

Nes PDF puslapių medis yra medis tik skaitomas iš viršaus žemyn. Jei eisite juo taip, kaip eina rekursinis kopijuotojas, sekdamas kiekvieną reikšmę kiekviename žodyne, puslapio žodynas jums įteiks /Parent, kuris rodo atgal į /Pages mazgą, iš kurio atėjote, o tas mazgas įteiks /Kids, kuris rodo atgal į puslapį. ISO 32000-1 §7.7.3 reikalauja /Parent kiekviename puslapių medžio mazge, išskyrus šaknį, todėl tai nėra iškraipytas failas, kurį galite atmesti — tai normali kiekvieno dokumento, kurį bet kada gausite, forma

Antroji problemos pusė yra numeracija. Netiesioginiai objektai identifikuojami objekto numeriu, lokaliu vienam failui (ISO 32000-1 §7.3.10), todėl objektas, nutemptas iš dokumento A į dokumentą B, turi būti pernumeruotas, ir kiekviena nuoroda į jį kopijuotame uždarinyje turi būti pernumeruota taip pat, kitaip dvi nuorodos, anksčiau rodžiusios į vieną bendrą šriftą, dabar rodys į du nesusijusius dalykus. Tas pernumeravimas yra tas pats darbas, kurį greitas sujungimas atlieka baitų lygiu, ir verta skaityti abu variantus greta: baitų lygio nuorodų postūmis greitam PDF sujungimui jį išsprendžia verčiant visus failus, o objektų lygio kopija turi jį spręsti po vieną briauną

Kodėl PDF kopijai tarp dokumentų Delphi reikia dėmesio: puslapio žodynas ir jo /Pages mazgas uždaro ciklą per /Parent ir /Kids, šrifto uždarinis eina žemyn ir baigiasi, o PDFlibPas permapuoja kiekvieną failui lokalų objekto numerį
Puslapių medis uždaro ciklą per /Parent ir /Kids, kol turinio uždariniai baigiasi, ir kiekvienas kopijuojamas objekto numeris kelyje turi būti permapuotas

Ką PDFlibPas CopyForeignObject iš tikrųjų kopijuoja

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) klonuoja vieną netiesioginį objektą ir viską, kas iš jo pasiekiama — įdėtinius žodynus, masyvus, eilutes, vardus, skaičius ir srautus su jų žodynais nepalietais — į šiuo metu pasirinktą dokumentą, ir grąžina nenulinį netiesioginės nuorodos rankenėlę. Šaltinio objektų numeriai permapuojami per gyvą žemėlapį, laikomą viso kvietimo metu, todėl objektas, uždarinyje pasiektas du kartus, klonuojamas kartą ir dalijamas du kartus. Jis grąžina nulį, nekeldamas išimties, kai šaltinio dokumento ID nežinomas, kai šaltinis yra pats pasirinktas dokumentas arba kai ObjectNumber mažesnis nei 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 grąžina 1 sėkmės atveju
    SourceDoc := Lib.SelectedDocument;   // įkėlimas pasirinko tai, ką įkėlė
    Lib.SelectDocument(TargetDoc);       // kopija orientuota į pasirinktą dokumentą
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

Du niuansai pirmame paleidime įkanda žmonėms. LoadFromFile atsako 1 arba 0, o ne dokumento ID, todėl reikalinga rankenėlė gaunama iš SelectedDocument iškart po įkėlimo; o kopija visada rašo į tai, ką SelectDocument paskutinį kartą padarė esamu, niekada į dokumentą, iš kurio įkėlėte. Viduje rekursija taip pat neša standžias gylio lubas 64 — tai atsarga prieš patologinį įdėtinumą, o ne mechanizmas, tvarkantis ciklus; ciklų apdorojimas yra atskiras ir sąmoningas

Kodėl Nil atvaizdavimo rezervavimas nesutramdo ciklo?

Nes Nil atvaizdavimo lentelėje reiškia du skirtingus dalykus vienu metu, ir kodas jų neatskiria. Akivaizdi gynyba nuo ciklo — įdėti žemėlapio įrašą prieš rekursuojant į objektą, kad bet kas, grįžtantis atgal, rastų įrašą ir sustotų. Bet įrašas dar negali turėti tikro tikslo — tikslo nėra, kol žemiau esantis uždarinis neparašytas — todėl jis laiko Nil, o paieška, kuri turėtų pagauti atgalinę briauną, skaito Nil ir padaro išvadą, kad objektas niekada nebuvo įrašytas į žemėlapį

// Sugedęs: rezervuotas Nil tikslas neatskiriamas nuo „dar neužregistruoto žemėlapyje“
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;          // rezervuota, vis dar Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // užpildoma atgal tik grįžtant
end;

Persekite tai per puslapio ciklą. Puslapio klonas pasiekia /Parent, rekursuoja į /Pages mazgą, kuris pasiekia /Kids, kuris rekursuoja atgal į puslapį — kurio rezervuotas įrašas vis dar skaito Nil, todėl jis klonuojamas antrą kartą, ir trečią, kiekvienas lygis pastumdamas naują kadrą ir naują pusiau pastatytą objektą. Tai, ką stebite, nėra ir švarus steko perpildymas: išoriniai kadrai sėdi ant nuorodų, kurių tikslai niekada nebuvo priskirti, todėl pirmasis rašymas per vieną tų vietų yra prieigos pažeidimas kažkur, kur nieko neprimena puslapio kopijos, sukėlusios jį

Kodėl Nil žemėlapio tikslo rezervavimas nesustabdo ciklo PDFlibPas kopijoje tarp dokumentų: paieška negali atskirti rezervuoto įrašo nuo neužregistruoto, todėl kopijuotojas leidžiasi per vis gilesnius pusiau pastatytus kadrus, kol rašymas nulūžta
Kadangi Nil tikslas atsako į du skirtingus klausimus vienu metu, atgalinė briauna niekada neatpažįstama, ir puslapis klonuojamas iš naujo kiekvieno apėjimo metu

Pataisymas: atvira InProgress būsena

Pataisymas — nustoti perkrauti Nil ir užduoti klausimą tiesiogiai. Žemėlapio įrašas, kurio tikslas vis dar nepriskirtas, reiškia šis objektas šiuo metu klonuojamas, ir InProgress predikatas būtent tai patikrina prieš įprastą paiešką. Kai tai tiesa, briauna yra ciklas atgal į dabartinio klono protėvį, ir PDFlibPas išveda null objektą, vietoj to, kad juo sektų

// Žemėlapio įrašas su Nil tikslu žymi vykstantį klonavimą
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;

// ... CloneObject viduje, netiesioginei nuorodai:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // ciklinė atgalinė briauna, nesirekursuoti
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);            // pakibusi šaltinio nuoroda
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // rezervuoti, tada rekursuoti
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // užpildyti atgal
end;
Exit(NewRef);

Tai saugu apibendrinti tik dėl struktūrinio fakto apie PDF: ciklai objektų grafe atsiranda ant atgalinių saitų, o ne ant turinio briaunų. /Parent puslapių medyje ir /Prev outline grandinėje rodo aukštyn arba atgal į jau aplankytą dalyką; šrifto, vaizdo XObject arba formos XObject uždarinis eina žemyn ir baigiasi. Todėl šrifto aprašytojo, spalvų erdvės arba šešėliavimo žodyno kopiją null pakeitimas nepaveikia — niekas tose uždarinėse niekada nepataiko į InProgress. Kaštas, pasakyta tiesiai, yra tas, kad ciklinė briauna neišgyvena kopijos. Tokiu būdu klonuotas puslapio žodynas atkeliauja su /Parent kaip null objektu, kurį ISO 32000-1 §7.3.9 laiko lygiaverčiu nebūtiškam įrašui, todėl nukopijuotas puslapis yra teisingas objektas, nepriklausantis jokiam puslapių medžiui, kol pats jį susiesite į tikslinį /Pages mazgą ir pataisysite /Count. Nukopijuotas outline elementas taip pat praranda /Prev ir reikalauja seserų grandinės atstatymo. Tai sąžiningas mainai: CopyForeignObject duoda teisingą uždarinį ir struktūrinį perstatymą palieka kvietėjui — ta pati riba, kurioje dirba puslapių keitimas išsaugant objektų numerius

Pataisymas PDFlibPas CopyForeignObject Delphi aplinkoje: atviras InProgress patikrinimas vyksta prieš žemėlapio paiešką, ciklinė atgalinė briauna tampa null objektu, o kvietėjas vėliau persijungia nukopijuotą puslapį į tikslinį puslapių medį
Atvira in-progress būsena pakeičia perkrautą Nil, todėl atgalinė briauna išsprendžiama į null, o kvietėjui lieka vienas struktūrinis sutvarkymas

Kodėl žemėlapio įrašas turi būti rezervuotas prieš NewObjRef

Akivaizdi alternatyva apeitų visą in-progress šokį: pirmiausia priskirti tuščią apvalkalo objektą, užregistruoti jo tikrą numerį žemėlapyje, o apvalkalą užpildyti, kai vaikai klonuoti. Čia tai neveikia, nes TPDFIndObj.Obj yra tik skaitomas, ir jo turinio negalima pakeisti po sukūrimo — nėra apvalkalo, kurį užpildyti. Numerį ir turinį kartu nusprendžia NewObjRef, tai reiškia, žemėlapio įrašas turi būti sukurtas prieš rekursinį kvietimą ir užbaigtas po jo, ir tarpas tarp tų dviejų momentų yra būtent tai, ką InProgress turi dengti. Viena pasekmė, verta žinoti prieš lyginant išvestį: kadangi NewObjRef vyksta po to, kai vaiko uždarinis parašytas, numeracija tiksliniame dokumente išeina iš apačios į viršų, ir objektų numeriai neatspindės šaltinio tvarkos. Failo formatui tai nesvarbu, bet baitų lygio palyginimas su rankomis sukonstruotu lūkesčiu — svarbu. Jei paleidimas palieka objektus, kurių nusprendėte nesieti su niekuo, jie yra nei kam nenurodyti, o ne sugadinti, ir mark-and-sweep nepasiekiamų PDF objektų surinkimas yra įrankis, išvalantis juos prieš išsaugojimą

Šį atvejį dengianti regresija reikalauja vienos detalės, žmones, rašančius testus prieš TPDFlib, nustebinantis: konstruktorius jau turi numatytąjį dokumentą, todėl DocumentCount prasideda nuo 1, ir dviejų dokumentų fikstūra turi tikrinti >= 2, o ne = 2. Kartu su sėkminga kopija testas įkalina tris atmetimus — nežinomą šaltinio ID, pasirinktą dokumentą kaip paties savo šaltinį ir nulinį objekto numerį — visi grąžina 0, o ne kelias išimtis, nes sujungimo ciklas yra bloga vieta sužinoti, kad sargybinė sąlyga meta

Kur tai telpa sujungimo grandynėje

Objektų lygio kopijavimas yra primityvas, kurio imatės, kai viso failo sujungimas per šiurkštus: ištraukti vieną šrifto programą iš šablono, pernešti vieną formos XObject į antspauduojamą dokumentą arba perkelti anotaciją su jos išvaizdos srautais tarp failų, nevilkti su savimi likusios puslapio dalies. PDFlibPas jį atveria kaip vieną kvietimą prieš įkeltus dokumentus, o kaip jis dera su likusia žemo lygio objektų API, galite matyti PDFlibPas Delphi PDF Library nuorodoje