Tehnički članak

PDFlibPas MovePage: kad naslijeđeni okviri dijele instance

U PDFlibPasu, Delphi PDF biblioteci, stranica premještena s MovePage primala je isti onaj MediaBox, CropBox i Resources objekt koji je njezin stari Pages čvor držao, pa je kasniji SetPageBox ili DrawText na premještenoj stranici tiho prepisivao taj čvor i svaku sestru koja se i dalje naslanjala na njega. Od v3.539.36 premještena stranica dobiva vlastite kopije, a neizravna referenca ostaje referenca. Isto izdanje zatvara dva povezana puta: SetPageBox na neizravnom okviru koji dijeli više stranica i CopyPageRanges koji ostavlja stranice izvornog dokumenta vezane uz njihov Pages čvor, s CropBoxom vezanim uz MediaBox

Izvještaji koji vode ovamo nikad ne spominju identitet objekata. Kažu stvari poput „izrezao sam stranicu 7 i stranice 8 do 12 izrezale su se također", ili „suzio sam CropBox i MediaBox se pomaknuo s njim", ili, najzbunjujuće, „kopirao sam stranicu u novi dokument i izvorna datoteka se promijenila". Ništa se ne ruši, ništa ne curi, a spremljena datoteka sasvim je valjan PDF. Samo sadrži geometriju koju nitko nije tražio

Zašto SetPageBox na jednoj stranici mijenja veličinu njezinih sestara?

SetPageBox mijenjao je sestre jer su dva unosa stabla stranica pokazivala na jedno polje u memoriji, a SetPageBox uređuje ciljno polje na mjestu. Svaka stranica ili Pages čvor koji drži istu instancu vidio je izmjenu. Tri putanje koda u PDFlibPasu proizvele su to dijeljenje prije v3.539.36:

  • MovePage materijalizira nasljedive atribute na stranicu prije nego što je odvoji od roditelja, i priključivao je vlastite objekte predka umjesto kopija, pa su premještena stranica i njezine bivše sestre dijelile polje okvira i Resources rječnik
  • SetPageBox slijedio je neizravne reference i uređivao referencirano polje, pa je datoteka u kojoj nekoliko stranica pokazuje na jedan /MediaBox 11 0 R objekt imala sve te stranice promijenjene jednim pozivom, bio ili nije MovePage uopće umiješan
  • CopyPageRanges materijalizira naslijeđene vrijednosti na izvornu stranicu prije nego što je klonira u ciljni dokument, i priključivao je instance Pages čvora na izvornu stranicu, plus samu MediaBox instancu kao zadani CropBox
PDFlibPas MovePage aliasing gdje su premještena stranica i njezina bivša sestra obje držale instancu MediaBox polja samog predka, pa je SetPageBox uređivao jednu stranicu i mijenjao veličinu druge; od v3.539.36 materijalizacija priključuje dekodirane kopije i izmjene ostaju lokalne na stranici koju dirate
Dva unosa stabla stranica koja pokazuju na jedno polje u memoriji učinila su da svaka izmjena doskoči svakom držatelju, a spremljeni PDF cijelo je vrijeme ostajao valjan

MovePage slučaj ima kratku povijest. Prije v3.539.27 MovePage prenosio je preko samo /Resources, pa je stranica premještena pod drugi roditelj tiho preuzimala veličinu i rotaciju tog roditelja. v3.539.27 popravio je nedostajući MediaBox, CropBox i Rotate, na što se oslanja i CollateDocumentsEx kad preslažuje stranice, ali je vrijednosti predka priključivao kao dijeljene instance. To je prozor koji v3.539.36 zatvara. SetPageBox i CopyPageRanges putanje su starije; svaka verzija prije v3.539.36 ima ih

Izravne vrijednosti, neizravne reference i nasljeđivanje atributa stranice

Ispravna kopija naslijeđenog atributa stranice duplicira izravne vrijednosti i zadržava neizravne reference kao reference, jer to je razlika koju sam ISO 32000-1 crta. Izravni objekt poput [0 0 400 300] zapisan unutar rječnika pripada samo tom rječniku. Neizravni objekt, definiran jednom kao 11 0 obj i citiran kao 11 0 R, dijeli se po dizajnu: ISO 32000-1 §7.3.10 čini ga adresabilnim iz bilo kojeg mjesta u datoteci, a svaki 11 0 R znači isti objekt

Nasljeđivanje atributa stranice, ISO 32000-1 §7.7.3.4, dodaje treći slučaj. Resources, MediaBox, CropBox i Rotate mogu stajati na Pages čvoru i vrijediti za svaku potomkinju stranicu koja ne definira vlastite. Stranica ne drži vrijednost; traži je kroz /Parent. Taj lanac traženja puca onog trenutka kad stranica promijeni roditelje, pa MovePage i BalancePageTree prvo moraju zapisati djelotvorne vrijednosti na samu stranicu. Pitanje je samo kako ih zapisati

Zašto bazen objekata krije pogrešku

U PDFlibPasu svaki parsirani ili stvoreni PDF objekt u vlasništvu je dokumentovog TPDFStructure bazena, a rječnici i polja spremaju obične pokazivače na svoje unose. TPDFDictionary.Add zabilježi pokazivač i ništa više. Dodavanje jedne instance u dva roditeljska spremnika stoga je legalno na svakoj razini koju runtime može provjeriti: bez dvostrukog oslobađanja pri gašenju, bez brojača referenci koji može poći po zlu, bez iznimke. Serijalizacija je podjednako popustljiva jer svaki spremnik upisuje trenutačnu vrijednost dijeljene instance inline, a prije bilo koje izmjene izlaz je bajt po bajt ono što bi proizvela ispravna kopija

Aliasing izlazi na vidjelo tek kad netko mutira dijeljenu instancu na mjestu. SetPageBox to točno radi kroz pravokutni omot nad postojećim poljem, a crtanje na stranici čini to nad Resources rječnikom kad se registrira font ili slika. Izmjena doskoči, tiho, u svaki drugi spremnik koji drži pokazivač

Kako PDFlibPas v3.539.36 kopira umjesto da dijeli

PDFlibPas v3.539.36 popravlja problem na oba kraja: materijalizacija sada priključuje kopije, a upisi okvira sada uređuju samo polje koje stranica posjeduje. Svaki popravak pokriva slučaj koji drugi ne može

Pomoćnik materijalizacije, PLInheritPageAttributes, sada priključuje Page.Owner.Decode(Value.Output) umjesto Value. Prolaz tamo i natrag kroz serijalizator je naivan, ali egzaktan način da PDF semantiku dobijete besplatno. Izravno polje ili rječnik serijalizira se u svoj doslovni tekst i dekodira u svježu, neovisnu instancu. Neizravna referenca serijalizira se u 11 0 R i dekodira u novi referentni objekt koji pokazuje na isti objekt 11, pa stranica i dalje upućuje na dijeljeni objekt umjesto da primi inline kopiju, što čuva referentno ponašanje uvedeno u v3.539.27. Kopija je točno onoliko duboka koliko je izravna struktura: sve što se doseže referencom unutar kopiranog rječnika ostaje dijeljeno, kako format datoteke i hoće. BalancePageTree poziva isti pomoćnik za svaku stranicu kojoj mijenja roditelja, pa i stranice materijalizirane tamo dobivaju odvojene instance

PDFlibPas povratni put materijalizacije gdje PLInheritPageAttributes priključuje Page.Owner.Decode(Value.Output): izravno polje se serijalizira u doslovni tekst i dekodira u svježu instancu, dok se neizravni 11 0 R serijalizira i dekodira u novu referencu koja i dalje pokazuje na dijeljeni objekt 11
Serijalizacija i ponovno parsiranje dobivaju semantiku PDF objekata besplatno: izravne se vrijednosti kopiraju, reference ostaju reference, točno kako ISO 32000-1 hoće

Samo kopiranje nije dovoljno jer slučaj reference i dalje pokazuje na dijeljeni objekt. Da je SetPageBox slijedio tu referencu i uredio objekt 11, premještena stranica bi opet mijenjala veličinu starog roditelja i njegove ostale djece. Zato upisivač okvira sada primjenjuje copy-on-write: uređuje na mjestu samo kad je vlastiti unos stranice izravno polje, a neizravan ili nedostajući okvir zamjenjuje novim izravnim poljem. Objekt 11 ostaje netaknut za svaku drugu stranicu koja ga citira

PDFlibPas SetPageBox copy-on-write odluka: kad je vlastiti unos stranice izravno polje, uređuje se na mjestu, a kad je neizravna referenca ili nedostaje, upisivač ga zamjenjuje novim izravnim poljem tako da dijeljeni objekt 11 zadržava vrijednost za svaku drugu stranicu koja ga citira
Kopiranje pri materijalizaciji nije dovoljno dok reference i dalje pokazuju na dijeljene objekte, pa upisivač okvira uređuje samo ono što stranica posjeduje
Putanja kodaPrije v3.539.36Od v3.539.36
MovePage materijalizacijaStranica drži vlastite izravne instance predkaStranica drži dekodirane kopije; reference ostaju reference
SetPageBoxSlijedi referencu i uređuje dijeljeno poljeUređuje samo izravno polje na stranici, inače zapisuje novo
CopyPageRanges izvorna stranicaDijeli okvire Pages čvora; CropBox je MediaBox instancaSvaka materijalizirana vrijednost na izvornoj stranici jest kopija
Zadani okviri pri kloniranju resursa straniceCropBox, BleedBox, TrimBox i ArtBox dijele jedno poljeSvaki zadani okvir dobiva vlastito polje

Posljednji je redak latentni. Kad biblioteka klonira resurse stranice radi hvatanja stranice ili spajanja, popunjava nedostajuće CropBox, BleedBox, TrimBox i ArtBox unose, a ti su nekad bila ista instanca polja. Nijedan trenutačni pozivatelj nije dopustio da taj alias preživi dovoljno dugo da ga se uredi, ali sljedeći bi da. Kako se te zadane okvirne vrijednosti biraju vlastita je tema, obrađena u PDFlibPas vodiču za TrimBox, BleedBox i CropBox zadane vrijednosti

Reprodukcija MovePage aliasinga ručno građenim PDF-om

Najbrži način da provjerite bilo koju PDFlibPas verziju jest mali ručno pisani PDF učitan s LoadFromString, gdje je svaki broj objekta unaprijed poznat. Pomoćnik dolje piše klasičnu cross-reference tablicu s točno izračunatim bajtnim pomacima, pa se test ne oslanja na ponašanje oporavka parsera za oštećene datoteke

uses
  System.SysUtils, PDFlibrary;

function BuildPdf(const Objects: array of AnsiString): AnsiString;
var
  Offsets: array of Integer;
  I, XRefPos: Integer;
begin
  Result := '%PDF-1.4'#10;
  SetLength(Offsets, Length(Objects));
  for I := 0 to High(Objects) do
  begin
    Offsets[I] := Length(Result);   // 0-bazirani bajtni pomak zapisa "N 0 obj"
    Result := Result + AnsiString(IntToStr(I + 1)) + ' 0 obj'#10 +
      Objects[I] + #10'endobj'#10;
  end;
  XRefPos := Length(Result);
  Result := Result + 'xref'#10'0 ' + AnsiString(IntToStr(Length(Objects) + 1)) +
    #10'0000000000 65535 f '#10;
  for I := 0 to High(Offsets) do      // svaki je unos točno 20 bajtova
    Result := Result + AnsiString(Format('%.10d 00000 n ', [Offsets[I]])) + #10;
  Result := Result + 'trailer'#10'<< /Size ' +
    AnsiString(IntToStr(Length(Objects) + 1)) + ' /Root 1 0 R >>'#10 +
    'startxref'#10 + AnsiString(IntToStr(XRefPos)) + #10'%%EOF'#10;
end;

function StreamObj(const Content: AnsiString): AnsiString;
begin
  Result := '<< /Length ' + AnsiString(IntToStr(Length(Content))) +
    ' >>'#10'stream'#10 + Content + #10'endstream';
end;

Testni dokument ima dva posredna Pages čvora. Čvor 3 nosi neizravni MediaBox (objekt 11, 400 puta 300 točaka), izravni CropBox i izravni Resources rječnik, i posjeduje dvije stranice. Čvor 4 ima MediaBox veličine Letter i posjeduje treću stranicu. Premještanje stranice 1 na poziciju 3 mijenja joj roditelja na čvor 4, što je točno taj premještaj koji treba materijalizaciju: bez nje stranica bi postala Letter stranica

procedure Check(Condition: Boolean; const Msg: string);
begin
  if not Condition then
    raise Exception.Create(Msg);
end;

procedure CheckMovedPageIsIsolated;
var
  Lib: TPDFlib;
  FontID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Check(Lib.LoadFromString(BuildPdf([
      '<< /Type /Catalog /Pages 2 0 R >>',
      '<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 3 >>',
      '<< /Type /Pages /Parent 2 0 R /Kids [5 0 R 6 0 R] /Count 2 ' +
        '/MediaBox 11 0 R /CropBox [10 20 390 280] /Resources << >> >>',
      '<< /Type /Pages /Parent 2 0 R /Kids [7 0 R] /Count 1 ' +
        '/MediaBox [0 0 612 792] >>',
      '<< /Type /Page /Parent 3 0 R /Contents 8 0 R >>',
      '<< /Type /Page /Parent 3 0 R /Contents 9 0 R >>',
      '<< /Type /Page /Parent 4 0 R /Contents 10 0 R >>',
      StreamObj('1 w'), StreamObj('2 w'), StreamObj('3 w'),
      '[0 0 400 300]']), '') = 1, 'load failed');

    Lib.SelectPage(1);
    Check(Lib.MovePage(3) = 1, 'MovePage failed');
    Lib.SelectPage(3);                       // stranica koju smo upravo premjestili
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'inherited MediaBox lost');

    Lib.SetPageBox(1, 0, 200, 200, 200);     // MediaBox 200 x 200
    Lib.SetPageBox(2, 0, 100, 100, 100);     // CropBox 100 x 100
    FontID := Lib.AddStandardFont(4);        // Helvetica
    Lib.SelectFont(FontID);
    Lib.SetTextSize(12);
    Lib.DrawText(20, 20, 'MOVED');

    // Pregledajte stari roditelj PRIJE odabira druge stranice (vidi dolje)
    Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
      'font registered in the old Pages node');

    Lib.SelectPage(1);                       // bivša stranica 2, još pod čvorom 3
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling MediaBox changed');
    Check(Abs(Lib.GetPageBox(2, 2) - 380) < 0.001, 'sibling CropBox changed');
    Check(Pos(AnsiString('400'), Lib.GetObjectToString(11)) > 0,
      'shared object 11 was rewritten');
  finally
    Lib.Free;
  end;
end;

GetPageBox(BoxType, Dimension) prima tip okvira 1 za MediaBox i 2 za CropBox, te dimenziju 2 za širinu. Sa zadanim ishodištem dolje lijevo, SetPageBox(1, 0, 200, 200, 200) znači lijevo 0, gore 200, 200 široko i 200 visoko. Na verzijama između v3.539.27 i v3.539.35 sestrinske provjere padaju: izmjena CropBoxa doskoči u izravno polje čvora 3, a izmjena MediaBoxa prepisuje objekt 11 kroz referencu

Mijenja li CopyPageRanges izvorni dokument?

Od v3.539.36 CopyPageRanges i dalje zapisuje na izvorne stranice, ali svaka vrijednost koju zapiše jest odvojena kopija, pa kasnije izmjene na izvoru ostaju lokalne na stranici koju uređujete. Samo zapisivanje je namjerno: izvorna stranica treba eksplicitne MediaBox, CropBox, Rotate i Resources prije nego se njezin rječnik klonira u cilj, inače bi kopija izgubila sve što je naslijedila. Renumeriranje i kopiranje stranice u cilj obrađeno je u dubokom kopiranju objekata između dokumenata u PDFlibPasu; ovaj je bug sjedio na strani izvora, za koji većina ljudi pretpostavlja da ga kopija samo čita

Izlaz to nikad nije pokazao. Dijeljene ili kopirane, materijalizirane vrijednosti serijaliziraju se identično, pa su oba dokumenta spremljena bajt po bajt ista prije i poslije popravka. Tek je izmjena izvornog dokumenta nakon kopije otkrila alias:

procedure CheckSourceSurvivesCopy;
var
  Lib: TPDFlib;
  SourceID, TargetID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Check(Lib.LoadFromString(BuildPdf([
      '<< /Type /Catalog /Pages 2 0 R >>',
      '<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 ' +
        '/MediaBox [0 0 400 300] /Resources << >> >>',
      '<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>',
      '<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>',
      StreamObj('1 w'), StreamObj('2 w')]), '') = 1, 'load failed');
    SourceID := Lib.SelectedDocument;

    TargetID := Lib.NewDocument;             // postaje odabrani dokument
    Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');

    Lib.SelectDocument(SourceID);
    Lib.SelectPage(1);
    Lib.SetPageBox(2, 50, 250, 100, 100);    // suzi samo CropBox
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'MediaBox followed CropBox');
    Lib.SetPageBox(1, 0, 200, 200, 200);

    Lib.SelectPage(2);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling page resized');

    Lib.SelectDocument(TargetID);            // kopija zadržava izvornu veličinu
    Lib.SelectPage(Lib.PageCount);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
  finally
    Lib.Free;
  end;
end;

Prije v3.539.36 obje stranice ovdje nasljeđivale su izravni MediaBox korijenskog čvora, kopija je tu instancu priključila izvornoj stranici 1, i ponovno je priključila kao CropBox stranice 1. Suzavanje CropBoxa stoga je suzilo MediaBox, a mijenjanje veličine MediaBoxa mijenjalo je stranicu 2 kroz korijenski čvor. Tokovi rada koji kopiraju stranice van, a zatim nastavljaju uređivati izvor, poput slaganja dupleks skenova u jedan PDF prije šišanja originala, mjesto su gdje se to pokazalo

Zašto je aliasing instanci tako teško testirati?

Aliasing instanci teško je testirati jer opaživi učinak treba tri koraka u određenom redoslijedu: stvorite alias, mutirajte jednu stranu, pa pregledajte drugu stranu prije nego što je išta drugo dotakne. Većina testova napravi samo prvi korak i uspoređuje spremljeni izlaz, koji je identičan postoji li alias ili ne

Zamka redoslijeda u PDFlibPasu jest SelectPage. Odabir stranice ponovno primjenjuje trenutačni font kroz SelectFont, koji taj font registrira u resurse stranice. Stranica bez vlastitog /Resources razrješuje se na rječnik roditelja, pa sam odabir takve stranice legalno dodaje /Font na Pages čvor. U MovePage testu gore odabir bivše stranice 2 dodaje Helvetica unos u čvor 3, što je ispravno ponašanje, a ne curenje. Zato se GetObjectToString(3) provjera izvodi prije SelectPage(1)a; zamijenite ih i test pada na popravljenoj verziji

To pravilo također označava što v3.539.36 namjerno ostavlja na miru. Zapisivanje resursa na stranicu koja nasljeđuje svoj Resources rječnik zapisuje u rječnik predka, i svaka sestra vidi novi unos. To je nasljeđivanje kako je specificirano, a ne dijeljenje instanci, i bezopasno je jer dodavanje imena fonta ili slike dijeljenom rječniku ne mijenja kako se druge stranice renderiraju. Ako trebate da stranica prestane nasljeđivati, prvo joj dajte vlastiti Resources rječnik

Kontrolna lista za kod PDF objektnog modela

Lekcije se generaliziraju na svaki PDF objektni model izgrađen na bazenu i spremnicima pokazivača, u Delphiju ili gdje god drugdje:

  • Kad materijalizirate naslijeđene atribute prema ISO 32000-1 §7.7.3.4, duboko kopirajte izravne vrijednosti i zadržite neizravne reference kao nove reference na isti objekt
  • Nikad ne dodajte postojeću instancu Addom u drugi spremnik osim ako je dijeljenje namjerno i dokumentirano; vlasništvo bazena znači da se runtime nikad neće žaliti
  • Uređujte na mjestu samo ono što trenutačni čvor posjeduje kao izravni objekt; neizravne ili naslijeđene vrijednosti zamijenite svježim izravnim objektom (copy-on-write)
  • Zadane vrijednosti izvedene iz drugog unosa, poput CropBoxa iz MediaBoxa, trebaju vlastitu instancu
  • Aliasing testirajte nizovima mutiraj-pa-pregledaj na drugom držatelju, i provjerite redoslijed poziva koji bi u međuvremenu mogli legalno zapisivati
  • Uspoređivanje spremljenog izlaza ovdje ništa ne dokazuje: dijeljene i kopirane vrijednosti serijaliziraju se identično do prve izmjene
  • Na PDFlibPasu nadogradite na v3.539.36 ili kasniji ako pozivate MovePage, CollateDocumentsEx, BalancePageTree ili CopyPageRanges, a zatim uređujete okvire stranica ili crtate po stranicama

PDFlibPas izlaže uređivanje stabla stranica, kopiranje između dokumenata i kontrolu okvira stranica kroz jedan TPDFlib razred za Delphi, C++Builder i Free Pascal. Pogledajte stranicu proizvoda PDFlibPas Delphi PDF biblioteke za izdanja, platforme i potpunu API referencu