Articolo tecnico

L'indice font BIFF8 salta il 4: run di rich text in HotXLS

HotXLS numera ogni riferimento font BIFF8 nel modo in cui [MS-XLS] §2.5.129 FontIndex lo definisce: i valori da 0 a 3 sono in base zero, i valori sopra 4 sono in base uno, e 4 non compare mai, così il quinto record FONT è ifnt 5 e il più grande ifnt valido coincide col numero di record FONT. Da HotXLS 2.384.4 il writer XF, il reader XF, i run di stringhe rich text e la migrazione dei run tra workbook seguono tutti quella regola, e 2.384.5 e 2.384.6 la estendono ai run di commenti e text box, anche attraverso copie e inserimenti di righe

La regola sembra un refuso finché non ci sbattete contro. Qualcuno apre un workbook con otto record FONT, trova un XF che punta al font 8, e conclude che il writer ha prodotto un indice fuori intervallo. Quel ragionamento esatto è arrivato in HotXLS 2.384.1 come una "correzione", e ha trasformato un'implementazione corretta in una dove ogni font personalizzato in un file aperto da Excel finiva uno slot prima. La parte interessante non è l'off-by-one in sé, è quante sedi in una libreria BIFF8 portano la stessa convenzione, e come un binding di font possa sopravvivere a un salvataggio e rompersi al secondo. Se avete già lottato con le stranezze di lunghezza e codifica trattate in decodificare cch e fHigh di BIFF8 XLUnicodeString, questa è la stessa famiglia di bug: il file sta bene, l'aritmetica no

Che cosa dice davvero la regola FontIndex di [MS-XLS]?

[MS-XLS] §2.5.129 dice che un FontIndex sotto 4 è una posizione di record in base zero, un FontIndex sopra 4 è una posizione di record in base uno, e il valore 4 non deve essere usato. Lo stesso tipo FontIndex è usato dai record XF, dai run di formattazione SST e dai run di formattazione TXO, quindi una regola fraintesa corrompe tutti e tre. La prova è facile da riprodurre con file scritti da Excel: SOLVSAMP.XLS distribuito con Office ha 19 record FONT e un massimo ifnt XF di 19, un workbook da 43 record si ferma a 43, e un file salvato da Excel 16 con 30 record FONT punta le sue celle Courier New a ifnt 22, il 22esimo record. Nessuno contiene mai un 4. Se dovete analizzare la mappatura voi stessi in uno strumento diagnostico, la conversione sono due funzioni brevi

// [MS-XLS] 2.5.129 FontIndex: 0..3 in base zero, > 4 in base uno, 4 invalido
function FontIndexToRecordNo(Ifnt: Word): Integer;  // record FONT in base 1
begin
  if Ifnt < 4 then
    Result := Ifnt + 1
  else if Ifnt > 4 then
    Result := Ifnt
  else
    Result := -1;  // 4 non deve mai comparire
end;

function RecordNoToFontIndex(RecordNo: Integer): Word;
begin
  if RecordNo <= 4 then
    Result := RecordNo - 1
  else
    Result := RecordNo;
end;
Mappatura FontIndex di HotXLS secondo MS-XLS 2.5.129 dove ifnt da 0 a 3 sono posizioni di record FONT in base zero, ifnt 5 e oltre sono in base uno e il valore 4 non compare mai, con la conversione FontIndexToRecordNo e le prove da workbook scritti da Excel come SOLVSAMP.XLS
Il quinto record FONT è ifnt 5, non 4 — un workbook da 19 record si ferma a ifnt 19, e nessun file scritto da Excel memorizza mai il valore vietato in mezzo

Dentro HotXLS la stessa regola vive in due punti specchiati. TXLSFontList.GetSaveIndex prende la posizione in base 1 di un font nella lista referenziata e decrementa solo le posizioni da 1 a 4, quindi la posizione 5 viene scritta come ifnt 5. TXLSReader.ParseXF fa l'inverso al caricamento: ogni ifnt di 5 o più viene decrementato a uno slot della lista font in base zero, e tutto ciò che sta sotto resta dov'è. Il remap dei run rich del SST e CountRichRunFontRefs applicano la stessa conversione ifnt >= 5, ed è questo il punto: una convenzione, ogni consumatore

// TXLSFontList.GetSaveIndex (lato writer)
Result := inherited GetSaveIndex(Index);   // posizione referenziata in base 1
if (Result > 0) and (Result < 5) then
  Dec(Result);                             // 1..4 diventano 0..3, 5+ invariato

// TXLSReader.ParseXF (lato reader)
fnti := Data.GetWord(0);
if fnti >= 5 then
  Dec(fnti);                               // ifnt 5 è lo slot 4 della lista font

Perché una "correzione" in base zero spostava di uno ogni font personalizzato?

La riscrittura in base zero di HotXLS 2.384.1 spostava ogni font personalizzato perché leggeva un indice in base uno come se fosse in base zero e poi cambiava quattro call site per adattarli a quella lettura sbagliata: GetSaveIndex, ParseXF, il remap dei run SST e la migrazione dei run tra workbook in Sheets.AddCopy. I round trip di HotXLS sembravano ancora a posto, perché writer e reader erano d'accordo tra loro. Excel non era d'accordo. Un file scritto da 2.384.1 metteva il primo font personalizzato a ifnt 4, che Excel tratta come font predefinito, e ogni font successivo un record prima; aprire un file Excel andava nell'altra direzione, legando ogni font un record dopo

Regressione di HotXLS 2.384.1 dove GetSaveIndex e ParseXF leggono valori FontIndex in base uno come se fossero in base zero, scrivendo il primo font personalizzato come il vietato ifnt 4 che Excel risolve al font predefinito e facendo finire ogni font successivo un record prima mentre i round trip restavano verdi
Writer e reader erano d'accordo sulla stessa lettura sbagliata, quindi un test salva-riapri restava verde mentre ogni font aperto da Excel finiva uno slot fuori posto — quando una convenzione vive in sette posti e ne cambiate quattro, sospettate prima del vostro cambiamento

L'indizio che avrebbe dovuto fermare la modifica era seduto nello stesso codebase. CountRichRunFontRefs, il remap FONTX e FBI dei grafici e la lista font del motore di stili non furono mai toccati e continuavano a usare skip-4, quindi la libreria si contraddisse nel momento stesso in cui 2.384.1 atterrò, e solo la coincidenza che i font rich text erano di solito referenziati anche da qualche XF tenne la contraddizione nascosta. Quando una convenzione compare in sette posti e ne state cambiando quattro, sospettate della vostra modifica prima che degli altri tre. La versione 2.384.4 ha ripristinato la numerazione della specifica in tutti e quattro i posti, e il vecchio test di regressione, che asseriva ifnt < FontCount e quindi codificava la lettura sbagliata, è stato sostituito da test che rimappano ogni ifnt scritto al nome di un record FONT tramite la formula della specifica. Resta un limite onesto: i file salvati da 2.384.1 a 2.384.3 con cinque o più font portano indici spostati che un reader non sa distinguere da dati validi, quindi l'unica cura è rigenerarli

Perché i run font dei commenti si rompono solo al secondo salvataggio?

I run di commenti e text box si rompevano al secondo salvataggio perché HotXLS teneva i primi N-1 record FONT incondizionatamente e scartava solo l'ultimo quando nessun XF lo referenziava, mentre i run di formattazione TXO ([MS-XLS] §2.4.329) venivano riscritti byte per byte senza rinumerare. I file .xls scritti da Excel finiscono sempre con un font finale non referenziato (un DengXian 9pt su un sistema con locale cinese), quindi al primo salvataggio il font usato solo da un run di commento non era mai l'ultimo, e nulla si muoveva visivamente. Quel primo salvataggio però scartava il font finale e promuoveva il font solo-dei-commenti all'ultima posizione. Il secondo salvataggio lo scartava allora come non referenziato, l'ifnt del run puntava oltre la fine, ed Excel ricadeva sul font predefinito; se il workbook aveva guadagnato un nuovo font nel frattempo, il run si legava in silenzio a quello, cosa che nei test ha trasformato un run di text box con stile in Arial. I file ricchi di commenti come quelli descritti in costruire un workflow di revisione di commenti e collegamenti ipertestuali sono esattamente dove questo morde, perché vengono aperti, annotati e salvati ripetutamente

Tabella font di HotXLS su due salvataggi dove il record FONT finale non referenziato che Excel scrive sempre cade per primo, il font solo-dei-commenti diventa ultimo e viene poi scartato perché i run di formattazione TXO venivano riscritti senza contare i riferimenti, finché CountRichRunFontRefs non ha corretto il filtro di sopravvivenza in 2.384.5
Il primo salvataggio sembrava pulito perché il font finale prendeva la perdita, e il font dei commenti spariva solo al secondo — mappate ogni ifnt al nome di un record FONT attraverso i salvataggi invece di fidarvi di un test in memoria

HotXLS 2.384.5 tratta i run TXO come i run SST. CountRichRunFontRefs ora percorre ogni TMSOShapeTextBox su ogni foglio di lavoro, converte l'ifnt skip-4 di ogni run in uno slot e lo conta come riferimento, così un font usato solo da run sopravvive al filtro di salvataggio. La tabella da slot a save index risultante finisce nel FontRunRemap di ogni drawing, e TMSOShapeTextBox.Store riscrive gli indici dei run su una copia privata dei byte grezzi dei run, lasciando in pace il TxOLastRun finale perché non porta alcun font. Per il codice applicativo il contratto è semplice: TXLSComment.TextRuns.FontIndex e TXLSTextBox.TextRuns.FontIndex usano la numerazione del file, con 4 saltato, esattamente come letti; gli indici dei run sono in base 1 e CharIndex è l'offset di carattere dove il run inizia. Dopo un salvataggio il numero memorizzato può differire da quello impostato, ma punta comunque allo stesso font

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];   // numerazione del file, 4 saltato
      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;

Copie, inserimenti di righe e migrazione dei run tra workbook

Da HotXLS 2.384.6, ogni percorso di copia del motore classico conserva i run di formattazione dei commenti, perché Range.Copy, CopyRange, Sheets.AddCopy e gli spostamenti di celle dietro Range.Insert e Range.Delete passano tutti per TXLSRange.CopyCell, e CopyCell copiava solo testo e autore del commento. Uno spostamento è una copia più una pulizia, quindi inserire una singola riga sopra una nota a due run la lasciava con zero run e un font. La correzione copia ogni run e sposta il suo font tramite TXLSWorkbook.MigrateRunFontIndex, che converte l'indice skip-4 in uno slot, migra il font per valore nella tabella font di destinazione e riconverte alla numerazione del file; la migrazione rich text del SST in Sheets.AddCopy ora chiama la stessa funzione invece di portarsi dietro la propria copia dell'aritmetica. Sono arrivati anche due casi limite: un paste sul posto dove sorgente e destinazione sono lo stesso commento non deve pulire i suoi run prima di leggerli, e Sheets.AddCopy ora fa un secondo passaggio per i commenti attaccati a celle senza record cella memorizzato, che prima saltava del tutto. Il lato tabella font della copia tra workbook segue la stessa logica per valore del lato formule trattato in copia tra workbook e rebinding delle formule. Sull'engine XLSX i percorsi di copia clonavano già i run per valore; il buco era nella parte commenti, dove il reader ignorava rFont, strike, u e vertAlign e il writer non emetteva mai u né vertAlign, quindi ora i run sopravvivono a salvataggio e riapertura in modo simmetrico

Come si devono testare gli indici font nei file BIFF8?

Testate gli indici font salvando e riaprendo, idealmente per più di una generazione, e rimappando ogni ifnt a un record FONT invece di asserire un intervallo numerico. Ogni bug di questa storia ha passato un test in memoria: la regressione del 2.384.1 viveva in una coppia writer e reader abbinati, la deriva TXO richiedeva due salvataggi con una modifica della tabella font in mezzo, e i run dei commenti persi su XLSX si vedevano solo dopo una riapertura. Un harness utile apre un campione scritto da Excel, lo salva due volte tramite HotXLS, aggiunge o rimuove un font tra i salvataggi e poi controlla le posizioni dei run più, a livello di byte, i nomi font dietro ogni ifnt. Non confrontate i valori di FontIndex prima e dopo un salvataggio, perché la rinumerazione è legittima

procedure CheckNoteSurvivesShiftAndSave(const SrcFile, OutFile: string);
var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  RunCount: Integer;
  SecondRunAt: Word;
begin
  Book := TXLSWorkbook.Create;
  Assert(Book.Open(SrcFile) = 1);          // creato da Excel, C2 ha due run
  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 si sposta in C3
  Assert(Book.SaveAs(OutFile) = 1);

  Book := TXLSWorkbook.Create;               // riapertura, non fidatevi mai della memoria
  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 leggete e scrivete XLS classico da Delphi o C++Builder e preferireste non tenere traccia di quale dei tanti consumatori di font di una libreria concorda ancora con [MS-XLS] §2.5.129, la numerazione skip-4, la rinumerazione dei run al salvataggio e la migrazione dei run per valore descritte qui sono integrate nel componente foglio di calcolo HotXLS per Delphi, che legge e scrive XLS e XLSX senza Excel né automazione OLE