Artigo Técnico

Índice de fonte BIFF8 pula o 4: runs de rich text no HotXLS

O HotXLS numera toda referência de fonte BIFF8 do jeito que o [MS-XLS] §2.5.129 FontIndex define: valores 0 a 3 são de base zero, valores acima de 4 são de base um, e 4 nunca aparece, então o quinto registro FONT é ifnt 5 e o maior ifnt válido iguala o número de registros FONT. Desde o HotXLS 2.384.4 o writer de XF, o reader de XF, runs de strings de rich text e a migração de runs entre pastas de trabalho seguem essa regra, e 2.384.5 e 2.384.6 a estendem para runs de comentário e caixa de texto, inclusive através de cópias e inserções de linha

A regra parece um typo até você esbarrar nela. Alguém abre uma pasta de trabalho com oito registros FONT, acha um XF apontando para a fonte 8, e conclui que o writer produziu um índice fora da faixa. Esse raciocínio exato foi embarcado no HotXLS 2.384.1 como um "fix", e transformou uma implementação correta numa em que toda fonte customizada num arquivo aberto pelo Excel caía um slot cedo. A parte interessante não é o off-by-one em si, é quantos lugares numa biblioteca BIFF8 carregam a mesma convenção, e como um vínculo de fonte pode sobreviver a um save e quebrar no segundo. Se você já brigou com as excentricidades de comprimento e encoding cobertas em decodificar cch e fHigh do XLUnicodeString BIFF8, este é da mesma família de bug: o arquivo está bem, a aritmética não

O que a regra FontIndex do [MS-XLS] realmente diz?

O [MS-XLS] §2.5.129 diz que um FontIndex abaixo de 4 é uma posição de registro de base zero, um FontIndex acima de 4 é uma posição de registro de base um, e o valor 4 NÃO DEVE ser usado. O mesmo tipo FontIndex é usado por registros XF, runs de formatação do SST e runs de formatação do TXO, então uma regra mal lida corrompe os três. A evidência é fácil de reproduzir com arquivos gerados pelo Excel: o SOLVSAMP.XLS que vem com o Office tem 19 registros FONT e um máximo ifnt de XF igual a 19, uma pasta de trabalho de 43 registros estoura em 43, e um arquivo salvo pelo Excel 16 com 30 registros FONT aponta as células Courier New para o ifnt 22, o 22º registro. Nenhum deles jamais contém um 4. Se você precisa analisar o mapeamento por conta própria numa ferramenta de diagnóstico, a conversão são duas funções curtas

// [MS-XLS] 2.5.129 FontIndex: 0..3 de base zero, > 4 de base um, 4 inválido
function FontIndexToRecordNo(Ifnt: Word): Integer;  // registro FONT de base 1
begin
  if Ifnt < 4 then
    Result := Ifnt + 1
  else if Ifnt > 4 then
    Result := Ifnt
  else
    Result := -1;  // 4 não pode ocorrer
end;

function RecordNoToFontIndex(RecordNo: Integer): Word;
begin
  if RecordNo <= 4 then
    Result := RecordNo - 1
  else
    Result := RecordNo;
end;
Mapeamento FontIndex do HotXLS conforme MS-XLS 2.5.129 em que ifnt 0 a 3 são posições de registro FONT de base zero, ifnt 5 e acima são de base um e o valor 4 nunca ocorre, com a conversão FontIndexToRecordNo e evidência de pastas de trabalho geradas pelo Excel como SOLVSAMP.XLS
O quinto registro FONT é ifnt 5, não 4 — uma pasta de trabalho de 19 registros estoura em ifnt 19, e nenhum arquivo gerado pelo Excel jamais guarda o valor proibido no meio

Dentro do HotXLS a mesma regra vive em dois pontos espelhados. O TXLSFontList.GetSaveIndex pega a posição de base 1 de uma fonte na lista referida e decrementa só as posições 1 a 4, então a posição 5 é gravada como ifnt 5. O TXLSReader.ParseXF faz o inverso no load: qualquer ifnt de 5 ou mais é decrementado para um slot de base zero da lista de fontes, e qualquer coisa abaixo fica onde está. O remapeamento de rich runs do SST e o CountRichRunFontRefs aplicam a mesma conversão ifnt >= 5, que é o ponto: uma convenção, todo consumidor

// TXLSFontList.GetSaveIndex (lado writer)
Result := inherited GetSaveIndex(Index);   // posição referida de base 1
if (Result > 0) and (Result < 5) then
  Dec(Result);                             // 1..4 viram 0..3, 5+ inalterado

// TXLSReader.ParseXF (lado reader)
fnti := Data.GetWord(0);
if fnti >= 5 then
  Dec(fnti);                               // ifnt 5 é o slot 4 da lista de fontes

Por que um "fix" de base zero deslocou toda fonte customizada em um?

A reescrita de base zero no HotXLS 2.384.1 deslocou toda fonte customizada porque leu um índice de base 1 como se fosse de base zero e depois mudou quatro call sites para casar com essa leitura errada: GetSaveIndex, ParseXF, o remapeamento de runs do SST e a migração de runs entre pastas de trabalho no Sheets.AddCopy. Os round trips do HotXLS continuavam parecendo bons, porque writer e reader concordavam entre si. O Excel não concordava. Um arquivo escrito pelo 2.384.1 punha a primeira fonte customizada no ifnt 4, que o Excel trata como a fonte padrão, e toda fonte customizada seguinte um registro cedo; abrir um arquivo do Excel ia para o outro lado, vinculando cada fonte um registro tarde

Regressão do HotXLS 2.384.1 em que GetSaveIndex e ParseXF liam valores FontIndex de base 1 como de base zero, gravando a primeira fonte customizada como o proibido ifnt 4 que o Excel resolve para a fonte padrão e fazendo toda fonte seguinte cair um registro cedo, enquanto os round trips ainda passavam
Writer e reader concordavam na mesma leitura errada, então um teste de salvar e reabrir seguia verde enquanto toda fonte aberta pelo Excel caía um slot fora — quando uma convenção vive em sete lugares e você muda quatro, desconfie primeiro da sua mudança

A pista que deveria ter barrado a mudança estava no mesmo codebase. O CountRichRunFontRefs, o remapeamento FONTX e FBI de gráfico e a lista de fontes do motor de estilos nunca foram tocados e continuavam usando skip-4, então a biblioteca se contradisse no instante em que o 2.384.1 aterrissou, e só a coincidência de que fontes de rich text normalmente também eram referenciadas por algum XF manteve a contradição escondida. Quando uma convenção aparece em sete lugares e você está mudando quatro, desconfie da sua mudança antes de desconfiar dos outros três. A versão 2.384.4 restaurou a numeração da spec nos quatro lugares, e o antigo teste de regressão, que dava assert em ifnt < FontCount e portanto codificava a leitura errada, foi substituído por testes que mapeiam todo ifnt gravado de volta para um nome de registro FONT pela fórmula da spec. Uma limitação honesta permanece: arquivos salvos pelo 2.384.1 até o 2.384.3 com cinco ou mais fontes carregam índices deslocados que um reader não distingue de dados válidos, então a única cura é regerá-los

Por que runs de fonte de comentário quebram só no segundo save?

Runs de comentário e caixa de texto quebravam no segundo save porque o HotXLS mantinha os primeiros N-1 registros FONT incondicionalmente e descartava só o último quando nenhum XF o referenciava, enquanto os runs de formatação do TXO ([MS-XLS] §2.4.329) eram gravados de volta byte a byte sem renumeração. Arquivos .xls gerados pelo Excel sempre terminam com uma fonte final não referenciada (uma DengXian 9pt num sistema de locale chinês), então no primeiro save a fonte usada só por um run de comentário nunca era a última, e nada se moveu visivelmente. Esse primeiro save derrubou a fonte final, porém, e promoveu a fonte só de comentário à última posição. O segundo save então a descartou como não referenciada, o ifnt do run apontou para além do fim, e o Excel caiu na fonte padrão; se a pasta de trabalho tinha ganho uma fonte nova no meio, o run silenciosamente se vinculou a essa em vez, o que em teste transformou um run estilizado de caixa de texto em Arial. Arquivos cheios de comentários como os descritos em construir um workflow de revisão de comentários e hyperlinks são exatamente onde isso morde, porque são abertos, anotados e salvos repetidamente

Tabela de fontes do HotXLS ao longo de dois saves em que o registro FONT final não referenciado que o Excel sempre grava cai primeiro, a fonte só de comentário vira a última e é então descartada porque os runs de formatação do TXO eram gravados de volta sem contar referências, até o CountRichRunFontRefs consertar o filtro de sobrevivência no 2.384.5
O primeiro save parecia limpo porque a fonte final levou a perda, e a fonte de comentário só sumiu no segundo — mapeie cada ifnt para um nome de registro FONT através dos saves em vez de confiar num teste em memória

O HotXLS 2.384.5 trata runs do TXO como runs do SST. O CountRichRunFontRefs agora percorre todo TMSOShapeTextBox em toda planilha, converte o ifnt skip-4 de cada run para um slot e o conta como referência, então uma fonte usada só por runs sobrevive ao filtro de save. A tabela resultante de slot para índice de save vai para o FontRunRemap de cada drawing, e o TMSOShapeTextBox.Store reescreve os índices dos runs numa cópia privada dos bytes crus dos runs, deixando o TxOLastRun final em paz porque ele não carrega fonte. Para código de aplicação o contrato é simples: TXLSComment.TextRuns.FontIndex e TXLSTextBox.TextRuns.FontIndex usam a numeração do arquivo, com 4 pulado, exatamente como lido; índices de run são de base 1 e CharIndex é o offset de caractere onde o run começa. Depois de um save o número armazenado pode diferir do que você setou, mas ainda aponta para a mesma fonte

var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  I: Integer;
  Ifnt: Word;
begin
  Book := TXLSWorkbook.Create;
  if Book.Open('review-notes.xls') <> 1 then
    raise Exception.Create('Cannot open review-notes.xls');
  Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
  if Note <> nil then
    for I := 1 to Note.TextRuns.Count do
    begin
      Ifnt := Note.TextRuns.FontIndex[I];   // numeração do arquivo, 4 pulado
      if Ifnt = 4 then
        raise Exception.CreateFmt('Run %d uses invalid ifnt 4', [I]);
      Writeln(Format('run %d at char %d: ifnt %d = FONT record #%d',
        [I, Note.TextRuns.CharIndex[I], Ifnt, FontIndexToRecordNo(Ifnt)]));
    end;
end;

Cópias, inserções de linha e migração de runs entre pastas de trabalho

Desde o HotXLS 2.384.6, todo caminho de cópia do motor Classic preserva runs de formatação de comentário, porque Range.Copy, CopyRange, Sheets.AddCopy e os deslocamentos de célula por trás do Range.Insert e do Range.Delete passam todos pelo TXLSRange.CopyCell, e o CopyCell copiava só o texto do comentário e o autor. Um deslocamento é uma cópia mais uma limpeza, então inserir uma única linha acima de uma nota de dois runs a deixava com zero runs e uma fonte. O fix copia cada run e move a fonte dele por meio do TXLSWorkbook.MigrateRunFontIndex, que converte o índice skip-4 para um slot, migra a fonte por valor para a tabela de fontes de destino e converte de volta para a numeração do arquivo; a migração de rich text do SST no Sheets.AddCopy agora chama a mesma função em vez de carregar a própria cópia da aritmética. Dois edge cases vieram junto: um paste in place em que origem e destino são o mesmo comentário não pode limpar os runs dele antes de lê-los, e o Sheets.AddCopy agora faz uma segunda passada para comentários anexados a células sem registro de célula armazenado, que antes eram pulados por completo. O lado da tabela de fontes da cópia entre pastas de trabalho segue a mesma lógica por valor do lado de fórmulas coberto em cópia entre pastas de trabalho e rebinding de fórmulas. No motor XLSX os caminhos de cópia já clonavam runs por valor; a lacuna estava na parte de comentários em si, onde o reader ignorava rFont, strike, u e vertAlign e o writer nunca emitia u nem vertAlign, então os runs agora sobrevivem a save e reopen simetricamente

Como você deve testar índices de fonte em arquivos BIFF8?

Teste índices de fonte salvando e reabrindo, de preferência por mais de uma geração, e mapeando cada ifnt de volta para um registro FONT em vez de dar assert numa faixa numérica. Todo bug desta história passou num teste em memória: a regressão do 2.384.1 vivia num par casado de writer e reader, a deriva do TXO precisava de dois saves com uma mudança de tabela de fontes no meio, e os runs de comentário perdidos no XLSX só apareceram depois de um reopen. Um harness útil abre uma amostra gerada pelo Excel, salva duas vezes pelo HotXLS, adiciona ou remove uma fonte entre os saves, e então confere as posições dos runs mais, a nível de byte, os nomes de fonte por trás de cada ifnt. Não compare valores de FontIndex antes e depois de um save, já que renumeração é legítima

procedure CheckNoteSurvivesShiftAndSave(const SrcFile, OutFile: string);
var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  RunCount: Integer;
  SecondRunAt: Word;
begin
  Book := TXLSWorkbook.Create;
  Assert(Book.Open(SrcFile) = 1);          // gerado pelo Excel, C2 tem dois runs
  Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
  RunCount := Note.TextRuns.Count;
  SecondRunAt := Note.TextRuns.CharIndex[2];

  Book.Sheets[1].Range['C2', 'C2'].Copy(Book.Sheets[1].Range['E5', 'E5']);
  Book.Sheets[1].Range['C1', 'C1'].Insert(xlShiftDown);   // C2 vai para C3
  Assert(Book.SaveAs(OutFile) = 1);

  Book := TXLSWorkbook.Create;               // reabra, nunca confie na memória
  Assert(Book.Open(OutFile) = 1);
  Note := Book.Sheets[1].Range['C3', 'C3'].Comment;
  Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
  Assert(Note.TextRuns.CharIndex[2] = SecondRunAt);
  Note := Book.Sheets[1].Range['E5', 'E5'].Comment;
  Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
end;

Se você lê e escreve XLS clássico do Delphi ou do C++Builder e prefere não rastrear qual dos muitos consumidores de fonte de uma biblioteca ainda concorda com o [MS-XLS] §2.5.129, a numeração skip-4, a renumeração de runs no save e a migração de runs por valor descritas aqui vêm embutidas no componente de planilha HotXLS para Delphi, que lê e escreve XLS e XLSX sem Excel nem automação OLE