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;
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
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
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