Articolo tecnico

Misurazione del testo PDF per il layout e l'a capo automatico in Delphi

La chiamata che inserisce il testo in una pagina PDF è semplice. Passi a AddText una stringa, un font, una dimensione e una posizione, e i glifi appaiono. Quello che non fa è dirti quanto sarà larga quella stringa una volta disegnata, e non spezza una stringa lunga su più righe. Una singola chiamata dipinge una singola serie di testo in una determinata posizione. Se la serie è più larga della colonna in cui volevi inserirla, semplicemente supera il bordo, e nulla nella chiamata di disegno ti avvisa. Nel momento in cui desideri un paragrafo invece di una singola etichetta, il pezzo mancante è la larghezza di una stringa nel font e nella dimensione scelti, misurata prima di confermarla sulla pagina

Questo è il classico problema del layout. Per impaginare un paragrafo in una colonna devi sapere, parola per parola, quanto spazio orizzontale occuperà ogni potenziale riga, e devi saperlo prima di disegnare qualsiasi cosa. L'a capo automatico è un ciclo di misurazione avvolto attorno a una chiamata di disegno, e un binding che si limita a disegnare ti offre solo la seconda metà. Il supporto per la misurazione del testo nel componente PDFium colma questa lacuna con due funzioni, MeasureText e MeasureTextWidth, che riportano l'estensione renderizzata di una stringa senza lasciare alcun segno sulla pagina

Perché la misurazione è un class helper, non un nuovo metodo su TPdf

Il supporto per la misurazione arriva sotto forma di class helper Delphi per TPdf, risiedendo nella sua unità, piuttosto che come nuovi metodi integrati forzatamente nella classe TPdf. Un class helper è una funzionalità del linguaggio che ti permette di allegare metodi a un tipo esistente dall'esterno della sua dichiarazione. Una volta che l'unità è nell'ambito visibile, i nuovi metodi vengono chiamati esattamente come se appartenessero alla classe, quindi un metodo helper si legge come Pdf.MeasureTextWidth(...) senza alcun oggetto separato da costruire o passare

Il motivo di questa stratificazione è la separazione. Il tipo TPdf di base rimane così com'è, senza campi aggiunti e senza toccare alcuna firma esistente, in modo che un progetto che non ha mai bisogno di layout non porti mai con sé il codice di misurazione. Un progetto che invece ne ha bisogno aggiunge un'unità alla clausola uses e i metodi si attivano. La funzionalità diventa facoltativa con la granularità di una singola unità, che è il modo più pulito per estendere un tipo che non possiedi o che non vuoi disturbare

uses
  PDFium, FPdfView, FPdfEdit,
  FPdfMeasure;   // the helper unit; brings MeasureText into scope on TPdf

// With the unit in scope the methods read as members of TPdf:
var
  W, H: Double;
begin
  Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
  // W and H are now the rendered width and height in PDF user units
end;

Misurare senza toccare la pagina

La misurazione deve essere priva di effetti collaterali. Deve riportare una larghezza senza lasciare tracce, perché la chiami molte volte mentre decidi un layout e la pagina deve apparire esattamente come sarebbe apparsa se non avessi misurato affatto. La tecnica che rende possibile questo è costruire un oggetto di testo, chiedergli la sua dimensione e gettarlo via prima ancora che venga allegato a una pagina

La sequenza è di quattro chiamate PDFium. FPDFPageObj_NewTextObj crea un oggetto di testo associato al documento, dato il nome del font e la dimensione. FPDFText_SetText imposta la stringa che l'oggetto porta con sé. FPDFPageObj_GetBounds legge il riquadro di delimitazione (bounding box) dell'oggetto. FPDFPageObj_Destroy libera l'oggetto. Cosa fondamentale, nulla in quella sequenza chiama l'API di inserimento nella pagina. L'oggetto viene creato, interrogato e distrutto in isolamento, cosicché il documento rimane invariato quando la funzione ritorna. È una sonda usa e getta il cui unico output sono i quattro numeri del suo riquadro di delimitazione

Questo è il modo robusto di farlo perché PDFium non espone una comoda larghezza di avanzamento per glifo che potresti sommare tu stesso. Le metriche dei glifi dipendono dal programma del font, dalla codifica e da come PDFium carica il carattere, e non c'è una chiamata pubblica che ti restituisca l'avanzamento di ciascun carattere in una stringa. Il riquadro di delimitazione di un vero oggetto di testo, d'altra parte, viene calcolato dagli stessi meccanismi che impaginerebbero i glifi per il disegno, quindi riflette l'estensione effettivamente renderizzata piuttosto che un'approssimazione. Costruire un singolo oggetto usa e getta e leggerne i limiti è la misurazione più affidabile che la libreria possa fornire

// The shape of MeasureText, expressed against the verified PDFium calls.
// A text object is built, measured, and destroyed; no page is involved.
procedure TPdfMeasureHelper.MeasureText(const Text, Font: WString;
  FontSize: Single; out Width, Height: Double);
var
  TextObject: FPDF_PAGEOBJECT;
  L, B, R, T: Single;
begin
  Width  := 0;
  Height := 0;
  if Self.Document = nil then
    Exit;
  TextObject := FPDFPageObj_NewTextObj(Self.Document,
    FPDF_BYTESTRING(AnsiString(Font)), FontSize);
  if TextObject = nil then
    Exit;
  try
    if FPDFText_SetText(TextObject, FPDF_WIDESTRING(WideString(Text))) = 0 then
      Exit;
    if FPDFPageObj_GetBounds(TextObject, L, B, R, T) <> 0 then
    begin
      Width  := R - L;
      Height := T - B;
    end;
  finally
    FPDFPageObj_Destroy(TextObject);   // probe discarded, page untouched
  end;
end;

Coordinate e unità di misura del risultato

Il riquadro di delimitazione viene restituito come quattro bordi: sinistro, inferiore, destro e superiore, e le due dimensioni si ottengono per sottrazione. La larghezza è destra meno sinistra e l'altezza è superiore meno inferiore. Entrambe sono espresse in unità utente PDF, dove un'unità è un settantaseiesimo di pollice, lo stesso spazio di coordinate in cui posizioni il testo sulla pagina. In questa fase non ci sono unità nascoste del dispositivo né pixel coinvolti. Una larghezza di 36 significa mezzo pollice di pagina, indipendentemente dall'eventuale risoluzione di rendering

L'asse verticale scorre nel modo in cui lo definisce il PDF, con la Y in aumento verso l'alto, ed è per questo che l'altezza è superiore meno inferiore e non viceversa. Questo dettaglio è importante quando si fa avanzare un cursore verso il basso lungo una colonna. Misuri l'altezza di una riga, quindi la sottrai dalla linea di base corrente per trovare la successiva, perché spostarsi verso il basso nella pagina significa spostarsi verso una Y più piccola. Se la tua destinazione è uno schermo anziché la carta, converti le unità utente in pixel del dispositivo con la risoluzione del display: un valore in unità utente moltiplicato per i DPI e diviso per 72 fornisce i pixel, quindi la larghezza di una colonna che imposti in punti può essere confrontata con una serie misurata prima di decidere dove inserire l'interruzione

Cosa succede con input degenerati

Le funzioni sono scritte per fallire silenziosamente. Se non c'è alcun documento aperto, o se l'oggetto di testo non può essere creato, il risultato è un'estensione pari a zero invece di un'eccezione sollevata. Larghezza e altezza vengono inizializzate a zero all'inizio e vengono sovrascritte solo una volta che un riquadro di delimitazione è stato letto con successo. Una stringa vuota, un documento mancante, un font che la libreria non riesce a risolvere in un oggetto: ciascuno di questi casi restituisce zero invece di generare un'eccezione

Questa scelta mantiene semplice un ciclo di misurazione, perché un ciclo che scorre migliaia di parole non è il posto giusto per la gestione delle eccezioni ad ogni iterazione. Il prezzo da pagare è che il chiamante deve occuparsi del controllo. Una larghezza pari a zero è una sentinella, non un dato di fatto sul testo, quindi il codice che divide per una larghezza misurata o presuppone un valore positivo deve proteggersi dallo zero prima di fare affidamento su di esso. Tratta lo zero come "impossibile misurare" e il contratto è chiaro; ignoralo e un input degenerato diventerà silenziosamente un layout con una colonna di glifi sovrapposti

Un a capo automatico "greedy" costruito sulla misurazione

Avendo a disposizione una funzione per la larghezza, l'a capo automatico è un breve ciclo "greedy" (avido). Dividi il paragrafo in parole, mantieni una riga corrente e per ogni parola misuri come sarebbe la riga se vi aggiungessi quella parola. Finché la riga di prova rientra ancora nella larghezza della colonna, continui ad aggiungere; quando traboccherebbe svuoti la riga corrente con AddText e ne inizi una nuova con la parola che non ci stava. L'accumulo viene fatto interamente con MeasureTextWidth, e l'unica cosa che raggiunge mai la pagina è una riga di cui hai già confermato l'idoneità

procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<string>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := string(Para).Split([' ']);
  Line  := '';
  Y     := TopY;
  for I := 0 to High(Words) do
  begin
    if Line = '' then
      Trial := Words[I]
    else
      Trial := Line + ' ' + Words[I];
    // Measure the candidate line before drawing anything.
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(Line, Font, FontSize, X, Y);   // flush the line that fit
      Y    := Y - LineHeight;                    // Y decreases going down
      Line := Words[I];                          // overflowing word starts next line
    end
    else
      Line := Trial;
  end;
  if Line <> '' then
    Pdf.AddText(Line, Font, FontSize, X, Y);      // flush the final line
end;

Il ciclo misura la riga di prova anziché misurare ogni singola parola e farne la somma, perché la larghezza di una riga non è la somma delle larghezze delle sue parole. Gli spazi tra le parole contribuiscono, e una serie misurata cattura questo aspetto direttamente. La regola "greedy" (avida), inserire quante più parole lo consente la colonna e andare a capo con l'ultima che ci sta, è la stessa regola che colma il divario tra un grezzo AddText e un vero paragrafo. La chiamata di disegno non è mai stata la parte difficile. La misurazione che la deve precedere lo è, ed è esattamente ciò che fornisce l'helper

Come si integra

La misurazione è il livello intermedio tra la generazione del contenuto e il suo rendering, quindi si sposa naturalmente con il resto di un flusso di lavoro per documenti creati da zero. Se stai assemblando pagine e posizionando testo innanzitutto, le basi sono in creare documenti PDF da zero con il componente PDFium in Delphi, dove vengono trattati integralmente AddText e l'impostazione della pagina. Quando il font che stai misurando conta tanto quanto la stringa, perché le metriche dipendono dal tipo di carattere, analizzare le proprietà dei font PDF con il componente PDFium in Delphi mostra come la libreria riporti le informazioni sul font che determinano quei riquadri di delimitazione. Entrambi si basano sullo stesso binding, il Componente PDFium per Delphi e Lazarus, dove l'helper di misurazione è incluso insieme alle API per documenti, pagine e testo descritte in questo blog