Artículo técnico

¿Glifos CJK erróneos con OTF? Revisa el charset CFF

Una factura china compuesta en Noto Sans SC desde su fichero .otf puede abrirse en mutool con ideogramas que no pintan nada donde debería ir el nombre del cliente, mientras que una fuente TrueType dibuja la misma página sin fallo. El culpable es el charset CFF de la fuente; PDFlibPas lo reescribe a la identidad en la copia incrustada, así que los códigos de índice de glifo llegan al glifo previsto

Que una fuente sufra el problema depende de cómo se construyó. Un programa TrueType incrustado como fuente CIDFontType2 recibe un CIDToGIDMap de tipo Identity, y el código del content stream aterriza en el glifo buscado. La mayoría de las fuentes latinas OpenType con contornos CFF son name-keyed, y un programa CFF name-keyed usa el CID entrante directamente como índice de glifo. ISO 32000-1 llega a señalar en §9.7.4.2 que CID y GID coinciden en muchas fuentes. No en todas. Las grandes familias CJK construidas como fuentes CID-keyed — Noto Sans CJK y sus hermanas Source Han entre ellas — llevan su propia numeración de CID, y un writer que manda los índices de glifo directo a esa numeración se lleva el glifo que casualmente tenga el CID correspondiente

Qué hace un programa CFF CID-keyed con un CID

ISO 32000-1 §9.7.4.2 divide los programas CFF incrustados en dos casos según el Top DICT. Si el Top DICT usa los operadores CIDFont — en la práctica, abre con ROS (operador 12 30: registry, ordering, supplement) — el lector toma el CID del content stream, busca el glifo cuya entrada del charset lleva ese CID y solo entonces va a por el contorno al CharStrings INDEX. Sin operadores CIDFont, el CID es el índice de glifo y el charset no pinta nada. Cualquier lector que siga §9.7.4.2 al pie de la letra dibujará el glifo equivocado para un código de índice de glifo siempre que el charset no sea la identidad, uses el viewer que uses. El charset en sí, definido en Adobe Technical Note #5176, viene en tres formatos con dos casos especiales:

PDFlibPas incrustando fuentes CJK en Delphi: búsqueda por charset CFF CID-keyed según ISO 32000-1 §9.7.4.2 que dibuja el glifo equivocado para códigos de índice de glifo salvo que el charset incrustado sea la identidad
Los códigos de índice de glifo llegan al contorno previsto en una fuente CFF CID-keyed solo cuando su charset mapea cada glifo al CID del mismo número
  • El formato 0 guarda un SID o CID de 2 bytes por glifo, en orden de glifo
  • El formato 1 guarda rangos: un primer CID seguido de un contador de 1 byte con los glifos que le siguen con CIDs consecutivos
  • El formato 2 es el mismo diseño de rangos con un contador de 2 bytes, ideal para fuentes con series largas sin cortes
  • El glifo 0 es .notdef y no tiene entrada en ningún formato; un offset de charset de 0, 1 o 2 nombra uno de los charsets predefinidos en lugar de apuntar a datos

Así que lo primero es averiguar qué clase de fuente tienes. Identity-H en el lado PDF solo dice que un código de 2 bytes es igual al CID; cómo ese CID llega a un glifo vive por entero dentro del programa de la fuente. La función de abajo lee la tabla CFF de un .otf, recorre el Top DICT en busca de ROS y comprueba si el charset mapea cada glifo a un CID con el mismo número. Solo necesita la RTL

uses
  System.SysUtils, System.IOUtils;

type
  TCFFCharsetReport = record
    IsCFF, CIDKeyed, IdentityCharset: Boolean;
    CharsetFormat, GlyphCount: Integer;
  end;

{$RANGECHECKS ON}  // un fichero truncado lanza ERangeError en lugar de leer mal
function InspectOtfCharset(const FileName: string): TCFFCharsetReport;
var
  B: TBytes;
  NumTables, I, Cff, P, Dict, DictEnd, Unused1, Unused2: Integer;
  Op, Operand, CharsetOfs, CharStringsOfs, G, Left: Integer;

  function BE(Pos, Count: Integer): Integer;
  var
    K: Integer;
  begin
    Result := 0;
    for K := 0 to Count - 1 do
      Result := (Result shl 8) or B[Pos + K];
  end;

  // Devuelve el final del INDEX situado en Pos y acota su primer objeto
  function SkipIndex(Pos: Integer; out ObjStart, ObjEnd: Integer): Integer;
  var
    Count, OffSize, Base: Integer;
  begin
    Count := BE(Pos, 2);
    if Count = 0 then
    begin
      ObjStart := Pos + 2;
      ObjEnd := ObjStart;
      Exit(Pos + 2);
    end;
    OffSize := B[Pos + 2];
    Base := Pos + 2 + (Count + 1) * OffSize;  // los offsets son 1-based desde aquí
    ObjStart := Base + BE(Pos + 3, OffSize);
    ObjEnd := Base + BE(Pos + 3 + OffSize, OffSize);
    Result := Base + BE(Pos + 3 + Count * OffSize, OffSize);
  end;

begin
  Result := Default(TCFFCharsetReport);
  Result.CharsetFormat := -1;
  B := TFile.ReadAllBytes(FileName);
  if (Length(B) < 12) or (BE(0, 4) <> $4F54544F) then  // 'OTTO'
    Exit;
  Cff := -1;
  NumTables := BE(4, 2);
  for I := 0 to NumTables - 1 do
    if BE(12 + I * 16, 4) = $43464620 then               // 'CFF '
      Cff := BE(12 + I * 16 + 8, 4);
  if Cff < 0 then
    Exit;
  Result.IsCFF := True;
  P := SkipIndex(Cff + B[Cff + 2], Unused1, Unused2);    // Name INDEX
  SkipIndex(P, Dict, DictEnd);                           // Top DICT INDEX
  Operand := 0;
  CharsetOfs := 0;
  CharStringsOfs := 0;
  P := Dict;
  while P < DictEnd do
  begin
    Op := B[P];
    case Op of
      12:
        begin
          if B[P + 1] = 30 then
            Result.CIDKeyed := True;                     // ROS
          Inc(P, 2);
        end;
      0..11, 13..21:
        begin
          if Op = 15 then
            CharsetOfs := Operand
          else if Op = 17 then
            CharStringsOfs := Operand;
          Inc(P);
        end;
      28: begin Operand := SmallInt(BE(P + 1, 2)); Inc(P, 3); end;
      29: begin Operand := BE(P + 1, 4); Inc(P, 5); end;
      30:
        begin                                            // número real
          repeat
            Inc(P);
          until ((B[P] and $F) = $F) or ((B[P] shr 4) = $F);
          Inc(P);
        end;
      32..246: begin Operand := Op - 139; Inc(P); end;
      247..250: begin Operand := (Op - 247) * 256 + B[P + 1] + 108; Inc(P, 2); end;
      251..254: begin Operand := -(Op - 251) * 256 - B[P + 1] - 108; Inc(P, 2); end;
    else
      Exit;                                              // byte reservado
    end;
  end;
  if (not Result.CIDKeyed) or (CharsetOfs <= 2) or (CharStringsOfs <= 0) then
    Exit;
  Result.GlyphCount := BE(Cff + CharStringsOfs, 2);
  P := Cff + CharsetOfs;
  Result.CharsetFormat := B[P];
  if Result.CharsetFormat > 2 then
    Exit;
  Inc(P);
  Result.IdentityCharset := True;
  G := 1;                                                // .notdef no tiene entrada
  while G < Result.GlyphCount do
  begin
    if BE(P, 2) <> G then
      Result.IdentityCharset := False;
    case Result.CharsetFormat of
      0: Left := 0;
      1: Left := B[P + 2];
    else
      Left := BE(P + 2, 2);
    end;
    Inc(P, Result.CharsetFormat + 2);
    Inc(G, Left + 1);
  end;
end;

Un informe con CIDKeyed = True y IdentityCharset = False describe justo la fuente de este artículo: cualquier writer que meta índices de glifo en el content stream sin contar con ese charset dibujará glifos equivocados con ella. Un resultado name-keyed, o una fuente CID-keyed cuyo charset ya sea la identidad de casualidad, se dibuja bien con el mismo writer. Así es como una fuente CJK de un producto funciona y la siguiente no. El bucle compara solo el primer CID de cada rango, y basta: un rango cubre glifos consecutivos con CIDs consecutivos, así que un rango que arranca en su propio índice de glifo se queda en él hasta el final

Los índices de glifo atraviesan todo el diccionario de fuentes

Un writer PDF rara vez tiene CIDs a mano. La tabla cmap y el motor de shaping devuelven índices de glifo, así que el diseño natural escribe esos índices como códigos Identity-H de 2 bytes y ancla todo lo demás a ellos: el array de anchos /W, el bitmap /CIDSet que pide PDF/A y la CMap ToUnicode que permite a la extracción recuperar el texto original. TrueType tiene una vía de escape: la Tabla 117 ofrece CIDToGIDMap, pero solo para CIDFonts de tipo 2; un programa CFF vive en una fuente CIDFontType0, que no tiene forma de redirigir un CID desde el lado PDF. Eso también descarta el truco que algunos writers usan con TrueType — dar a un glifo reutilizado un segundo CID "alias" más allá del final de la fuente, para que ToUnicode pueda asociar texto distinto a cada uso. En una fuente CFF ese alias no selecciona ningún glifo, y mutool informa de que no puede dibujarlo. Las fuentes que mapean U+4E00 y U+2F00 (KANGXI RADICAL ONE) a un mismo contorno son el caso habitual. Si tu documento necesita que ambos se extraigan de forma distinta, envuelve la aparición en un span de marked-content con /ActualText (ISO 32000-1 §14.9.4), que el lector usa en lugar del mapeo de la fuente

Reescribir el charset in situ

Hay dos caminos para reconciliar los códigos de índice de glifo con un programa CID-keyed. Uno traduce cada índice de glifo al CID de la fuente antes de escribirlo, lo que toca los códigos del content stream, las claves de /W, las posiciones de bit de /CIDSet, los códigos fuente de ToUnicode y cada subset en el guardado. El otro cambia la única estructura que discrepa, el charset, para que la fuente responda a los índices de glifo como lo haría un programa name-keyed. El segundo camino es el seguro: el shaper, la tabla de anchos, el subsetter y el generador de ToUnicode siguen trabajando en espacio de glifo, y el único código que tiene que entender CIDs es una rutina corta que se ejecuta una vez sobre la copia incrustada de la fuente. PDFlibPas toma ese camino

La reescritura conserva el formato del charset y su longitud en bytes. En el formato 0 cada entrada se sobrescribe con su propio índice de glifo. En los formatos 1 y 2 solo cambia el primer CID de cada rango, y los contadores se quedan como están. Nada crece ni encoge. Los offsets del Top DICT hacia CharStrings, los Private DICTs, FDArray y FDSelect siguen válidos sin recodificar nada, y FDSelect — que asigna diccionarios de hinting por índice de glifo — deja cada glifo en su FD original. Los programas name-keyed, los charsets predefinidos, las tablas CFF2 (no tienen charset) y los programas que no parsean se dejan tranquilos. Tres rutas de código ejecutan la reescritura:

PDFlibPas reescribiendo un charset CFF CID-keyed a la identidad in situ, conservando las longitudes en bytes de los formatos 0, 1 y 2 para que los offsets del Top DICT sigan válidos en AddTrueTypeFontFromFile, subsets y DrawOpenTypeText
PDFlibPas sobrescribe solo los CIDs dentro del charset de la copia CFF incrustada, conservando su formato y longitud, y ningún offset del Top DICT tiene que moverse
  • AddTrueTypeFontFromFile sobre un .otf incrusta la tabla CFF desnuda como FontFile3 con subtype CIDFontType0C, con el charset ya reescrito
  • El subset del guardado conserva los índices de glifo originales pero también los CIDs de origen, así que su charset se reescribe una segunda vez
  • El compañero de shaping detrás de DrawOpenTypeText y DrawShapedText conserva el sfnt entero; tras la reescritura se recalcula el checksum de la tabla CFF , y el checkSumAdjustment de la tabla head pasa a ser 0xB1B0AFBA menos la suma del fichero
  • Ese ajuste está definido solo para una fuente única, así que una fuente sacada de una colección se queda con el checksum de tabla solamente
uses
  SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
  FontID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    // Un .otf con contornos CFF se incrusta como FontFile3 /CIDFontType0C;
    // la copia incrustada recibe un charset de identidad cuando es CID-keyed
    FontID := Lib.AddTrueTypeFontFromFile('C:\Fonts\NotoSansSC-Regular.otf');
    if FontID = 0 then
      raise Exception.Create('the font file could not be read or parsed');
    Lib.SetTextSize(18);
    Lib.DrawText(56, 760, '季度报表:华东区销售汇总');
    Lib.DrawText(56, 730, '发票号码 2026-11-0187,金额 ¥12,480.00');
    if Lib.SaveToFile('noto-sc.pdf') <> 1 then
      raise Exception.CreateFmt('save failed, error %d', [Lib.LastErrorCode]);
  finally
    Lib.Free;
  end;
end;

Comprueba la salida renderizándola; la extracción no revela el problema. pdftotext y cualquier otro extractor leen ToUnicode, que se ancla al código del content stream y no sabe nada sobre qué contorno elige la fuente para él, así que una página con ideogramas equivocados sigue extrayendo la cadena correcta. mutool draw -r 150 -o page%d.png noto-sc.pdf muestra cómo es la página de verdad, y para esta ruta DrawText el pdffonts noto-sc.pdf de poppler debería listar la fuente como CID Type 0C. Lo esperado: ambas líneas se dibujan en Noto Sans SC con los glifos que nombra el texto original, y Ghostscript produce la misma salida

¿Qué forma de incrustación necesita PDF 1.6?

La de subtype OpenType. La Tabla 126 de ISO 32000-1 lista un stream FontFile3 de subtype CIDFontType0C como característica de PDF 1.3, mientras que el subtype OpenType — el sfnt entero con su tabla CFF — aparece por primera vez en PDF 1.6. Los documentos nuevos de PDFlibPas arrancan en la versión 1.5, donde cabe la ruta DrawText de CFF puro. La primera llamada a DrawOpenTypeText, DrawShapedText o escritura vertical con una fuente CFF crea un compañero OpenType y eleva el documento a 1.6 en el momento, algo que GetPDFVersion reporta de inmediato. Un bloqueo de versión lo cambia todo: si LockSaveVersion ha fijado el documento más abajo, la elevación se rechaza y SaveToFile devuelve 0 con LastErrorCode 602, así que ningún fichero reclama una versión que sus fuentes no cumplen. Cuando el perfil de entrega fija la versión por debajo de 1.6 — PDF/A-1 es el caso común, pues se apoya en PDF 1.4 — escribe texto CJK con DrawText y el .otf, o usa un corte TrueType de la familia para lo que necesite shaping

Flujo de versión PDF al incrustar fuentes CFF con PDFlibPas: DrawText mantiene FontFile3 CIDFontType0C en PDF 1.5, DrawOpenTypeText y DrawShapedText lo elevan a PDF 1.6, y LockSaveVersion hace que SaveToFile falle con error 602
DrawText mantiene una fuente CFF como FontFile3 CIDFontType0C puro dentro de PDF 1.5, mientras que las llamadas de shaping pasan a un compañero OpenType que necesita PDF 1.6 y choca con un pin de LockSaveVersion más bajo
var
  Lib: TPDFlib;
  FontID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    FontID := Lib.AddTrueTypeFontFromFile('NotoSansSC-Regular.otf');
    Writeln('before: ', Lib.GetPDFVersion);      // Esperado: 1.5
    if Lib.DrawOpenTypeText(56, 760, FontID, 18, '《年度报告》第三章', 0) = 1 then
      Writeln('after: ', Lib.GetPDFVersion);     // Esperado: 1.6
    Lib.SaveToFile('shaped.pdf');  // Esperado en pdffonts: CID Type 0C (OT)
  finally
    Lib.Free;
  end;

  Lib := TPDFlib.Create;
  try
    Lib.LockSaveVersion('1.5');
    FontID := Lib.AddTrueTypeFontFromFile('NotoSansSC-Regular.otf');
    Lib.DrawOpenTypeText(56, 760, FontID, 18, '《年度报告》第三章', 0);
    if Lib.SaveToFile('locked.pdf') = 0 then
      Writeln('save refused, error ', Lib.LastErrorCode);  // Esperado: 602
  finally
    Lib.Free;
  end;
end;

Anchos y ToUnicode anclados a los mismos números

Cuando el charset es la identidad, el CID, el índice de glifo y el código del content stream son un solo número. Los anchos lo siguen. ISO 32000-1 §9.7.4.3 indexa /W por el propio CID, en grupos de un CID inicial seguido de un array de anchos o de un primer CID, un último CID y un ancho compartido, sin el offset /FirstChar de las fuentes simples. Cualquier CID que el array omita toma /DW, y la Tabla 117 fija el valor por defecto de /DW en 1000. Cero está mal: un lector que recurre a 0 para los CIDs no listados aplasta una línea de ideogramas sobre sí misma. Para un .otf cargado con AddTrueTypeFontFromFile, PDFlibPas escribe un array /W que arranca en el CID 0 y lista el avance de cada glifo, junto a un /DW 1000 explícito. En el lado de lectura, el texto CJK que se extrae como basura suele fallar en la mitad de codespace del mismo pipeline, y una auditoría de fuentes PDF/UA de anchos, CharSet y CIDSet confronta estos arrays con el programa incrustado

ToUnicode tiene su propia regla de numeración. Una entrada bfrange guarda solo la primera cadena de destino, y §9.10.3 exige que su último byte no quede empujado más allá de 255 por la longitud del rango, porque un lector genera los destinos siguientes incrementando solo ese último byte. Los glifos consecutivos que mapean a U+4EFE, U+4EFF, U+4F00 y U+4F01 parecen un solo rango, pero no lo son: un lector decodificaría el tercero como U+4E00. El writer de PDFlibPas fusiona pares vecinos de glifo a Unicode en un solo bfrange solo cuando tanto los códigos fuente como los destinos quedan dentro de un mismo bloque de 256, y parte la serie en caso contrario. La escritura CJK vertical pasa por las mismas fuentes compañeras, así que el texto japonés y chino vertical hereda el charset de identidad y estos rangos ToUnicode sin llamadas extra

Toda fuente que InspectOtfCharset reporte como CID-keyed con un charset que no sea la identidad es una donde los códigos de índice de glifo y la fuente discrepan. Incrustada a través de PDFlibPas para Delphi, esa fuente conserva sus contornos, offsets y longitud de charset, y lo que cambia son los CIDs de esa única tabla, más los checksums cuando se conserva el sfnt entero