Artigo Técnico

Glifos CJK errados de uma fonte OTF? Veja o CFF charset

Uma fatura chinesa composta em Noto Sans SC a partir do arquivo .otf dela pode abrir no mutool com ideogramas sem relação nenhuma onde deveria estar o nome do cliente, enquanto uma fonte TrueType renderiza a mesma página corretamente. A causa é o CFF charset da fonte; o PDFlibPas o regrava para o identity na cópia incorporada, e os códigos de índice de glifo chegam aos glifos pretendidos

Se uma fonte apresenta o problema depende de como ela foi construída. Um programa TrueType incorporado como fonte CIDFontType2 recebe um CIDToGIDMap Identity, e o código no content stream cai no glifo pretendido. A maioria das fontes OpenType latinas com contornos CFF é name-keyed, e um programa CFF name-keyed usa o CID recebido diretamente como índice de glifo. A ISO 32000-1 até observa, em §9.7.4.2, que CID e GID coincidem em muitas fontes. Mas não em todas. As grandes famílias CJK construídas como fontes CID-keyed — Noto Sans CJK e suas irmãs Source Han entre elas — carregam numeração de CID própria, e um writer que manda índices de glifo direto nessa numeração recebe o glifo que por acaso é dono do CID correspondente

O que um programa CFF CID-keyed faz com um CID

A ISO 32000-1 §9.7.4.2 divide os programas CFF incorporados em dois casos olhando o Top DICT. Se o Top DICT usa os operadores CIDFont — na prática, ele abre com ROS (operador 12 30: registry, ordering, supplement) — o reader pega o CID do content stream, localiza o glifo cuja entrada de charset carrega esse CID e só então busca o contorno no CharStrings INDEX. Sem operadores CIDFont, o CID é o próprio índice de glifo; o charset não participa. Qualquer reader que seguir §9.7.4.2 como escrito vai desenhar o glifo errado para um código de índice de glifo sempre que o charset não for o identity, não importa qual viewer você abra. O charset em si, definido no Adobe Technical Note #5176, vem em três layouts com dois casos especiais:

Incorporação de fonte CJK no Delphi com PDFlibPas: busca de charset CFF CID-keyed sob a ISO 32000-1 §9.7.4.2 desenhando o glifo errado para códigos de índice de glifo a menos que o charset incorporado seja o identity
códigos de índice de glifo chegam ao contorno pretendido em uma fonte CFF CID-keyed só quando o charset dela mapeia cada glifo para o CID de mesmo número
  • Format 0 armazena um SID ou CID de 2 bytes por glifo, na ordem de glifos
  • Format 1 armazena faixas: um primeiro CID seguido de uma contagem de 1 byte dos glifos que o seguem com CIDs consecutivos
  • Format 2 é o mesmo layout de faixa com uma contagem de 2 bytes, o que serve para fontes com sequências longas contínuas
  • O glifo 0 é o .notdef e não tem entrada em nenhum formato; um offset de charset de 0, 1 ou 2 nomeia um dos charsets predefinidos em vez de apontar para dados

Então comece descobrindo que tipo de fonte você tem. Identity-H do lado do PDF só diz que um código de 2 bytes é igual ao CID. Como esse CID chega a um glifo vive inteiramente dentro do programa de fonte. A função abaixo lê a tabela CFF de um .otf, percorre o Top DICT atrás de ROS e verifica se o charset mapeia cada glifo para um CID de mesmo número. Ela precisa apenas da RTL

uses
  System.SysUtils, System.IOUtils;

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

{$RANGECHECKS ON}  // um arquivo truncado levanta ERangeError em vez de ser lido errado
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;

  // Devolve o fim do INDEX em Pos e demarca o primeiro objeto dele
  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;  // os offsets são 1-based daqui em diante
    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 não tem 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;

Um relatório com CIDKeyed = True e IdentityCharset = False descreve a fonte deste artigo: qualquer writer que põe índices de glifo no content stream sem considerar esse charset vai desenhar os glifos errados com ela. Um resultado name-keyed, ou uma fonte CID-keyed cujo charset por acaso já é o identity, renderiza corretamente sob o mesmo writer. É assim que uma fonte CJK em um produto funciona e a seguinte não. O loop compara apenas o primeiro CID de cada faixa, e isso basta: uma faixa cobre glifos consecutivos carregando CIDs consecutivos, então uma faixa que começa no próprio índice de glifo fica nele até o fim

Os índices de glifo atravessam todo o dicionário de fonte

Um writer PDF raramente tem CIDs em mãos. A tabela cmap e o shaping engine devolvem ambos índices de glifo, então o design natural escreve esses índices como códigos Identity-H de 2 bytes e chaveia todo o resto neles: o array de larguras /W, o bitmap /CIDSet que o PDF/A pede e o ToUnicode CMap que permite à extração recuperar o texto de origem. O TrueType tem uma válvula de escape. A Tabela 117 oferece o CIDToGIDMap, mas só para CIDFonts Type 2; um programa CFF mora em uma fonte CIDFontType0, que não tem como redirecionar um CID do lado do PDF. Isso também descarta um truque que alguns writers usam com TrueType — dar a um glifo reutilizado um segundo CID "alias" depois do fim da fonte, para que o ToUnicode possa ligar textos diferentes a cada uso. Em uma fonte CFF esse alias não seleciona glifo nenhum, e o mutool informa que não consegue renderizá-lo. Fontes que mapeiam U+4E00 e U+2F00 (KANGXI RADICAL ONE) para um único contorno são o caso comum. Se o documento precisa que os dois sejam extraídos de forma diferente, envolva a ocorrência em um span de marked-content com /ActualText (ISO 32000-1 §14.9.4), que o reader usa no lugar do mapeamento da fonte

Regravando o charset no lugar

Há dois caminhos para reconciliar códigos de índice de glifo com um programa CID-keyed. Um traduz cada índice de glifo para o CID da fonte antes de escrevê-lo, o que mexe nos códigos do content stream, nas chaves do /W, nas posições de bit do /CIDSet, nos códigos de origem do ToUnicode e em todo subset gerado ao salvar. O outro muda a única estrutura em desacordo, o charset, para que a fonte responda a índices de glifo do jeito que um programa name-keyed responderia. O segundo caminho é o mais seguro. O shaper, a tabela de larguras, o subsetter e o construtor de ToUnicode continuam trabalhando em espaço de glifo, e o único código que precisa entender CIDs é uma rotina curta que roda uma vez sobre a cópia incorporada da fonte. O PDFlibPas segue esse caminho

A regravação preserva o formato do charset e o comprimento em bytes dele. No format 0, cada entrada é sobrescrita com o próprio índice de glifo dela. Nos formats 1 e 2, só o primeiro CID de cada faixa muda, e as contagens ficam como estão. Nada cresce ou encolhe. Os offsets do Top DICT para CharStrings, os Private DICTs, FDArray e FDSelect continuam válidos sem re-encoder nada, e o FDSelect — que atribui dicionários de hinting por índice de glifo — mantém cada glifo no FD original dele. Programas name-keyed, charsets predefinidos, tabelas CFF2 (que não têm charset) e programas que não parseiam ficam intocados. Três caminhos de código executam a regravação:

PDFlibPas regravando um charset CFF CID-keyed para o identity no lugar, preservando os comprimentos em bytes dos formats 0, 1 e 2 para que os offsets do Top DICT continuem válidos no AddTrueTypeFontFromFile, nos subsets e no DrawOpenTypeText
o PDFlibPas sobrescreve apenas os CIDs dentro do charset da cópia CFF incorporada, preservando formato e comprimento, e nenhum offset do Top DICT precisa se mover
  • O AddTrueTypeFontFromFile em um .otf incorpora a tabela CFF pura como FontFile3 com subtype CIDFontType0C, com o charset já regravado
  • O subset gerado ao salvar mantém os índices de glifo originais, mas também os CIDs de origem, então o charset dele é regravado uma segunda vez
  • O companheiro de shaping por trás do DrawOpenTypeText e do DrawShapedText mantém o sfnt inteiro; após a regravação o checksum da tabela CFF é recalculado, e o checkSumAdjustment da tabela head vira 0xB1B0AFBA menos a soma do arquivo
  • Esse ajuste só está definido para uma fonte única, então uma fonte tirada de uma coleção fica só com o checksum de tabela
uses
  SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
  FontID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    // Um .otf com contornos CFF é incorporado como FontFile3 /CIDFontType0C;
    // a cópia incorporada recebe um charset identity quando é 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;

Confira a saída renderizando-a. A extração não mostra o problema. O pdftotext e todo outro extrator leem o ToUnicode, que é chaveado pelo código no content stream e não sabe nada sobre qual contorno a fonte escolhe para ele, então uma página exibindo os ideogramas errados ainda extrai a string certa. O mutool draw -r 150 -o page%d.png noto-sc.pdf mostra como a página realmente fica, e para esse caminho DrawText o pdffonts noto-sc.pdf do poppler deve listar a fonte como CID Type 0C. Esperado: as duas linhas renderizam em Noto Sans SC com os glifos que o texto de origem nomeia, e a mesma saída aparece no Ghostscript

Qual forma de incorporação pede PDF 1.6?

A do OpenType. A Tabela 126 da ISO 32000-1 lista um stream FontFile3 de subtype CIDFontType0C como recurso do PDF 1.3, enquanto o subtype OpenType — o sfnt inteiro com a tabela CFF dele — aparece pela primeira vez no PDF 1.6. Documentos PDFlibPas novos começam na versão 1.5, na qual o caminho DrawText de CFF puro cabe. A primeira chamada DrawOpenTypeText, DrawShapedText ou de escrita vertical com uma fonte CFF cria um companheiro OpenType e eleva o documento para 1.6 na hora, o que o GetPDFVersion informa imediatamente. Um lock de versão muda isso. Se o LockSaveVersion fixou o documento em um valor mais baixo, a elevação é recusada e o SaveToFile retorna 0 com LastErrorCode 602, para que nenhum arquivo reclame uma versão que as fontes dele não atendem. Quando um perfil de entrega trava a versão abaixo de 1.6 — PDF/A-1 sendo o caso comum, já que se apoia no PDF 1.4 —, defina texto CJK via DrawText com o .otf, ou use um corte TrueType da família para tudo que precisar de shaping

Fluxo de versão de PDF na incorporação de fonte CFF pelo PDFlibPas: DrawText mantém FontFile3 CIDFontType0C no PDF 1.5, DrawOpenTypeText e DrawShapedText elevam para o PDF 1.6, e LockSaveVersion faz o SaveToFile falhar com erro 602
o DrawText mantém uma fonte CFF como FontFile3 CIDFontType0C puro dentro do PDF 1.5, enquanto chamadas de shaping trocam para um companheiro OpenType que pede PDF 1.6 e colide com um LockSaveVersion fixado mais baixo
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 no 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;

Larguras e ToUnicode chaveados pelos mesmos números

Quando o charset é o identity, o CID, o índice de glifo e o código do content stream são um só número. As larguras o acompanham. A ISO 32000-1 §9.7.4.3 indexa o /W pelo próprio CID, em grupos de um CID inicial seguido de um array de larguras ou de um primeiro CID, um último CID e uma largura compartilhada, sem offset /FirstChar do tipo que fontes simples usam. Qualquer CID que o array deixar de fora recebe o /DW, e a Tabela 117 define o default do /DW como 1000. Zero está errado. Um reader que recai em 0 para CIDs não listados colapsa uma linha de ideogramas sobre si mesma. Para um .otf carregado via AddTrueTypeFontFromFile, o PDFlibPas escreve um array /W que começa no CID 0 e lista o advance de cada glifo, ao lado de um /DW 1000 explícito. Do lado da leitura, texto CJK que sai como lixo na extração normalmente erra na metade codespace desse mesmo pipeline, e uma auditoria de fontes PDF/UA de larguras, CharSet e CIDSet confronta esses arrays com o programa incorporado

O ToUnicode tem uma regra de numeração própria. Uma entrada bfrange armazena apenas a primeira string de destino, e a §9.10.3 exige que o último byte dela não seja empurrado além de 255 pelo comprimento da faixa, porque um reader gera os destinos seguintes incrementando apenas esse último byte. Glifos consecutivos mapeados para U+4EFE, U+4EFF, U+4F00 e U+4F01 parecem uma faixa única — e não são: um reader decodificaria o terceiro como U+4E00. O writer do PDFlibPas mescla pares vizinhos de glifo-para-Unicode em um bfrange só quando os códigos de origem e os destinos ficam dentro de um mesmo bloco de 256, e divide a sequência caso contrário. A composição CJK vertical passa pelas mesmas fontes companheiras, então o texto vertical em japonês e chinês herda o charset identity e essas faixas ToUnicode sem chamadas extras

Toda fonte que o InspectOtfCharset reporta como CID-keyed com charset não identity é um caso em que os códigos de índice de glifo e a fonte discordam. Incorporada via PDFlibPas for Delphi, essa fonte mantém os contornos, os offsets e o comprimento de charset dela, e o que muda são os CIDs nessa única tabela, mais os checksums quando o sfnt inteiro é mantido