Articolo tecnico

Text shaping collegabile: Uniscribe e HarfBuzz in Delphi

Lo shaping del testo nel componente PDFium passa attraverso un oggetto installabile. ConfigureTextShaper installa lo shaper attraverso cui ogni punto di ingresso di shaping instrada, sostituendo e liberando qualunque cosa ci fosse; ActiveTextShaper restituisce quello installato e crea il default della piattaforma al primo uso; ActiveTextShaperName riporta quale backend sia attivo; ClearTextShaper elimina l'installazione e lascia che il default venga ricreato. Su Windows il default è TPdfUniscribeTextShaper. Sotto Free Pascal c'è TPdfHarfBuzzTextShaper, che collega libharfbuzz a runtime così una libreria mancante è una condizione riportata anziché un fallimento di caricamento

Architettura dello shaping del testo collegabile nel componente PDFium Delphi: ConfigureTextShaper, ActiveTextShaper e ClearTextShaper gestiscono un solo backend installato, Uniscribe su Windows e un HarfBuzz collegato a runtime sotto Free Pascal
Ogni chiamata di shaping instrada attraverso il singolo oggetto shaper installato, con un default della piattaforma su ogni target

Un'interfaccia, due backend che dividono il lavoro in modo completamente diverso. Comprendere quell'asimmetria è ciò che impedisce alla via portabile di produrre testo con shaping corretto e posizionamento sbagliato

Perché il backend Windows è una classe e quello portabile tre pezzi?

Perché Uniscribe è quattro API che fingono di essere una. ScriptItemize segmenta una stringa per script e risolve i livelli bidirezionali; ScriptShape mappa i caratteri in glifi; ScriptPlace calcola avanzamenti e offset; ScriptLayout mette i run risultanti in ordine visivo. Un backend costruito su di essa quindi non ha più nulla da aggiungere, ed è per questo che lo shaper Windows è una singola classe con un singolo metodo

HarfBuzz copre le due centrali. Dà shape e colloca un run la cui direzione e script il chiamante ha già deciso, e non ha opinioni su come un paragrafo si divida in run o in quale ordine quei run compaiano. Così il backend portabile fornisce il resto: l'algoritmo bidirezionale risolve i livelli di embedding, le funzioni Unicode di HarfBuzz segmentano il testo per script, e i run sono disposti nell'ordine visivo che la regola L2 di UAX #9 produce. La metà bidirezionale è abbastanza sostanziosa da essere un'unità propria, descritta nell'articolo sui livelli di embedding UAX #9

Confronto della pipeline di shaping per il testo PDF: Uniscribe fornisce ScriptItemize, ScriptShape, ScriptPlace e ScriptLayout dentro una sola classe, mentre HarfBuzz copre solo shaping e collocazione attorno alle fasi UAX #9 proprie del componente
Uniscribe copre tutte e quattro le fasi; la via portabile deve fornire da sé itemization e ordine visivo

Lo shaper non risolve i font, ed è deliberato

Uniscribe legge il binario del font da un device context GDI. Non c'è equivalente portabile di questo, e inventarne uno dentro un'unità di shaping significherebbe decidere, per conto di ogni applicazione, se i font vengano da fontconfig, da CoreText, da una cartella font dell'applicazione o da un database. Così il backend HarfBuzz accetta un resolver: una callback che mappa un nome di font nei byte TrueType o OpenType. Restituire False fa fallire la richiesta di shaping allo stesso modo in cui un font GDI illeggibile la fa fallire su Windows

uses
  FPdfTextShaping
{$IFDEF FPC}
  , FPdfTextShapingHb
{$ENDIF}
  ;

function TFontCatalogue.Resolve(const FontName: WideString;
  out FontData: TBytes): Boolean;
var
  Path: string;
begin
  // La politica di Lei: fontconfig, CoreText, una cartella font dell'app, un database
  Result := FLookup.TryGetValue(LowerCase(FontName), Path);
  if Result then
    FontData := TFile.ReadAllBytes(Path);
end;

procedure InstallShaper(Catalogue: TFontCatalogue);
begin
{$IFDEF FPC}
  // La proprietà passa all'unità; chiamare una volta all'avvio,
  // prima che qualcosa dia shape al testo
  ConfigureTextShaper(TPdfHarfBuzzTextShaper.Create(Catalogue.Resolve));
{$ENDIF}
  // Su Delphi il default della piattaforma (Uniscribe) è creato a richiesta,
  // quindi non serve alcuna installazione
  LogInfo('shaping backend: ' + ActiveTextShaperName);
end;

Tenere la scoperta dei font fuori dallo shaper ha un secondo beneficio che compare nei server: lo stesso processo può dare shape con un insieme di font incorporato che non c'entra nulla con ciò che è installato sulla macchina, che è ciò che si vuole quando l'output deve essere riproducibile al byte tra host. Il componente espone anche un provider di font del sistema ospite per i casi in cui si vogliono i font installati, trattato nell'articolo sul provider di font di sistema

Il record di risultato è neutrale rispetto al backend, e i cluster sono la ragione

Entrambi i backend riempiono lo stesso TPdfShapedText: il testo sorgente, il nome del font, la dimensione, i byte del font, un array di run, la larghezza totale, il numero di glifi e il numero di caratteri logici. Ogni TPdfShapedRun trasporta il proprio intervallo nel testo sorgente, la propria posizione X visiva, la propria larghezza, il proprio livello bidirezionale e un flag destra-verso-sinistra, più i propri glifi. Ogni TPdfShapedGlyph trasporta un identificatore di glifo, un avanzamento, offset X e Y, e il cluster a cui appartiene come inizio e lunghezza nel testo sorgente

Quei campi di cluster sono ciò che rende il record utilizzabile anziché semplicemente informativo. Lo shaping non è una mappatura uno a uno: una sillaba devanagari diventa un glifo da quattro caratteri, una legatura araba ne fonde due, e un singolo carattere può produrre segni multipli. Senza intervalli di cluster non si può collocare un cursore, fare hit-test di un clic o evidenziare una selezione, perché non si può dire a quali caratteri appartenga un glifo. Con essi, l'aritmetica è locale e lo stesso codice funziona per entrambi i backend

Intervalli di cluster dei glifi in TPdfShapedText: un glifo di sillaba devanagari da quattro caratteri, una legatura araba da due, e una base più segno da un carattere, ognuno mappato indietro attraverso ClusterStart e ClusterLength
Gli intervalli di cluster mappano ogni glifo ai suoi caratteri sorgente così cursori, hit test e selezioni funzionano
var
  Shaped: TPdfShapedText;
  R, G: Integer;
begin
  if ShapePdfText(Line, 'Noto Sans Arabic', 14, ptdAuto, Shaped) then
    for R := 0 to High(Shaped.Runs) do
    begin
      // I run arrivano già in ordine visivo con VisualX compilato
      X := Shaped.Runs[R].VisualX;
      for G := 0 to High(Shaped.Runs[R].Glyphs) do
      begin
        EmitGlyph(Shaped.Runs[R].Glyphs[G].GlyphID,
          X + Shaped.Runs[R].Glyphs[G].OffsetX,
          Shaped.Runs[R].Glyphs[G].OffsetY);
        X := X + Shaped.Runs[R].Glyphs[G].Advance;
      end;
    end;
end;

I budget appartengono al record di opzioni

TPdfTextShapingOptions trasporta una direzione più tre tetti: caratteri massimi, glifi massimi e run massimi, con una funzione di classe Default che riempie valori sensati. I tetti non sono paranoia su input malformato; sono aritmetica. Lo shaping espande: un font con sostituzione contestuale aggressiva può emettere più glifi che caratteri di input, e un paragrafo che alterna script ogni pochi caratteri produce un run per commutazione. Un documento assemblato per massimizzare entrambi trasforma una stringa modesta in una grande allocazione, e un servizio che dà shape al testo da PDF non fidati ha bisogno di un limite scelto da sé anziché di un limite imposto dalla macchina

Impostare la direzione esplicitamente anziché lasciarla su automatico vale la pena farlo ogni volta che la si conosce già. L'automatico applica le regole della direzione di paragrafo per indovinare dal primo carattere forte, il che è giusto per testo libero e sbagliato per un campo modulo la cui direzione è una proprietà del campo anziché del valore che qualcuno ha digitato dentro

Binding a runtime, non una dipendenza di build

Il backend HarfBuzz carica la libreria dinamicamente. È una decisione di deployment con conseguenze reali: un solo binario gira su una macchina con HarfBuzz e su una senza, riportando capacità ridotte nel secondo caso invece di fallire l'avvio. Per una libreria spedita ad altri sviluppatori è l'unico assetto praticabile, perché non si può richiedere a ogni consumatore di un componente PDF di procurarsi e far coincidere le versioni di una libreria di shaping di cui potrebbe non avere bisogno

La regola corrispondente per i chiamanti è verificare. ActiveTextShaper restituisce nil quando la piattaforma non ha default e nessuno è stato configurato, e il punto di ingresso di shaping riporta quello come shaper non disponibile anziché come fallimento di shaping. Sono problemi diversi e meritano messaggi diversi: uno è un vuoto di deployment, l'altro è un problema di font o di testo

Installare una volta, prima che qualcosa dia shape

L'installazione sostituisce e libera lo shaper precedente, quindi chiamarla ripetutamente è sicuro ma inutile, e chiamarla mentre un altro thread sta dando shape non è affatto sicuro. Farlo durante l'avvio. Se occorre ripiegare sul default della piattaforma in seguito, passare nil, che è anche il modo di disfare un test double alla fine di un test

Una volta installato un backend, misurazione e wrapping si comportano allo stesso modo su entrambe le piattaforme, poiché consumano le metriche di run e glifi anziché chiamare la piattaforma direttamente; il modello di wrapping è descritto nell'articolo su misurazione del testo e word wrap. Le piattaforme e le toolchain supportate per il componente sono elencate nella pagina di prodotto di PDFium Delphi component