Artículo técnico

Subconjuntar fuentes ya incrustadas en un PDF desde Delphi

PDFium Component subconjunta fuentes que ya están incrustadas en un PDF existente a través de SubsetPdfDocumentFonts en la unidad FPdfDocumentFontSubset. La función reemplaza un programa /FontFile2 TrueType incrustado por uno más pequeño solo cuando los recursos de la propia fuente prueban cada glifo que el documento puede referenciar; una fuente cuyo dominio de glifos no se puede probar se deja exactamente como estaba

La razón habitual para querer esto es un archivo que usted no produjo. Las facturas, los estados de cuenta y los reportes de generadores viejos suelen llevar un Arial completo o una fuente CJK completa en cada archivo, y unos cuantos cientos de kilobytes de fuente por documento de dos páginas se acumulan rápido a lo largo de unos millones de documentos. Volver a renderizar desde el origen rara vez es una opción. Lo que usted tiene es el PDF, y la única pregunta segura es cuánto de cada fuente incrustada puede irse sin que una sola página pierda un carácter

¿Por qué subconjuntar un PDF existente arriesga perder glifos?

Subconjuntar un PDF existente arriesga perder glifos porque el postprocesador no sabe qué glifos usó el escritor original. El productor que incrustó la fuente conocía su propio texto; una herramienta que corre después tiene que reconstruir ese conocimiento, y cualquier glifo que se le escape se convierte en un recuadro vacío o una forma .notdef en una página que nadie mira hasta que la mira un cliente

El enfoque obvio es parsear cada content stream, cada Form XObject, cada apariencia de anotación y cada apariencia por defecto de campo, recolectar los códigos mostrados con cada fuente, y conservar esos glifos. Funciona hasta que se topa con un recurso de fuente compartido entre páginas y forms, un campo de formulario cuya apariencia un visor regenera desde /DA, o un content stream con una secuencia de operadores que el escáner no modela. SubsetPdfDocumentFonts deliberadamente no escanea content streams. Deriva el dominio de glifos de los metadatos que el PDF tiene que llevar de todos modos sobre cada fuente: el /CIDToGIDMap, los arreglos de anchos /W y /W2, el bitmap /CIDSet, la CMap /ToUnicode, el arreglo /Widths de fuente simple y el /Encoding. Cuando esos metadatos no acotan el dominio, la fuente queda intacta

Cómo PDFium Component prueba el dominio de glifos al subconjuntar fuentes TrueType incrustadas desde Delphi: /CIDToGIDMap, los anchos /W y /W2, /CIDSet, /ToUnicode, /Widths y /Encoding acotan el dominio sin escanear content streams, y una fuente no demostrable se preserva intacta
SubsetPdfDocumentFonts convierte los metadatos de la fuente en un dominio de glifos probado antes de cortar nada, y una fuente cuyos recursos no pueden acotar el dominio se reporta y se deja intacta

Conservar los IDs de glifo originales para que los content streams queden intactos

El backend de subconjuntos detrás de SubsetPdfDocumentFonts, TPdfTrueTypeSubsetBackend, conserva cada hueco de ID de glifo original, así que ningún content stream, CID ni /CIDToGIDMap tiene que reescribirse jamás. Reconstruye las tablas glyf y loca, le da a cada glifo sin usar un rango loca de largo cero y deja maxp.numGlyphs sin cambios. El glifo 4711 sigue siendo el glifo 4711 tras el subconjuntado; solo que tiene contornos si el documento puede alcanzarlo

Esa elección elimina la parte más riesgosa de subconjuntar un documento terminado. Renumerar glifos significaría reescribir cada cadena en cada content stream que use la fuente, más el stream de mapeo, más cualquier cosa que cachee IDs de glifo. Los IDs retenidos cuestan un poco de tamaño, porque loca guarda una entrada por glifo original, pero el grueso de un programa TrueType son datos de contorno, y eso es lo que se va. Los glifos compuestos se siguen hasta sus componentes, así que una letra acentuada conserva la letra base y el acento con el que se construye; las referencias compuestas cíclicas o demasiado profundas hacen fallar la fuente en lugar de producir un cierre roto

IDs de glifo retenidos tras subconjuntar fuentes TrueType incrustadas con PDFium Component en Delphi: el glifo 4711 conserva su número y contornos, los glifos sin uso reciben rangos loca de largo cero mientras maxp.numGlyphs queda sin cambios, así los content streams, los CIDs y el /CIDToGIDMap nunca se reescriben
Como los IDs de glifo sobreviven al subconjunto, los content streams, los CIDs y el /CIDToGIDMap nunca necesitan reescribirse - la ganancia de tamaño viene de los contornos descartados, no de la renumeración

La fuente renombrada también sigue ISO 32000-1 §9.6.4, que exige que el nombre de una fuente de subconjunto empiece con seis letras mayúsculas y un signo más. El componente deriva las seis letras de un digest SHA-256 del nuevo programa de fuente, quita primero cualquier prefijo ABCDEF+ existente, resuelve colisiones entre fuentes del mismo documento, y escribe el nuevo nombre tanto en el /FontName del descriptor como en el /BaseFont del diccionario de fuente, incluido el padre Type0 de un CIDFont

uses
  System.SysUtils, System.Classes, FPdfDocumentFontSubset;

procedure SubsetEmbeddedFonts(const SourceName, TargetName: string);
var
  Source, Target: TFileStream;
  Options: TPdfDocumentFontSubsetOptions;
  Report: TPdfDocumentFontSubsetReport;
  Output: TBytes;
begin
  Options := TPdfDocumentFontSubsetOptions.Default;
  Source := TFileStream.Create(SourceName, fmOpenRead or fmShareDenyWrite);
  try
    if not SubsetPdfDocumentFonts(Source, Options, Output, Report) then
      raise Exception.Create('Font subsetting failed: ' + Report.ErrorMessage);
  finally
    Source.Free;
  end;
  // La salida siempre es un PDF completo: el archivo reconstruido cuando Report.Applied
  // es True, de lo contrario una copia byte a byte del origen
  Target := TFileStream.Create(TargetName, fmCreate);
  try
    if Length(Output) > 0 then
      Target.WriteBuffer(Output[0], Length(Output));
  finally
    Target.Free;
  end;
end;

¿Cómo prueba SubsetPdfDocumentFonts el dominio de glifos de un CIDFont?

SubsetPdfDocumentFonts prueba el dominio de glifos de una fuente CIDFontType2 a partir de su mapeo CID a glifo, y el mapeo decide cuánta evidencia extra hace falta. Un stream /CIDToGIDMap explícito (ISO 32000-1 §9.7.4.2) ya es una lista de cada glifo que la fuente puede direccionar, así que cada GID en el stream entra al dominio. Un stream de mapeo de más de 65536 entradas, o uno con conteo de bytes impar, hace fallar el perfil acotado y la fuente se preserva

/CIDToGIDMap /Identity es el caso difícil, y también el más común en archivos modernos. Identity dice que CID es igual a GID, lo que no le dice nada sobre qué CIDs usa el documento. El componente por tanto exige evidencia primaria: un arreglo de anchos /W o /W2 explícito (§9.7.4.3), cuyas entradas CID marcan glifos para los que el productor declaró métricas, o un stream /CIDSet en el descriptor de fuente (§9.8.3), un bitmap con un bit por CID, bit más significativo primero. Una fuente Identity pelada sin ninguno de los dos se preserva con la razón Identity CIDToGIDMap requires an explicit W array or CIDSet stream

La trampa de usar /W como evidencia es /DW. A un productor se le permite omitir todo CID cuyo ancho sea igual al ancho por defecto, y un escritor bien optimizado hace exactamente eso con los anchos de glifo más comunes. Si el dominio viniera solo de /W, esos glifos desaparecerían silenciosamente del subconjunto. Para cerrar ese hueco el componente recorre cada fuente Type0 cuyo /DescendantFonts apunta al CIDFont, exige que su /Encoding sea /Identity-H o /Identity-V para que un código de 2 bytes sea el CID, y mezcla los códigos de origen de cada entrada beginbfchar y beginbfrange de su CMap /ToUnicode (§9.10.3) dentro del dominio. Cualquier otra codificación, o un stream /ToUnicode que no se pueda decodificar dentro del presupuesto, preserva la fuente

Decisión de evidencia para un CIDToGIDMap Identity en el subconjuntado de fuentes de PDFium Component para Delphi: un stream de mapeo explícito ya lista cada glifo, Identity exige un arreglo de anchos /W o /W2 o un bitmap /CIDSet, y los códigos de origen /ToUnicode cierran el punto ciego del ancho por defecto /DW
Un CIDToGIDMap Identity no dice nada sobre el uso por sí solo, así que el subsetter exige evidencia /W o /CIDSet y mezcla códigos /ToUnicode para cubrir los glifos que /W omite en /DW

Fuentes TrueType simples: de Widths a IDs de glifo

Las fuentes de un byte /Subtype /TrueType se prueban a través de su rango de códigos y su codificación, y luego se resuelven a IDs de glifo por el cmap del propio programa de fuente. El componente exige 0 <= FirstChar <= LastChar <= 255 y un arreglo /Widths cuyo largo sea exactamente LastChar - FirstChar + 1; un arreglo corto, largo o no numérico significa que el dominio de códigos no está probado, y la fuente se queda como está

Cada código en ese rango, más cualquier código nombrado en /Differences o cubierto por /ToUnicode, se mapea luego como describe ISO 32000-1 §9.6.6. Los códigos bajo /WinAnsiEncoding, /MacRomanEncoding o /StandardEncoding pasan por la tabla correspondiente hacia Unicode. Los nombres de glifo de /Differences se resuelven por la Adobe Glyph List, los nombres uniXXXX y uXXXX, los 258 nombres de glifo estándar de Macintosh y la tabla de nombres post formato 2.0 de la propia fuente. Los valores resultantes se buscan en subtablas cmap de formato 0, 4, 6 y 12, incluidas Unicode (3,1), (3,10) y (0,*), Mac Roman (1,0), y Windows Symbol (3,0) en F000 más el código

Cuando una fuente no tiene ninguna codificación base, el componente marca la unión de toda interpretación plausible en lugar de adivinar una. Eso conserva unos cuantos glifos que probablemente no se usan, que es la dirección correcta para equivocarse. El caso opuesto es estricto: un nombre de /Differences que no mapea a ningún ID de glifo probado rechaza toda la fuente, porque un nombre sin resolver significa que el dominio está incompleto

procedure ReportFontSubset(Source: TStream; Log: TStrings);
var
  Options: TPdfDocumentFontSubsetOptions;
  Report: TPdfDocumentFontSubsetReport;
  Output: TBytes;
  I: Integer;
begin
  Options := TPdfDocumentFontSubsetOptions.Default;
  Options.SubsetOptions.MinimumSavingPercent := 10;  // por fuente
  Options.MinimumDocumentSavingPercent := 5;         // archivo completo
  if not SubsetPdfDocumentFonts(Source, Options, Output, Report) then
  begin
    Log.Add('Rejected: ' + Report.ErrorMessage);
    Exit;
  end;
  Log.Add(Format('Fonts %d, subset %d, preserved %d, glyphs %d',
    [Report.FontCount, Report.SubsetFontCount, Report.PreservedFontCount,
     Report.GlyphCount]));
  Log.Add(Format('Font bytes %d -> %d, file bytes %d -> %d',
    [Report.OriginalFontBytes, Report.SubsetFontBytes,
     Report.SourceByteCount, Report.OutputByteCount]));
  for I := 0 to High(Report.Issues) do
    Log.Add(Format('  object %d kept: %s',
      [Report.Issues[I].ObjectNumber, Report.Issues[I].Reason]));
end;

¿Qué deja intacto SubsetPdfDocumentFonts?

SubsetPdfDocumentFonts deja intacta cada fuente que no puede probar, y reporta cada una en Report.Issues con el número de objeto y una razón en lugar de hacer fallar el documento. Report.Complete es True solo cuando ninguna fuente se preservó. Los casos preservados valen conocerlos antes de prometer una reducción de tamaño:

  • Programas Type 1 /FontFile y CFF u OpenType /FontFile3, reportados como Only TrueType FontFile2 programs are supported
  • CIDFonts Identity sin /W, /W2 ni /CIDSet, y CIDFonts sin stream de mapeo ni /Identity
  • Programas de fuente referenciados desde otro lugar distinto de sus descriptores de fuente, y descriptores compartidos con un consumidor que no es CIDFontType2 ni TrueType simple
  • Fuentes cuyo subconjunto no alcanza SubsetOptions.MinimumSavingPercent, y todas las fuentes cuando el archivo reconstruido no alcanza MinimumDocumentSavingPercent; en ese caso Report.Applied es False y la salida son los bytes originales

Dos entradas fallan de plano en lugar de preservarse. Los documentos firmados y los documentos con campos de firma se rechazan, porque reemplazar objetos de fuente invalidaría el rango de bytes de cada firma, y los archivos cifrados necesitan primero un origen descifrado. Una corrida exitosa escribe los objetos de reemplazo como una actualización dispersa y luego reconstruye el archivo a través de la misma pasada de alcanzabilidad descrita en la guía para podar objetos huérfanos y deduplicar recursos, así los programas de fuente viejos no sobreviven como objetos inalcanzables y el resultado es una única revisión limpia

Subconjuntar texto nuevo al guardar con saSubsetNewFonts

Las fuentes que PDFium Component incrusta durante la sesión actual son un caso aparte, manejado por la opción de guardado saSubsetNewFonts en lugar de por SubsetPdfDocumentFonts. saSubsetNewFonts se mapea al flag de PDFium FPDF_SUBSET_NEW_FONTS, que corre el subsetter nativo de HarfBuzz sobre las fuentes que llamadas nativas como TPdf.AddText cargaron del sistema, incluidas las caras CFF de OpenType. El PDFium de origen todavía marca el flag como experimental. No toca las fuentes que ya estaban en el archivo

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    Pdf.AddPage(0, 595, 842);
    Pdf.AddText('Quarterly statement', 'Arial', 14, 72, 770, clBlack, $FF, 0.0);
    if not Pdf.SaveAs('statement.pdf', saSubsetNewFonts) then
      raise Exception.Create('Save failed');
  finally
    Pdf.Free;
  end;
end;

En la sonda del componente, un documento con texto Arial bajó de 590 KB a 22 KB bajo saSubsetNewFonts, y la extracción de texto del archivo guardado siguió coincidiendo exactamente. Una fuente de color COLR v0, Segoe UI Emoji de 7.0 MB, pasó por la misma ruta hasta 10.5 KB, renderizada como sus contornos base ya que PDF no tiene noción de capas COLR. Las fuentes variables son la excepción, y el bloque se pone delante del subsetter: el GetFontData de GDI devolvió un stream sustituto de 18.3 MB para el archivo Segoe UI Variable de 1.85 MB, y la creación del objeto de texto falló antes de que comenzara cualquier subconjuntado. El recorrido de la incrustación de fuentes CID Type 2 desde Delphi cubre la ruta de incrustación que esas fuentes atraviesan primero

Dónde se detiene la prueba

La prueba dentro de SubsetPdfDocumentFonts es exactamente tan fuerte como los metadatos de fuente que el productor escribió. Un CIDFont Identity cuya evidencia es solo /W todavía tiene un punto ciego: un glifo cuyo ancho es igual a /DW, que no tiene entrada /ToUnicode, y que el descriptor no lista en un /CIDSet. Tal glifo no es demostrable desde los recursos, y el componente no puede saber que existe sin el escaneo de content streams que evita a propósito. Los productores que omiten tanto /ToUnicode como /CIDSet mientras comprimen /W son raros, pero existen, así que para un archivo de un generador desconocido, renderice una muestra antes y después y compare píxeles y texto extraído

Antes de correr un lote también ayuda saber con qué está lidiando. El enfoque de leer propiedades de fuentes PDF con PDFium Component le dice qué fuentes están incrustadas siquiera, y el Report.Issues de una corrida en seco le dice cuáles de ellas se preservarán y por qué. Las opciones por defecto también acotan el trabajo: 1024 fuentes, cerca de un millón de glifos en total, 128 MB de origen y un presupuesto de trabajo fijo, con un CancellationToken opcional para trabajos por lotes largos

FAQ

¿SubsetPdfDocumentFonts cambia el texto o el layout del PDF?

No. Los IDs de glifo originales, los CIDs, los anchos y los content streams se conservan, así que la extracción de texto y el renderizado usan los mismos glifos que antes; solo se quitan los contornos inalcanzables y los nombres de fuente reciben un nuevo prefijo de subconjunto

¿Por qué una fuente incrustada no se subconjuntó?

Lea el Report.Issues. Cada fuente preservada lleva una razón como un /W o /CIDSet faltante en un CIDFont Identity, un programa no TrueType, un arreglo /Widths malformado o un ahorro por debajo del umbral

¿saSubsetNewFonts es lo mismo que SubsetPdfDocumentFonts?

No. saSubsetNewFonts subconjunta fuentes incrustadas en la sesión actual al guardar, mientras que SubsetPdfDocumentFonts trabaja sobre un stream PDF existente y reemplaza fuentes que otro productor ya había incrustado

El subconjuntado de fuentes de documento, el subconjuntado de fuentes al guardar y la pasada de poda de huérfanos detrás de todos ellos vienen con el PDFium Component for Delphi y C++Builder, que envuelve PDFium para proyectos Delphi, C++Builder y Lazarus