Articolo tecnico

Selezione riga di testo PDF con char box PDFium in Delphi

Una text page PDF espone caratteri e box, mai righe. PDFium Component costruisce una riga visiva raggruppando box di caratteri i cui centri verticali cadono entro metà dell'altezza del carattere seme, scandendo verso l'esterno dal carattere cliccato finché la tolleranza non viene superata. Ogni percorso di selezione nel viewer chiama quell'unico helper, così mouse, tastiera e codice concordano

Il sintomo che ti porta a cercare questo è specifico e sgradevole. Un utente fa triplo click su un paragrafo in un report a due colonne e ottiene metà pagina. Oppure fa triplo click su una cella di tabella e la selezione ingoia l'intera riga più il numero di pagina nel piè di pagina. Il viewer non è rotto; sta ponendo una domanda a cui il file non può rispondere. Non esiste una riga in un PDF da selezionare, e qualsiasi implementazione che finga il contrario sta indovinando. Questo articolo riguarda il rendere quell'indovinare deliberato e coerente. Se ciò di cui hai realmente bisogno è estrarre testo da un documento, vedi estrarre testo da documenti PDF con PDFium; se stai impaginando testo e hai bisogno di larghezze, vedi misurazione del testo e a capo automatico. Qui l'argomento è più ristretto: decidere dove inizia e finisce una riga visiva, e selezionare esattamente quella

Perché una text page PDF non ha oggetti riga?

Perché un content stream PDF descrive il disegno, non la struttura. ISO 32000-1 §9.4 definisce un oggetto testo come una coppia BT / ET contenente operatori di posizionamento e visualizzazione. Gli operatori di posizionamento di §9.4.2 (Td, TD, Tm, T*) muovono una matrice di testo per la pagina, e gli operatori di visualizzazione di §9.4.3 (Tj, TJ, ', ") dipingono glifi ovunque quella matrice punti attualmente. Niente in quel modello dice "questa sequenza di glifi è una riga". Una riga è ciò che un umano vede dopo che la pittura è terminata

I producer peggiorano questo in modi che non puoi controllare. Un paragrafo giustificato può essere emesso come un array TJ per riga, o come un Tj per parola con un esplicito Tm prima di ciascuna, o come una singola operazione di visualizzazione con aggiustamenti di kerning che portano la spaziatura. Un layout a due colonne può emettere la colonna sinistra dall'alto in basso e poi la colonna destra, oppure può interlacciarle se il producer ha percorso il proprio elenco interno di oggetti in un ordine diverso. La sequenza di caratteri che PDFium ti consegna segue il content stream, e il content stream segue qualunque cosa l'applicazione generatrice abbia deciso di fare. Quindi le due funzioni che ottieni realmente sono FPDFText_CountChars, che riporta quanti caratteri contiene la pagina, e FPDFText_GetCharBox, che restituisce il riquadro delimitante di un carattere nello spazio pagina. Questo è l'intero vocabolario grezzo. Tutto ciò che sta sopra, parole, righe, paragrafi, colonne, è inferenza che esegui tu sulla geometria

Perché il rilevamento di CR e LF è il test sbagliato?

Perché i caratteri contro cui testeresti non sono affidabilmente presenti, e quando sono presenti non sono affidabilmente tuoi. PDFium inietta caratteri sintetici nella text page per rendere leggibile il testo estratto: uno spazio dove due run sono visivamente separati, un CR o LF dove il run successivo inizia su una nuova linea di base. FPDFText_IsGenerated esiste precisamente perché tu possa distinguere quelli dai caratteri che provengono dal file, e PDFium Component lo espone come la proprietà CharacterGenerated

Dividi su quei caratteri ed erediti ogni scelta di giudizio che PDFium ha fatto sintetizzandoli. Un'interruzione di riga forzata dentro un paragrafo su più righe e un a capo automatico appaiono identici dopo la sintesi. Una riga di tabella che il producer ha emesso cella per cella potrebbe non ottenere alcuna interruzione tra l'ultima cella e la prima cella della riga successiva, perché le linee di base capitano di essere abbastanza vicine. Nel frattempo un titolo seguito da testo del corpo a una dimensione diversa può ottenere due interruzioni dove un umano ne vede una. I caratteri generati sono una comodità di rendering per l'estrazione a pagina intera; non sono un modello di riga, e degradano esattamente nei documenti dove la selezione conta di più

Raggruppare i box di carattere per centro verticale

Il segnale affidabile è la geometria. Prendi il carattere che l'utente ha cliccato come seme, calcola il centro verticale del suo box, e cammina verso l'esterno in entrambe le direzioni finché i box vicini mantengono i propri centri verticali entro la tolleranza. PDFium Component usa metà dell'altezza del box seme come quella tolleranza, con un minimo di 0.5 unità pagina così che box degeneri, un punto, uno spazio sottile, un glifo con un box di altezza quasi zero, non collassino la tolleranza a niente e taglino la riga dopo un solo carattere

function TPdfView.LineRangeAt(TxtPage: FPDF_TEXTPAGE; CharIndex: Integer;
  out StartIndex, Count: Integer): Boolean;
var
  Lo, Hi, Total: Integer;
  SeedBox, Box: TPdfRectangle;
  SeedYMid, BoxYMid, HalfH: Double;
begin
  Result := False;
  StartIndex := -1;
  Count := 0;
  Total := FPDFText_CountChars(TxtPage);
  if (CharIndex < 0) or (CharIndex >= Total) then
    Exit;

  if FPDFText_GetCharBox(TxtPage, CharIndex, SeedBox.Left, SeedBox.Right,
    SeedBox.Bottom, SeedBox.Top) = 0 then
    Exit;
  SeedYMid := (SeedBox.Top + SeedBox.Bottom) / 2;
  HalfH := Abs(SeedBox.Top - SeedBox.Bottom) / 2;
  if HalfH < 0.5 then          // floor for degenerate boxes
    HalfH := 0.5;

  Lo := CharIndex;
  Hi := CharIndex;
  while Lo > 0 do
  begin
    if FPDFText_GetCharBox(TxtPage, Lo - 1, Box.Left, Box.Right,
      Box.Bottom, Box.Top) = 0 then
      Break;
    BoxYMid := (Box.Top + Box.Bottom) / 2;
    if Abs(BoxYMid - SeedYMid) > HalfH then
      Break;
    Dec(Lo);
  end;
  while Hi < Total - 1 do
  begin
    if FPDFText_GetCharBox(TxtPage, Hi + 1, Box.Left, Box.Right,
      Box.Bottom, Box.Top) = 0 then
      Break;
    BoxYMid := (Box.Top + Box.Bottom) / 2;
    if Abs(BoxYMid - SeedYMid) > HalfH then
      Break;
    Inc(Hi);
  end;
  StartIndex := Lo;
  Count := Hi - Lo + 1;
  Result := True;
end;

Tre dettagli in quel ciclo si guadagnano il proprio posto. La tolleranza deriva dal seme piuttosto che da una costante, così un titolo da 24pt ottiene una banda larga e un testo di nota a piè di pagina da 7pt ne ottiene una stretta, e nessuno dei due ruba caratteri all'altro. Il confronto usa i centri verticali piuttosto che le linee di base o le sommità dei box, il che mantiene un apice, un run inline di dimensione diversa, o una frase con font misto sulla stessa riga dei propri vicini. E un FPDFText_GetCharBox fallito termina la scansione invece di essere saltato, perché un carattere senza geometria recuperabile non fornisce evidenza in nessuna direzione, e continuare oltre esso permetterebbe alla camminata di saltare oltre un vero confine sulla base di un carattere più avanti

Perché ogni percorso di selezione deve condividere un helper?

Perché tre percorsi di codice che implementano ciascuno "la riga" divergeranno, e divergeranno silenziosamente. In PDFium Component, l'espansione del triplo click, Shift+Home, Shift+End, e il metodo pubblico SelectLineAt risolvono tutti i propri confini attraverso la stessa chiamata LineRangeAt. Il triplo click la semina dall'ancora di selezione; i tasti shift la seminano dal cursore di selezione e muovono solo quell'estremità; SelectLineAt la semina da un indice di carattere fornito dal chiamante e consegna il risultato a SelectTextRange, lo stesso validatore di intervallo che usa il percorso del mouse. Duplica invece la logica e il fallimento non è un crash, è una lenta deriva. Qualcuno regola la tolleranza del triplo click per correggere un report con interlinea stretta, e ora Shift+End si ferma un carattere prima di dove si ferma il triplo click sullo stesso paragrafo. Un utente seleziona una riga con il mouse, la estende con la tastiera, e vede la selezione restringersi. Poiché SelectLineAt alimenta la pipeline di selezione ordinaria, anche la selezione programmatica resta indipendente da se l'input del mouse sia abilitato, e ottiene comunque gratuitamente la validazione dell'intervallo, il ridisegno, e la notifica OnSelectionChange

// Select the visual line under a client-space point, then read it back
procedure TForm1.SelectLineUnderCursor(X, Y: Integer);
var
  CharIndex: Integer;
begin
  CharIndex := PdfView1.CharacterIndexAtPos(X, Y, 6.0, 6.0);
  if CharIndex < 0 then
    Exit;
  if PdfView1.SelectLineAt(PdfView1.CurrentPage, CharIndex) then
    Memo1.Lines.Add(PdfView1.SelectedText);
end;

Nota gli argomenti di tolleranza su CharacterIndexAtPos. L'hit testing ha il proprio margine, espresso in unità pagina, ed è una preoccupazione separata dalla tolleranza di riga. Un click che atterra nell'interlinea tra due righe si risolve verso qualunque carattere sia più vicino entro quel riquadro; la scansione di riga poi gira da qualunque carattere sia risultato quello. Alimentare una tolleranza di hit troppo generosa nel seme è uno dei modi più facili per selezionare una riga a cui l'utente non stava puntando

Due spazi di indice: indice carattere e indice testo

Una volta che hai un intervallo, resisti alla tentazione di usarlo come offset di stringa. FPDFText_GetText restituisce il testo della pagina come un buffer UTF-16, ma i suoi indici non sono lo stesso spazio di indice degli indici di carattere usati da FPDFText_GetCharBox e FPDFText_CountChars. I caratteri generati discussi in precedenza siedono nel buffer di testo occupando slot di carattere senza geometria utilizzabile, e le due numerazioni divergono nel corso della pagina. I ponti sono FPDFText_GetTextIndexFromCharIndex e FPDFText_GetCharIndexFromTextIndex, avvolti da PDFium Component come CharacterIndexToTextIndex e TextIndexToCharacterIndex

var
  TextStart, TextEnd: Integer;
begin
  // char-index range from LineRangeAt -> offsets into the page text buffer
  TextStart := Pdf.CharacterIndexToTextIndex(StartIndex);
  TextEnd   := Pdf.CharacterIndexToTextIndex(StartIndex + Count - 1);
  if (TextStart >= 0) and (TextEnd >= TextStart) then
    Caption := Pdf.Text(TextStart, TextEnd - TextStart + 1);
end;

La direzione che morde più duramente è quella inversa. Una ricerca implementata sulla stringa estratta ti dà indici testo, e passarli direttamente a un'API di box o selezione indirizza silenziosamente i caratteri sbagliati, con un errore che cresce quanto più scendi nella pagina. Converti con TextIndexToCharacterIndex prima che qualsiasi cosa geometrica tocchi il numero. Le coppie surrogate aggiungono un secondo problema di offset indipendente sopra questo, trattato nell'articolo su emoji, CJK, e coppie surrogate

Dove l'euristica si piega

Sii onesto con te stesso sui limiti, perché sono reali e raggiungibili. Il testo ruotato è il caso più chiaro: un box di carattere è un rettangolo allineato agli assi nello spazio pagina, così per testo ruotato di 90 gradi i box di una riga visiva hanno centri verticali sparsi per la pagina, e la scansione si ferma quasi immediatamente. Ciò che ottieni è una selezione corta piuttosto che una sbagliata, che è la modalità di fallimento migliore, ma è comunque un fallimento. Le modalità di scrittura verticale si comportano allo stesso modo per lo stesso motivo. I layout a due colonne funzionano quando le colonne sono verticalmente sfalsate l'una dall'altra e si rompono quando non lo sono. Se entrambe le colonne condividono una griglia di linee di base, i caratteri della colonna destra siedono entro tolleranza della riga della colonna sinistra, e la scansione correrà dritta attraverso il corridoio, perché in pura geometria non c'è nulla lì a fermarla. Rilevare questo richiede un test di gap orizzontale sopra il raggruppamento verticale, e scegliere la soglia del gap è una propria scelta di giudizio su quali documenti sei disposto a sbagliare. Le dimensioni di font miste sono il caso che la tolleranza relativa al seme gestisce bene: uno span di codice inline da 8pt dentro un testo del corpo da 11pt mantiene il proprio centro dentro la banda, e un titolo da 24pt sulla linea di base successiva non trascina a sé la riga del corpo

La semantica di selezione riga descritta qui è distribuita in PDFium Component per Delphi e C++Builder, insieme alle API di hit testing, intervallo di selezione, e indice testo usate negli esempi; la pagina prodotto porta il riferimento completo per la text page e il modello di selezione