Î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:
MovePagematerializează 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 ResourcesSetPageBoxurmă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 Ravea toate paginile acelea redimensionate de un singur apel, indiferent dacăMovePageera implicat vreodatăCopyPageRangesmaterializează 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
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
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ă
| Cale de cod | Înainte de v3.539.36 | Din v3.539.36 |
|---|---|---|
Materializarea MovePage | Pagina deține instanțele directe ale ancestor-ului | Pagina deține copii decodate; referințele rămân referințe |
SetPageBox | Urmărește o referință și editează tabloul partajat | Editează doar un tablou direct pe pagină, altfel scrie unul nou |
Pagina sursă CopyPageRanges | Partajează casetele nodului Pages; CropBox e instanța MediaBox | Fiecare valoare materializată pe pagina sursă e o copie |
| Casete implicite la clonarea resurselor paginii | CropBox, BleedBox, TrimBox și ArtBox partajează un tablou | Fiecare 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ă
Addunei 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,BalancePageTreesauCopyPageRangesș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