In PDFlibPas, la libreria PDF Delphi, una pagina spostata con MovePage riceveva i medesimi oggetti MediaBox, CropBox e Resources che il suo vecchio nodo Pages teneva, così una successiva SetPageBox o DrawText sulla pagina spostata riscriveva in silenzio quel nodo e ogni pagina sorella che ereditava ancora da lui. Dalla v3.539.36 la pagina spostata riceve copie proprie, e un riferimento indiretto resta un riferimento. La stessa release chiude due percorsi collegati: SetPageBox su una box indiretta condivisa da più pagine, e CopyPageRanges che lasciava le pagine del documento sorgente legate al loro nodo Pages, con il CropBox legato al MediaBox
Le segnalazioni che portano qui non parlano mai di identità degli oggetti. Dicono cose come "ho ritagliato la pagina 7 e le pagine da 8 a 12 si sono ritagliate anche loro", oppure "ho ristretto il CropBox e il MediaBox si è spostato con lui", oppure, la più fuorviante, "ho copiato una pagina in un nuovo documento e il file originale è cambiato". Niente crash, niente leak, e il file salvato è un PDF perfettamente valido. Contiene solo una geometria che nessuno aveva chiesto
Perché SetPageBox su una pagina ridimensiona le sue sorelle?
SetPageBox ridimensionava le sorelle perché due voci dell'albero pagine puntavano su un unico array in memoria, e SetPageBox modifica il suo array bersaglio sul posto. Qualsiasi pagina o nodo Pages che teneva la stessa istanza vedeva la modifica. Tre percorsi di codice in PDFlibPas producevano quella condivisione prima della v3.539.36:
MovePagematerializza gli attributi ereditabili sulla pagina prima di staccarla dal genitore, e attaccava gli oggetti dell'antenato invece che copie, così la pagina spostata e le sue ex sorelle condividevano un array di box e un dizionario ResourcesSetPageBoxseguiva i riferimenti indiretti e modificava l'array referenziato, così un file in cui più pagine puntano a un oggetto/MediaBox 11 0 Rvedeva tutte quelle pagine ridimensionate da una sola chiamata, con o senzaMovePagein mezzoCopyPageRangesmaterializza i valori ereditati sulla pagina sorgente prima di clonarla nel documento di destinazione, e attaccava le istanze del nodo Pages alla pagina sorgente, più l'istanza del MediaBox stessa come CropBox di default
Il caso MovePage ha una storia breve. Prima della v3.539.27, MovePage portava con sé solo /Resources, così una pagina spostata sotto un genitore diverso ne assumeva silenziosamente dimensione e rotazione. La v3.539.27 corresse il MediaBox, il CropBox e il Rotate mancanti, che è anche ciò su cui conta CollateDocumentsEx quando riordina le pagine, ma attaccava i valori dell'antenato come istanze condivise. Quella è la finestra che la v3.539.36 chiude. I percorsi SetPageBox e CopyPageRanges sono più vecchi; qualsiasi build prima della v3.539.36 li ha
Valori diretti, riferimenti indiretti ed ereditarietà degli attributi di pagina
Una copia corretta di un attributo di pagina ereditato duplica i valori diretti e conserva i riferimenti indiretti come riferimenti, perché è questa la distinzione che traccia lo stesso ISO 32000-1. Un oggetto diretto come [0 0 400 300] scritto dentro un dizionario appartiene a quel dizionario da solo. Un oggetto indiretto, definito una volta come 11 0 obj e citato come 11 0 R, è condiviso per progetto: ISO 32000-1 §7.3.10 lo rende indirizzabile da qualsiasi punto del file, e ogni 11 0 R significa lo stesso oggetto
L'ereditarietà degli attributi di pagina, ISO 32000-1 §7.7.3.4, aggiunge un terzo caso. Resources, MediaBox, CropBox e Rotate possono stare su un nodo Pages e valere per ogni pagina discendente che non definisce i propri. La pagina non tiene il valore; lo risale attraverso /Parent. Quella catena di ricerca si spezza nel momento in cui una pagina cambia genitore, ed è per questo che MovePage e BalancePageTree devono prima scrivere i valori effettivi sulla pagina stessa. La domanda è solo come scriverli
Perché un object pool nasconde l'errore
In PDFlibPas ogni oggetto PDF analizzato o creato appartiene al pool TPDFStructure del documento, e i dizionari e gli array memorizzano semplici puntatori alle proprie voci. TPDFDictionary.Add registra il puntatore e nient'altro. Aggiungere una istanza a due contenitori padre è quindi legale a ogni livello che il runtime può controllare: niente doppia liberazione allo smontaggio, niente reference count che sbaglia, niente eccezioni. La serializzazione è altrettanto permissiva, dato che ogni contenitore scrive inline il valore corrente dell'istanza condivisa, e prima di qualsiasi modifica l'output è byte per byte ciò che una copia corretta produrrebbe
L'aliasing emerge solo quando qualcuno modifica sul posto l'istanza condivisa. SetPageBox fa esattamente questo attraverso un wrapper rettangolo sull'array esistente, e disegnare su una pagina lo fa al dizionario Resources quando si registra un font o un'immagine. La modifica atterra, in silenzio, in ogni altro contenitore che tiene il puntatore
Come PDFlibPas v3.539.36 copia invece di condividere
PDFlibPas v3.539.36 corregge il problema alle due estremità: la materializzazione ora attacca copie, e le scritture delle box ora modificano solo un array che la pagina possiede. Ogni correzione copre un caso che l'altra non può
L'helper di materializzazione, PLInheritPageAttributes, ora attacca Page.Owner.Decode(Value.Output) invece di Value. Andare e tornare attraverso il serializzatore è un modo rozzo ma esatto di ottenere gratis la semantica PDF. Un array o dizionario diretto si serializza nel proprio testo letterale e si decodifica in una istanza fresca e indipendente. Un riferimento indiretto si serializza in 11 0 R e si decodifica in un nuovo oggetto riferimento che punta allo stesso oggetto 11, così la pagina continua a riferirsi all'oggetto condiviso invece di ricevere una copia inline, il che preserva il comportamento da riferimento introdotto nella v3.539.27. La copia è profonda esattamente quanto la struttura diretta: tutto ciò che si raggiunge attraverso un riferimento dentro un dizionario copiato resta condiviso, come il formato file intende. BalancePageTree chiama lo stesso helper per ogni pagina di cui cambia genitore, così anche le pagine materializzate lì ricevono istanze separate
Copiare da solo non basta, perché il caso riferimento punta ancora a un oggetto condiviso. Se SetPageBox seguisse quel riferimento e modificasse l'oggetto 11, la pagina spostata ridimensionerebbe di nuovo il vecchio genitore e i suoi altri figli. Quindi lo scrittore di box ora applica copy-on-write: modifica sul posto solo quando la voce della pagina è un array diretto, e sostituisce una box indiretta o assente con un nuovo array diretto. L'oggetto 11 resta intoccato per ogni altra pagina che lo cita
| Percorso di codice | Prima della v3.539.36 | Dalla v3.539.36 |
|---|---|---|
Materializzazione di MovePage | La pagina tiene le istanze dirette dell'antenato | La pagina tiene copie decodificate; i riferimenti restano riferimenti |
SetPageBox | Segue un riferimento e modifica l'array condiviso | Modifica solo un array diretto sulla pagina, altrimenti ne scrive uno nuovo |
Pagina sorgente di CopyPageRanges | Condivide le box del nodo Pages; il CropBox è l'istanza del MediaBox | Ogni valore materializzato sulla pagina sorgente è una copia |
| Box di default alla clonazione delle risorse di pagina | CropBox, BleedBox, TrimBox e ArtBox condividono un array | Ogni box di default riceve il proprio array |
L'ultima riga è quella latente. Quando la libreria clona le risorse di una pagina per cattura o unione di pagine, riempie le voci mancanti di CropBox, BleedBox, TrimBox e ArtBox, e quelle erano la stessa istanza di array. Nessun chiamante attuale ha lasciato sopravvivere quell'alias abbastanza da modificarlo, ma il chiamante successivo ci sarebbe riuscito. Come si scelgono quei valori di box di default è un tema a sé, coperto nella guida PDFlibPas ai default di TrimBox, BleedBox e CropBox
Riprodurre l'aliasing di MovePage con un PDF costruito a mano
Il modo più rapido di verificare qualsiasi build di PDFlibPas è un piccolo PDF scritto a mano caricato con LoadFromString, dove ogni numero di oggetto è noto in anticipo. L'helper qui sotto scrive una classica tabella di riferimenti incrociati con offset di byte calcolati correttamente, così il test non conta sul comportamento di recupero del parser per file danneggiati
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 di byte a base zero di "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 // ogni voce è esattamente 20 byte
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;
Il documento di prova ha due nodi Pages intermedi. Il nodo 3 porta un MediaBox indiretto (oggetto 11, 400 per 300 punti), un CropBox diretto e un dizionario Resources diretto, e possiede due pagine. Il nodo 4 ha un MediaBox formato Letter e possiede la terza pagina. Spostare la pagina 1 alla posizione 3 le cambia genitore sotto il nodo 4, che è esattamente lo spostamento che richiede materializzazione: senza, la pagina diventerebbe una pagina 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); // la pagina appena spostata
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');
// Ispeziona il vecchio parent PRIMA di selezionare un'altra pagina (vedi sotto)
Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
'font registered in the old Pages node');
Lib.SelectPage(1); // ex pagina 2, ancora sotto il nodo 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) prende tipo di box 1 per il MediaBox e 2 per il CropBox, e dimensione 2 per la larghezza. Con l'origine di default in basso a sinistra, SetPageBox(1, 0, 200, 200, 200) significa sinistra 0, alto 200, largo 200 e alto 200. Sulle build tra v3.539.27 e v3.539.35 i controlli sulle sorelle falliscono: la modifica del CropBox atterra nell'array diretto del nodo 3, e la modifica del MediaBox riscrive l'oggetto 11 attraverso il riferimento
CopyPageRanges cambia il documento sorgente?
Dalla v3.539.36, CopyPageRanges scrive ancora sulle pagine sorgente, ma ogni valore che scrive è una copia separata, così le modifiche successive sulla sorgente restano locali alla pagina che modifichi. La scrittura in sé è intenzionale: la pagina sorgente ha bisogno di MediaBox, CropBox, Rotate e Resources espliciti prima che il suo dizionario venga clonato nel documento di destinazione, altrimenti la copia perderebbe tutto ciò che ha ereditato. Rinumerare e copiare la pagina nel target è coperto in copia profonda di oggetti tra documenti in PDFlibPas; questo bug stava sul lato sorgente, che la maggior parte della gente assume una copia solo legga
L'output non lo mostrava mai. Condiviso o copiato, i valori materializzati si serializzano identici, quindi entrambi i documenti si salvavano byte per byte uguali prima e dopo la correzione. Solo una modifica al documento sorgente dopo la copia rivelava l'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; // diventa il documento selezionato
Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');
Lib.SelectDocument(SourceID);
Lib.SelectPage(1);
Lib.SetPageBox(2, 50, 250, 100, 100); // restringe solo il 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); // la copia conserva la dimensione originale
Lib.SelectPage(Lib.PageCount);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
finally
Lib.Free;
end;
end;
Prima della v3.539.36 entrambe le pagine qui ereditavano il MediaBox diretto del nodo radice, la copia attaccava quella istanza alla pagina sorgente 1, e la riattaccava come CropBox della pagina 1. Restringere il CropBox restringeva quindi il MediaBox, e ridimensionare il MediaBox ridimensionava la pagina 2 attraverso il nodo radice. I flussi di lavoro che copiano pagine fuori e poi continuano a modificare la sorgente, come assemblare scansioni duplex in un unico PDF prima di ritagliare gli originali, sono dove questo emergeva
Perché l'aliasing di istanze è così difficile da testare?
L'aliasing di istanze è difficile da testare perché l'effetto osservabile richiede tre passi in un ordine preciso: creare l'alias, modificare un lato, poi ispezionare l'altro lato prima che qualsiasi altra cosa lo tocchi. La maggior parte dei test fa solo il primo passo e confronta l'output salvato, che è identico che l'alias esista o no
La trappola d'ordine in PDFlibPas è SelectPage. Selezionare una pagina ri-applica il font corrente attraverso SelectFont, che registra quel font nelle risorse della pagina. Una pagina senza /Resources proprie si risolve nel dizionario del genitore, quindi semplicemente selezionare una tale pagina aggiunge legittimamente /Font al nodo Pages. Nel test MovePage qui sopra, selezionare l'ex pagina 2 aggiunge la voce Helvetica al nodo 3, che è comportamento corretto e non un leak. Ecco perché il controllo GetObjectToString(3) gira prima di SelectPage(1); scambiali e il test fallisce su una build corretta
Quella regola marca anche ciò che la v3.539.36 lascia deliberatamente in pace. Scrivere una risorsa su una pagina che eredita il proprio dizionario Resources scrive nel dizionario dell'antenato, e ogni sorella vede la nuova voce. Quella è l'ereditarietà che funziona come specificato, non condivisione di istanze, ed è innocua perché aggiungere un nome di font o immagine a un dizionario condiviso non cambia come renderizzano le altre pagine. Se ti serve che una pagina smetta di ereditare, dagli prima il suo dizionario Resources
Checklist per il codice sul object model PDF
Le lezioni si generalizzano a qualsiasi object model PDF costruito su un pool e contenitori a puntatori, in Delphi o altrove:
- Nel materializzare attributi ereditati secondo ISO 32000-1 §7.7.3.4, copia in profondità i valori diretti e conserva i riferimenti indiretti come nuovi riferimenti allo stesso oggetto
- Non fare mai
Adddi una istanza esistente in un secondo contenitore se la condivisione non è voluta e documentata; la proprietà di un pool significa che il runtime non si lamenterà mai - Modifica sul posto solo ciò che il nodo corrente possiede come oggetto diretto; sostituisci i valori indiretti o ereditati con un oggetto diretto fresco (copy-on-write)
- I valori di default derivati da un'altra voce, come un CropBox da un MediaBox, richiedono la propria istanza
- Testa l'aliasing con sequenze modifica-poi-ispeziona sull'altro detentore, e controlla l'ordine delle chiamate che potrebbero scrivere legittimamente in mezzo
- Confrontare l'output salvato non prova nulla qui: i valori condivisi e copiati si serializzano identici fino alla prima modifica
- Su PDFlibPas, passa alla v3.539.36 o successiva se chiami
MovePage,CollateDocumentsEx,BalancePageTreeoCopyPageRangese poi modifichi le page box o disegni sulle pagine
PDFlibPas espone editing dell'albero pagine, copia tra documenti e controllo delle page box attraverso un'unica classe TPDFlib per Delphi, C++Builder e Free Pascal. Vedi la pagina prodotto della PDFlibPas Delphi PDF library per edizioni, piattaforme e il riferimento API completo