Articolo tecnico

PDFlibPas MovePage: quando le box condividono istanze

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:

  • MovePage materializza 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 Resources
  • SetPageBox seguiva i riferimenti indiretti e modificava l'array referenziato, così un file in cui più pagine puntano a un oggetto /MediaBox 11 0 R vedeva tutte quelle pagine ridimensionate da una sola chiamata, con o senza MovePage in mezzo
  • CopyPageRanges materializza 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
Alias di MovePage in PDFlibPas dove una pagina spostata e la sua ex sorella tenevano entrambe l'istanza dell'array MediaBox dell'antenato, così SetPageBox modificava una pagina e ridimensionava l'altra; dalla v3.539.36 la materializzazione attacca copie decodificate e le modifiche restano locali alla pagina che tocchi
Due voci dell'albero pagine puntate su un unico array in memoria facevano atterrare ogni modifica in ogni detentore, e il PDF salvato restava valido tutto il tempo

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

Round-trip di materializzazione PDFlibPas in cui PLInheritPageAttributes attacca Page.Owner.Decode(Value.Output): un array diretto si serializza in testo letterale e si decodifica in una istanza fresca, mentre un 11 0 R indiretto si serializza e si decodifica in un nuovo riferimento che punta ancora all'oggetto condiviso 11
Serializzare e ri-analizzare dà gratis la semantica degli oggetti PDF: i valori diretti si copiano, i riferimenti restano riferimenti, esattamente come intende ISO 32000-1

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

Decisione copy-on-write di SetPageBox in PDFlibPas: quando la voce della pagina è un array diretto viene modificato sul posto, e quando è un riferimento indiretto o manca lo scrittore lo sostituisce con un nuovo array diretto così l'oggetto condiviso 11 conserva il proprio valore per ogni altra pagina che lo cita
Copiare alla materializzazione non basta finché i riferimenti puntano ancora a oggetti condivisi, quindi lo scrittore di box modifica solo ciò che la pagina possiede
Percorso di codicePrima della v3.539.36Dalla v3.539.36
Materializzazione di MovePageLa pagina tiene le istanze dirette dell'antenatoLa pagina tiene copie decodificate; i riferimenti restano riferimenti
SetPageBoxSegue un riferimento e modifica l'array condivisoModifica solo un array diretto sulla pagina, altrimenti ne scrive uno nuovo
Pagina sorgente di CopyPageRangesCondivide le box del nodo Pages; il CropBox è l'istanza del MediaBoxOgni valore materializzato sulla pagina sorgente è una copia
Box di default alla clonazione delle risorse di paginaCropBox, BleedBox, TrimBox e ArtBox condividono un arrayOgni 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 Add di 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, BalancePageTree o CopyPageRanges e 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