Articolo tecnico

Livelli di embedding BiDi per testo PDF senza Uniscribe

Uniscribe fa più lavoro di quanto la maggior parte dei chiamanti si renda conto. ScriptItemize esegue l'analisi bidirezionale e la segmentazione per script in una sola passata, e ScriptLayout produce l'ordine visivo dei run risultanti. HarfBuzz, il rimpiazzo portabile a cui la gente arriva, non fa nessuna delle due: dà shape a un singolo run la cui direzione e script sono già stati decisi da qualcun altro. Così la parte difficile del portare una pipeline di testo PDF Windows su Linux o macOS non è collegare un motore di shaping. È fornire l'algoritmo bidirezionale che Uniscribe stava fornendo in silenzio, e nel componente PDFium è a questo che serve FPdfBidi

L'unità implementa UAX #9 direttamente: regole P2 e P3 per la direzione del paragrafo, X1 a X10 per embedding espliciti e isolati, W1 a W7 per i tipi deboli, N0 a N2 per neutri e parentesi, I1 e I2 per i livelli impliciti, e L1 e L2 per il riordino finale. Due funzioni la trasportano: PdfResolveBidiLevels restituisce un livello di embedding per unità di codice UTF-16, e PdfBidiVisualOrder trasforma quei livelli nella permutazione che colloca le unità di codice da sinistra a destra

Cosa dà l'algoritmo, e cosa no

Dà numeri. I livelli pari sono da sinistra a destra, i livelli dispari da destra a sinistra, e il livello di ogni carattere codifica l'annidamento dei run direzionali dentro cui quel carattere si trova. Da quei numeri L2 deriva una permutazione. Ciò che l'algoritmo deliberatamente non fa è decidere quale font usare, formare legature o riordinare i glifi dentro un cluster; quelle sono questioni di shaping e appartengono alla fase dopo questa

Pipeline FPdfBidi per il testo PDF senza Uniscribe: PdfResolveBidiLevels assegna un livello di embedding UAX #9 per unità di codice UTF-16 e PdfBidiVisualOrder applica la regola L2 per produrre l'ordine visivo
I livelli codificano l'annidamento dei run, e la regola L2 li trasforma nella permutazione che si legge da sinistra a destra
uses
  FPdfBidi;

var
  Levels: TPdfBidiLevels;
  Order: TPdfBidiOrder;
  ParagraphLevel: Byte;
  Text, Visual: WideString;
  I: Integer;
begin
  Text := SourceLine;
  // pbdAuto applica P2-P3: il primo carattere forte decide
  if PdfResolveBidiLevels(Text, pbdAuto, Levels, ParagraphLevel) then
  begin
    Order := PdfBidiVisualOrder(Text, Levels);
    SetLength(Visual, Length(Order));
    for I := 0 to High(Order) do
      Visual[I + 1] := Text[Order[I] + 1];
    // Visual ora si legge da sinistra a destra; Levels[] dice ancora quali
    // run sono RTL così uno shaper può ricevere le direzioni corrette
  end;
end;

La tabella delle classi di carattere è generata, non scritta

Ogni code point ha una proprietà Bidi_Class, e l'algoritmo la consulta di continuo, quindi la tabella è la fondamenta su cui tutto il resto sta in piedi. È generata dalla Unicode Character Database anziché mantenuta a mano: il campo cinque di UnicodeData.txt fornisce le classi assegnate, e le dichiarazioni @missing in DerivedBidiClass.txt forniscono i default per i code point che il database non assegna, ed è così che i blocchi non allocati correttamente vanno in default a R, AL, ET o BN anziché a L

Il trucco di compressione è emettere solo gli intervalli la cui classe non è L. Qualsiasi cosa che cada fuori da ogni intervallo è L, che è sia il default Unicode sia la classe della schiacciante maggioranza dei code point. Questo porta una tabella che altrimenti correrebbe a migliaia di voci giù a 745 intervalli e circa 6,7 KB. La conseguenza operativa merita di essere detta: quando si passa a una nuova versione di Unicode, far girare di nuovo il generatore. Modificare a mano il file include funzionerà, e divergerà anche silenziosamente dal database al prossimo aggiornamento

L2 deve riordinare code point, non unità di codice UTF-16

Questo è l'errore che produce output genuinamente corrotto, e la prima implementazione l'ha commesso. L2 dice di invertire i run contigui a ciascun livello dal più alto giù al più basso livello dispari. Scritto contro una stringa UTF-16, "invertire un run" significa naturalmente invertire le unità di codice in esso. Per i caratteri nel Basic Multilingual Plane va bene. Per un carattere RTL in un piano astrale, come quelli nei blocchi cipriota o Sud Arabia antico vicino a U+10800, non va: il carattere è una coppia di surrogate, invertire il run mette la surrogate bassa prima di quella alta, e la stringa ora contiene due surrogate non appaiate invece di un carattere. Nulla a valle può recuperarlo

La correzione è fare L2 su unità di code point. L'implementazione fonde le unità di codice in unità di code point, esegue le inversioni su quelle unità, ed espande il risultato di nuovo a indici di unità di codice alla fine. È per questo che PdfBidiVisualOrder prende il testo e non solo l'array dei livelli: non può dire dai soli livelli dove sono i confini delle surrogate. La stessa disciplina delle coppie di surrogate attraversa in generale le API di testo, come descritto nell'articolo su emoji, CJK e coppie di surrogate

Corruzione di coppie di surrogate nel riordino bidi: invertire le unità di codice UTF-16 spacca un carattere astrale vicino a U+10800 in surrogate non appaiate, mentre invertire le unità di code point fuse lo tiene intatto
La regola L2 deve fondere le unità di codice in code point prima di invertire, poi espanderle di nuovo dopo

La discesa attraverso i livelli deve includere livelli che non occorrono

Il secondo errore è più sottile e non produce crash, solo testo che non viene riordinato. L2 dice di partire dal livello più alto presente e scendere al più basso livello dispari. Un'ottimizzazione naturale è raccogliere l'insieme dei livelli che occorrono davvero e iterare su quell'insieme. È sbagliata

Si consideri una riga di testo latino dentro un embedding da destra a sinistra. Il livello del paragrafo è 0, l'embedding spinge i caratteri latini al livello 2, e nessun carattere sta al livello 1. Iterare sui livelli occorrenti trova solo 0 e 2, e non c'è alcun livello dispari, così il ciclo non esegue alcuna inversione. Quella risposta è corretta, ma per una ragione che l'ottimizzazione non conosce: un'inversione al livello 2 seguita da un'inversione al livello 1 si cancellerebbe esattamente, quindi non eseguire nessuna delle due è l'esito giusto. Cambiando leggermente l'input, così che esistano caratteri sia di livello 1 sia di livello 3 ma non di livello 2, il ciclo basato sull'insieme salta l'inversione al livello 2 che l'algoritmo richiede

// Corretto: percorrere ogni livello dal massimo giù al più basso livello
// dispari, compresi i livelli che nessun carattere ha davvero
Level := MaxLevel;
while Level >= LowestOddLevel do
begin
  ReverseRunsAtOrAbove(Level);   // no-op quando nessun run qualifica
  Dec(Level);
end;

Scritto come un semplice ciclo a decremento il comportamento viene gratis, e le iterazioni no-op non costano nulla di misurabile. Questo è un caso in cui l'ottimizzazione ovvia non è leggermente sbagliata, è sbagliata in un modo dipendente dall'input che un piccolo corpus di test non rivelerà mai

Insidia della discesa dei livelli bidi in UAX #9: iterare solo i livelli che occorrono salta l'inversione al livello 2 richiesta, mentre un semplice ciclo a decremento da MaxLevel al più basso livello dispari riordina sempre correttamente
Percorrere ogni livello fino al più basso dispari non costa nulla e non salta mai un'inversione richiesta

Parentesi: BD16 con una tabella pragmatica

La regola N0 e l'algoritmo delle coppie di parentesi BD16 esistono perché una parentesi in testo a direzione mista si risolva nella direzione di ciò che racchiude anziché in qualunque cosa capiti essere adiacente. Serve una tabella di coppie di parentesi. L'implementazione trasporta le coppie in uso generale anziché il contenuto completo del file delle parentesi Unicode: parentesi ASCII, CJK, fullwidth, matematiche e ornamentali

Una parentesi non elencata non è un errore. Si risolve come un neutro ordinario attraverso N1 e N2, che è esattamente il comportamento che ogni implementazione aveva prima che Unicode 6.3 introducesse N0. Quindi il confine è "meno raffinato per le parentesi rare", non "incorretto". Un dettaglio richiede gestione esplicita: l'equivalenza canonica tra le parentesi angolari a U+2329 e U+232A e quelle a U+3008 e U+3009 va ripiegata quando si abbinano le coppie, o una parentesi aperta scritta in un modo non riuscirà ad abbinarsi a una parentesi chiusa scritta nell'altro

Come si testano trenta regole che interagiscono

Non con un grande corpus, almeno non per primi. L'approccio produttivo è stato sedici casi verificati a mano, ognuno scelto per esercitare una regola specifica e ognuno controllato contro i livelli che UAX #9 dice debba produrre: rilevamento della direzione del paragrafo sotto P2 e P3, le regole di tipo debole W2, W3 e W7, le regole di livello implicito I1 e I2, embedding esplicito via X2 e X7, isolati via X5a e X6a, il reset L1 degli spazi bianchi finali e dei separatori, un caso di parentesi N0, e un caso con un carattere astrale per fissare la gestione delle surrogate

Sedici casi con livelli attesi notoriamente corretti colgono più di milleseicento casi con output dall'aspetto plausibile, perché la modalità di guasto di un'implementazione bidirezionale è testo che si legge quasi giusto. Una volta che quelli passano, un corpus è utile per trovare vuoti di tabella e problemi di prestazioni, che sono classi di difetto diverse

Dentro il componente PDFium i livelli alimentano due consumatori. Sul lato scrittura dicono al backend di shaping la direzione di ogni run, che è l'input di cui HarfBuzz ha bisogno. Sul lato lettura informano la geometria di selezione e l'ordine di lettura, poiché un clic in testo RTL deve mappare a una posizione logica anziché visiva; quella mappatura è trattata nell'articolo sulla selezione delle righe visive e il modello di ordine di lettura in blocchi di testo strutturato e ordine di lettura. I dettagli del supporto piattaforme per il componente sono sulla pagina di prodotto di PDFium Delphi component