Technical Article

Il bug in EndDoc che disabilitava silenziosamente il subsetting dei font

Genera un report, incorpora un font TrueType e l'output si aprirà correttamente in qualsiasi visualizzatore. I glifi sono corretti, il testo è selezionabile, il file è valido. L'unica cosa errata è la dimensione. Un documento che utilizza poche decine di caratteri latini porta con sé l'intero font da 350 KB. Un documento che stampa un paragrafo in cinese include un font CJK da 14 MB anziché la porzione da mezzo megabyte effettivamente necessaria. Nessuna eccezione sollevata, nessun avviso registrato e il file supera la convalida. Questo è l'aspetto esterno di un passaggio di finalizzazione non ordinato correttamente: nulla fallisce e l'unica prova è un numero troppo grande.

Il bug che lo produceva è rimasto in HotPDF per una linea di rilascio ed è stato corretto. Vale la pena parlarne non come avviso di difetto, ma come lezione, perché la natura dell'errore è generale. Qualsiasi motore di documenti ha una fase di finalizzazione che modifica gli oggetti appena prima di scriverli, e la correttezza di questa fase dipende interamente dall'ordinamento dei suoi passaggi rispetto alla serializzazione. Se un passaggio finisce dalla parte sbagliata della scrittura, non farà nulla, in silenzio.

Cosa dovrebbe fare il subsetting dei font

Un font sottoinsieme (subset) è la parte di un file TrueType effettivamente utilizzata da un documento. La norma ISO 32000-1 §9.9 descrive come un programma di font incorporato risieda in uno stream a cui fa riferimento il descrittore del font, e per un programma TrueType tale stream è /FontFile2 con un valore /Length1 che indica il conteggio dei byte non compressi. Il subsetting riscrive le tabelle glyf e loca in modo che contengano solo i glifi a cui fa riferimento il documento, rinumera gli identificatori dei glifi e antepone al nome /BaseFont un tag di sei lettere come ABCDEF+ per contrassegnare il font come sottoinsieme, esattamente come richiesto dalle specifiche. Un font latino che viene ridotto a dieci o quindici kilobyte fa la differenza tra un PDF leggero e uno che include un intero carattere per un solo titolo.

Il momento in cui questo avviene è fondamentale. Il subsetting non è una trasformazione applicata ai byte già su disco. Modifica il grafo degli oggetti in memoria: riduce il contenuto dello stream /FontFile2, sistema /Length1 e riscrive la stringa /BaseFont. Tutto questo deve essere pronto quando il serializzatore scorre il grafo ed emette i byte. Se le modifiche avvengono dopo la scrittura dei byte, aggiorneranno oggetti che nessuno leggerà mai.

Il sintomo e perché non ci sono stati errori

Il comportamento segnalato consisteva nell'inclusione di font completi nell'output senza alcuna diagnostica. Un utente che registrava un font TrueType Unicode e generava un documento normale notava che l'oggetto del font incorporato aveva la stessa lunghezza del file .ttf di origine e che il nome /BaseFont non conteneva il prefisso di sei lettere del subset. L'output non si riduceva mai, sia che si utilizzassero dieci glifi sia che se ne usassero diecimila.

L'assenza di qualsiasi errore è ciò che rende questa classe di bug costosa. Una routine di subsetting che viene eseguita al momento sbagliato viene comunque avviata. Analizza l'uso dei punti di codice accumulati, crea un subset perfettamente corretto e lo applica al grafo degli oggetti in memoria. Internamente il lavoro viene svolto e la chiamata termina correttamente. L'unica cosa errata è che il grafo degli oggetti modificato non è più quello scritto, poiché il writer ha già terminato. Dal punto di vista del chiamante, il documento è stato generato e salvato senza problemi, che è esattamente l'impressione data da un fallimento silenzioso.

La causa principale era l'ordine di finalizzazione

In HotPDF il lavoro di chiusura avviene all'interno di EndDoc. Il passaggio di subsetting è una routine interna denominata BuildAndApplyUnicodeFontSubset. Legge l'insieme dei punti di codice utilizzati per documento, memorizzato in una bitmap popolata dal percorso di emissione del testo man mano che i glifi vengono mostrati, mappa ogni punto di codice utilizzato tramite la tabella dei punti di codice in glifi memorizzata nella cache in un identificatore di glifo reale e riscrive il programma del font attorno a tale chiusura. Quando viene registrato un font TrueType Unicode, il percorso di emissione imposta un bit nell'insieme dei punti di codice utilizzati per ogni carattere disegnato, in modo che alla chiusura del documento il motore sappia esattamente quali glifi conservare nel subset.

Il difetto era che BuildAndApplyUnicodeFontSubset veniva invocato dopo che SaveToStream o SaveToFile avevano già serializzato il documento. Le modifiche del subsetter a /FontFile2, la lunghezza corretta /Length1 e il prefisso di sei lettere di /BaseFont venivano calcolati su un grafo degli oggetti già trasformato in byte. La correzione è consistita nel riordinare una riga: spostare la chiamata al subset prima della serializzazione, in modo che il writer emetta il font ridotto anziché l'originale. La sequenza corretta esegue prima il subsetter e poi serializza.

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
    Pdf.CurrentPage.TextOut(72, 760, 0, '报表标题 Report Heading');
    Pdf.EndDoc;                 // subsetting runs here, before the write
    Pdf.SaveToFile('Report.pdf');
  finally
    Pdf.Free;
  end;
end;

Con l'ordine corretto, il codice chiamante non cambia in alcun modo. Il subsetting è attivo per impostazione predefinita una volta registrato un font TrueType Unicode. Si registra il font, si avvia il documento, si disegna e lo si chiude; il subset viene compilato partendo dai glifi usati prima che i byte vengano scritti in memoria.

Perché un singolo passaggio fuori posto rappresenta un'intera categoria

Il motivo per cui questa è una lezione e non una semplice nota a piè di pagina è che EndDoc esegue una serie di passaggi di chiusura, e ognuno di essi è sensibile alla sua posizione rispetto alla scrittura. Il subsetting dei font è uno di questi. L'output PDF/A richiede uno stream /CIDSet stream che elenchi esattamente gli identificatori dei glifi presenti nel subset, un vincolo imposto da ISO 19005 affinché un validatore possa confermare che il programma incorporato corrisponda a quanto dichiarato dal descrittore del font; tale stream viene emesso nella stessa finestra di finalizzazione e dipende dal fatto che il subset sia stato creato prima. La specifica PDF/UA-1 richiede, secondo ISO 14289-1 §7.18.3, che ogni pagina contenente un'annotazione dichiari /Tabs con il valore /S, e una routine interna chiamata EnsurePDFUATabsOnAnnotatedPages scrive tale chiave durante la stessa fase. Anche i controlli sull'intento di output vengono eseguiti qui.

Lo stesso errore di ordinamento che disabilitava il subsetting escludeva anche la chiave dell'ordine di tabulazione PDF/UA sulle pagine annotate, poiché tale passaggio si trovava dallo stesso lato sbagliato della scrittura. veraPDF e PAC segnalano la mancanza di /Tabs /S come violazione del checkpoint 21-001 del protocollo Matterhorn. Quindi, una singola chiamata fuori posto non ha solo gonfiato la dimensione del file; ha anche violato silenziosamente un requisito di conformità dell'accessibilità, senza generare alcun errore. Questo è il rischio di una fase di finalizzazione: i suoi passaggi condividono una precondizione e un singolo errore di ordinamento può comprometterne diversi contemporaneamente, mentre ogni chiamata restituisce comunque un esito positivo.

Come viene effettivamente rilevato un errore di emissione silenzioso

Un bug che non solleva eccezioni non viene rilevato eseguendo il programma. Viene individuato esaminando l'output e confrontandolo con ciò che l'input avrebbe dovuto produrre. Per il subsetting dei font, i controlli sono concreti. Confronta le dimensioni del file di output con un valore atteso approssimativo: un documento che utilizza pochi glifi non dovrebbe avere le dimensioni di un carattere completo. Apri l'oggetto font incorporato e leggi la sua lunghezza in byte; un /FontFile2 ridotto per un carattere latino è una piccola frazione del file di origine. Leggi il nome /BaseFont e conferma la presenza del prefisso di sei lettere, poiché la sua assenza è un segnale diretto che non è stato applicato alcun subset.

var
  Pdf: THotPDF;
  Output: TMemoryStream;
begin
  Output := TMemoryStream.Create;
  try
    Pdf := THotPDF.Create(nil);
    try
      Pdf.RegisterUnicodeTTF('C:\Fonts\DejaVuSans.ttf');
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('DejaVu Sans', [], 11);
      Pdf.CurrentPage.TextOut(72, 760, 0, 'Subset me');
      Pdf.EndDoc;
      Pdf.SaveToStream(Output);
    finally
      Pdf.Free;
    end;
    // A few glyphs from a ~700 KB face must not yield a multi-hundred-KB stream.
    if Output.Size > 100 * 1024 then
      raise Exception.Create('Font subset did not shrink the output');
  finally
    Output.Free;
  end;
end;

Per l'output PDF/A il controllo è ancora più preciso, poiché un validatore svolge il lavoro al posto tuo. Imposta il livello di conformità ed esegui il risultato tramite veraPDF: un /CIDSet mancante, o un subset che non corrisponde al descrittore, viene segnalato come clausola non superata anziché essere lasciato alla tua osservazione visiva. I selettori di conformità che guidano questo lavoro di finalizzazione sono proprietà del documento. PDFACompliance accetta una stringa come '2B' per PDF/A-2 Livello B, e PDFUACompliance è un booleano che attiva i requisiti per il PDF taggato e l'ordine di tabulazione.

Pdf := THotPDF.Create(nil);
try
  Pdf.PDFACompliance := '2B';     // PDF/A-2 Level B, drives /CIDSet emission
  Pdf.PDFUACompliance := True;    // stamps /Tabs /S on annotated pages
  Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
  Pdf.BeginDoc;
  Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
  Pdf.CurrentPage.TextOut(72, 760, 0, '合规报告');
  Pdf.EndDoc;
  Pdf.SaveToFile('Report_PDFA.pdf');
finally
  Pdf.Free;
end;

La lezione di ingegneria

Da questo derivano due regole. La prima è che qualsiasi passaggio di finalizzazione che modifichi gli oggetti deve essere eseguito prima della serializzazione di tali oggetti, e la fase di chiusura di un motore di documenti deve essere interpretata come una pipeline ordinata in cui la serializzazione is l'ultima azione, non un'azione tra le altre. La seconda è quella che ha richiesto più tempo in questo caso: per una fase di emissione, l'assenza di un errore non è prova di successo. Una routine che crea il subset corretto e lo applica al grafo errato, già scritto, non segnala anomalie perché dal proprio punto di vista tutto era corretto. La verifica deve esaminare l'artefatto, non il codice di ritorno. Controlla le dimensioni dell'output, leggi la lunghezza in byte del font incorporato e il suo prefisso /BaseFont, e lascia che veraPDF valuti l'output PDF/A in cui un /CIDSet mancante trasforma una carenza silenziosa in un errore dichiarato.

La gestione dei font lato produttore, inclusa la registrazione e l'incorporamento dei caratteri per l'output dei report, è trattata nel nostro articolo sui font e le immagini nell'output dei report. La convalida, in cui questi passaggi di finalizzazione vengono verificati rispetto agli standard, è descritta nella guida alla convalida PDF/A e PDF/UA. Entrambi si affiancano al lavoro su subsetting e conformità descritto qui, fornito come parte di HotPDF Component per Delphi e C++Builder, insieme alle API di caricamento, modifica, crittografia e firma trattate altrove in questo blog.