I glifi shaped vengono renderizzati come box .notdef quando un subsetter di font mantiene solo i glifi raggiungibili dai code point emessi. HotPDF, il componente VCL PDF nativo per Delphi e C++Builder, ha portato esattamente quel difetto fino alla versione 2.435.0: l'output GSUB di OpenType veniva registrato in una bitmap di utilizzo interna che il subsetter dichiarava di voler onorare e poi non leggeva mai realmente
Questo è un fallimento diverso da quello descritto nel bug di EndDoc che disabilitava silenziosamente il subsetting dei font. Quel bug riguardava quando il subsetting veniva eseguito rispetto alla serializzazione, e disabilitava il subsetting del tutto. Questo riguarda cosa contiene il subset quando il subsetting viene eseguito perfettamente secondo programma. La pipeline scatta al momento giusto, il prefisso di subset a sei lettere compare su /BaseFont esattamente come richiesto da ISO 32000-1 §9.6.4, il file diventa più piccolo, ogni pagina in latino risulta pulita alla prova, e una pagina in arabo esce come una fila di rettangoli vuoti. I bug di ordinamento sono rumorosi non appena guardi. I bug di closure restano silenziosi per sempre, perché il subset è strutturalmente valido e sbaglia solo riguardo al proprio elenco di appartenenza
Perché i glifi shaped vengono renderizzati come .notdef?
Perché l'insieme dei code point che un documento emette non è l'insieme dei glifi che il documento disegna, e un subsetter che confonde i due scarta ogni glifo prodotto dallo shaping. Lo shaping del testo trasforma una sequenza logica di caratteri in una sequenza posizionata di glifi, e il suo intero scopo è produrre glifi a cui nessun singolo carattere di input corrisponde: una heh mediale araba, una legatura fi, un congiunto devanagari, un alternativo contestuale selezionato dalla feature rclt. Ognuno di questi è un GID che una lookup GSUB ha fabbricato, non uno che la tabella cmap ti consegna per qualunque carattere della tua stringa. Un subsetter guidato puramente dal cmap sta quindi percorrendo l'indice sbagliato. Conserva fedelmente ogni glifo che il testo avrebbe potuto usare prima dello shaping e scarta esattamente i glifi che il testo usa dopo lo shaping. Il renderer chiede quindi al font incorporato il GID 1847, il subset ha azzerato quella voce in loca, e torna indietro l'indice glifo 0. L'indice glifo 0 è .notdef per definizione OpenType, motivo per cui la firma del fallimento è un box vuoto anziché una lettera sbagliata o un crash. Nulla nel PDF è malformato; il font semplicemente non contiene il glifo che il content stream ha richiesto
I code point non sono glifi: le tre fonti di un subset
Una closure di subset corretta deve unire tre fonti indipendenti, ciascuna con il proprio accumulatore. La prima è l'insieme derivato dai code point: HotPDF accumula FUnicodeUsedCps man mano che i caratteri BMP vengono emessi e FUnicodeSmpUsed per i caratteri del piano supplementare raggiunti tramite coppie surrogate, poi mappa ciascuno tramite FUnicodeCpToGid a un GID. La seconda è l'insieme derivato dallo shaping, i GID che una sostituzione GSUB ha prodotto, registrati tramite MarkUnicodeGlyphUsed e EnableShapingFeatureForSubset in FUnicodeExtraUsedGlyphs. La terza è la closure composita: un glifo il cui numberOfContours è -1 in glyf viene assemblato da GID componenti, e mantenere il composito mentre si scartano i suoi componenti produce un contorno vuoto anziché un .notdef, il che è probabilmente peggio perché si legge come un bug di spaziatura
HotPDF ha sempre gestito la prima e la terza. BuildAndApplyUnicodeFontSubset, il punto di ingresso del subsetting che EndDoc chiama prima della serializzazione, inizializza l'array dei glifi usati con GID 0, percorre i code point BMP, percorre l'elenco di utilizzo SMP, e passa l'array a un builder di subset che risolve internamente i componenti compositi. La seconda fonte era scritta ma mai consumata, e poiché le tre fonti falliscono su contenuti diversi, il divario può nascondersi per anni in una codebase il cui corpus di regressione è per lo più latino
L'array che era scritto e mai letto
Il contratto era documentato in tre punti e rispettato in nessuno di essi. La dichiarazione di FUnicodeExtraUsedGlyphs affermava che il subsetter di EndDoc lo unisce all'utilizzo derivato dai code point; il commento di intestazione su ApplyArabicGSUBRefinement prometteva che ogni GID sostitutivo emesso viene passato attraverso MarkUnicodeGlyphUsed così il subsetter porta il glifo nel font incorporato; la stessa promessa compare identica su ApplyArabicGSUBContextualRefinement per il percorso rclt. Entrambi i chiamanti hanno rispettato la propria metà. Un grep su ogni riferimento al campo ha risolto l'altra metà in circa novanta secondi: una dichiarazione, un'allocazione SetLength dentro RegisterUnicodeTTF, e scritture nelle due routine di marcatura. Nemmeno una lettura. Questa è la diagnosi che vale la pena interiorizzare, perché si generalizza ben oltre i font. Quando un campo viene scritto da più punti di chiamata e letto da nessuno, la funzionalità che rappresenta non esiste, per quanto accuratamente sia commentata. Lo Step 1 del subsetter è abbastanza piccolo da leggere in una sola schermata, e il divario è evidente una volta che sai dove cercarlo
// Step 1: derive the used-glyph set (as it stood before 2.435.0)
SetLength(UsedGlyphs, FUnicodeNumGlyphs);
for I := 0 to FUnicodeNumGlyphs - 1 do
UsedGlyphs[I] := False;
UsedGlyphs[0] := True; // .notdef is always present
for Cp := 0 to $FFFF do // source 1a: BMP code points
if (Cp < Length(FUnicodeUsedCps)) and FUnicodeUsedCps[Cp]
and (Cp < Length(FUnicodeCpToGid)) then
begin
GID := FUnicodeCpToGid[Cp];
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
for I := 0 to High(FUnicodeSmpUsed) do // source 1b: SMP code points
begin
GID := FUnicodeSmpUsed[I].GID;
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
// source 2 was missing here: nothing ever consulted FUnicodeExtraUsedGlyphs
La correzione a un ciclo, e marcare i glifi tu stesso
La correzione è un'unione, e il suo argomento di sicurezza deriva dalla direzione dell'operazione: imposta solo bit, non li azzera mai, quindi nessun glifo che prima sopravviveva al subset può iniziare a essere scartato
// v2.435.0: pull GSUB-derived extra glyphs into the subset.
// MarkUnicodeGlyphUsed / EnableShapingFeatureForSubset record GIDs that
// shaping produced but that no emitted code point maps to directly.
for I := 0 to FUnicodeNumGlyphs - 1 do
if (I < Length(FUnicodeExtraUsedGlyphs)) and FUnicodeExtraUsedGlyphs[I] then
UsedGlyphs[I] := True;
Tre proprietà rendono questa una modifica a basso rischio anziché una riscrittura del motore font. È monotona, come sopra. È un no-op su font che non hanno mai shaped nulla, poiché FUnicodeExtraUsedGlyphs resta tutto-False e l'output in byte per un documento solo latino resta invariato. E arriva prima dello Step 2, così entrambi i builder di subset la ereditano: il builder sparso che preserva la numerazione GID originale, e il builder compatto _BuildCompactSubsetTTF che HotPDF seleziona sotto PDF/A per rinumerare i glifi mantenuti in un intervallo denso, ridurre maxp.numGlyphs, ed emettere la mappatura vecchio-nuovo come lo stream /CIDToGIDMap richiesto da ISO 32000-1 §9.7.4.2. Entrambi chiamano internamente _TTFWalkCompositeClosure, quindi un glifo shaped che risulta essere composito ora trascina dentro anche i suoi componenti. La closure composita non è mai stata rotta; semplicemente non veniva mai raggiunta per questi GID, perché i GID non erano nell'insieme che percorre. Se guidi direttamente il motore GSUB invece di affidarti ai passaggi di raffinamento integrati, la closure diventa tua responsabilità, e ogni GID sostitutivo che emetti deve essere marcato prima che EndDoc congeli l'insieme dei glifi usati
var
Pdf: THotPDF;
GIDs: array[0..1] of Word;
LigGID: Word;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'shaped.pdf';
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF('C:\Fonts\NotoNaskhArabic-Regular.ttf');
Pdf.ShapingFeatures := [sfArabicGSUB, sfStandardLigatures,
sfContextualAlternates];
GIDs[0] := Pdf.GetUnicodeGlyphForCodepoint($0644); // lam
GIDs[1] := Pdf.GetUnicodeGlyphForCodepoint($0627); // alef
if Pdf.ApplyLigatureSubstitution(GIDs, 0, 'liga', LigGID) then
Pdf.MarkUnicodeGlyphUsed(LigGID); // omit this and you get .notdef
Pdf.EnableShapingFeatureForSubset('rclt');
Pdf.CurrentPage.RtLTextOut(50, 700, 0, WideString(ArabicText));
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
EnableShapingFeatureForSubset è la controparte in batch della chiamata a singolo GID, ed è deliberatamente conservativa. Percorre l'elenco di lookup GSUB per le lookup collegate a un tag feature di quattro byte sotto il percorso di script e lingua attualmente selezionato, e marca i GID sostitutivi che quelle lookup possono produrre. È un no-op difensivo quando il font non porta alcuna tabella GSUB o quando la feature è assente da quel percorso, quindi chiamarla incondizionatamente è sicuro. È anche una sovrastima per progetto: può mantenere glifi che un dato documento non disegna mai. Per il subsetting, l'inclusione eccessiva costa byte e l'inclusione insufficiente costa correttezza, il che rende quel compromesso facile. La struttura di queste lookup, e le tabelle di copertura che decidono quali glifi partecipano, è trattata in la trattazione degli alternativi stilistici GSUB in puro Delphi
Come dimostri che il glifo è davvero nel subset?
Leggendo il font emesso, non guardando a occhio la pagina in un visualizzatore che potrebbe star sostituendo un font di sistema alle tue spalle. Il controllo che cattura questa intera classe di bug è meccanico: estrai lo stream /FontFile2 dal PDF di output, analizza loca, e conferma che il GID che ti aspetti porta una voce non vuota, cioè i suoi offset di inizio e fine differiscono. Una voce vuota è il subsetter che ha deciso che il glifo non è usato. Due abitudini rendono poi molto più difficile spedire di nuovo il fallimento. Mantieni una pagina in script shaped nel corpus di smoke test automatizzato anziché solo nel set di proofing manuale, perché arabo, devanagari e khmer esercitano percorsi di closure che nessuna quantità di copertura latina toccherà mai. E ogni volta che esiste un accumulatore, verifica che qualcosa lo consumi, poiché un campo solo-scrittura è una funzionalità che compila, passa i test verdi sul corpus sbagliato, e non fa nulla
Dove si ferma la correzione
La closure del subset è necessaria perché un glifo shaped venga renderizzato, ma non è sufficiente. Il glifo deve anche essere indirizzabile dal content stream, il che è un problema separato con il proprio confine. I passaggi di raffinamento arabo integrati in HotPDF confermano una sostituzione solo quando ogni GID sostitutivo è raggiungibile tramite un code point di forma di presentazione Unicode via una scansione cmap inversa su circa 690 code point in U+FB50-U+FDFF e U+FE70-U+FEFF. Quando un sostituto atterra su un GID fuori da quell'intervallo, la finestra di input passa invariata anziché emettere qualcosa che il lettore non può indirizzare; gli alternativi specifici del font a GID arbitrari necessitano di un code point sintetico ad uso privato allocato in U+E000-U+F8FF per trasportarli lungo il percorso di emissione. Quindi il riepilogo onesto è che la correzione 2.435.0 ha rimosso un blocco rigido anziché completare la storia. Prima di essa, un glifo poteva essere shaped correttamente, emesso correttamente, e comunque svanire al momento del subsetting, il che significava che il motore di shaping non poteva essere considerato affidabile end-to-end per quanto buone fossero le sue lookup. Ciò che rimane è l'indirizzabilità, e quel vincolo almeno fallisce visibilmente nel punto di emissione anziché silenziosamente in un passo di build eseguito dopo tutto ciò che stavi osservando. Per il lato emissione della stessa pipeline, vedi la guida allo shaping del testo arabo e RTL nei PDF Delphi
Il subsetting dei font, il motore GSUB, e lo shaping per script complessi descritti qui vengono forniti nel componente standard HotPDF Component per Delphi e C++Builder; la pagina prodotto riporta il riferimento API completo per le chiamate ai font Unicode e allo shaping nominate sopra