Artigo Técnico

FontIndex BIFF8 salta o 4: rich text runs no HotXLS

O HotXLS numera todas as referências de fonte BIFF8 da forma como o [MS-XLS] §2.5.129 FontIndex define: os valores 0 a 3 são de base zero, os valores acima de 4 são de base um, e o 4 nunca aparece, por isso o quinto registo FONT é ifnt 5 e o maior ifnt válido iguala o número de registos FONT. Desde o HotXLS 2.384.4 que o escritor XF, o leitor XF, os runs de strings de rich text e a migração de runs entre livros seguem essa regra, e a 2.384.5 e a 2.384.6 estendem-na aos runs de comentários e caixas de texto, incluindo através de cópias e inserções de linhas

A regra parece um erro de dactilografia até se tropeçar nela. Alguém abre um livro com oito registos FONT, encontra um XF a apontar para a fonte 8, e conclui que o escritor produziu um índice fora do intervalo. Esse raciocínio exato foi lançado no HotXLS 2.384.1 como uma "correção", e transformou uma implementação correta numa em que toda a fonte personalizada num ficheiro aberto no Excel aterrava uma posição mais cedo. A parte interessante não é o off-by-one em si, é quantos sítios numa biblioteca BIFF8 transportam a mesma convenção, e como um vínculo de fonte pode sobreviver a uma gravação e partir na segunda. Se já lutou com as peculiaridades de comprimento e codificação cobertas em decifrar o cch e o fHigh do XLUnicodeString BIFF8, este é da mesma família de bug: o ficheiro está bem, a aritmética não

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

O [MS-XLS] §2.5.129 diz que um FontIndex abaixo de 4 é uma posição de registo de base zero, um FontIndex acima de 4 é uma posição de registo de base um, e o valor 4 NÃO DEVE ser usado. O mesmo tipo FontIndex é usado pelos registos XF, pelos runs de formatação do SST e pelos runs de formatação do TXO, por isso uma regra mal lida corrompe os três. A evidência é fácil de reproduzir com ficheiros escritos pelo Excel: o SOLVSAMP.XLS que acompanha o Office tem 19 registos FONT e um máximo de ifnt XF de 19, um livro com 43 registos para em 43, e um ficheiro gravado pelo Excel 16 com 30 registos FONT aponta as suas células Courier New para o ifnt 22, o 22.º registo. Nenhum deles contém jamais um 4. Se precisar de analisar o mapeamento você próprio 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;  // registo FONT de base um
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 no HotXLS segundo o MS-XLS 2.5.129, em que ifnt 0 a 3 são posições de registo 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 livros escritos no Excel como o SOLVSAMP.XLS
O quinto registo FONT é o ifnt 5, não o 4 — um livro de 19 registos para no ifnt 19, e nenhum ficheiro escrito no Excel guarda jamais o valor proibido no meio

Dentro do HotXLS a mesma regra vive em dois sítios espelhados. O TXLSFontList.GetSaveIndex recebe a posição de base um de uma fonte na lista referenciada e decrementa só as posições 1 a 4, por isso a posição 5 é escrita como ifnt 5. O TXLSReader.ParseXF faz o inverso ao carregar: qualquer ifnt de 5 ou mais é decrementado para uma posição de base zero na lista de fontes, e tudo o que esteja 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, todos os consumidores

// TXLSFontList.GetSaveIndex (lado do escritor)
Result := inherited GetSaveIndex(Index);   // posição referenciada de base um
if (Result > 0) and (Result < 5) then
  Dec(Result);                             // 1..4 tornam-se 0..3, 5+ inalterado

// TXLSReader.ParseXF (lado do leitor)
fnti := Data.GetWord(0);
if fnti >= 5 then
  Dec(fnti);                               // ifnt 5 é a posição 4 da lista de fontes

Porque é que uma "correção" de base zero deslocou todas as fontes personalizadas?

A reescrita de base zero no HotXLS 2.384.1 deslocou todas as fontes personalizadas porque leu um índice de base um como de base zero e depois alterou quatro sítios de chamada para bater certo com essa leitura errada: o GetSaveIndex, o ParseXF, o remapeamento de runs do SST e a migração de runs entre livros no Sheets.AddCopy. Os round trips do HotXLS continuavam a parecer bons, porque escritor e leitor concordavam entre si. O Excel não concordava. Um ficheiro escrito pela 2.384.1 punha a primeira fonte personalizada no ifnt 4, que o Excel trata como a fonte predefinida, e cada fonte personalizada seguinte um registo mais cedo; abrir um ficheiro do Excel ia pelo outro lado, vinculando cada fonte um registo mais tarde

Regressão do HotXLS 2.384.1 em que GetSaveIndex e ParseXF liam valores FontIndex de base um como de base zero, escrevendo a primeira fonte personalizada como o proibido ifnt 4 que o Excel resolve para a fonte predefinida e aterrando cada fonte seguinte um registo mais cedo enquanto os round trips continuavam a passar
Escritor e leitor concordavam na mesma leitura errada, por isso um teste de gravar e reabrir continuava verde enquanto cada fonte num ficheiro do Excel aterrava uma posição deslocada — quando uma convenção vive em sete sítios e muda quatro, desconfie primeiro da sua alteração

A pista que devia ter travado a alteração estava no mesmo código. O CountRichRunFontRefs, o remapeamento FONTX e FBI dos gráficos, e a lista de fontes do motor de estilos nunca foram tocados e continuavam a usar o salto do 4, por isso a biblioteca contradizia-se no momento em que a 2.384.1 aterrava, e só a coincidência de as fontes de rich text serem normalmente também referenciadas por algum XF mantinha a contradição escondida. Quando uma convenção aparece em sete sítios e está a mudar quatro, desconfie da sua alteração antes de desconfiar dos outros três. A versão 2.384.4 restaurou a numeração da spec nos quatro sítios, e o velho teste de regressão, que verificava ifnt < FontCount e portanto codificava a leitura errada, foi substituído por testes que mapeiam cada ifnt escrito de volta para um nome de registo FONT pela fórmula da spec. Fica uma limitação honesta: ficheiros gravados pela 2.384.1 até à 2.384.3 com cinco ou mais fontes transportam índices deslocados que um leitor não consegue distinguir de dados válidos, por isso a única cura é regenerá-los

Porque é que os runs de fontes de comentários só partem na segunda gravação?

Os runs de comentários e caixas de texto partiam na segunda gravação porque o HotXLS mantinha incondicionalmente os primeiros N-1 registos FONT e largava só o último quando nenhum XF o referenciava, enquanto os runs de formatação TXO ([MS-XLS] §2.4.329) eram escritos de volta byte a byte sem renumeração. Ficheiros .xls escritos no Excel terminam sempre com uma fonte final não referenciada (uma DengXian de 9pt num sistema com locale chinês), por isso na primeira gravação a fonte usada só por um run de comentário nunca era a última, e nada se mexia visivelmente. Essa primeira gravação largava, porém, a fonte final, e promovia a fonte só de comentário para a última posição. A segunda gravação descartava-a então como não referenciada, o ifnt do run apontava para além do fim, e o Excel recuava para a fonte predefinida; se o livro entretanto tivesse ganho uma fonte nova, o run vinculava-se em silêncio a essa, o que em testes transformou um run estilizado de caixa de texto em Arial. Ficheiros com muitos comentários como os descritos em construir um fluxo de revisão de comentários e hyperlinks são exatamente onde isto morde, porque são abertos, anotados e gravados repetidamente

Tabela de fontes do HotXLS ao longo de duas gravações, em que o registo FONT final não referenciado que o Excel escreve sempre cai primeiro, a fonte só de comentário passa a última e é depois descartada porque os runs de formatação TXO eram escritos de volta sem contar referências, até o CountRichRunFontRefs corrigir o filtro de sobrevivência na 2.384.5
A primeira gravação parecia limpa porque a fonte final levou a perda, e a fonte do comentário só desapareceu na segunda — mapeie cada ifnt para um nome de registo FONT entre gravações em vez de confiar num teste em memória

O HotXLS 2.384.5 trata os runs TXO como runs SST. O CountRichRunFontRefs percorre agora cada TMSOShapeTextBox em cada folha de cálculo, converte o ifnt com salto do 4 de cada run numa posição, e conta-o como referência, por isso uma fonte só de run sobrevive ao filtro de gravação. A tabela resultante de posição para índice de gravação vai para o FontRunRemap de cada drawing, e o TMSOShapeTextBox.Store reescreve os índices dos runs numa cópia privada dos bytes brutos dos runs, deixando o TxOLastRun final quieto porque não transporta fonte. Para o código da aplicação o contrato é simples: TXLSComment.TextRuns.FontIndex e TXLSTextBox.TextRuns.FontIndex usam a numeração do ficheiro, com salto do 4, exatamente como lida; os índices dos runs são de base um e o CharIndex é o desvio de caracteres onde o run começa. Depois de uma gravação o número armazenado pode diferir do que definiu, mas continua a apontar 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 ficheiro, salto do 4
      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 linhas e migração de runs entre livros

Desde o HotXLS 2.384.6 que todos os caminhos de cópia do motor clássico preservam os runs de formatação de comentários, porque o Range.Copy, o CopyRange, o Sheets.AddCopy e as movimentações de células 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. Uma movimentação é uma cópia mais uma limpeza, por isso inserir uma única linha acima de uma nota de dois runs deixava-a com zero runs e uma fonte. A correção copia cada run e move a sua fonte através do TXLSWorkbook.MigrateRunFontIndex, que converte o índice com salto do 4 numa posição, migra a fonte por valor para a tabela de fontes de destino, e reconverte para a numeração do ficheiro; a migração de rich text do SST no Sheets.AddCopy agora chama a mesma função em vez de carregar a sua própria cópia da aritmética. Vieram dois casos de bordo: uma colagem no lugar em que origem e destino são o mesmo comentário não pode limpar os seus runs antes de os ler, e o Sheets.AddCopy faz agora uma segunda passagem pelos comentários apegados a células sem registo de célula armazenado, que antes saltava por completo. O lado da tabela de fontes na cópia entre livros segue a mesma lógica por valor do lado das fórmulas coberto em cópia entre livros e revinculação de fórmulas. No motor XLSX os caminhos de cópia já clonavam runs por valor; a falha estava na parte dos comentários propriamente dita, onde o leitor ignorava rFont, strike, u e vertAlign e o escritor nunca emitia u nem vertAlign, por isso os runs agora sobrevivem simetricamente à gravação e à reabertura

Como deve testar índices de fontes em ficheiros BIFF8?

Teste índices de fontes gravando e reabrindo, idealmente ao longo de mais de uma geração, e mapeando cada ifnt de volta para um registo FONT em vez de verificar um intervalo numérico. Todos os bugs desta história passaram num teste em memória: a regressão da 2.384.1 vivia num par escritor-leitor combinado, a deriva do TXO precisava de duas gravações com uma alteração da tabela de fontes no meio, e os runs de comentários perdidos no XLSX só apareciam depois de uma reabertura. Um harness útil abre uma amostra escrita no Excel, grava-a duas vezes através do HotXLS, acrescenta ou remove uma fonte entre gravações, e depois verifica as posições dos runs mais, ao nível do byte, os nomes das fontes por trás de cada ifnt. Não compare valores de FontIndex antes e depois de uma gravação, porque a 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);          // escrito no 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 passa para C3
  Assert(Book.SaveAs(OutFile) = 1);

  Book := TXLSWorkbook.Create;               // reabrir, nunca confiar 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 lê e escreve XLS clássico a partir de Delphi ou C++Builder e prefere não andar a rastrear qual dos muitos consumidores de fontes de uma biblioteca ainda concorda com o [MS-XLS] §2.5.129, a numeração com salto do 4, a renumeração de runs na gravação e a migração de runs por valor aqui descritas estão incorporadas no componente de folhas de cálculo HotXLS para Delphi, que lê e escreve XLS e XLSX sem Excel nem automação OLE