Articol tehnic

PDFlibPas MovePage: când casetele moștenite se partajează

În PDFlibPas, biblioteca PDF pentru Delphi, o pagină mutată cu MovePage primea de obicei exact aceleași obiecte MediaBox, CropBox și Resources pe care le ținea vechiul ei nod Pages, astfel încât un SetPageBox sau un DrawText ulterior pe pagina mutată rescria în tăcere acel nod și fiecare soră care moștenia încă de la el. Din v3.539.36 pagina mutată primește copiile ei proprii, iar o referință indirectă rămâne referință. Aceeași versiune închide două căi înrudite: SetPageBox pe o casetă indirectă partajată de mai multe pagini și CopyPageRanges care lăsa paginile documentului sursă legate de nodul lor Pages, cu CropBox-ul legat de MediaBox

Rapoartele care duc aici nu menționează niciodată identitatea obiectelor. Spun lucruri precum „am decupat pagina 7 și paginile 8 până la 12 s-au decupat și ele”, sau „am îngustat CropBox-ul și MediaBox-ul s-a mutat cu el”, sau, cel mai confuz, „am copiat o pagină într-un document nou și fișierul original s-a schimbat”. Nimic nu se blochează, nimic nu se scurge, iar fișierul salvat e un PDF perfect valid. Doar că conține geometrie pe care nimeni nu a cerut-o

De ce redimensionează SetPageBox pe o pagină paginile ei surori?

SetPageBox redimensiona surorile pentru că două intrări din arborele de pagini arătau către același tablou în memorie, iar SetPageBox își editează tabloul țintă în loc. Orice pagină sau nod Pages care deținea aceeași instanță vedea editarea. Trei căi de cod din PDFlibPas produceau partajarea aceea înainte de v3.539.36:

  • MovePage materializează atributele moștenibile pe pagină înainte să o detașeze de părinte, iar el atașa obiectele proprii ale ancestor-ului în loc de copii, deci pagina mutată și fostele ei surori partajau un tablou de casete și un dicționar Resources
  • SetPageBox urmărea referințele indirecte și edita tabloul referențiat, deci un fișier în care mai multe pagini arată către un obiect /MediaBox 11 0 R avea toate paginile acelea redimensionate de un singur apel, indiferent dacă MovePage era implicat vreodată
  • CopyPageRanges materializează valorile moștenite pe pagina sursă înainte să o cloneze în documentul țintă, iar el atașa instanțele nodului Pages paginii sursă, plus instanța MediaBox în sine ca CropBox implicit
Aliasarea MovePage din PDFlibPas, unde pagina mutată și fosta ei soră dețineau ambele instanța tabloului MediaBox al ancestor-ului, astfel încât SetPageBox edita o pagină și o redimensiona pe cealaltă; din v3.539.36 materializarea atașează copii decodate, iar editările rămân locale paginii atinse
Două intrări din arborele de pagini arătând către același tablou în memorie făceau fiecare editare să aterizeze în fiecare deținător, iar PDF-ul salvat rămânea valid tot timpul

Cazul MovePage are o istorie scurtă. Înainte de v3.539.27, MovePage cara doar /Resources peste, deci o pagină mutată sub alt părinte prelua în tăcere dimensiunea și rotația acelui părinte. v3.539.27 a reparat lipsa MediaBox, CropBox și Rotate, de care depinde și CollateDocumentsEx când reordonează pagini, dar atașa valorile ancestor-ului ca instanțe partajate. Fereastra aceea e cea pe care o închide v3.539.36. Căile SetPageBox și CopyPageRanges sunt mai vechi; orice build înainte de v3.539.36 le are

Valori directe, referințe indirecte și moștenirea atributelor de pagină

O copie corectă a unui atribut de pagină moștenit duplică valorile directe și păstrează referințele indirecte ca referințe, pentru că distincția aceasta o trasează chiar ISO 32000-1. Un obiect direct precum [0 0 400 300] scris în interiorul unui dicționar aparține doar acelui dicționar. Un obiect indirect, definit o dată ca 11 0 obj și citat ca 11 0 R, e partajat prin design: ISO 32000-1 §7.3.10 îl face adresabil de oriunde din fișier, iar fiecare 11 0 R înseamnă același obiect

Moștenirea atributelor de pagină, ISO 32000-1 §7.7.3.4, adaugă un al treilea caz. Resources, MediaBox, CropBox și Rotate pot sta pe un nod Pages și se aplică fiecărei pagini descendente care nu își definește propriile. Pagina nu deține valoarea; o caută prin /Parent. Lanțul acela de căutare se rupe în momentul în care o pagină își schimbă părinții, motiv pentru care MovePage și BalancePageTree trebuie să scrie mai întâi valorile efective pe pagina însăși. Întrebarea e doar cum să le scrie

De ce un pool de obiecte ascunde greșeala

În PDFlibPas fiecare obiect PDF parsat sau creat e deținut de pool-ul TPDFStructure al documentului, iar dicționarele și tablourile stochează pointeri simpli către intrările lor. TPDFDictionary.Add înregistrează pointerul și nimic altceva. Adăugarea unei instanțe în două containere părinte e prin urmare legală la fiecare nivel pe care runtime-ul îl poate verifica: niciun double free la demolare, niciun reference count care să meargă prost, nicio excepție. Serializarea e la fel de iertătoare, fiecare container scriind valoarea curentă a instanței partajate inline, iar înaintea oricărei editări output-ul e octet cu octet ceea ce ar produce o copie corectă

Aliasarea iese la suprafață doar când cineva mută instanța partajată în loc. SetPageBox face exact asta printr-un wrapper dreptunghi peste tabloul existent, iar desenarea pe o pagină face același lucru dicționarului Resources când un font sau o imagine e înregistrată. Editarea aterizează, în tăcere, în fiecare alt container care ține pointerul

Cum copiază PDFlibPas v3.539.36 în loc să partajeze

PDFlibPas v3.539.36 repară problema la ambele capete: materializarea atașează acum copii, iar scrierile de casete editează acum doar un tablou deținut de pagină. Fiecare reparare acoperă un caz pe care cealaltă nu-l poate

Helper-ul de materializare, PLInheritPageAttributes, atașează acum Page.Owner.Decode(Value.Output) în loc de Value. Dus-întors prin serializer e o cale nu chiar subtilă, dar exactă de a obține gratis semantică PDF. Un tablou sau dicționar direct se serializează în textul lui literal și se decodează într-o instanță proaspătă, independentă. O referință indirectă se serializează în 11 0 R și se decodează într-un nou obiect referință care arată către același obiect 11, deci pagina se referă în continuare la obiectul partajat în loc să primească o copie inline, ceea ce păstrează comportamentul de referință introdus în v3.539.27. Copia e exact cât de adâncă e structura directă: orice ajunge printr-o referință în interiorul unui dicționar copiat rămâne partajat, cum intenționează formatul de fișier. BalancePageTree apelează același helper pentru fiecare pagină pe care o re-atașează altui părinte, deci paginile materializate acolo primesc și ele instanțe separate

Dus-întorsul materializării din PDFlibPas, unde PLInheritPageAttributes atașează Page.Owner.Decode(Value.Output): un tablou direct se serializează în text literal și se decodează într-o instanță proaspătă, în timp ce un 11 0 R indirect se serializează și se decodează într-o referință nouă care arată tot către obiectul partajat 11
Serializatul și re-parsatul aduc gratis semantica de obiecte PDF: valorile directe se copiază, referințele rămân referințe, exact cum intenționează ISO 32000-1

Copiatul singur nu e de ajuns, pentru că cazul de referință arată în continuare către un obiect partajat. Dacă SetPageBox ar urmări referința aceea și ar edita obiectul 11, pagina mutată ar redimensiona din nou părintele vechi și ceilalți copii ai lui. Scriitorul de casete aplică deci acum copy-on-write: editează în loc doar când intrarea proprie a paginii e un tablou direct, iar o casetă indirectă sau lipsă o înlocuiește cu un tablou direct nou. Obiectul 11 rămâne neatins pentru fiecare altă pagină care îl citează

Decizia copy-on-write a lui SetPageBox în PDFlibPas: când intrarea proprie a paginii e un tablou direct, ea e editată în loc, iar când e o referință indirectă sau lipsește, scriitorul o înlocuiește cu un tablou direct nou, astfel încât obiectul partajat 11 își păstrează valoarea pentru orice altă pagină care îl citează
Copiatul la materializare nu e de ajuns cât timp referințele arată către obiecte partajate, deci scriitorul de casete editează doar ce deține pagina
Cale de codÎnainte de v3.539.36Din v3.539.36
Materializarea MovePagePagina deține instanțele directe ale ancestor-uluiPagina deține copii decodate; referințele rămân referințe
SetPageBoxUrmărește o referință și editează tabloul partajatEditează doar un tablou direct pe pagină, altfel scrie unul nou
Pagina sursă CopyPageRangesPartajează casetele nodului Pages; CropBox e instanța MediaBoxFiecare valoare materializată pe pagina sursă e o copie
Casete implicite la clonarea resurselor paginiiCropBox, BleedBox, TrimBox și ArtBox partajează un tablouFiecare casetă implicită își primește propriul tablou

Ultimul rând e cel latent. Când biblioteca clonează resursele unei pagini pentru captură de pagini sau îmbinare, completează intrările CropBox, BleedBox, TrimBox și ArtBox lipsă, iar acelea erau odinioară aceeași instanță de tablou. Niciun apelant curent n-a lăsat aliasul acela să supraviețuiască destul cât să fie editat, dar următorul apelant ar fi făcut-o. Felul în care se aleg valorile implicite ale casetelor e o temă în sine, acoperită în ghidul PDFlibPas despre valorile implicite TrimBox, BleedBox și CropBox

Reproducerea aliasării MovePage cu un PDF construit de mână

Cea mai rapidă cale de a verifica orice build PDFlibPas e un PDF mic scris de mână, încărcat cu LoadFromString, în care fiecare număr de obiect e cunoscut dinainte. Helper-ul de mai jos scrie o tabelă clasică de cross-reference cu offset-uri de octet corect calculate, astfel încât testul să nu se bizuie pe comportamentul de recuperare al parser-ului pentru fișiere deteriorate

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);   // offset de octet cu bază 0 al lui "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      // fiecare intrare are exact 20 de octeți
    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;

Documentul de test are două noduri Pages intermediare. Nodul 3 cară un MediaBox indirect (obiectul 11, 400 pe 300 de puncte), un CropBox direct și un dicționar Resources direct, și deține două pagini. Nodul 4 are un MediaBox de mărime Letter și deține a treia pagină. Mutarea paginii 1 la poziția 3 o re-atașează sub nodul 4, exact mutarea care are nevoie de materializare: fără ea, pagina s-ar transforma într-o pagină Letter

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);                       // pagina pe care tocmai am mutat-o
    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');

    // Examinați părintele vechi ÎNAINTE să selectați altă pagină (vezi mai jos)
    Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
      'font registered in the old Pages node');

    Lib.SelectPage(1);                       // fosta pagină 2, tot sub nodul 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) primește tipul de casetă 1 pentru MediaBox și 2 pentru CropBox, iar dimensiunea 2 pentru lățime. Cu originea implicită stânga-jos, SetPageBox(1, 0, 200, 200, 200) înseamnă stânga 0, sus 200, 200 lățime și 200 înălțime. Pe build-urile dintre v3.539.27 și v3.539.35 verificările pe surori eșuează: editarea CropBox aterizează în tabloul direct al nodului 3, iar editarea MediaBox rescrie obiectul 11 prin referință

Schimbă CopyPageRanges documentul sursă?

Din v3.539.36, CopyPageRanges scrie în continuare pe paginile sursă, dar fiecare valoare pe care o scrie e o copie separată, deci editările ulterioare pe sursă rămân locale paginii editate. Scrierea în sine e intenționată: pagina sursă are nevoie de MediaBox, CropBox, Rotate și Resources explicite înainte ca dicționarul ei să fie clonat în țintă, altfel copia ar pierde tot ce a moștenit. Renumerotarea și copiatul paginii în țintă sunt acoperite în copiatul profund de obiecte între documente în PDFlibPas; bug-ul acesta stătea pe partea de sursă, pe care cei mai mulți o presupun doar citită de o copie

Output-ul nu arăta niciodată asta. Partajate sau copiate, valorile materializate se serializează identic, deci ambele documente se salvau octet cu octet la fel înainte și după reparare. Doar o editare a documentului sursă după copiere revela aliasul:

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;             // devine documentul selectat
    Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');

    Lib.SelectDocument(SourceID);
    Lib.SelectPage(1);
    Lib.SetPageBox(2, 50, 250, 100, 100);    // îngustează doar CropBox-ul
    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);            // copia își păstrează dimensiunea originală
    Lib.SelectPage(Lib.PageCount);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
  finally
    Lib.Free;
  end;
end;

Înainte de v3.539.36 ambele pagini de aici moșteneau MediaBox-ul direct al nodului rădăcină, copia atașa instanța aceea paginii sursă 1 și o atașa din nou ca CropBox al paginii 1. Îngustarea CropBox-ului îngusta deci MediaBox-ul, iar redimensionarea MediaBox-ului redimensiona pagina 2 prin nodul rădăcină. Fluxurile de lucru care copiază pagini afară și apoi continuă să editeze sursa, precum colationarea scanărilor duplex într-un singur PDF înainte de a tăia originalele, sunt locul unde asta apărea

De ce e aliasarea de instanțe atât de greu de testat?

Aliasarea de instanțe e greu de testat pentru că efectul observabil are nevoie de trei pași într-o ordine specifică: creează aliasul, mută o parte, apoi inspectează cealaltă parte înainte ca orice altceva s-o atingă. Majoritatea testelor fac doar primul pas și compară output-ul salvat, identic indiferent dacă aliasul există sau nu

Capcana de ordine în PDFlibPas e SelectPage. Selectarea unei pagini re-aplică fontul curent prin SelectFont, care înregistrează fontul acela în resursele paginii. O pagină fără /Resources propriu se rezolvă la dicționarul părintelui, deci simpla selectare a unei asemenea pagini adaugă în mod legitim /Font nodului Pages. În testul MovePage de mai sus, selectarea fostei pagini 2 adaugă intrarea Helvetica nodului 3, comportament corect, nu o scurgere. De aceea verificarea GetObjectToString(3) rulează înainte de SelectPage(1); interschimbați-le și testul eșuează pe un build reparat

Regula aceea marchează și ce lasă v3.539.36 în mod deliberat în pace. Scrierea unei resurse către o pagină care își moștenește dicționarul Resources scrie în dicționarul ancestor-ului, iar fiecare soră vede intrarea nouă. Aia e moștenire funcționând conform specificației, nu partajare de instanțe, și e inofensivă pentru că adăugarea unui nume de font sau imagine într-un dicționar partajat nu schimbă felul în care randează celelalte pagini. Dacă aveți nevoie ca o pagină să înceteze moștenirea, dați-i mai întâi un dicționar Resources propriu

Listă de verificare pentru codul de model de obiecte PDF

Lecțiile se generalizează la orice model de obiecte PDF construit pe un pool și containere de pointeri, în Delphi sau oriunde altundeva:

  • Când materializați atribute moștenite conform ISO 32000-1 §7.7.3.4, copiați profund valorile directe și păstrați referințele indirecte ca referințe noi către același obiect
  • Nu faceți niciodată Add unei instanțe existente într-un al doilea container decât dacă partajarea e intenționată și documentată; deținerea de către un pool înseamnă că runtime-ul nu se va plânge niciodată
  • Editați în loc doar ce deține nodul curent ca obiect direct; înlocuiți valorile indirecte sau moștenite cu un obiect direct proaspăt (copy-on-write)
  • Valorile implicite derivate dintr-o altă intrare, precum un CropBox dintr-un MediaBox, au nevoie de propria instanță
  • Testați aliasarea cu secvențe mută-apoi-inspectează pe celălalt deținător și verificați ordinea apelurilor care ar putea scrie legitim între timp
  • Compararea output-ului salvat nu demonstrează nimic aici: valorile partajate și cele copiate se serializează identic până la prima editare
  • Pe PDFlibPas, faceți upgrade la v3.539.36 sau mai nou dacă apelați MovePage, CollateDocumentsEx, BalancePageTree sau CopyPageRanges și apoi editați casete de pagini sau desenați pe pagini

PDFlibPas expune editarea arborelui de pagini, copiatul între documente și controlul casetelor de pagină printr-o singură clasă TPDFlib pentru Delphi, C++Builder și Free Pascal. Veziți pagina de produs a bibliotecii PDFlibPas Delphi PDF pentru ediții, platforme și referința completă a API-ului